diff --git a/.github/workflows/bot-proxy.yml b/.github/workflows/bot-proxy.yml new file mode 100644 index 00000000000..9bef0630d32 --- /dev/null +++ b/.github/workflows/bot-proxy.yml @@ -0,0 +1,48 @@ +# This workflow must be kept in sync to some extent with bot.yml +name: GitHub Bot Proxy + +on: + # Watch for any completed run on bot.yml workflow + workflow_run: + workflows: [GitHub Bot] + types: [completed] + +jobs: + # This workflow monitors any run completed on the GitHub Bot workflow and + # checks if the event that triggered it is limited to read-only permissions + # (e.g 'pull_request_review' on a pull request opened from a fork). + # In this case, it reruns the GitHub Bot workflow using a 'workflow_dispatch' + # event, thereby allowing it to run with write permissions. + # + # Complete flow: + # 'pull_request_review' from fork on bot.yml (read-only) -> 'workflow_run' on bot-proxy.yml (write) -> 'workflow_dispatch' on bot.yml (write) + rerun-with-write-perm: + name: Rerun Bot with write permission + # Skip this workflow if the original event is not 'pull_request_review' + if: github.event.workflow_run.event == 'pull_request_review' + runs-on: ubuntu-latest + permissions: + actions: write + + steps: + - name: Download artifact from previous run + uses: actions/download-artifact@v4 + with: + name: pr-number + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + # Even if the artifact doesn't exist, do not mark the workflow as failed + # Useful if the 'pull_request_review' event was emitted by a PR opened + # from a branch on the main repo, so it has already been processed by + # the bot workflow, and no artifact has been uploaded. + continue-on-error: true + id: download + + - name: Send workflow_dispatch event to Github Bot + # Run only if an artifact was downloaded + if: steps.download.outcome == 'success' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.event.workflow_run.repository.full_name }} + run: | + gh workflow run bot.yml -R "$REPO" -f "pull-request-list=$(cat pr-number)" diff --git a/.github/workflows/bot.yml b/.github/workflows/bot.yml index 300a5928e25..add800fe2bf 100644 --- a/.github/workflows/bot.yml +++ b/.github/workflows/bot.yml @@ -1,3 +1,4 @@ +# This workflow must be kept in sync to some extent with bot-proxy.yml name: GitHub Bot on: @@ -37,8 +38,14 @@ jobs: # handle the parallel processing of the pull-requests define-prs-matrix: name: Define PRs matrix - # Prevent bot from retriggering itself and ignore event emitted by codecov - if: ${{ github.actor != vars.GH_BOT_LOGIN && github.actor != 'codecov[bot]' }} + # Skip this workflow if: + # - the bot is retriggering itself + # - the event is emitted by codecov + # - the event is a review on a pull request from a fork (see save-pr-number job below) + if: | + github.actor != vars.GH_BOT_LOGIN && + github.actor != 'codecov[bot]' && + (github.event_name != 'pull_request_review' || github.event.pull_request.base.repo.full_name == github.event.pull_request.head.repo.full_name) runs-on: ubuntu-latest permissions: pull-requests: read @@ -61,13 +68,36 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: go run . matrix -matrix-key 'pr-numbers' -verbose + # This job is executed if an event with read-only permission has triggered this + # workflow (e.g 'pull_request_review' on a pull request opened from a fork). + # In this case, this job persists the PR number in an artifact so that the + # proxy workflow can use it to rerun the current workflow with write permission. + # See bot-proxy.yml for more info. + save-pr-number: + name: Persist PR number for proxy + # Run this job if the event is a review on a pull request opened from a fork + if: github.event_name == 'pull_request_review' && github.event.pull_request.base.repo.full_name != github.event.pull_request.head.repo.full_name + runs-on: ubuntu-latest + + steps: + - name: Write PR number to a file + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: echo $PR_NUMBER > pr-number + + - name: Upload it as an artifact + uses: actions/upload-artifact@v4 + with: + name: pr-number + path: pr-number + # This job processes each pull request in the matrix individually while ensuring # that a same PR cannot be processed concurrently by mutliple runners process-pr: name: Process PR needs: define-prs-matrix # Just skip this job if PR numbers matrix is empty (prevent failed state) - if: ${{ needs.define-prs-matrix.outputs.pr-numbers != '[]' && needs.define-prs-matrix.outputs.pr-numbers != '' }} + if: needs.define-prs-matrix.outputs.pr-numbers != '[]' && needs.define-prs-matrix.outputs.pr-numbers != '' runs-on: ubuntu-latest strategy: matrix: diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index 1b955b52cd0..3baf8e1ee8a 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -24,12 +24,19 @@ jobs: - uses: actions/setup-go@v5 with: go-version-file: go.mod + - run: echo "GOROOT=$(go env GOROOT)" >> $GITHUB_ENV + - run: echo $GOROOT + - run: "cd misc/stdlib_diff && make gen" - run: "cd misc/gendocs && make install gen" + - run: "mkdir -p pages_output/stdlib_diff" + - run: | + cp -r misc/gendocs/godoc/* pages_output/ + cp -r misc/stdlib_diff/stdlib_diff/* pages_output/stdlib_diff/ - uses: actions/configure-pages@v5 id: pages - uses: actions/upload-pages-artifact@v3 with: - path: ./misc/gendocs/godoc + path: ./pages_output deploy: if: ${{ github.repository == 'gnolang/gno' }} # Alternatively, validate based on provided tokens and permissions. diff --git a/.github/workflows/gnoland.yml b/.github/workflows/gnoland.yml index 4817e2db0e3..59050f1baa4 100644 --- a/.github/workflows/gnoland.yml +++ b/.github/workflows/gnoland.yml @@ -16,3 +16,25 @@ jobs: tests-extra-args: "-coverpkg=github.com/gnolang/gno/gno.land/..." secrets: codecov-token: ${{ secrets.CODECOV_TOKEN }} + + gnoweb_generate: + strategy: + fail-fast: false + matrix: + go-version: ["1.22.x"] + # unittests: TODO: matrix with contracts + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - uses: actions/setup-node@v4 + with: + node-version: lts/Jod # (22.x) https://github.com/nodejs/Release + - uses: actions/checkout@v4 + - run: | + make -C gno.land/pkg/gnoweb fclean generate + # Check if there are changes after running generate.gnoweb + git diff --exit-code || \ + (echo "\`gnoweb generate\` out of date, please run \`make gnoweb.generate\` within './gno.land'" && exit 1) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc125a6da73..b58d63c6c75 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -469,6 +469,18 @@ Resources for idiomatic Go docs: - [godoc](https://go.dev/blog/godoc) - [Go Doc Comments](https://tip.golang.org/doc/comment) +## Avoding Unhelpful Contributions + +While we welcome all contributions to the Gno project, it's important to ensure that your changes provide meaningful value or improve the quality of the codebase. Contributions that fail to meet these criteria may not be accepted. Examples of unhelpful contributions include (but not limited to): + +- Airdrop farming & karma farming: Making minimal, superficial changes, with the goal of becoming eligible for airdrops and GovDAO participation. +- Incomplete submissions: Changes that lack adequate context, link to a related issue, documentation, or test coverage. + +Before submitting a pull request, ask yourself: +- Does this change solve a specific problem or add clear value? +- Is the implementation aligned with the gno.land's goals and style guide? +- Have I tested my changes and included relevant documentation? + ## Additional Notes ### Issue and Pull Request Labels @@ -502,3 +514,4 @@ automatic label management. | info needed | Issue is lacking information needed for resolving | | investigating | Issue is still being investigated by the team | | question | Issue starts a discussion or raises a question | + diff --git a/Dockerfile b/Dockerfile index b858589640f..effc30ca32f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,7 +52,6 @@ ENTRYPOINT ["/usr/bin/gno"] # gnoweb FROM base AS gnoweb COPY --from=build-gno /gnoroot/build/gnoweb /usr/bin/gnoweb -COPY --from=build-gno /opt/gno/src/gno.land/cmd/gnoweb /opt/gno/src/gnoweb EXPOSE 8888 ENTRYPOINT ["/usr/bin/gnoweb"] diff --git a/contribs/github-bot/internal/config/config.go b/contribs/github-bot/internal/config/config.go index fd29f5e5f57..86acc6cfa83 100644 --- a/contribs/github-bot/internal/config/config.go +++ b/contribs/github-bot/internal/config/config.go @@ -24,7 +24,7 @@ type ManualCheck struct { // This is the description for a persistent rule with a non-standard behavior // that allow maintainer to force the "success" state of the CI check -const ForceSkipDescription = "**SKIP**: Do not block the CI for this PR" +const ForceSkipDescription = "**IGNORE** the bot requirements for this PR (force green CI check)" // This function returns the configuration of the bot consisting of automatic and manual checks // in which the GitHub client is injected. @@ -35,11 +35,6 @@ func Config(gh *client.GitHub) ([]AutomaticCheck, []ManualCheck) { If: c.CreatedFromFork(), Then: r.MaintainerCanModify(), }, - { - Description: "The pull request head branch must be up-to-date with its base ([more info](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/keeping-your-pull-request-in-sync-with-the-base-branch))", - If: c.Always(), - Then: r.UpToDateWith(gh, r.PR_BASE), - }, { Description: "Changes to 'docs' folder must be reviewed/authored by at least one devrel and one tech-staff", If: c.FileChanged(gh, "^docs/"), diff --git a/contribs/gnodev/cmd/gnobro/main.go b/contribs/gnodev/cmd/gnobro/main.go index 092a441542a..91713d6c6d8 100644 --- a/contribs/gnodev/cmd/gnobro/main.go +++ b/contribs/gnodev/cmd/gnobro/main.go @@ -429,14 +429,14 @@ func getSignerForAccount(io commands.IO, address string, kb keys.Keybase, cfg *b } // try empty password first - if _, err := kb.ExportPrivKeyUnsafe(address, ""); err != nil { + if _, err := kb.ExportPrivKey(address, ""); err != nil { prompt := fmt.Sprintf("[%.10s] Enter password:", address) signer.Password, err = io.GetPassword(prompt, true) if err != nil { return nil, fmt.Errorf("error while reading password: %w", err) } - if _, err := kb.ExportPrivKeyUnsafe(address, signer.Password); err != nil { + if _, err := kb.ExportPrivKey(address, signer.Password); err != nil { return nil, fmt.Errorf("invalid password: %w", err) } } diff --git a/contribs/gnodev/cmd/gnodev/main.go b/contribs/gnodev/cmd/gnodev/main.go index 082d0cb8270..95f1d95e0a6 100644 --- a/contribs/gnodev/cmd/gnodev/main.go +++ b/contribs/gnodev/cmd/gnodev/main.go @@ -57,9 +57,10 @@ type devCfg struct { txsFile string // Web Configuration + noWeb bool + webHTML bool webListenerAddr string webRemoteHelperAddr string - webWithHTML bool // Node Configuration minimal bool @@ -123,6 +124,20 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { "gno root directory", ) + fs.BoolVar( + &c.noWeb, + "no-web", + defaultDevOptions.noWeb, + "disable gnoweb", + ) + + fs.BoolVar( + &c.webHTML, + "web-html", + defaultDevOptions.webHTML, + "gnoweb: enable unsafe HTML parsing in markdown rendering", + ) + fs.StringVar( &c.webListenerAddr, "web-listener", @@ -137,13 +152,6 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { "gnoweb: web server help page's remote addr (default to )", ) - fs.BoolVar( - &c.webWithHTML, - "web-with-html", - defaultDevOptions.webWithHTML, - "gnoweb: enable HTML parsing in markdown rendering", - ) - fs.StringVar( &c.nodeRPCListenerAddr, "node-rpc-listener", @@ -323,7 +331,10 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { defer server.Close() // Setup gnoweb - webhandler := setupGnoWebServer(logger.WithGroup(WebLogName), cfg, devNode) + webhandler, err := setupGnoWebServer(logger.WithGroup(WebLogName), cfg, devNode) + if err != nil { + return fmt.Errorf("unable to setup gnoweb server: %w", err) + } // Setup unsafe APIs if enabled if cfg.unsafeAPI { @@ -351,14 +362,17 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { mux.Handle("/", webhandler) } - go func() { - err := server.ListenAndServe() - cancel(err) - }() + // Serve gnoweb + if !cfg.noWeb { + go func() { + err := server.ListenAndServe() + cancel(err) + }() - logger.WithGroup(WebLogName). - Info("gnoweb started", - "lisn", fmt.Sprintf("http://%s", server.Addr)) + logger.WithGroup(WebLogName). + Info("gnoweb started", + "lisn", fmt.Sprintf("http://%s", server.Addr)) + } watcher, err := watcher.NewPackageWatcher(loggerEvents, emitterServer) if err != nil { @@ -377,7 +391,7 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { return runEventLoop(ctx, logger, book, rt, devNode, watcher) } -var helper string = `For more in-depth documentation, visit the GNO Tooling CLI documentation: +var helper string = `For more in-depth documentation, visit the GNO Tooling CLI documentation: https://docs.gno.land/gno-tooling/cli/gno-tooling-gnodev P Previous TX - Go to the previous tx diff --git a/contribs/gnodev/cmd/gnodev/setup_web.go b/contribs/gnodev/cmd/gnodev/setup_web.go index d55814142a6..e509768d2a1 100644 --- a/contribs/gnodev/cmd/gnodev/setup_web.go +++ b/contribs/gnodev/cmd/gnodev/setup_web.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "log/slog" "net/http" @@ -9,19 +10,25 @@ import ( ) // setupGnowebServer initializes and starts the Gnoweb server. -func setupGnoWebServer(logger *slog.Logger, cfg *devCfg, dnode *gnodev.Node) http.Handler { - webConfig := gnoweb.NewDefaultConfig() +func setupGnoWebServer(logger *slog.Logger, cfg *devCfg, dnode *gnodev.Node) (http.Handler, error) { + if cfg.noWeb { + return http.HandlerFunc(http.NotFound), nil + } + + remote := dnode.GetRemoteAddress() - webConfig.HelpChainID = cfg.chainId - webConfig.RemoteAddr = dnode.GetRemoteAddress() - webConfig.HelpRemote = cfg.webRemoteHelperAddr - webConfig.WithHTML = cfg.webWithHTML + appcfg := gnoweb.NewDefaultAppConfig() + appcfg.UnsafeHTML = cfg.webHTML + appcfg.NodeRemote = remote + appcfg.ChainID = cfg.chainId + if cfg.webRemoteHelperAddr != "" { + appcfg.RemoteHelp = cfg.webRemoteHelperAddr + } - // If `HelpRemote` is empty default it to `RemoteAddr` - if webConfig.HelpRemote == "" { - webConfig.HelpRemote = webConfig.RemoteAddr + router, err := gnoweb.NewRouter(logger, appcfg) + if err != nil { + return nil, fmt.Errorf("unable to create router app: %w", err) } - app := gnoweb.MakeApp(logger, webConfig) - return app.Router + return router, nil } diff --git a/contribs/gnodev/go.mod b/contribs/gnodev/go.mod index 2053a61db6c..b5b5a402c2a 100644 --- a/contribs/gnodev/go.mod +++ b/contribs/gnodev/go.mod @@ -1,6 +1,8 @@ module github.com/gnolang/gno/contribs/gnodev -go 1.22.0 +go 1.22 + +toolchain go1.22.4 replace github.com/gnolang/gno => ../.. @@ -27,7 +29,7 @@ require ( require ( dario.cat/mergo v1.0.1 // indirect - github.com/alecthomas/chroma/v2 v2.8.0 // indirect + github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect @@ -48,7 +50,7 @@ require ( github.com/creack/pty v1.1.21 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect - github.com/dlclark/regexp2 v1.4.0 // indirect + github.com/dlclark/regexp2 v1.11.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -57,10 +59,6 @@ require ( github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.0 // indirect - github.com/gorilla/mux v1.8.1 // indirect - github.com/gorilla/securecookie v1.1.1 // indirect - github.com/gorilla/sessions v1.2.1 // indirect - github.com/gotuna/gotuna v0.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect github.com/libp2p/go-buffer-pool v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect @@ -81,7 +79,7 @@ require ( github.com/rs/xid v1.6.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - github.com/yuin/goldmark v1.5.4 // indirect + github.com/yuin/goldmark v1.7.2 // indirect github.com/yuin/goldmark-emoji v1.0.2 // indirect github.com/zondax/hid v0.9.2 // indirect github.com/zondax/ledger-go v0.14.3 // indirect diff --git a/contribs/gnodev/go.sum b/contribs/gnodev/go.sum index f9250d34462..bab6e5364e8 100644 --- a/contribs/gnodev/go.sum +++ b/contribs/gnodev/go.sum @@ -1,12 +1,12 @@ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= -github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink= -github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ= -github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264= -github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw= -github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk= -github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= +github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= +github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -91,8 +91,8 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeC github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= -github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= -github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= @@ -128,16 +128,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/csrf v1.7.0/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= -github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= -github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI= -github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -233,8 +225,8 @@ github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= -github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.2 h1:NjGd7lO7zrUn/A7eKwn5PEOt4ONYGqpxSEeZuduvgxc= +github.com/yuin/goldmark v1.7.2/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s= github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY= github.com/zondax/hid v0.9.2 h1:WCJFnEDMiqGF64nlZz28E9qLVZ0KSJ7xpc5DLEyma2U= diff --git a/contribs/gnodev/pkg/dev/node.go b/contribs/gnodev/pkg/dev/node.go index 0502c03c86f..fa9e2d11e29 100644 --- a/contribs/gnodev/pkg/dev/node.go +++ b/contribs/gnodev/pkg/dev/node.go @@ -122,12 +122,9 @@ func NewDevNode(ctx context.Context, cfg *NodeConfig) (*Node, error) { initialState: cfg.InitialTxs, currentStateIndex: len(cfg.InitialTxs), } - - // generate genesis state - genesis := gnoland.GnoGenesisState{ - Balances: cfg.BalancesList, - Txs: append(pkgsTxs, cfg.InitialTxs...), - } + genesis := gnoland.DefaultGenState() + genesis.Balances = cfg.BalancesList + genesis.Txs = append(pkgsTxs, cfg.InitialTxs...) if err := devnode.rebuildNode(ctx, genesis); err != nil { return nil, fmt.Errorf("unable to initialize the node: %w", err) @@ -288,10 +285,9 @@ func (n *Node) Reset(ctx context.Context) error { // Append initialTxs txs := append(pkgsTxs, n.initialState...) - genesis := gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: txs, - } + genesis := gnoland.DefaultGenState() + genesis.Balances = n.config.BalancesList + genesis.Txs = txs // Reset the node with the new genesis state. err = n.rebuildNode(ctx, genesis) @@ -413,10 +409,10 @@ func (n *Node) rebuildNodeFromState(ctx context.Context) error { if err != nil { return fmt.Errorf("unable to load pkgs: %w", err) } - - return n.rebuildNode(ctx, gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, Txs: txs, - }) + genesis := gnoland.DefaultGenState() + genesis.Balances = n.config.BalancesList + genesis.Txs = txs + return n.rebuildNode(ctx, genesis) } state, err := n.getBlockStoreState(ctx) @@ -431,10 +427,9 @@ func (n *Node) rebuildNodeFromState(ctx context.Context) error { } // Create genesis with loaded pkgs + previous state - genesis := gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: append(pkgsTxs, state...), - } + genesis := gnoland.DefaultGenState() + genesis.Balances = n.config.BalancesList + genesis.Txs = append(pkgsTxs, state...) // Reset the node with the new genesis state. err = n.rebuildNode(ctx, genesis) diff --git a/contribs/gnodev/pkg/dev/node_state.go b/contribs/gnodev/pkg/dev/node_state.go index 73362a5f1c8..3f996bc7716 100644 --- a/contribs/gnodev/pkg/dev/node_state.go +++ b/contribs/gnodev/pkg/dev/node_state.go @@ -92,10 +92,9 @@ func (n *Node) MoveBy(ctx context.Context, x int) error { newState := n.state[:newIndex] // Create genesis with loaded pkgs + previous state - genesis := gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: append(pkgsTxs, newState...), - } + genesis := gnoland.DefaultGenState() + genesis.Balances = n.config.BalancesList + genesis.Txs = append(pkgsTxs, newState...) // Reset the node with the new genesis state. if err = n.rebuildNode(ctx, genesis); err != nil { @@ -132,10 +131,11 @@ func (n *Node) ExportStateAsGenesis(ctx context.Context) (*bft.GenesisDoc, error // Get current blockstore state doc := *n.Node.GenesisDoc() // copy doc - doc.AppState = gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: state, - } + + genState := doc.AppState.(gnoland.GnoGenesisState) + genState.Balances = n.config.BalancesList + genState.Txs = state + doc.AppState = genState return &doc, nil } diff --git a/docs/reference/stdlibs/std/banker.md b/docs/reference/stdlibs/std/banker.md index 71eb3709ea2..b60b55ee93b 100644 --- a/docs/reference/stdlibs/std/banker.md +++ b/docs/reference/stdlibs/std/banker.md @@ -38,6 +38,10 @@ Returns `Banker` of the specified type. ```go banker := std.GetBanker(std.) ``` + +:::info `Banker` methods expect qualified denomination of the coins. Read more [here](./realm.md#coindenom). +::: + --- ## GetCoins diff --git a/docs/reference/stdlibs/std/chain.md b/docs/reference/stdlibs/std/chain.md index b1791e65608..6a1da6483fd 100644 --- a/docs/reference/stdlibs/std/chain.md +++ b/docs/reference/stdlibs/std/chain.md @@ -162,3 +162,19 @@ Derives the Realm address from its `pkgpath` parameter. ```go realmAddr := std.DerivePkgAddr("gno.land/r/demo/tamagotchi") // g1a3tu874agjlkrpzt9x90xv3uzncapcn959yte4 ``` +--- + +## CoinDenom +```go +func CoinDenom(pkgPath, coinName string) string +``` +Composes a qualified denomination string from the realm's `pkgPath` and the provided coin name, e.g. `/gno.land/r/demo/blog:blgcoin`. This method should be used to get fully qualified denominations of coins when interacting with the `Banker` module. It can also be used as a method of the `Realm` object, Read more[here](./realm.md#coindenom). + +#### Parameters +- `pkgPath` **string** - package path of the realm +- `coinName` **string** - The coin name used to build the qualified denomination. Must start with a lowercase letter, followed by 2–15 lowercase letters or digits. + +#### Usage +```go +denom := std.CoinDenom("gno.land/r/demo/blog", "blgcoin") // /gno.land/r/demo/blog:blgcoin +``` diff --git a/docs/reference/stdlibs/std/realm.md b/docs/reference/stdlibs/std/realm.md index 0c99b7134ea..f69cd874c75 100644 --- a/docs/reference/stdlibs/std/realm.md +++ b/docs/reference/stdlibs/std/realm.md @@ -14,6 +14,7 @@ type Realm struct { func (r Realm) Addr() Address {...} func (r Realm) PkgPath() string {...} func (r Realm) IsUser() bool {...} +func (r Realm) CoinDenom(coinName string) string {...} ``` ## Addr @@ -39,3 +40,15 @@ Checks if the realm it was called upon is a user realm. ```go if r.IsUser() {...} ``` +--- +## CoinDenom +Composes a qualified denomination string from the realm's `pkgPath` and the provided coin name, e.g. `/gno.land/r/demo/blog:blgcoin`. This method should be used to get fully qualified denominations of coins when interacting with the `Banker` module. + +#### Parameters +- `coinName` **string** - The coin name used to build the qualified denomination. Must start with a lowercase letter, followed by 2–15 lowercase letters or digits. + +#### Usage +```go +// in "gno.land/r/gnoland/blog" +denom := r.CoinDenom("blgcoin") // /gno.land/r/gnoland/blog:blgcoin +``` diff --git a/examples/gno.land/p/demo/avl/pager/pager.gno b/examples/gno.land/p/demo/avl/pager/pager.gno index cccdc0df645..ca3eadde032 100644 --- a/examples/gno.land/p/demo/avl/pager/pager.gno +++ b/examples/gno.land/p/demo/avl/pager/pager.gno @@ -11,7 +11,7 @@ import ( // Pager is a struct that holds the AVL tree and pagination parameters. type Pager struct { - Tree *avl.Tree + Tree avl.ITree PageQueryParam string SizeQueryParam string DefaultPageSize int @@ -37,7 +37,7 @@ type Item struct { } // NewPager creates a new Pager with default values. -func NewPager(tree *avl.Tree, defaultPageSize int, reversed bool) *Pager { +func NewPager(tree avl.ITree, defaultPageSize int, reversed bool) *Pager { return &Pager{ Tree: tree, PageQueryParam: "page", diff --git a/examples/gno.land/p/demo/avl/rotree/gno.mod b/examples/gno.land/p/demo/avl/rotree/gno.mod new file mode 100644 index 00000000000..d2cb439b2eb --- /dev/null +++ b/examples/gno.land/p/demo/avl/rotree/gno.mod @@ -0,0 +1 @@ +module gno.land/p/demo/avl/rotree diff --git a/examples/gno.land/p/demo/avl/rotree/rotree.gno b/examples/gno.land/p/demo/avl/rotree/rotree.gno new file mode 100644 index 00000000000..3e093c4d0e0 --- /dev/null +++ b/examples/gno.land/p/demo/avl/rotree/rotree.gno @@ -0,0 +1,162 @@ +// Package rotree provides a read-only wrapper for avl.Tree with safe value transformation. +// +// It is useful when you want to expose a read-only view of a tree while ensuring that +// the sensitive data cannot be modified. +// +// Example: +// +// // Define a user structure with sensitive data +// type User struct { +// Name string +// Balance int +// Internal string // sensitive field +// } +// +// // Create and populate the original tree +// privateTree := avl.NewTree() +// privateTree.Set("alice", &User{ +// Name: "Alice", +// Balance: 100, +// Internal: "sensitive", +// }) +// +// // Create a safe transformation function that copies the struct +// // while excluding sensitive data +// makeEntrySafeFn := func(v interface{}) interface{} { +// u := v.(*User) +// return &User{ +// Name: u.Name, +// Balance: u.Balance, +// Internal: "", // omit sensitive data +// } +// } +// +// // Create a read-only view of the tree +// PublicTree := rotree.Wrap(tree, makeEntrySafeFn) +// +// // Safely access the data +// value, _ := roTree.Get("alice") +// user := value.(*User) +// // user.Name == "Alice" +// // user.Balance == 100 +// // user.Internal == "" (sensitive data is filtered) +package rotree + +import ( + "gno.land/p/demo/avl" +) + +// Wrap creates a new ReadOnlyTree from an existing avl.Tree and a safety transformation function. +// If makeEntrySafeFn is nil, values will be returned as-is without transformation. +// +// makeEntrySafeFn is a function that transforms a tree entry into a safe version that can be exposed to external users. +// This function should be implemented based on the specific safety requirements of your use case: +// +// 1. No-op transformation: For primitive types (int, string, etc.) or already safe objects, +// simply pass nil as the makeEntrySafeFn to return values as-is. +// +// 2. Defensive copying: For mutable types like slices or maps, you should create a deep copy +// to prevent modification of the original data. +// Example: func(v interface{}) interface{} { return append([]int{}, v.([]int)...) } +// +// 3. Read-only wrapper: Return a read-only version of the object that implements +// a limited interface. +// Example: func(v interface{}) interface{} { return NewReadOnlyObject(v) } +// +// 4. DAO transformation: Transform the object into a data access object that +// controls how the underlying data can be accessed. +// Example: func(v interface{}) interface{} { return NewDAO(v) } +// +// The function ensures that the returned object is safe to expose to untrusted code, +// preventing unauthorized modifications to the original data structure. +func Wrap(tree *avl.Tree, makeEntrySafeFn func(interface{}) interface{}) *ReadOnlyTree { + return &ReadOnlyTree{ + tree: tree, + makeEntrySafeFn: makeEntrySafeFn, + } +} + +// ReadOnlyTree wraps an avl.Tree and provides read-only access. +type ReadOnlyTree struct { + tree *avl.Tree + makeEntrySafeFn func(interface{}) interface{} +} + +// Verify that ReadOnlyTree implements ITree +var _ avl.ITree = (*ReadOnlyTree)(nil) + +// getSafeValue applies the makeEntrySafeFn if it exists, otherwise returns the original value +func (roTree *ReadOnlyTree) getSafeValue(value interface{}) interface{} { + if roTree.makeEntrySafeFn == nil { + return value + } + return roTree.makeEntrySafeFn(value) +} + +// Size returns the number of key-value pairs in the tree. +func (roTree *ReadOnlyTree) Size() int { + return roTree.tree.Size() +} + +// Has checks whether a key exists in the tree. +func (roTree *ReadOnlyTree) Has(key string) bool { + return roTree.tree.Has(key) +} + +// Get retrieves the value associated with the given key, converted to a safe format. +func (roTree *ReadOnlyTree) Get(key string) (interface{}, bool) { + value, exists := roTree.tree.Get(key) + if !exists { + return nil, false + } + return roTree.getSafeValue(value), true +} + +// GetByIndex retrieves the key-value pair at the specified index in the tree, with the value converted to a safe format. +func (roTree *ReadOnlyTree) GetByIndex(index int) (string, interface{}) { + key, value := roTree.tree.GetByIndex(index) + return key, roTree.getSafeValue(value) +} + +// Iterate performs an in-order traversal of the tree within the specified key range. +func (roTree *ReadOnlyTree) Iterate(start, end string, cb avl.IterCbFn) bool { + return roTree.tree.Iterate(start, end, func(key string, value interface{}) bool { + return cb(key, roTree.getSafeValue(value)) + }) +} + +// ReverseIterate performs a reverse in-order traversal of the tree within the specified key range. +func (roTree *ReadOnlyTree) ReverseIterate(start, end string, cb avl.IterCbFn) bool { + return roTree.tree.ReverseIterate(start, end, func(key string, value interface{}) bool { + return cb(key, roTree.getSafeValue(value)) + }) +} + +// IterateByOffset performs an in-order traversal of the tree starting from the specified offset. +func (roTree *ReadOnlyTree) IterateByOffset(offset int, count int, cb avl.IterCbFn) bool { + return roTree.tree.IterateByOffset(offset, count, func(key string, value interface{}) bool { + return cb(key, roTree.getSafeValue(value)) + }) +} + +// ReverseIterateByOffset performs a reverse in-order traversal of the tree starting from the specified offset. +func (roTree *ReadOnlyTree) ReverseIterateByOffset(offset int, count int, cb avl.IterCbFn) bool { + return roTree.tree.ReverseIterateByOffset(offset, count, func(key string, value interface{}) bool { + return cb(key, roTree.getSafeValue(value)) + }) +} + +// Set is not supported on ReadOnlyTree and will panic. +func (roTree *ReadOnlyTree) Set(key string, value interface{}) bool { + panic("Set operation not supported on ReadOnlyTree") +} + +// Remove is not supported on ReadOnlyTree and will panic. +func (roTree *ReadOnlyTree) Remove(key string) (value interface{}, removed bool) { + panic("Remove operation not supported on ReadOnlyTree") +} + +// RemoveByIndex is not supported on ReadOnlyTree and will panic. +func (roTree *ReadOnlyTree) RemoveByIndex(index int) (key string, value interface{}) { + panic("RemoveByIndex operation not supported on ReadOnlyTree") +} diff --git a/examples/gno.land/p/demo/avl/rotree/rotree_test.gno b/examples/gno.land/p/demo/avl/rotree/rotree_test.gno new file mode 100644 index 00000000000..fbc14bd688d --- /dev/null +++ b/examples/gno.land/p/demo/avl/rotree/rotree_test.gno @@ -0,0 +1,222 @@ +package rotree + +import ( + "testing" + + "gno.land/p/demo/avl" +) + +func TestExample(t *testing.T) { + // User represents our internal data structure + type User struct { + ID string + Name string + Balance int + Internal string // sensitive internal data + } + + // Create and populate the original tree with user pointers + tree := avl.NewTree() + tree.Set("alice", &User{ + ID: "1", + Name: "Alice", + Balance: 100, + Internal: "sensitive_data_1", + }) + tree.Set("bob", &User{ + ID: "2", + Name: "Bob", + Balance: 200, + Internal: "sensitive_data_2", + }) + + // Define a makeEntrySafeFn that: + // 1. Creates a defensive copy of the User struct + // 2. Omits sensitive internal data + makeEntrySafeFn := func(v interface{}) interface{} { + originalUser := v.(*User) + return &User{ + ID: originalUser.ID, + Name: originalUser.Name, + Balance: originalUser.Balance, + Internal: "", // Omit sensitive data + } + } + + // Create a read-only view of the tree + roTree := Wrap(tree, makeEntrySafeFn) + + // Test retrieving and verifying a user + t.Run("Get User", func(t *testing.T) { + // Get user from read-only tree + value, exists := roTree.Get("alice") + if !exists { + t.Fatal("User 'alice' not found") + } + + user := value.(*User) + + // Verify user data is correct + if user.Name != "Alice" || user.Balance != 100 { + t.Errorf("Unexpected user data: got name=%s balance=%d", user.Name, user.Balance) + } + + // Verify sensitive data is not exposed + if user.Internal != "" { + t.Error("Sensitive data should not be exposed") + } + + // Verify it's a different instance than the original + originalValue, _ := tree.Get("alice") + originalUser := originalValue.(*User) + if user == originalUser { + t.Error("Read-only tree should return a copy, not the original pointer") + } + }) + + // Test iterating over users + t.Run("Iterate Users", func(t *testing.T) { + count := 0 + roTree.Iterate("", "", func(key string, value interface{}) bool { + user := value.(*User) + // Verify each user has empty Internal field + if user.Internal != "" { + t.Error("Sensitive data exposed during iteration") + } + count++ + return false + }) + + if count != 2 { + t.Errorf("Expected 2 users, got %d", count) + } + }) + + // Verify that modifications to the returned user don't affect the original + t.Run("Modification Safety", func(t *testing.T) { + value, _ := roTree.Get("alice") + user := value.(*User) + + // Try to modify the returned user + user.Balance = 999 + user.Internal = "hacked" + + // Verify original is unchanged + originalValue, _ := tree.Get("alice") + originalUser := originalValue.(*User) + if originalUser.Balance != 100 || originalUser.Internal != "sensitive_data_1" { + t.Error("Original user data was modified") + } + }) +} + +func TestReadOnlyTree(t *testing.T) { + // Example of a makeEntrySafeFn that appends "_readonly" to demonstrate transformation + makeEntrySafeFn := func(value interface{}) interface{} { + return value.(string) + "_readonly" + } + + tree := avl.NewTree() + tree.Set("key1", "value1") + tree.Set("key2", "value2") + tree.Set("key3", "value3") + + roTree := Wrap(tree, makeEntrySafeFn) + + tests := []struct { + name string + key string + expected interface{} + exists bool + }{ + {"ExistingKey1", "key1", "value1_readonly", true}, + {"ExistingKey2", "key2", "value2_readonly", true}, + {"NonExistingKey", "key4", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value, exists := roTree.Get(tt.key) + if exists != tt.exists || value != tt.expected { + t.Errorf("For key %s, expected %v (exists: %v), got %v (exists: %v)", tt.key, tt.expected, tt.exists, value, exists) + } + }) + } +} + +// Add example tests showing different makeEntrySafeFn implementations +func TestMakeEntrySafeFnVariants(t *testing.T) { + tree := avl.NewTree() + tree.Set("slice", []int{1, 2, 3}) + tree.Set("map", map[string]int{"a": 1}) + + tests := []struct { + name string + makeEntrySafeFn func(interface{}) interface{} + key string + validate func(t *testing.T, value interface{}) + }{ + { + name: "Defensive Copy Slice", + makeEntrySafeFn: func(v interface{}) interface{} { + original := v.([]int) + return append([]int{}, original...) + }, + key: "slice", + validate: func(t *testing.T, value interface{}) { + slice := value.([]int) + // Modify the returned slice + slice[0] = 999 + // Verify original is unchanged + originalValue, _ := tree.Get("slice") + original := originalValue.([]int) + if original[0] != 1 { + t.Error("Original slice was modified") + } + }, + }, + // Add more test cases for different makeEntrySafeFn implementations + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + roTree := Wrap(tree, tt.makeEntrySafeFn) + value, exists := roTree.Get(tt.key) + if !exists { + t.Fatal("Key not found") + } + tt.validate(t, value) + }) + } +} + +func TestNilMakeEntrySafeFn(t *testing.T) { + // Create a tree with some test data + tree := avl.NewTree() + originalValue := []int{1, 2, 3} + tree.Set("test", originalValue) + + // Create a ReadOnlyTree with nil makeEntrySafeFn + roTree := Wrap(tree, nil) + + // Test that we get back the original value + value, exists := roTree.Get("test") + if !exists { + t.Fatal("Key not found") + } + + // Verify it's the exact same slice (not a copy) + retrievedSlice := value.([]int) + if &retrievedSlice[0] != &originalValue[0] { + t.Error("Expected to get back the original slice reference") + } + + // Test through iteration as well + roTree.Iterate("", "", func(key string, value interface{}) bool { + retrievedSlice := value.([]int) + if &retrievedSlice[0] != &originalValue[0] { + t.Error("Expected to get back the original slice reference in iteration") + } + return false + }) +} diff --git a/examples/gno.land/p/demo/avl/tree.gno b/examples/gno.land/p/demo/avl/tree.gno index e7aa55eb7e4..3834246d2cd 100644 --- a/examples/gno.land/p/demo/avl/tree.gno +++ b/examples/gno.land/p/demo/avl/tree.gno @@ -1,5 +1,23 @@ package avl +type ITree interface { + // read operations + + Size() int + Has(key string) bool + Get(key string) (value interface{}, exists bool) + GetByIndex(index int) (key string, value interface{}) + Iterate(start, end string, cb IterCbFn) bool + ReverseIterate(start, end string, cb IterCbFn) bool + IterateByOffset(offset int, count int, cb IterCbFn) bool + ReverseIterateByOffset(offset int, count int, cb IterCbFn) bool + + // write operations + + Set(key string, value interface{}) (updated bool) + Remove(key string) (value interface{}, removed bool) +} + type IterCbFn func(key string, value interface{}) bool //---------------------------------------- @@ -101,3 +119,6 @@ func (tree *Tree) ReverseIterateByOffset(offset int, count int, cb IterCbFn) boo }, ) } + +// Verify that Tree implements TreeInterface +var _ ITree = (*Tree)(nil) diff --git a/examples/gno.land/p/demo/memeland/memeland.gno b/examples/gno.land/p/demo/memeland/memeland.gno index 9c302ca365b..38f42239bec 100644 --- a/examples/gno.land/p/demo/memeland/memeland.gno +++ b/examples/gno.land/p/demo/memeland/memeland.gno @@ -160,8 +160,8 @@ func (m *Memeland) RemovePost(id string) string { panic("id cannot be empty") } - if err := m.CallerIsOwner(); err != nil { - panic(err) + if !m.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } for i, post := range m.Posts { diff --git a/examples/gno.land/p/demo/ownable/exts/authorizable/authorizable.gno b/examples/gno.land/p/demo/ownable/exts/authorizable/authorizable.gno index f9f0ea15dd9..95bd2ac4959 100644 --- a/examples/gno.land/p/demo/ownable/exts/authorizable/authorizable.gno +++ b/examples/gno.land/p/demo/ownable/exts/authorizable/authorizable.gno @@ -41,7 +41,7 @@ func NewAuthorizableWithAddress(addr std.Address) *Authorizable { } func (a *Authorizable) AddToAuthList(addr std.Address) error { - if err := a.CallerIsOwner(); err != nil { + if !a.CallerIsOwner() { return ErrNotSuperuser } @@ -55,7 +55,7 @@ func (a *Authorizable) AddToAuthList(addr std.Address) error { } func (a *Authorizable) DeleteFromAuthList(addr std.Address) error { - if err := a.CallerIsOwner(); err != nil { + if !a.CallerIsOwner() { return ErrNotSuperuser } diff --git a/examples/gno.land/p/demo/ownable/ownable.gno b/examples/gno.land/p/demo/ownable/ownable.gno index 48a1c15fffa..f565e27c0f2 100644 --- a/examples/gno.land/p/demo/ownable/ownable.gno +++ b/examples/gno.land/p/demo/ownable/ownable.gno @@ -6,6 +6,7 @@ const OwnershipTransferEvent = "OwnershipTransfer" // Ownable is meant to be used as a top-level object to make your contract ownable OR // being embedded in a Gno object to manage per-object ownership. +// Ownable is safe to export as a top-level object type Ownable struct { owner std.Address } @@ -24,9 +25,8 @@ func NewWithAddress(addr std.Address) *Ownable { // TransferOwnership transfers ownership of the Ownable struct to a new address func (o *Ownable) TransferOwnership(newOwner std.Address) error { - err := o.CallerIsOwner() - if err != nil { - return err + if !o.CallerIsOwner() { + return ErrUnauthorized } if !newOwner.IsValid() { @@ -48,9 +48,8 @@ func (o *Ownable) TransferOwnership(newOwner std.Address) error { // Top-level usage: disables all only-owner actions/functions, // Embedded usage: behaves like a burn functionality, removing the owner from the struct func (o *Ownable) DropOwnership() error { - err := o.CallerIsOwner() - if err != nil { - return err + if !o.CallerIsOwner() { + return ErrUnauthorized } prevOwner := o.owner @@ -71,12 +70,8 @@ func (o Ownable) Owner() std.Address { } // CallerIsOwner checks if the caller of the function is the Realm's owner -func (o Ownable) CallerIsOwner() error { - if std.PrevRealm().Addr() == o.owner { - return nil - } - - return ErrUnauthorized +func (o Ownable) CallerIsOwner() bool { + return std.PrevRealm().Addr() == o.owner } // AssertCallerIsOwner panics if the caller is not the owner diff --git a/examples/gno.land/p/demo/ownable/ownable_test.gno b/examples/gno.land/p/demo/ownable/ownable_test.gno index dee40fa6e1d..f58af9642c6 100644 --- a/examples/gno.land/p/demo/ownable/ownable_test.gno +++ b/examples/gno.land/p/demo/ownable/ownable_test.gno @@ -6,6 +6,7 @@ import ( "gno.land/p/demo/testutils" "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" ) var ( @@ -19,18 +20,14 @@ func TestNew(t *testing.T) { o := New() got := o.Owner() - if alice != got { - t.Fatalf("Expected %s, got: %s", alice, got) - } + uassert.Equal(t, got, alice) } func TestNewWithAddress(t *testing.T) { o := NewWithAddress(alice) got := o.Owner() - if alice != got { - t.Fatalf("Expected %s, got: %s", alice, got) - } + uassert.Equal(t, got, alice) } func TestTransferOwnership(t *testing.T) { @@ -39,14 +36,11 @@ func TestTransferOwnership(t *testing.T) { o := New() err := o.TransferOwnership(bob) - if err != nil { - t.Fatalf("TransferOwnership failed, %v", err) - } + urequire.NoError(t, err) got := o.Owner() - if bob != got { - t.Fatalf("Expected: %s, got: %s", bob, got) - } + + uassert.Equal(t, got, bob) } func TestCallerIsOwner(t *testing.T) { @@ -58,8 +52,7 @@ func TestCallerIsOwner(t *testing.T) { std.TestSetRealm(std.NewUserRealm(unauthorizedCaller)) std.TestSetOrigCaller(unauthorizedCaller) // TODO(bug): should not be needed - err := o.CallerIsOwner() - uassert.Error(t, err) // XXX: IsError(..., unauthorizedCaller) + uassert.False(t, o.CallerIsOwner()) } func TestDropOwnership(t *testing.T) { @@ -68,7 +61,7 @@ func TestDropOwnership(t *testing.T) { o := New() err := o.DropOwnership() - uassert.NoError(t, err, "DropOwnership failed") + urequire.NoError(t, err, "DropOwnership failed") owner := o.Owner() uassert.Empty(t, owner, "owner should be empty") @@ -85,13 +78,8 @@ func TestErrUnauthorized(t *testing.T) { std.TestSetRealm(std.NewUserRealm(bob)) std.TestSetOrigCaller(bob) // TODO(bug): should not be needed - err := o.TransferOwnership(alice) - if err != ErrUnauthorized { - t.Fatalf("Should've been ErrUnauthorized, was %v", err) - } - - err = o.DropOwnership() - uassert.ErrorContains(t, err, ErrUnauthorized.Error()) + uassert.ErrorContains(t, o.TransferOwnership(alice), ErrUnauthorized.Error()) + uassert.ErrorContains(t, o.DropOwnership(), ErrUnauthorized.Error()) } func TestErrInvalidAddress(t *testing.T) { diff --git a/examples/gno.land/p/demo/pausable/pausable.gno b/examples/gno.land/p/demo/pausable/pausable.gno index e9cce63c1e3..e6a85771fa6 100644 --- a/examples/gno.land/p/demo/pausable/pausable.gno +++ b/examples/gno.land/p/demo/pausable/pausable.gno @@ -34,8 +34,8 @@ func (p Pausable) IsPaused() bool { // Pause sets the state of Pausable to true, meaning all pausable functions are paused func (p *Pausable) Pause() error { - if err := p.CallerIsOwner(); err != nil { - return err + if !p.CallerIsOwner() { + return ownable.ErrUnauthorized } p.paused = true @@ -46,8 +46,8 @@ func (p *Pausable) Pause() error { // Unpause sets the state of Pausable to false, meaning all pausable functions are resumed func (p *Pausable) Unpause() error { - if err := p.CallerIsOwner(); err != nil { - return err + if !p.CallerIsOwner() { + return ownable.ErrUnauthorized } p.paused = false diff --git a/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno b/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno index 8a4c10b687b..be661e70129 100644 --- a/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno +++ b/examples/gno.land/p/demo/subscription/lifetime/lifetime.gno @@ -67,7 +67,7 @@ func (ls *LifetimeSubscription) HasValidSubscription(addr std.Address) error { // UpdateAmount allows the owner of the LifetimeSubscription contract to update the subscription price. func (ls *LifetimeSubscription) UpdateAmount(newAmount int64) error { - if err := ls.CallerIsOwner(); err != nil { + if !ls.CallerIsOwner() { return ErrNotAuthorized } diff --git a/examples/gno.land/p/demo/subscription/recurring/recurring.gno b/examples/gno.land/p/demo/subscription/recurring/recurring.gno index b5277bd716e..8f116009aa6 100644 --- a/examples/gno.land/p/demo/subscription/recurring/recurring.gno +++ b/examples/gno.land/p/demo/subscription/recurring/recurring.gno @@ -90,7 +90,7 @@ func (rs *RecurringSubscription) GetExpiration(addr std.Address) (time.Time, err // UpdateAmount allows the owner of the subscription contract to change the required subscription amount. func (rs *RecurringSubscription) UpdateAmount(newAmount int64) error { - if err := rs.CallerIsOwner(); err != nil { + if !rs.CallerIsOwner() { return ErrNotAuthorized } diff --git a/examples/gno.land/p/n2p5/loci/gno.mod b/examples/gno.land/p/n2p5/loci/gno.mod new file mode 100644 index 00000000000..ec30d72d752 --- /dev/null +++ b/examples/gno.land/p/n2p5/loci/gno.mod @@ -0,0 +1 @@ +module gno.land/p/n2p5/loci diff --git a/examples/gno.land/p/n2p5/loci/loci.gno b/examples/gno.land/p/n2p5/loci/loci.gno new file mode 100644 index 00000000000..7bd5c29c3af --- /dev/null +++ b/examples/gno.land/p/n2p5/loci/loci.gno @@ -0,0 +1,44 @@ +// loci is a single purpose datastore keyed by the caller's address. It has two +// functions: Set and Get. loci is plural for locus, which is a central or core +// place where something is found or from which it originates. In this case, +// it's a simple key-value store where an address (the key) can store exactly +// one value (in the form of a byte slice). Only the caller can set the value +// for their address, but anyone can retrieve the value for any address. +package loci + +import ( + "std" + + "gno.land/p/demo/avl" +) + +// LociStore is a simple key-value store that uses +// an AVL tree to store the data. +type LociStore struct { + internal *avl.Tree +} + +// New creates a reference to a new LociStore. +func New() *LociStore { + return &LociStore{ + internal: avl.NewTree(), + } +} + +// Set stores a byte slice in the AVL tree using the `std.PrevRealm().Addr()` +// string as the key. +func (s *LociStore) Set(value []byte) { + key := string(std.PrevRealm().Addr()) + s.internal.Set(key, value) +} + +// Get retrieves a byte slice from the AVL tree using the provided address. +// The return values are the byte slice value and a boolean indicating +// whether the value exists. +func (s *LociStore) Get(addr std.Address) []byte { + value, exists := s.internal.Get(string(addr)) + if !exists { + return nil + } + return value.([]byte) +} diff --git a/examples/gno.land/p/n2p5/loci/loci_test.gno b/examples/gno.land/p/n2p5/loci/loci_test.gno new file mode 100644 index 00000000000..bb216a8539e --- /dev/null +++ b/examples/gno.land/p/n2p5/loci/loci_test.gno @@ -0,0 +1,84 @@ +package loci + +import ( + "std" + "testing" + + "gno.land/p/demo/testutils" +) + +func TestLociStore(t *testing.T) { + t.Parallel() + + u1 := testutils.TestAddress("u1") + u2 := testutils.TestAddress("u1") + + t.Run("TestSet", func(t *testing.T) { + t.Parallel() + store := New() + u1 := testutils.TestAddress("u1") + + m1 := []byte("hello") + m2 := []byte("world") + std.TestSetOrigCaller(u1) + + // Ensure that the value is nil before setting it. + r1 := store.Get(u1) + if r1 != nil { + t.Errorf("expected value to be nil, got '%s'", r1) + } + store.Set(m1) + // Ensure that the value is correct after setting it. + r2 := store.Get(u1) + if string(r2) != "hello" { + t.Errorf("expected value to be 'hello', got '%s'", r2) + } + store.Set(m2) + // Ensure that the value is correct after overwriting it. + r3 := store.Get(u1) + if string(r3) != "world" { + t.Errorf("expected value to be 'world', got '%s'", r3) + } + }) + t.Run("TestGet", func(t *testing.T) { + t.Parallel() + store := New() + u1 := testutils.TestAddress("u1") + u2 := testutils.TestAddress("u2") + u3 := testutils.TestAddress("u3") + u4 := testutils.TestAddress("u4") + + m1 := []byte("hello") + m2 := []byte("world") + m3 := []byte("goodbye") + + std.TestSetOrigCaller(u1) + store.Set(m1) + std.TestSetOrigCaller(u2) + store.Set(m2) + std.TestSetOrigCaller(u3) + store.Set(m3) + + // Ensure that the value is correct after setting it. + r0 := store.Get(u4) + if r0 != nil { + t.Errorf("expected value to be nil, got '%s'", r0) + } + // Ensure that the value is correct after setting it. + r1 := store.Get(u1) + if string(r1) != "hello" { + t.Errorf("expected value to be 'hello', got '%s'", r1) + } + // Ensure that the value is correct after setting it. + r2 := store.Get(u2) + if string(r2) != "world" { + t.Errorf("expected value to be 'world', got '%s'", r2) + } + // Ensure that the value is correct after setting it. + r3 := store.Get(u3) + if string(r3) != "goodbye" { + t.Errorf("expected value to be 'goodbye', got '%s'", r3) + } + }) + +} diff --git a/examples/gno.land/p/n2p5/mgroup/mgroup.gno b/examples/gno.land/p/n2p5/mgroup/mgroup.gno index 0c029401ff7..566d625a003 100644 --- a/examples/gno.land/p/n2p5/mgroup/mgroup.gno +++ b/examples/gno.land/p/n2p5/mgroup/mgroup.gno @@ -44,8 +44,8 @@ func New(ownerAddress std.Address) *ManagedGroup { // AddBackupOwner adds a backup owner to the group by std.Address. // If the caller is not the owner, an error is returned. func (g *ManagedGroup) AddBackupOwner(addr std.Address) error { - if err := g.owner.CallerIsOwner(); err != nil { - return err + if !g.owner.CallerIsOwner() { + return ownable.ErrUnauthorized } if !addr.IsValid() { return ErrInvalidAddress @@ -57,8 +57,8 @@ func (g *ManagedGroup) AddBackupOwner(addr std.Address) error { // RemoveBackupOwner removes a backup owner from the group by std.Address. // The owner cannot be removed. If the caller is not the owner, an error is returned. func (g *ManagedGroup) RemoveBackupOwner(addr std.Address) error { - if err := g.owner.CallerIsOwner(); err != nil { - return err + if !g.owner.CallerIsOwner() { + return ownable.ErrUnauthorized } if !addr.IsValid() { return ErrInvalidAddress @@ -90,8 +90,8 @@ func (g *ManagedGroup) ClaimOwnership() error { // AddMember adds a member to the group by std.Address. // If the caller is not the owner, an error is returned. func (g *ManagedGroup) AddMember(addr std.Address) error { - if err := g.owner.CallerIsOwner(); err != nil { - return err + if !g.owner.CallerIsOwner() { + return ownable.ErrUnauthorized } if !addr.IsValid() { return ErrInvalidAddress @@ -104,8 +104,8 @@ func (g *ManagedGroup) AddMember(addr std.Address) error { // The owner cannot be removed. If the caller is not the owner, // an error is returned. func (g *ManagedGroup) RemoveMember(addr std.Address) error { - if err := g.owner.CallerIsOwner(); err != nil { - return err + if !g.owner.CallerIsOwner() { + return ownable.ErrUnauthorized } if !addr.IsValid() { return ErrInvalidAddress diff --git a/examples/gno.land/r/demo/foo20/foo20.gno b/examples/gno.land/r/demo/foo20/foo20.gno index 97b2e52b94b..5c7d7f12b99 100644 --- a/examples/gno.land/r/demo/foo20/foo20.gno +++ b/examples/gno.land/r/demo/foo20/foo20.gno @@ -17,11 +17,11 @@ import ( var ( Token, privateLedger = grc20.NewToken("Foo", "FOO", 4) UserTeller = Token.CallerTeller() - owner = ownable.NewWithAddress("g1manfred47kzduec920z88wfr64ylksmdcedlf5") // @manfred + Ownable = ownable.NewWithAddress("g1manfred47kzduec920z88wfr64ylksmdcedlf5") // @manfred ) func init() { - privateLedger.Mint(owner.Owner(), 1_000_000*10_000) // @privateLedgeristrator (1M) + privateLedger.Mint(Ownable.Owner(), 1_000_000*10_000) // @privateLedgeristrator (1M) getter := func() *grc20.Token { return Token } grc20reg.Register(getter, "") } @@ -66,13 +66,13 @@ func Faucet() { } func Mint(to pusers.AddressOrName, amount uint64) { - owner.AssertCallerIsOwner() + Ownable.AssertCallerIsOwner() toAddr := users.Resolve(to) checkErr(privateLedger.Mint(toAddr, amount)) } func Burn(from pusers.AddressOrName, amount uint64) { - owner.AssertCallerIsOwner() + Ownable.AssertCallerIsOwner() fromAddr := users.Resolve(from) checkErr(privateLedger.Burn(fromAddr, amount)) } diff --git a/examples/gno.land/r/demo/grc20factory/grc20factory.gno b/examples/gno.land/r/demo/grc20factory/grc20factory.gno index cfd32479f9d..58874409d7f 100644 --- a/examples/gno.land/r/demo/grc20factory/grc20factory.gno +++ b/examples/gno.land/r/demo/grc20factory/grc20factory.gno @@ -120,6 +120,17 @@ func Burn(symbol string, from std.Address, amount uint64) { checkErr(inst.ledger.Burn(from, amount)) } +// instance admin functionality +func DropInstanceOwnership(symbol string) { + inst := mustGetInstance(symbol) + checkErr(inst.admin.DropOwnership()) +} + +func TransferInstanceOwnership(symbol string, newOwner std.Address) { + inst := mustGetInstance(symbol) + checkErr(inst.admin.TransferOwnership(newOwner)) +} + func Render(path string) string { parts := strings.Split(path, "/") c := len(parts) diff --git a/examples/gno.land/r/demo/users/z_13_filetest.gno b/examples/gno.land/r/demo/users/z_13_filetest.gno new file mode 100644 index 00000000000..6ef312dc41c --- /dev/null +++ b/examples/gno.land/r/demo/users/z_13_filetest.gno @@ -0,0 +1,22 @@ +package main + +// SEND: 200000000ugnot + +import ( + "strconv" + + "gno.land/r/demo/users" +) + +func main() { + { + // Verify pre-registered test1 user + names := users.ListUsersByPrefix("test1", 1) + println("# names: " + strconv.Itoa(len(names))) + println("name: " + names[0]) + } +} + +// Output: +// # names: 1 +// name: test1 diff --git a/examples/gno.land/r/gnoland/events/administration.gno b/examples/gno.land/r/gnoland/events/administration.gno deleted file mode 100644 index 02914adee69..00000000000 --- a/examples/gno.land/r/gnoland/events/administration.gno +++ /dev/null @@ -1,26 +0,0 @@ -package events - -import ( - "std" - - "gno.land/p/demo/ownable/exts/authorizable" -) - -var ( - su = std.Address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5") // @leohhhn - auth = authorizable.NewAuthorizableWithAddress(su) -) - -// GetOwner gets the owner of the events realm -func GetOwner() std.Address { - return auth.Owner() -} - -// AddModerator adds a moderator to the events realm -func AddModerator(mod std.Address) { - auth.AssertCallerIsOwner() - - if err := auth.AddToAuthList(mod); err != nil { - panic(err) - } -} diff --git a/examples/gno.land/r/gnoland/events/events.gno b/examples/gno.land/r/gnoland/events/events.gno index baf9ba3d4af..d72638ceaaf 100644 --- a/examples/gno.land/r/gnoland/events/events.gno +++ b/examples/gno.land/r/gnoland/events/events.gno @@ -9,6 +9,7 @@ import ( "strings" "time" + "gno.land/p/demo/ownable/exts/authorizable" "gno.land/p/demo/seqid" "gno.land/p/demo/ufmt" ) @@ -28,6 +29,9 @@ type ( ) var ( + su = std.Address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5") // @leohhhn + Auth = authorizable.NewAuthorizableWithAddress(su) + events = make(eventsSlice, 0) // sorted idCounter seqid.ID ) @@ -42,7 +46,7 @@ const ( // AddEvent adds auth new event // Start time & end time need to be specified in RFC3339, ie 2024-08-08T12:00:00+02:00 func AddEvent(name, description, link, location, startTime, endTime string) (string, error) { - auth.AssertOnAuthList() + Auth.AssertOnAuthList() if strings.TrimSpace(name) == "" { return "", ErrEmptyName @@ -81,7 +85,7 @@ func AddEvent(name, description, link, location, startTime, endTime string) (str // DeleteEvent deletes an event with auth given ID func DeleteEvent(id string) { - auth.AssertOnAuthList() + Auth.AssertOnAuthList() e, idx, err := GetEventByID(id) if err != nil { @@ -99,7 +103,7 @@ func DeleteEvent(id string) { // It only updates values corresponding to non-empty arguments sent with the call // Note: if you need to update the start time or end time, you need to provide both every time func EditEvent(id string, name, description, link, location, startTime, endTime string) { - auth.AssertOnAuthList() + Auth.AssertOnAuthList() e, _, err := GetEventByID(id) if err != nil { diff --git a/examples/gno.land/r/gnoland/events/rendering.gno b/examples/gno.land/r/gnoland/events/render.gno similarity index 100% rename from examples/gno.land/r/gnoland/events/rendering.gno rename to examples/gno.land/r/gnoland/events/render.gno diff --git a/examples/gno.land/r/gnoland/home/home.gno b/examples/gno.land/r/gnoland/home/home.gno index facb1817fe2..2d1aad8a1a0 100644 --- a/examples/gno.land/r/gnoland/home/home.gno +++ b/examples/gno.land/r/gnoland/home/home.gno @@ -70,14 +70,14 @@ func Render(_ string) string { func lastBlogposts(limit int) ui.Element { posts := blog.RenderLastPostsWidget(limit) return ui.Element{ - ui.H3("[Latest Blogposts](/r/gnoland/blog)"), + ui.H2("[Latest Blogposts](/r/gnoland/blog)"), ui.Text(posts), } } func lastContributions(limit int) ui.Element { return ui.Element{ - ui.H3("Latest Contributions"), + ui.H2("Latest Contributions"), // TODO: import r/gh to ui.Link{Text: "View latest contributions", URL: "https://github.com/gnolang/gno/pulls"}, } @@ -86,7 +86,7 @@ func lastContributions(limit int) ui.Element { func upcomingEvents() ui.Element { out, _ := events.RenderEventWidget(events.MaxWidgetSize) return ui.Element{ - ui.H3("[Latest Events](/r/gnoland/events)"), + ui.H2("[Latest Events](/r/gnoland/events)"), ui.Text(out), } } @@ -95,14 +95,14 @@ func latestHOFItems(num int) ui.Element { submissions := hof.RenderExhibWidget(num) return ui.Element{ - ui.H3("[Hall of Fame](/r/leon/hof)"), + ui.H2("[Hall of Fame](/r/leon/hof)"), ui.Text(submissions), } } func introSection() ui.Element { return ui.Element{ - ui.H3("We’re building gno.land, set to become the leading open-source smart contract platform, using Gno, an interpreted and fully deterministic variation of the Go programming language for succinct and composable smart contracts."), + ui.Text("**We’re building gno.land, set to become the leading open-source smart contract platform, using Gno, an interpreted and fully deterministic variation of the Go programming language for succinct and composable smart contracts.**"), ui.Paragraph("With transparent and timeless code, gno.land is the next generation of smart contract platforms, serving as the “GitHub” of the ecosystem, with realms built using fully transparent, auditable code that anyone can inspect and reuse."), ui.Paragraph("Intuitive and easy to use, gno.land lowers the barrier to web3 and makes censorship-resistant platforms accessible to everyone. If you want to help lay the foundations of a fairer and freer world, join us today."), } @@ -135,7 +135,7 @@ func worxDAO() ui.Element { ## Contributors ``*/ return ui.Element{ - ui.H3("Contributions (WorxDAO & GoR)"), + ui.H2("Contributions (WorxDAO & GoR)"), // TODO: GoR dashboard + WorxDAO topics ui.Text(`coming soon`), } @@ -154,28 +154,28 @@ func quoteOfTheBlock() ui.Element { qotb := quotes[idx] return ui.Element{ - ui.H3(ufmt.Sprintf("Quote of the ~Day~ Block#%d", height)), + ui.H2(ufmt.Sprintf("Quote of the ~Day~ Block#%d", height)), ui.Quote(qotb), } } func socialLinks() ui.Element { return ui.Element{ - ui.H3("Socials"), + ui.H2("Socials"), ui.BulletList{ // XXX: improve UI to support a nice GO api for such links ui.Text("Check out our [community projects](https://github.com/gnolang/awesome-gno)"), - ui.Text("![Discord](static/img/ico-discord.svg) [Discord](https://discord.gg/S8nKUqwkPn)"), - ui.Text("![Twitter](static/img/ico-twitter.svg) [Twitter](https://twitter.com/_gnoland)"), - ui.Text("![Youtube](static/img/ico-youtube.svg) [Youtube](https://www.youtube.com/@_gnoland)"), - ui.Text("![Telegram](static/img/ico-telegram.svg) [Telegram](https://t.me/gnoland)"), + ui.Text("[Discord](https://discord.gg/S8nKUqwkPn)"), + ui.Text("[Twitter](https://twitter.com/_gnoland)"), + ui.Text("[Youtube](https://www.youtube.com/@_gnoland)"), + ui.Text("[Telegram](https://t.me/gnoland)"), }, } } func playgroundSection() ui.Element { return ui.Element{ - ui.H3("[Gno Playground](https://play.gno.land)"), + ui.H2("[Gno Playground](https://play.gno.land)"), ui.Paragraph(`Gno Playground is a web application designed for building, running, testing, and interacting with your Gno code, enhancing your understanding of the Gno language. With Gno Playground, you can share your code, execute tests, deploy your realms and packages to gno.land, and explore a multitude of other features.`), @@ -186,12 +186,12 @@ execute tests, deploy your realms and packages to gno.land, and explore a multit func packageStaffPicks() ui.Element { // XXX: make it modifiable from a DAO return ui.Element{ - ui.H3("Explore New Packages and Realms"), + ui.H2("Explore New Packages and Realms"), ui.Columns{ 3, []ui.Element{ { - ui.H4("[r/gnoland](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/gnoland)"), + ui.H3("[r/gnoland](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/gnoland)"), ui.BulletList{ ui.Link{URL: "r/gnoland/blog"}, ui.Link{URL: "r/gnoland/dao"}, @@ -199,14 +199,14 @@ func packageStaffPicks() ui.Element { ui.Link{URL: "r/gnoland/home"}, ui.Link{URL: "r/gnoland/pages"}, }, - ui.H4("[r/sys](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/sys)"), + ui.H3("[r/sys](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/sys)"), ui.BulletList{ ui.Link{URL: "r/sys/names"}, ui.Link{URL: "r/sys/rewards"}, ui.Link{URL: "/r/sys/validators/v2"}, }, }, { - ui.H4("[r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo)"), + ui.H3("[r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo)"), ui.BulletList{ ui.Link{URL: "r/demo/boards"}, ui.Link{URL: "r/demo/users"}, @@ -222,7 +222,7 @@ func packageStaffPicks() ui.Element { ui.Text("..."), }, }, { - ui.H4("[p/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/demo)"), + ui.H3("[p/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/demo)"), ui.BulletList{ ui.Link{URL: "p/demo/avl"}, ui.Link{URL: "p/demo/blog"}, @@ -247,7 +247,7 @@ func discoverLinks() ui.Element { ui.Text(`
-### Learn about gno.land +## Learn about gno.land - [About](/about) - [GitHub](https://github.com/gnolang) @@ -262,7 +262,7 @@ func discoverLinks() ui.Element {
-### Build with Gno +## Build with Gno - [Write Gno in the browser](https://play.gno.land) - [Read about the Gno Language](/gnolang) @@ -274,7 +274,7 @@ func discoverLinks() ui.Element {
-### Explore the universe +## Explore the universe - [Discover demo packages](https://github.com/gnolang/gno/tree/master/examples) - [Gnoscan](https://gnoscan.io) diff --git a/examples/gno.land/r/gnoland/home/home_filetest.gno b/examples/gno.land/r/gnoland/home/home_filetest.gno index 4825c9fc588..5b5ff5740c3 100644 --- a/examples/gno.land/r/gnoland/home/home_filetest.gno +++ b/examples/gno.land/r/gnoland/home/home_filetest.gno @@ -11,8 +11,7 @@ func main() { // // # Welcome to gno.land // -// ### We’re building gno.land, set to become the leading open-source smart contract platform, using Gno, an interpreted and fully deterministic variation of the Go programming language for succinct and composable smart contracts. -// +// **We’re building gno.land, set to become the leading open-source smart contract platform, using Gno, an interpreted and fully deterministic variation of the Go programming language for succinct and composable smart contracts.** // // With transparent and timeless code, gno.land is the next generation of smart contract platforms, serving as the “GitHub” of the ecosystem, with realms built using fully transparent, auditable code that anyone can inspect and reuse. // @@ -24,7 +23,7 @@ func main() { //
//
// -// ### Learn about gno.land +// ## Learn about gno.land // // - [About](/about) // - [GitHub](https://github.com/gnolang) @@ -39,7 +38,7 @@ func main() { // //
// -// ### Build with Gno +// ## Build with Gno // // - [Write Gno in the browser](https://play.gno.land) // - [Read about the Gno Language](/gnolang) @@ -51,7 +50,7 @@ func main() { //
//
// -// ### Explore the universe +// ## Explore the universe // // - [Discover demo packages](https://github.com/gnolang/gno/tree/master/examples) // - [Gnoscan](https://gnoscan.io) @@ -66,19 +65,19 @@ func main() { //
//
// -// ### [Latest Blogposts](/r/gnoland/blog) +// ## [Latest Blogposts](/r/gnoland/blog) // // No posts. //
//
// -// ### [Latest Events](/r/gnoland/events) +// ## [Latest Events](/r/gnoland/events) // // No events. //
//
// -// ### [Hall of Fame](/r/leon/hof) +// ## [Hall of Fame](/r/leon/hof) // // //
@@ -87,7 +86,7 @@ func main() { // // --- // -// ### [Gno Playground](https://play.gno.land) +// ## [Gno Playground](https://play.gno.land) // // // Gno Playground is a web application designed for building, running, testing, and interacting @@ -100,12 +99,12 @@ func main() { // // --- // -// ### Explore New Packages and Realms +// ## Explore New Packages and Realms // //
//
// -// #### [r/gnoland](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/gnoland) +// ### [r/gnoland](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/gnoland) // // - [r/gnoland/blog](r/gnoland/blog) // - [r/gnoland/dao](r/gnoland/dao) @@ -113,7 +112,7 @@ func main() { // - [r/gnoland/home](r/gnoland/home) // - [r/gnoland/pages](r/gnoland/pages) // -// #### [r/sys](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/sys) +// ### [r/sys](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/sys) // // - [r/sys/names](r/sys/names) // - [r/sys/rewards](r/sys/rewards) @@ -122,7 +121,7 @@ func main() { //
//
// -// #### [r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo) +// ### [r/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/r/demo) // // - [r/demo/boards](r/demo/boards) // - [r/demo/users](r/demo/users) @@ -140,7 +139,7 @@ func main() { //
//
// -// #### [p/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/demo) +// ### [p/demo](https://github.com/gnolang/gno/tree/master/examples/gno.land/p/demo) // // - [p/demo/avl](p/demo/avl) // - [p/demo/blog](p/demo/blog) @@ -160,7 +159,7 @@ func main() { // // --- // -// ### Contributions (WorxDAO & GoR) +// ## Contributions (WorxDAO & GoR) // // coming soon // @@ -170,18 +169,18 @@ func main() { //
//
// -// ### Socials +// ## Socials // // - Check out our [community projects](https://github.com/gnolang/awesome-gno) -// - ![Discord](static/img/ico-discord.svg) [Discord](https://discord.gg/S8nKUqwkPn) -// - ![Twitter](static/img/ico-twitter.svg) [Twitter](https://twitter.com/_gnoland) -// - ![Youtube](static/img/ico-youtube.svg) [Youtube](https://www.youtube.com/@_gnoland) -// - ![Telegram](static/img/ico-telegram.svg) [Telegram](https://t.me/gnoland) +// - [Discord](https://discord.gg/S8nKUqwkPn) +// - [Twitter](https://twitter.com/_gnoland) +// - [Youtube](https://www.youtube.com/@_gnoland) +// - [Telegram](https://t.me/gnoland) // //
//
// -// ### Quote of the ~Day~ Block#123 +// ## Quote of the ~Day~ Block#123 // // > Now, you Gno. // diff --git a/examples/gno.land/r/gnoland/monit/monit.gno b/examples/gno.land/r/gnoland/monit/monit.gno index 8747ea582b3..be94fbdd2bb 100644 --- a/examples/gno.land/r/gnoland/monit/monit.gno +++ b/examples/gno.land/r/gnoland/monit/monit.gno @@ -20,7 +20,7 @@ var ( lastUpdate time.Time lastCaller std.Address wd = watchdog.Watchdog{Duration: 5 * time.Minute} - owner = ownable.New() // TODO: replace with -> ownable.NewWithAddress... + Ownable = ownable.New() // TODO: replace with -> ownable.NewWithAddress... watchdogDuration = 5 * time.Minute ) @@ -37,9 +37,8 @@ func Incr() int { // Reset resets the realm state. // This function can only be called by the admin. func Reset() { - if owner.CallerIsOwner() != nil { // TODO: replace with owner.AssertCallerIsOwner - panic("unauthorized") - } + Ownable.AssertCallerIsOwner() + counter = 0 lastCaller = std.PrevRealm().Addr() lastUpdate = time.Now() @@ -53,7 +52,3 @@ func Render(_ string) string { counter, lastUpdate, lastCaller, status, ) } - -// TransferOwnership transfers ownership to a new owner. This is a proxy to -// ownable.Ownable.TransferOwnership. -func TransferOwnership(newOwner std.Address) { owner.TransferOwnership(newOwner) } diff --git a/examples/gno.land/r/leon/hof/administration.gno b/examples/gno.land/r/leon/hof/administration.gno deleted file mode 100644 index 4b5b212eddf..00000000000 --- a/examples/gno.land/r/leon/hof/administration.gno +++ /dev/null @@ -1,24 +0,0 @@ -package hof - -import "std" - -// Exposing the ownable & pausable APIs -// Should not be needed as soon as MsgCall supports calling methods on exported variables - -func Pause() error { - return exhibition.Pause() -} - -func Unpause() error { - return exhibition.Unpause() -} - -func GetOwner() std.Address { - return owner.Owner() -} - -func TransferOwnership(newOwner std.Address) { - if err := owner.TransferOwnership(newOwner); err != nil { - panic(err) - } -} diff --git a/examples/gno.land/r/leon/hof/datasource.gno b/examples/gno.land/r/leon/hof/datasource.gno new file mode 100644 index 00000000000..180c4880177 --- /dev/null +++ b/examples/gno.land/r/leon/hof/datasource.gno @@ -0,0 +1,77 @@ +package hof + +import ( + "errors" + + "gno.land/p/demo/avl" + "gno.land/p/demo/ufmt" + "gno.land/p/jeronimoalbi/datasource" +) + +func NewDatasource() Datasource { + return Datasource{exhibition} +} + +type Datasource struct { + exhibition *Exhibition +} + +func (ds Datasource) Size() int { return ds.exhibition.itemsSorted.Size() } + +func (ds Datasource) Records(q datasource.Query) datasource.Iterator { + return &iterator{ + exhibition: ds.exhibition, + index: q.Offset, + maxIndex: q.Offset + q.Count, + } +} + +func (ds Datasource) Record(id string) (datasource.Record, error) { + v, found := ds.exhibition.itemsSorted.Get(id) + if !found { + return nil, errors.New("realm submission not found") + } + return record{v.(*Item)}, nil +} + +type record struct { + item *Item +} + +func (r record) ID() string { return r.item.id.String() } +func (r record) String() string { return r.item.pkgpath } + +func (r record) Fields() (datasource.Fields, error) { + fields := avl.NewTree() + fields.Set( + "details", + ufmt.Sprintf("Votes: ⏶ %d - ⏷ %d", r.item.upvote.Size(), r.item.downvote.Size()), + ) + return fields, nil +} + +func (r record) Content() (string, error) { + content := ufmt.Sprintf("# Submission #%d\n\n", int(r.item.id)) + content += r.item.Render(false) + return content, nil +} + +type iterator struct { + exhibition *Exhibition + index, maxIndex int + record *record +} + +func (it iterator) Record() datasource.Record { return it.record } +func (it iterator) Err() error { return nil } + +func (it *iterator) Next() bool { + if it.index >= it.maxIndex || it.index >= it.exhibition.itemsSorted.Size() { + return false + } + + _, v := it.exhibition.itemsSorted.GetByIndex(it.index) + it.record = &record{v.(*Item)} + it.index++ + return true +} diff --git a/examples/gno.land/r/leon/hof/datasource_test.gno b/examples/gno.land/r/leon/hof/datasource_test.gno new file mode 100644 index 00000000000..376f981875f --- /dev/null +++ b/examples/gno.land/r/leon/hof/datasource_test.gno @@ -0,0 +1,157 @@ +package hof + +import ( + "testing" + + "gno.land/p/demo/avl" + "gno.land/p/demo/uassert" + "gno.land/p/demo/urequire" + "gno.land/p/jeronimoalbi/datasource" +) + +var ( + _ datasource.Datasource = (*Datasource)(nil) + _ datasource.Record = (*record)(nil) + _ datasource.ContentRecord = (*record)(nil) + _ datasource.Iterator = (*iterator)(nil) +) + +func TestDatasourceRecords(t *testing.T) { + cases := []struct { + name string + items []*Item + recordIDs []string + options []datasource.QueryOption + }{ + { + name: "all items", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + recordIDs: []string{"0000001", "0000002", "0000003"}, + }, + { + name: "with offset", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + recordIDs: []string{"0000002", "0000003"}, + options: []datasource.QueryOption{datasource.WithOffset(1)}, + }, + { + name: "with count", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + recordIDs: []string{"0000001", "0000002"}, + options: []datasource.QueryOption{datasource.WithCount(2)}, + }, + { + name: "with offset and count", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + recordIDs: []string{"0000002"}, + options: []datasource.QueryOption{ + datasource.WithOffset(1), + datasource.WithCount(1), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Initialize a local instance of exhibition + exhibition := &Exhibition{itemsSorted: avl.NewTree()} + for _, item := range tc.items { + exhibition.itemsSorted.Set(item.id.String(), item) + } + + // Get a records iterator + ds := Datasource{exhibition} + query := datasource.NewQuery(tc.options...) + iter := ds.Records(query) + + // Start asserting + urequire.Equal(t, len(tc.items), ds.Size(), "datasource size") + + var records []datasource.Record + for iter.Next() { + records = append(records, iter.Record()) + } + urequire.Equal(t, len(tc.recordIDs), len(records), "record count") + + for i, r := range records { + uassert.Equal(t, tc.recordIDs[i], r.ID()) + } + }) + } +} + +func TestDatasourceRecord(t *testing.T) { + cases := []struct { + name string + items []*Item + id string + err string + }{ + { + name: "found", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + id: "0000001", + }, + { + name: "no found", + items: []*Item{{id: 1}, {id: 2}, {id: 3}}, + id: "42", + err: "realm submission not found", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Initialize a local instance of exhibition + exhibition := &Exhibition{itemsSorted: avl.NewTree()} + for _, item := range tc.items { + exhibition.itemsSorted.Set(item.id.String(), item) + } + + // Get a single record + ds := Datasource{exhibition} + r, err := ds.Record(tc.id) + + // Start asserting + if tc.err != "" { + uassert.ErrorContains(t, err, tc.err) + return + } + + urequire.NoError(t, err, "no error") + urequire.NotEqual(t, nil, r, "record not nil") + uassert.Equal(t, tc.id, r.ID()) + }) + } +} + +func TestItemRecord(t *testing.T) { + pkgpath := "gno.land/r/demo/test" + item := Item{ + id: 1, + pkgpath: pkgpath, + blockNum: 42, + upvote: avl.NewTree(), + downvote: avl.NewTree(), + } + item.downvote.Set("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5", struct{}{}) + item.upvote.Set("g1w4ek2u33ta047h6lta047h6lta047h6ldvdwpn", struct{}{}) + item.upvote.Set("g1w4ek2u3jta047h6lta047h6lta047h6l9huexc", struct{}{}) + + r := record{&item} + + uassert.Equal(t, "0000001", r.ID()) + uassert.Equal(t, pkgpath, r.String()) + + fields, _ := r.Fields() + details, found := fields.Get("details") + urequire.True(t, found, "details field") + uassert.Equal(t, "Votes: ⏶ 2 - ⏷ 1", details) + + content, _ := r.Content() + wantContent := "# Submission #1\n\n\n```\ngno.land/r/demo/test\n```\n\nby demo\n\n" + + "[View realm](/r/demo/test)\n\nSubmitted at Block #42\n\n" + + "#### [2👍](/r/leon/hof$help&func=Upvote&pkgpath=gno.land/r/demo/test) - " + + "[1👎](/r/leon/hof$help&func=Downvote&pkgpath=gno.land/r/demo/test)\n\n" + uassert.Equal(t, wantContent, content) +} diff --git a/examples/gno.land/r/leon/hof/hof.gno b/examples/gno.land/r/leon/hof/hof.gno index 2722c019497..147a0dd1a95 100644 --- a/examples/gno.land/r/leon/hof/hof.gno +++ b/examples/gno.land/r/leon/hof/hof.gno @@ -14,7 +14,10 @@ import ( var ( exhibition *Exhibition - owner *ownable.Ownable + + // Safe objects + Ownable *ownable.Ownable + Pausable *pausable.Pausable ) type ( @@ -23,7 +26,6 @@ type ( description string items *avl.Tree // pkgPath > Item itemsSorted *avl.Tree // same data but sorted, storing pointers - *pausable.Pausable } Item struct { @@ -41,14 +43,14 @@ func init() { itemsSorted: avl.NewTree(), } - owner = ownable.NewWithAddress(std.Address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5")) - exhibition.Pausable = pausable.NewFromOwnable(owner) + Ownable = ownable.NewWithAddress(std.Address("g125em6arxsnj49vx35f0n0z34putv5ty3376fg5")) + Pausable = pausable.NewFromOwnable(Ownable) } // Register registers your realm to the Hall of Fame // Should be called from within code func Register() { - if exhibition.IsPaused() { + if Pausable.IsPaused() { return } @@ -113,8 +115,8 @@ func Downvote(pkgpath string) { } func Delete(pkgpath string) { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !Ownable.CallerIsOwner() { + panic(ownable.ErrUnauthorized.Error()) } i, ok := exhibition.items.Get(pkgpath) diff --git a/examples/gno.land/r/leon/hof/hof_test.gno b/examples/gno.land/r/leon/hof/hof_test.gno index 72e8d2159be..4d6f70eab88 100644 --- a/examples/gno.land/r/leon/hof/hof_test.gno +++ b/examples/gno.land/r/leon/hof/hof_test.gno @@ -12,7 +12,7 @@ import ( const rlmPath = "gno.land/r/gnoland/home" var ( - admin = owner.Owner() + admin = Ownable.Owner() adminRealm = std.NewUserRealm(admin) alice = testutils.TestAddress("alice") ) @@ -27,7 +27,7 @@ func TestRegister(t *testing.T) { // Test register while paused std.TestSetRealm(adminRealm) - Pause() + Pausable.Pause() // Set legitimate caller std.TestSetRealm(std.NewCodeRealm(rlmPath)) @@ -37,7 +37,7 @@ func TestRegister(t *testing.T) { // Unpause std.TestSetRealm(adminRealm) - Unpause() + Pausable.Unpause() // Set legitimate caller std.TestSetRealm(std.NewCodeRealm(rlmPath)) diff --git a/examples/gno.land/r/leon/hof/render.gno b/examples/gno.land/r/leon/hof/render.gno index 0721c7d6e72..868262bedc7 100644 --- a/examples/gno.land/r/leon/hof/render.gno +++ b/examples/gno.land/r/leon/hof/render.gno @@ -80,9 +80,9 @@ func renderDashboard() string { out += "## Dashboard\n\n" out += ufmt.Sprintf("Total submissions: %d\n\n", exhibition.items.Size()) - out += ufmt.Sprintf("Exhibition admin: %s\n\n", owner.Owner().String()) + out += ufmt.Sprintf("Exhibition admin: %s\n\n", Ownable.Owner().String()) - if !exhibition.IsPaused() { + if !Pausable.IsPaused() { out += ufmt.Sprintf("[Pause exhibition](%s)\n\n", txlink.Call("Pause")) } else { out += ufmt.Sprintf("[Unpause exhibition](%s)\n\n", txlink.Call("Unpause")) diff --git a/examples/gno.land/r/n2p5/loci/gno.mod b/examples/gno.land/r/n2p5/loci/gno.mod new file mode 100644 index 00000000000..131e0d73467 --- /dev/null +++ b/examples/gno.land/r/n2p5/loci/gno.mod @@ -0,0 +1 @@ +module gno.land/r/n2p5/loci diff --git a/examples/gno.land/r/n2p5/loci/loci.gno b/examples/gno.land/r/n2p5/loci/loci.gno new file mode 100644 index 00000000000..36f282e729f --- /dev/null +++ b/examples/gno.land/r/n2p5/loci/loci.gno @@ -0,0 +1,68 @@ +package loci + +import ( + "encoding/base64" + "std" + + "gno.land/p/demo/ufmt" + "gno.land/p/n2p5/loci" +) + +var store *loci.LociStore + +func init() { + store = loci.New() +} + +// Set takes a base64 encoded string and stores it in the Loci store. +// Keyed by the address of the caller. It also emits a "set" event with +// the address of the caller. +func Set(value string) { + b, err := base64.StdEncoding.DecodeString(value) + if err != nil { + panic(err) + } + store.Set(b) + std.Emit("SetValue", "ForAddr", string(std.PrevRealm().Addr())) +} + +// Get retrieves the value stored at the provided address and +// returns it as a base64 encoded string. +func Get(addr std.Address) string { + return base64.StdEncoding.EncodeToString(store.Get(addr)) +} + +func Render(path string) string { + if path == "" { + return about + } + return renderGet(std.Address(path)) +} + +func renderGet(addr std.Address) string { + value := "```\n" + Get(addr) + "\n```" + + return ufmt.Sprintf(` +# Loci Value Viewer + +**Address:** %s + +%s + +`, addr, value) +} + +const about = ` +# Welcome to Loci + +Loci is a simple key-value store keyed by the caller's gno.land address. +Only the caller can set the value for their address, but anyone can +retrieve the value for any address. There are only two functions: Set and Get. +If you'd like to set a value, simply base64 encode any message you'd like and +it will be stored in in Loci. If you'd like to retrieve a value, simply provide +the address of the value you'd like to retrieve. + +For convenience, you can also use gnoweb to view the value for a given address, +if one exists. For instance append :g1j39fhg29uehm7twwnhvnpz3ggrm6tprhq65t0t to +this URL to view the value stored at that address. +` diff --git a/examples/gno.land/r/sys/users/verify.gno b/examples/gno.land/r/sys/users/verify.gno index a836e84683d..71869fda1a1 100644 --- a/examples/gno.land/r/sys/users/verify.gno +++ b/examples/gno.land/r/sys/users/verify.gno @@ -48,8 +48,8 @@ func VerifyNameByUser(enable bool, address std.Address, name string) bool { // Enable this package. func AdminEnable() { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !owner.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } enabled = true @@ -57,8 +57,8 @@ func AdminEnable() { // Disable this package. func AdminDisable() { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !owner.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } enabled = false @@ -66,8 +66,8 @@ func AdminDisable() { // AdminUpdateVerifyCall updates the method that verifies the namespace. func AdminUpdateVerifyCall(check VerifyNameFunc) { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !owner.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } checkFunc = check @@ -75,8 +75,8 @@ func AdminUpdateVerifyCall(check VerifyNameFunc) { // AdminTransferOwnership transfers the ownership to a new owner. func AdminTransferOwnership(newOwner std.Address) error { - if err := owner.CallerIsOwner(); err != nil { - panic(err) + if !owner.CallerIsOwner() { + panic(ownable.ErrUnauthorized) } return owner.TransferOwnership(newOwner) diff --git a/examples/gno.land/r/x/benchmark/storage/boards.gno b/examples/gno.land/r/x/benchmark/storage/boards.gno new file mode 100644 index 00000000000..adb3d2d709c --- /dev/null +++ b/examples/gno.land/r/x/benchmark/storage/boards.gno @@ -0,0 +1,97 @@ +package storage + +import ( + "strconv" + + "gno.land/p/demo/avl" +) + +var boards avl.Tree + +type Board interface { + AddPost(title, content string) + GetPost(id int) (Post, bool) + Size() int +} + +// posts are persisted in an avl tree +type TreeBoard struct { + id int + posts *avl.Tree +} + +func (b *TreeBoard) AddPost(title, content string) { + n := b.posts.Size() + p := Post{n, title, content} + b.posts.Set(strconv.Itoa(n), p) +} + +func (b *TreeBoard) GetPost(id int) (Post, bool) { + p, ok := b.posts.Get(strconv.Itoa(id)) + if ok { + return p.(Post), ok + } else { + return Post{}, ok + } +} + +func (b *TreeBoard) Size() int { + return b.posts.Size() +} + +// posts are persisted in a map +type MapBoard struct { + id int + posts map[int]Post +} + +func (b *MapBoard) AddPost(title, content string) { + n := len(b.posts) + p := Post{n, title, content} + b.posts[n] = p +} + +func (b *MapBoard) GetPost(id int) (Post, bool) { + p, ok := b.posts[id] + if ok { + return p, ok + } else { + return Post{}, ok + } +} + +func (b *MapBoard) Size() int { + return len(b.posts) +} + +// posts are persisted in a slice +type SliceBoard struct { + id int + posts []Post +} + +func (b *SliceBoard) AddPost(title, content string) { + n := len(b.posts) + p := Post{n, title, content} + b.posts = append(b.posts, p) +} + +func (b *SliceBoard) GetPost(id int) (Post, bool) { + if id < len(b.posts) { + p := b.posts[id] + + return p, true + } else { + return Post{}, false + } +} + +func (b *SliceBoard) Size() int { + return len(b.posts) +} + +type Post struct { + id int + title string + content string +} diff --git a/examples/gno.land/r/x/benchmark/storage/forum.gno b/examples/gno.land/r/x/benchmark/storage/forum.gno new file mode 100644 index 00000000000..8f1b3734de6 --- /dev/null +++ b/examples/gno.land/r/x/benchmark/storage/forum.gno @@ -0,0 +1,64 @@ +package storage + +import ( + "strconv" + + "gno.land/p/demo/avl" +) + +func init() { + // we write to three common data structure for persistence + // avl.Tree, map and slice. + posts0 := avl.NewTree() + b0 := &TreeBoard{0, posts0} + boards.Set(strconv.Itoa(0), b0) + + posts1 := make(map[int]Post) + b1 := &MapBoard{1, posts1} + boards.Set(strconv.Itoa(1), b1) + + posts2 := []Post{} + b2 := &SliceBoard{2, posts2} + boards.Set(strconv.Itoa(2), b2) +} + +// post to all boards. +func AddPost(title, content string) { + for i := 0; i < boards.Size(); i++ { + boardId := strconv.Itoa(i) + b, ok := boards.Get(boardId) + if ok { + b.(Board).AddPost(title, content) + } + } +} + +func GetPost(boardId, postId int) string { + b, ok := boards.Get(strconv.Itoa(boardId)) + var res string + + if ok { + p, ok := b.(Board).GetPost(postId) + if ok { + res = p.title + "," + p.content + } + } + return res +} + +func GetPostSize(boardId int) int { + b, ok := boards.Get(strconv.Itoa(boardId)) + var res int + + if ok { + res = b.(Board).Size() + } else { + res = -1 + } + + return res +} + +func GetBoardSize() int { + return boards.Size() +} diff --git a/examples/gno.land/r/x/benchmark/storage/gno.mod b/examples/gno.land/r/x/benchmark/storage/gno.mod new file mode 100644 index 00000000000..04bea3012f3 --- /dev/null +++ b/examples/gno.land/r/x/benchmark/storage/gno.mod @@ -0,0 +1 @@ +module gno.land/r/x/benchmark/storage diff --git a/gno.land/Makefile b/gno.land/Makefile index 7b2afd5779f..075560f44a9 100644 --- a/gno.land/Makefile +++ b/gno.land/Makefile @@ -47,6 +47,12 @@ install.gnoland:; go install ./cmd/gnoland install.gnoweb:; go install ./cmd/gnoweb install.gnokey:; go install ./cmd/gnokey +.PHONY: dev.gnoweb generate.gnoweb +dev.gnoweb: + make -C ./pkg/gnoweb dev +generate.gnoweb: + make -C ./pkg/gnoweb generate + .PHONY: fclean fclean: clean rm -rf gnoland-data genesis.json diff --git a/gno.land/cmd/gnoland/start.go b/gno.land/cmd/gnoland/start.go index 77d7e20b8ef..a420e652810 100644 --- a/gno.land/cmd/gnoland/start.go +++ b/gno.land/cmd/gnoland/start.go @@ -410,10 +410,10 @@ func generateGenesisFile(genesisFile string, pk crypto.PubKey, c *startCfg) erro genesisTxs = append(pkgsTxs, genesisTxs...) // Construct genesis AppState. - gen.AppState = gnoland.GnoGenesisState{ - Balances: balances, - Txs: genesisTxs, - } + defaultGenState := gnoland.DefaultGenState() + defaultGenState.Balances = balances + defaultGenState.Txs = genesisTxs + gen.AppState = defaultGenState // Write genesis state if err := gen.SaveAs(genesisFile); err != nil { diff --git a/gno.land/cmd/gnoland/testdata/addpkg_namespace.txtar b/gno.land/cmd/gnoland/testdata/addpkg_namespace.txtar index d207289e0ff..89da8a51820 100644 --- a/gno.land/cmd/gnoland/testdata/addpkg_namespace.txtar +++ b/gno.land/cmd/gnoland/testdata/addpkg_namespace.txtar @@ -14,70 +14,72 @@ gnoland start # Check if sys/users is disabled # gui call -> sys/users.IsEnable -gnokey maketx call -pkgpath gno.land/r/sys/users -func IsEnabled -gas-fee 100000ugnot -gas-wanted 2000000 -broadcast -chainid tendermint_test gui +gnokey maketx call -pkgpath gno.land/r/sys/users -func IsEnabled -gas-fee 100000ugnot -gas-wanted 200000 -broadcast -chainid tendermint_test gui stdout 'OK!' stdout 'false' # Gui should be able to addpkg on test1 addr # gui addpkg -> gno.land/r//mysuperpkg -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/$USER_ADDR_test1/mysuperpkg -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test gui +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/$USER_ADDR_test1/mysuperpkg -gas-fee 1000000ugnot -gas-wanted 400000 -broadcast -chainid=tendermint_test gui stdout 'OK!' # Gui should be able to addpkg on random name # gui addpkg -> gno.land/r/randomname/mysuperpkg -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/randomname/mysuperpkg -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test gui +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/randomname/mysuperpkg -gas-fee 1000000ugnot -gas-wanted 350000 -broadcast -chainid=tendermint_test gui stdout 'OK!' ## When `sys/users` is enabled # Enable `sys/users` # admin call -> sys/users.AdminEnable -gnokey maketx call -pkgpath gno.land/r/sys/users -func AdminEnable -gas-fee 100000ugnot -gas-wanted 2000000 -broadcast -chainid tendermint_test admin +gnokey maketx call -pkgpath gno.land/r/sys/users -func AdminEnable -gas-fee 100000ugnot -gas-wanted 1000000 -broadcast -chainid tendermint_test admin stdout 'OK!' # Check that `sys/users` has been enabled # gui call -> sys/users.IsEnable -gnokey maketx call -pkgpath gno.land/r/sys/users -func IsEnabled -gas-fee 100000ugnot -gas-wanted 2000000 -broadcast -chainid tendermint_test gui +gnokey maketx call -pkgpath gno.land/r/sys/users -func IsEnabled -gas-fee 100000ugnot -gas-wanted 200000 -broadcast -chainid tendermint_test gui stdout 'OK!' stdout 'true' # Try to add a pkg an with unregistered user # gui addpkg -> gno.land/r//one -! gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/$USER_ADDR_test1/one -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test gui +! gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/$USER_ADDR_test1/one -gas-fee 1000000ugnot -gas-wanted 1000000 -broadcast -chainid=tendermint_test gui stderr 'unauthorized user' # Try to add a pkg with an unregistered user, on their own address as namespace # gui addpkg -> gno.land/r//one -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/$USER_ADDR_gui/one -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test gui +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/$USER_ADDR_gui/one -gas-fee 1000000ugnot -gas-wanted 1000000 -broadcast -chainid=tendermint_test gui stdout 'OK!' ## Test unregistered namespace # Call addpkg with admin user on gui namespace # admin addpkg -> gno.land/r/guiland/one -! gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/guiland/one -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test admin +# This is expected to fail at the transaction simulation stage, which is why we set gas-wanted to 1. +! gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/guiland/one -gas-fee 1000000ugnot -gas-wanted 1 -broadcast -chainid=tendermint_test admin stderr 'unauthorized user' ## Test registered namespace # Test admin invites gui # admin call -> demo/users.Invite -gnokey maketx call -pkgpath gno.land/r/demo/users -func Invite -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test -args $USER_ADDR_gui admin +gnokey maketx call -pkgpath gno.land/r/demo/users -func Invite -gas-fee 1000000ugnot -gas-wanted 2500000 -broadcast -chainid=tendermint_test -args $USER_ADDR_gui admin stdout 'OK!' # test gui register namespace # gui call -> demo/users.Register -gnokey maketx call -pkgpath gno.land/r/demo/users -func Register -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test -args $USER_ADDR_admin -args 'guiland' -args 'im gui' gui +gnokey maketx call -pkgpath gno.land/r/demo/users -func Register -gas-fee 1000000ugnot -gas-wanted 2500000 -broadcast -chainid=tendermint_test -args $USER_ADDR_admin -args 'guiland' -args 'im gui' gui stdout 'OK!' # Test gui publishing on guiland/one # gui addpkg -> gno.land/r/guiland/one -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/guiland/one -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test gui +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/guiland/one -gas-fee 1000000ugnot -gas-wanted 1700000 -broadcast -chainid=tendermint_test gui stdout 'OK!' # Test admin publishing on guiland/two # admin addpkg -> gno.land/r/guiland/two -! gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/guiland/two -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test admin +# This is expected to fail at the transaction simulation stage, which is why we set gas-wanted to 1. +! gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/guiland/two -gas-fee 1000000ugnot -gas-wanted 1 -broadcast -chainid=tendermint_test admin stderr 'unauthorized user' -- one.gno -- diff --git a/gno.land/cmd/gnoland/testdata/addpkg_outofgas.txtar b/gno.land/cmd/gnoland/testdata/addpkg_outofgas.txtar index 56050f4733b..fc536b705c6 100644 --- a/gno.land/cmd/gnoland/testdata/addpkg_outofgas.txtar +++ b/gno.land/cmd/gnoland/testdata/addpkg_outofgas.txtar @@ -7,17 +7,12 @@ gnoland start gnokey maketx addpkg -pkgdir $WORK/foo -pkgpath gno.land/r/foo -gas-fee 1000000ugnot -gas-wanted 220000 -broadcast -chainid=tendermint_test test1 -# add bar package -# out of gas at store.GetPackage() with gas 60000 +# add bar package - out of gas at store.GetPackage() with gas 60000 ! gnokey maketx addpkg -pkgdir $WORK/bar -pkgpath gno.land/r/bar -gas-fee 1000000ugnot -gas-wanted 60000 -broadcast -chainid=tendermint_test test1 -# Out of gas error - stderr '--= Error =--' stderr 'Data: out of gas error' -stderr 'Msg Traces:' -stderr 'out of gas.*?in preprocess' stderr '--= /Error =--' @@ -28,8 +23,6 @@ stderr '--= /Error =--' stderr '--= Error =--' stderr 'Data: out of gas error' -stderr 'Msg Traces:' -stderr 'out of gas.*?in preprocess' stderr '--= /Error =--' diff --git a/gno.land/cmd/gnoland/testdata/append.txtar b/gno.land/cmd/gnoland/testdata/append.txtar index 3450b3e9b32..c5c5272d3be 100644 --- a/gno.land/cmd/gnoland/testdata/append.txtar +++ b/gno.land/cmd/gnoland/testdata/append.txtar @@ -3,69 +3,69 @@ loadpkg gno.land/p/demo/ufmt # start a new node gnoland start -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/append -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/append -gas-fee 1000000ugnot -gas-wanted 9000000 -broadcast -chainid=tendermint_test test1 stdout OK! # Call Append 1 -gnokey maketx call -pkgpath gno.land/r/append -func Append -gas-fee 1000000ugnot -gas-wanted 4000000 -args '1' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Append -gas-fee 1000000ugnot -gas-wanted 300000 -args '1' -broadcast -chainid=tendermint_test test1 stdout OK! -gnokey maketx call -pkgpath gno.land/r/append -func AppendNil -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func AppendNil -gas-fee 1000000ugnot -gas-wanted 300000 -broadcast -chainid=tendermint_test test1 stdout OK! # Call Append 2 -gnokey maketx call -pkgpath gno.land/r/append -func Append -gas-fee 1000000ugnot -gas-wanted 4000000 -args '2' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Append -gas-fee 1000000ugnot -gas-wanted 300000 -args '2' -broadcast -chainid=tendermint_test test1 stdout OK! # Call Append 3 -gnokey maketx call -pkgpath gno.land/r/append -func Append -gas-fee 1000000ugnot -gas-wanted 4000000 -args '3' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Append -gas-fee 1000000ugnot -gas-wanted 300000 -args '3' -broadcast -chainid=tendermint_test test1 stdout OK! # Call render -gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 4000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 1500000 -args '' -broadcast -chainid=tendermint_test test1 stdout '("1-2-3-" string)' stdout OK! # Call Pop -gnokey maketx call -pkgpath gno.land/r/append -func Pop -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Pop -gas-fee 1000000ugnot -gas-wanted 300000 -broadcast -chainid=tendermint_test test1 stdout OK! # Call render -gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 4000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 1500000 -args '' -broadcast -chainid=tendermint_test test1 stdout '("2-3-" string)' stdout OK! # Call Append 42 -gnokey maketx call -pkgpath gno.land/r/append -func Append -gas-fee 1000000ugnot -gas-wanted 4000000 -args '42' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Append -gas-fee 1000000ugnot -gas-wanted 300000 -args '42' -broadcast -chainid=tendermint_test test1 stdout OK! # Call render -gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 4000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 1500000 -args '' -broadcast -chainid=tendermint_test test1 stdout '("2-3-42-" string)' stdout OK! -gnokey maketx call -pkgpath gno.land/r/append -func CopyAppend -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func CopyAppend -gas-fee 1000000ugnot -gas-wanted 300000 -broadcast -chainid=tendermint_test test1 stdout OK! -gnokey maketx call -pkgpath gno.land/r/append -func PopB -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func PopB -gas-fee 1000000ugnot -gas-wanted 350000 -broadcast -chainid=tendermint_test test1 stdout OK! # Call render -gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 4000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 1500000 -args '' -broadcast -chainid=tendermint_test test1 stdout '("2-3-42-" string)' stdout OK! -gnokey maketx call -pkgpath gno.land/r/append -func AppendMoreAndC -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func AppendMoreAndC -gas-fee 1000000ugnot -gas-wanted 350000 -broadcast -chainid=tendermint_test test1 stdout OK! -gnokey maketx call -pkgpath gno.land/r/append -func ReassignC -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func ReassignC -gas-fee 1000000ugnot -gas-wanted 350000 -broadcast -chainid=tendermint_test test1 stdout OK! -gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 4000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 1500000 -args '' -broadcast -chainid=tendermint_test test1 stdout '("2-3-42-70-100-" string)' stdout OK! -gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 4000000 -args 'd' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/append -func Render -gas-fee 1000000ugnot -gas-wanted 1500000 -args 'd' -broadcast -chainid=tendermint_test test1 stdout '("1-" string)' stdout OK! diff --git a/gno.land/cmd/gnoland/testdata/assertorigincall.txtar b/gno.land/cmd/gnoland/testdata/assertorigincall.txtar index 62d660a9215..2c4a27f9d06 100644 --- a/gno.land/cmd/gnoland/testdata/assertorigincall.txtar +++ b/gno.land/cmd/gnoland/testdata/assertorigincall.txtar @@ -31,87 +31,89 @@ loadpkg gno.land/r/foo $WORK/r/foo loadpkg gno.land/p/demo/bar $WORK/p/demo/bar gnoland start +# The PANIC is expected to fail at the transaction simulation stage, which is why we set gas-wanted to 1. + # Test cases ## 1. MsgCall -> myrlm.A: PANIC -! gnokey maketx call -pkgpath gno.land/r/myrlm -func A -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +! gnokey maketx call -pkgpath gno.land/r/myrlm -func A -gas-fee 100000ugnot -gas-wanted 1 -broadcast -chainid tendermint_test test1 stderr 'invalid non-origin call' ## 2. MsgCall -> myrlm.B: PASS -gnokey maketx call -pkgpath gno.land/r/myrlm -func B -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/myrlm -func B -gas-fee 100000ugnot -gas-wanted 150000 -broadcast -chainid tendermint_test test1 stdout 'OK!' ## 3. MsgCall -> myrlm.C: PASS -gnokey maketx call -pkgpath gno.land/r/myrlm -func C -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/myrlm -func C -gas-fee 100000ugnot -gas-wanted 700000 -broadcast -chainid tendermint_test test1 stdout 'OK!' ## 4. MsgCall -> r/foo.A -> myrlm.A: PANIC -! gnokey maketx call -pkgpath gno.land/r/foo -func A -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +! gnokey maketx call -pkgpath gno.land/r/foo -func A -gas-fee 100000ugnot -gas-wanted 1 -broadcast -chainid tendermint_test test1 stderr 'invalid non-origin call' ## 5. MsgCall -> r/foo.B -> myrlm.B: PASS -gnokey maketx call -pkgpath gno.land/r/foo -func B -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/foo -func B -gas-fee 100000ugnot -gas-wanted 200000 -broadcast -chainid tendermint_test test1 stdout 'OK!' ## 6. MsgCall -> r/foo.C -> myrlm.C: PANIC -! gnokey maketx call -pkgpath gno.land/r/foo -func C -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +! gnokey maketx call -pkgpath gno.land/r/foo -func C -gas-fee 100000ugnot -gas-wanted 10000000 -broadcast -chainid tendermint_test test1 stderr 'invalid non-origin call' ## remove due to update to maketx call can only call realm (case 7,8,9) ## 7. MsgCall -> p/demo/bar.A: PANIC -## ! gnokey maketx call -pkgpath gno.land/p/demo/bar -func A -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +## ! gnokey maketx call -pkgpath gno.land/p/demo/bar -func A -gas-fee 100000ugnot -gas-wanted 5000000 -broadcast -chainid tendermint_test test1 ## stderr 'invalid non-origin call' ## 8. MsgCall -> p/demo/bar.B: PASS -## gnokey maketx call -pkgpath gno.land/p/demo/bar -func B -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +## gnokey maketx call -pkgpath gno.land/p/demo/bar -func B -gas-fee 100000ugnot -gas-wanted 5000000 -broadcast -chainid tendermint_test test1 ## stdout 'OK!' ## 9. MsgCall -> p/demo/bar.C: PANIC -## ! gnokey maketx call -pkgpath gno.land/p/demo/bar -func C -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +## ! gnokey maketx call -pkgpath gno.land/p/demo/bar -func C -gas-fee 100000ugnot -gas-wanted 5000000 -broadcast -chainid tendermint_test test1 ## stderr 'invalid non-origin call' ## 10. MsgRun -> run.main -> myrlm.A: PANIC -! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmA.gno +! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 5500000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmA.gno stderr 'invalid non-origin call' ## 11. MsgRun -> run.main -> myrlm.B: PASS -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmB.gno +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 10000000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmB.gno stdout 'OK!' ## 12. MsgRun -> run.main -> myrlm.C: PANIC -! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmC.gno +! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 5500000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmC.gno stderr 'invalid non-origin call' ## 13. MsgRun -> run.main -> foo.A: PANIC -! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/fooA.gno +! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 5500000 -broadcast -chainid tendermint_test test1 $WORK/run/fooA.gno stderr 'invalid non-origin call' ## 14. MsgRun -> run.main -> foo.B: PASS -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/fooB.gno +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 10000000 -broadcast -chainid tendermint_test test1 $WORK/run/fooB.gno stdout 'OK!' ## 15. MsgRun -> run.main -> foo.C: PANIC -! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/fooC.gno +! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 5500000 -broadcast -chainid tendermint_test test1 $WORK/run/fooC.gno stderr 'invalid non-origin call' ## 16. MsgRun -> run.main -> bar.A: PANIC -! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/barA.gno +! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 5500000 -broadcast -chainid tendermint_test test1 $WORK/run/barA.gno stderr 'invalid non-origin call' ## 17. MsgRun -> run.main -> bar.B: PASS -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/barB.gno +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 10000000 -broadcast -chainid tendermint_test test1 $WORK/run/barB.gno stdout 'OK!' ## 18. MsgRun -> run.main -> bar.C: PANIC -! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/barC.gno +! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 5500000 -broadcast -chainid tendermint_test test1 $WORK/run/barC.gno stderr 'invalid non-origin call' ## remove testcase 19 due to maketx call forced to call a realm ## 19. MsgCall -> std.AssertOriginCall: pass -## gnokey maketx call -pkgpath std -func AssertOriginCall -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +## gnokey maketx call -pkgpath std -func AssertOriginCall -gas-fee 100000ugnot -gas-wanted 10000000 -broadcast -chainid tendermint_test test1 ## stdout 'OK!' ## 20. MsgRun -> std.AssertOriginCall: PANIC -! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/baz.gno +! gnokey maketx run -gas-fee 100000ugnot -gas-wanted 10000000 -broadcast -chainid tendermint_test test1 $WORK/run/baz.gno stderr 'invalid non-origin call' diff --git a/gno.land/cmd/gnoland/testdata/ghverify.txtar b/gno.land/cmd/gnoland/testdata/ghverify.txtar index f8cd05c762f..b53849e85b5 100644 --- a/gno.land/cmd/gnoland/testdata/ghverify.txtar +++ b/gno.land/cmd/gnoland/testdata/ghverify.txtar @@ -4,36 +4,37 @@ loadpkg gno.land/r/gnoland/ghverify gnoland start # make a verification request -gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func RequestVerification -args 'deelawn' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func RequestVerification -args 'deelawn' -gas-fee 1000000ugnot -gas-wanted 3500000 -broadcast -chainid=tendermint_test test1 stdout OK! # request tasks to complete (this is done by the agent) -gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GnorkleEntrypoint -args 'request' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GnorkleEntrypoint -args 'request' -gas-fee 1000000ugnot -gas-wanted 6000000 -broadcast -chainid=tendermint_test test1 stdout '\("\[\{\\"id\\":\\"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5\\",\\"type\\":\\"0\\",\\"value_type\\":\\"string\\",\\"tasks\\":\[\{\\"gno_address\\":\\"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5\\",\\"github_handle\\":\\"deelawn\\"\}\]\}\]" string\)' # a verification request was made but there should be no verified address -gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GetHandleByAddress -args 'g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GetHandleByAddress -args 'g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5' -gas-fee 1000000ugnot -gas-wanted 700000 -broadcast -chainid=tendermint_test test1 stdout "" # a verification request was made but there should be no verified handle -gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GetAddressByHandle -args 'deelawn' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GetAddressByHandle -args 'deelawn' -gas-fee 1000000ugnot -gas-wanted 700000 -broadcast -chainid=tendermint_test test1 stdout "" # fail on ingestion with a bad task ID -! gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GnorkleEntrypoint -args 'ingest,a' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +# This is expected to fail at the transaction simulation stage, which is why we set gas-wanted to 1. +! gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GnorkleEntrypoint -args 'ingest,a' -gas-fee 1000000ugnot -gas-wanted 1 -broadcast -chainid=tendermint_test test1 stderr 'invalid ingest id: a' # the agent publishes their response to the task and the verification is complete -gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GnorkleEntrypoint -args 'ingest,g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5,OK' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GnorkleEntrypoint -args 'ingest,g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5,OK' -gas-fee 1000000ugnot -gas-wanted 5000000 -broadcast -chainid=tendermint_test test1 stdout OK! # get verified github handle by gno address -gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GetHandleByAddress -args 'g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GetHandleByAddress -args 'g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5' -gas-fee 1000000ugnot -gas-wanted 700000 -broadcast -chainid=tendermint_test test1 stdout "deelawn" # get verified gno address by github handle -gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GetAddressByHandle -args 'deelawn' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func GetAddressByHandle -args 'deelawn' -gas-fee 1000000ugnot -gas-wanted 700000 -broadcast -chainid=tendermint_test test1 stdout "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" -gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func Render -args '' -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 -stdout '\("\{\\"deelawn\\": \\"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5\\"\}" string\)' \ No newline at end of file +gnokey maketx call -pkgpath gno.land/r/gnoland/ghverify -func Render -args '' -gas-fee 1000000ugnot -gas-wanted 700000 -broadcast -chainid=tendermint_test test1 +stdout '\("\{\\"deelawn\\": \\"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5\\"\}" string\)' diff --git a/gno.land/cmd/gnoland/testdata/grc20_invalid_address.txtar b/gno.land/cmd/gnoland/testdata/grc20_invalid_address.txtar index da903315333..d3dcc86725c 100644 --- a/gno.land/cmd/gnoland/testdata/grc20_invalid_address.txtar +++ b/gno.land/cmd/gnoland/testdata/grc20_invalid_address.txtar @@ -4,9 +4,10 @@ loadpkg gno.land/r/demo/foo20 gnoland start # execute Faucet -gnokey maketx call -pkgpath gno.land/r/demo/foo20 -func Faucet -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/foo20 -func Faucet -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 stdout 'OK!' # execute Transfer for invalid address -! gnokey maketx call -pkgpath gno.land/r/demo/foo20 -func Transfer -args g1ubwj0apf60hd90txhnh855fkac34rxlsvua0aa -args 1 -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 -stderr '"gnokey" error: --= Error =--\nData: invalid address' \ No newline at end of file +# This is expected to fail at the transaction simulation stage, which is why we set gas-wanted to 1. +! gnokey maketx call -pkgpath gno.land/r/demo/foo20 -func Transfer -args g1ubwj0apf60hd90txhnh855fkac34rxlsvua0aa -args 1 -gas-fee 1000000ugnot -gas-wanted 1 -broadcast -chainid=tendermint_test test1 +stderr '"gnokey" error: --= Error =--\nData: invalid address' diff --git a/gno.land/cmd/gnoland/testdata/grc20_registry.txtar b/gno.land/cmd/gnoland/testdata/grc20_registry.txtar index a5f7ad5eee3..df11e92f8db 100644 --- a/gno.land/cmd/gnoland/testdata/grc20_registry.txtar +++ b/gno.land/cmd/gnoland/testdata/grc20_registry.txtar @@ -6,15 +6,15 @@ loadpkg gno.land/r/registry $WORK/registry gnoland start # we call Transfer with foo20, before it's registered -gnokey maketx call -pkgpath gno.land/r/registry -func TransferByName -args 'foo20' -args 'g123456789' -args '42' -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/registry -func TransferByName -args 'foo20' -args 'g123456789' -args '42' -gas-fee 1000000ugnot -gas-wanted 150000 -broadcast -chainid=tendermint_test test1 stdout 'not found' # add foo20, and foo20wrapper -gnokey maketx addpkg -pkgdir $WORK/foo20 -pkgpath gno.land/r/foo20 -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 -gnokey maketx addpkg -pkgdir $WORK/foo20wrapper -pkgpath gno.land/r/foo20wrapper -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx addpkg -pkgdir $WORK/foo20 -pkgpath gno.land/r/foo20 -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx addpkg -pkgdir $WORK/foo20wrapper -pkgpath gno.land/r/foo20wrapper -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test1 # we call Transfer with foo20, after it's registered -gnokey maketx call -pkgpath gno.land/r/registry -func TransferByName -args 'foo20' -args 'g123456789' -args '42' -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/registry -func TransferByName -args 'foo20' -args 'g123456789' -args '42' -gas-fee 1000000ugnot -gas-wanted 800000 -broadcast -chainid=tendermint_test test1 stdout 'same address, success!' -- registry/registry.gno -- diff --git a/gno.land/cmd/gnoland/testdata/grc721_emit.txtar b/gno.land/cmd/gnoland/testdata/grc721_emit.txtar index 9836e81a9be..6b4770e37c6 100644 --- a/gno.land/cmd/gnoland/testdata/grc721_emit.txtar +++ b/gno.land/cmd/gnoland/testdata/grc721_emit.txtar @@ -6,23 +6,23 @@ loadpkg gno.land/r/foo721 $WORK/foo721 gnoland start # Mint -gnokey maketx call -pkgpath gno.land/r/foo721 -func Mint -args g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 -args 1 -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/foo721 -func Mint -args g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 -args 1 -gas-fee 1000000ugnot -gas-wanted 3500000 -broadcast -chainid=tendermint_test test1 stdout '\[{\"type\":\"Mint\",\"attrs\":\[{\"key\":\"to\",\"value\":\"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5\"},{\"key\":\"tokenId\",\"value\":\"1\"}],\"pkg_path\":\"gno.land\/r\/foo721\",\"func\":\"mint\"}\]' # Approve -gnokey maketx call -pkgpath gno.land/r/foo721 -func Approve -args g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj -args 1 -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/foo721 -func Approve -args g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj -args 1 -gas-fee 1000000ugnot -gas-wanted 3500000 -broadcast -chainid=tendermint_test test1 stdout '\[{\"type\":\"Approval\",\"attrs\":\[{\"key\":\"owner\",\"value\":\"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5\"},{\"key\":\"to\",\"value\":\"g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj\"},{\"key\":\"tokenId\",\"value\":\"1\"}],\"pkg_path\":\"gno.land\/r\/foo721\",\"func\":\"Approve\"}\]' # SetApprovalForAll -gnokey maketx call -pkgpath gno.land/r/foo721 -func SetApprovalForAll -args g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj -args false -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/foo721 -func SetApprovalForAll -args g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj -args false -gas-fee 1000000ugnot -gas-wanted 3500000 -broadcast -chainid=tendermint_test test1 stdout '\[{\"type\":\"ApprovalForAll\",\"attrs\":\[{\"key\":\"owner\",\"value\":\"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5\"},{\"key\":\"to\",\"value\":\"g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj\"},{\"key\":\"approved\",\"value\":\"false\"}],\"pkg_path\":\"gno\.land/r/foo721\",\"func\":\"setApprovalForAll\"}\]' # TransferFrom -gnokey maketx call -pkgpath gno.land/r/foo721 -func TransferFrom -args g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 -args g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj -args 1 -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/foo721 -func TransferFrom -args g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 -args g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj -args 1 -gas-fee 1000000ugnot -gas-wanted 3500000 -broadcast -chainid=tendermint_test test1 stdout '\[{\"type\":\"Transfer\",\"attrs\":\[{\"key\":\"from\",\"value\":\"g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5\"},{\"key\":\"to\",\"value\":\"g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj\"},{\"key\":\"tokenId\",\"value\":\"1\"}],\"pkg_path\":\"gno\.land/r/foo721\",\"func\":\"transfer\"}\]' # Burn -gnokey maketx call -pkgpath gno.land/r/foo721 -func Burn -args 1 -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/foo721 -func Burn -args 1 -gas-fee 1000000ugnot -gas-wanted 3500000 -broadcast -chainid=tendermint_test test1 stdout '\[{\"type\":\"Burn\",\"attrs\":\[{\"key\":\"from\",\"value\":\"g1us8428u2a5satrlxzagqqa5m6vmuze025anjlj\"},{\"key\":\"tokenId\",\"value\":\"1\"}],\"pkg_path\":\"gno\.land/r/foo721\",\"func\":\"Burn\"}\]' diff --git a/gno.land/cmd/gnoland/testdata/issue_1167.txtar b/gno.land/cmd/gnoland/testdata/issue_1167.txtar index 73febb0235a..7e33d61e9cd 100644 --- a/gno.land/cmd/gnoland/testdata/issue_1167.txtar +++ b/gno.land/cmd/gnoland/testdata/issue_1167.txtar @@ -4,30 +4,30 @@ loadpkg gno.land/p/demo/avl gnoland start # add contract -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/demo/xx -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/demo/xx -gas-fee 1000000ugnot -gas-wanted 8000000 -broadcast -chainid=tendermint_test test1 stdout OK! # execute New -gnokey maketx call -pkgpath gno.land/r/demo/xx -func New -args X -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/xx -func New -args X -gas-fee 1000000ugnot -gas-wanted 700000 -broadcast -chainid=tendermint_test test1 stdout OK! # execute Delta for the first time -gnokey maketx call -pkgpath gno.land/r/demo/xx -func Delta -args X -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/xx -func Delta -args X -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 stdout OK! stdout '"1,1,1;" string' # execute Delta for the second time -gnokey maketx call -pkgpath gno.land/r/demo/xx -func Delta -args X -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/xx -func Delta -args X -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 stdout OK! stdout '1,1,1;2,2,2;" string' # execute Delta for the third time -gnokey maketx call -pkgpath gno.land/r/demo/xx -func Delta -args X -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/xx -func Delta -args X -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 stdout OK! stdout '1,1,1;2,2,2;3,3,3;" string' # execute Render -gnokey maketx call -pkgpath gno.land/r/demo/xx -func Render -args X -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/xx -func Render -args X -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 stdout OK! stdout '1,1,1;2,2,2;3,3,3;" string' diff --git a/gno.land/cmd/gnoland/testdata/issue_1786.txtar b/gno.land/cmd/gnoland/testdata/issue_1786.txtar index 7c92e81dfb6..0e66a882a6d 100644 --- a/gno.land/cmd/gnoland/testdata/issue_1786.txtar +++ b/gno.land/cmd/gnoland/testdata/issue_1786.txtar @@ -5,24 +5,24 @@ loadpkg gno.land/r/demo/wugnot gnoland start # add contract -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/demo/proxywugnot -gas-fee 1000000ugnot -gas-wanted 6000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/demo/proxywugnot -gas-fee 1000000ugnot -gas-wanted 16000000 -broadcast -chainid=tendermint_test test1 stdout OK! # approve wugnot to `proxywugnot ≈ g1fndyg0we60rdfchyy5dwxzkfmhl5u34j932rg3` -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Approve -args "g1fndyg0we60rdfchyy5dwxzkfmhl5u34j932rg3" -args 10000 -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Approve -args "g1fndyg0we60rdfchyy5dwxzkfmhl5u34j932rg3" -args 10000 -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 stdout OK! # send 10000ugnot to `proxywugnot` to wrap it -gnokey maketx call -pkgpath gno.land/r/demo/proxywugnot --send "10000ugnot" -func ProxyWrap -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/proxywugnot --send "10000ugnot" -func ProxyWrap -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 stdout OK! # check user's wugnot balance -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func BalanceOf -args "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func BalanceOf -args "g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5" -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 stdout OK! stdout '10000 uint64' # unwrap 500 wugnot -gnokey maketx call -pkgpath gno.land/r/demo/proxywugnot -func ProxyUnwrap -args 500 -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/proxywugnot -func ProxyUnwrap -args 500 -gas-fee 1000000ugnot -gas-wanted 4000000 -broadcast -chainid=tendermint_test test1 # XXX without patching anything it will panic # panic msg: insufficient coins error diff --git a/gno.land/cmd/gnoland/testdata/prevrealm.txtar b/gno.land/cmd/gnoland/testdata/prevrealm.txtar index 4a7cece6d62..20317d87345 100644 --- a/gno.land/cmd/gnoland/testdata/prevrealm.txtar +++ b/gno.land/cmd/gnoland/testdata/prevrealm.txtar @@ -34,19 +34,19 @@ env RFOO_ADDR=g1evezrh92xaucffmtgsaa3rvmz5s8kedffsg469 # Test cases ## 1. MsgCall -> myrlm.A: user address -gnokey maketx call -pkgpath gno.land/r/myrlm -func A -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/myrlm -func A -gas-fee 100000ugnot -gas-wanted 700000 -broadcast -chainid tendermint_test test1 stdout ${USER_ADDR_test1} ## 2. MsgCall -> myrealm.B -> myrlm.A: user address -gnokey maketx call -pkgpath gno.land/r/myrlm -func B -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/myrlm -func B -gas-fee 100000ugnot -gas-wanted 800000 -broadcast -chainid tendermint_test test1 stdout ${USER_ADDR_test1} ## 3. MsgCall -> r/foo.A -> myrlm.A: r/foo -gnokey maketx call -pkgpath gno.land/r/foo -func A -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/foo -func A -gas-fee 100000ugnot -gas-wanted 800000 -broadcast -chainid tendermint_test test1 stdout ${RFOO_ADDR} ## 4. MsgCall -> r/foo.B -> myrlm.B -> r/foo.A: r/foo -gnokey maketx call -pkgpath gno.land/r/foo -func B -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/foo -func B -gas-fee 100000ugnot -gas-wanted 800000 -broadcast -chainid tendermint_test test1 stdout ${RFOO_ADDR} ## remove due to update to maketx call can only call realm (case 5, 6, 13) @@ -59,27 +59,27 @@ stdout ${RFOO_ADDR} ## stdout ${USER_ADDR_test1} ## 7. MsgRun -> myrlm.A: user address -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmA.gno +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 12000000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmA.gno stdout ${USER_ADDR_test1} ## 8. MsgRun -> myrealm.B -> myrlm.A: user address -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmB.gno +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 12000000 -broadcast -chainid tendermint_test test1 $WORK/run/myrlmB.gno stdout ${USER_ADDR_test1} ## 9. MsgRun -> r/foo.A -> myrlm.A: r/foo -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/fooA.gno +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 12000000 -broadcast -chainid tendermint_test test1 $WORK/run/fooA.gno stdout ${RFOO_ADDR} ## 10. MsgRun -> r/foo.B -> myrlm.B -> r/foo.A: r/foo -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/fooB.gno +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 12000000 -broadcast -chainid tendermint_test test1 $WORK/run/fooB.gno stdout ${RFOO_ADDR} -## 11. MsgRun -> p/demo/bar.A: user address -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/barA.gno +## 11. MsgRun -> p/demo/bar.A -> myrlm.A: user address +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 12000000 -broadcast -chainid tendermint_test test1 $WORK/run/barA.gno stdout ${USER_ADDR_test1} -## 12. MsgRun -> p/demo/bar.B: user address -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/barB.gno +## 12. MsgRun -> p/demo/bar.B -> myrlm.B -> r/foo.A: user address +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 12000000 -broadcast -chainid tendermint_test test1 $WORK/run/barB.gno stdout ${USER_ADDR_test1} ## 13. MsgCall -> std.PrevRealm(): user address @@ -87,7 +87,7 @@ stdout ${USER_ADDR_test1} ## stdout ${USER_ADDR_test1} ## 14. MsgRun -> std.PrevRealm(): user address -gnokey maketx run -gas-fee 100000ugnot -gas-wanted 4000000 -broadcast -chainid tendermint_test test1 $WORK/run/baz.gno +gnokey maketx run -gas-fee 100000ugnot -gas-wanted 12000000 -broadcast -chainid tendermint_test test1 $WORK/run/baz.gno stdout ${USER_ADDR_test1} -- r/myrlm/myrlm.gno -- diff --git a/gno.land/cmd/gnoland/testdata/realm_banker_issued_coin_denom.txtar b/gno.land/cmd/gnoland/testdata/realm_banker_issued_coin_denom.txtar index 71ef6400471..be9a686bac6 100644 --- a/gno.land/cmd/gnoland/testdata/realm_banker_issued_coin_denom.txtar +++ b/gno.land/cmd/gnoland/testdata/realm_banker_issued_coin_denom.txtar @@ -12,6 +12,9 @@ gnokey maketx addpkg -pkgdir $WORK/short -pkgpath gno.land/r/test/realm_banker - ## add realm_banker with long package_name gnokey maketx addpkg -pkgdir $WORK/long -pkgpath gno.land/r/test/package89_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_1234567890 -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 +## add invalid realm_denom +gnokey maketx addpkg -pkgdir $WORK/invalid_realm_denom -pkgpath gno.land/r/test/invalid_realm_denom -gas-fee 1000000ugnot -gas-wanted 100000000 -broadcast -chainid=tendermint_test test1 + ## test2 spend all balance gnokey maketx send -send "9999999ugnot" -to g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5 -gas-fee 1ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test2 @@ -52,6 +55,22 @@ gnokey maketx call -pkgpath gno.land/r/test/package89_123456789_123456789_123456 gnokey query bank/balances/g1cq2ecdq3eyn5qa0fzznpurg87zq3k77g63q6u7 stdout '"100/gno.land/r/test/package89_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_123456789_1234567890:ugnot"' +## mint invalid base denom +! gnokey maketx call -pkgpath gno.land/r/test/realm_banker -func Mint -args "g1cq2ecdq3eyn5qa0fzznpurg87zq3k77g63q6u7" -args "2gnot" -args "100" -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test1 +stderr 'cannot issue coins with invalid denom base name, it should start by a lowercase letter and be followed by 2-15 lowercase letters or digits' + +## burn invalid base denom +! gnokey maketx call -pkgpath gno.land/r/test/realm_banker -func Burn -args "g1cq2ecdq3eyn5qa0fzznpurg87zq3k77g63q6u7" -args "2gnot" -args "100" -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test1 +stderr 'cannot issue coins with invalid denom base name, it should start by a lowercase letter and be followed by 2-15 lowercase letters or digits' + +## mint invalid realm denom +! gnokey maketx call -pkgpath gno.land/r/test/invalid_realm_denom -func Mint -args "g1cq2ecdq3eyn5qa0fzznpurg87zq3k77g63q6u7" -args "ugnot" -args "100" -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test1 +stderr 'invalid denom, can only issue/remove coins with the realm.s prefix' + +## burn invalid realm denom +! gnokey maketx call -pkgpath gno.land/r/test/invalid_realm_denom -func Burn -args "g1cq2ecdq3eyn5qa0fzznpurg87zq3k77g63q6u7" -args "ugnot" -args "100" -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test1 +stderr 'invalid denom, can only issue/remove coins with the realm.s prefix' + -- short/realm_banker.gno -- package realm_banker @@ -61,12 +80,12 @@ import ( func Mint(addr std.Address, denom string, amount int64) { banker := std.GetBanker(std.BankerTypeRealmIssue) - banker.IssueCoin(addr, denom, amount) + banker.IssueCoin(addr, std.CurrentRealm().CoinDenom(denom), amount) } func Burn(addr std.Address, denom string, amount int64) { banker := std.GetBanker(std.BankerTypeRealmIssue) - banker.RemoveCoin(addr, denom, amount) + banker.RemoveCoin(addr, std.CurrentRealm().CoinDenom(denom), amount) } -- long/realm_banker.gno -- @@ -77,6 +96,23 @@ import ( "std" ) +func Mint(addr std.Address, denom string, amount int64) { + banker := std.GetBanker(std.BankerTypeRealmIssue) + banker.IssueCoin(addr, std.CurrentRealm().CoinDenom(denom), amount) +} + +func Burn(addr std.Address, denom string, amount int64) { + banker := std.GetBanker(std.BankerTypeRealmIssue) + banker.RemoveCoin(addr, std.CurrentRealm().CoinDenom(denom), amount) +} + +-- invalid_realm_denom/realm_banker.gno -- +package invalid_realm_denom + +import ( + "std" +) + func Mint(addr std.Address, denom string, amount int64) { banker := std.GetBanker(std.BankerTypeRealmIssue) banker.IssueCoin(addr, denom, amount) diff --git a/gno.land/cmd/gnoland/testdata/restart_missing_type.txtar b/gno.land/cmd/gnoland/testdata/restart_missing_type.txtar index c492f1c6646..b02acc16d96 100644 --- a/gno.land/cmd/gnoland/testdata/restart_missing_type.txtar +++ b/gno.land/cmd/gnoland/testdata/restart_missing_type.txtar @@ -87,7 +87,7 @@ gnoland restart } ], "fee": { - "gas_wanted": "1000000", + "gas_wanted": "30000000", "gas_fee": "1000000ugnot" }, "signatures": [], @@ -162,7 +162,7 @@ gnoland restart } ], "fee": { - "gas_wanted": "20000000", + "gas_wanted": "35000000", "gas_fee": "1000000ugnot" }, "signatures": [], @@ -193,7 +193,7 @@ gnoland restart } ], "fee": { - "gas_wanted": "16000000", + "gas_wanted": "30000000", "gas_fee": "1000000ugnot" }, "signatures": [], diff --git a/gno.land/cmd/gnoland/testdata/simulate_gas.txtar b/gno.land/cmd/gnoland/testdata/simulate_gas.txtar index 9d3c8abe69f..8550419f205 100644 --- a/gno.land/cmd/gnoland/testdata/simulate_gas.txtar +++ b/gno.land/cmd/gnoland/testdata/simulate_gas.txtar @@ -6,11 +6,11 @@ gnoland start # simulate only gnokey maketx call -pkgpath gno.land/r/simulate -func Hello -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test -simulate only test1 -stdout 'GAS USED: 51299' +stdout 'GAS USED: 96411' # simulate skip gnokey maketx call -pkgpath gno.land/r/simulate -func Hello -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test -simulate skip test1 -stdout 'GAS USED: 51299' # same as simulate only +stdout 'GAS USED: 96411' # same as simulate only -- package/package.gno -- diff --git a/gno.land/cmd/gnoland/testdata/time_simple.txtar b/gno.land/cmd/gnoland/testdata/time_simple.txtar index 932a5721695..ace34fa00a5 100644 --- a/gno.land/cmd/gnoland/testdata/time_simple.txtar +++ b/gno.land/cmd/gnoland/testdata/time_simple.txtar @@ -3,7 +3,7 @@ gnoland start -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/time_simple -gas-fee 1ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/time_simple -gas-fee 1ugnot -gas-wanted 15000000 -broadcast -chainid=tendermint_test test1 stdout OK! -- time_simple.gno -- diff --git a/gno.land/cmd/gnoland/testdata/wugnot.txtar b/gno.land/cmd/gnoland/testdata/wugnot.txtar index 1640909fdb9..5fa7dab2945 100644 --- a/gno.land/cmd/gnoland/testdata/wugnot.txtar +++ b/gno.land/cmd/gnoland/testdata/wugnot.txtar @@ -2,44 +2,44 @@ loadpkg gno.land/r/demo/wugnot gnoland start -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 2000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 5000000 -args '' -broadcast -chainid=tendermint_test test1 stdout '# wrapped GNOT \(\$wugnot\)' stdout 'Decimals..: 0' stdout 'Total supply..: 0' stdout 'Known accounts..: 0' stdout 'OK!' -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Deposit -send 12345678ugnot -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Deposit -send 12345678ugnot -gas-fee 1000000ugnot -gas-wanted 5000000 -broadcast -chainid=tendermint_test test1 stdout 'OK!' -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 2000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 5000000 -args '' -broadcast -chainid=tendermint_test test1 stdout 'Total supply..: 12345678' stdout 'Known accounts..: 1' stdout 'OK!' # XXX: use test2 instead (depends on https://github.com/gnolang/gno/issues/1269#issuecomment-1806386069) -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Deposit -send 12345678ugnot -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Deposit -send 12345678ugnot -gas-fee 1000000ugnot -gas-wanted 5000000 -broadcast -chainid=tendermint_test test1 stdout 'OK!' -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 2000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 5000000 -args '' -broadcast -chainid=tendermint_test test1 stdout 'Total supply..: 24691356' stdout 'Known accounts..: 1' # should be 2 once we can use test2 stdout 'OK!' # XXX: replace hardcoded address with test3 -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Transfer -gas-fee 1000000ugnot -gas-wanted 2000000 -args 'g1u7y667z64x2h7vc6fmpcprgey4ck233jaww9zq' -args '10000000' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Transfer -gas-fee 1000000ugnot -gas-wanted 5000000 -args 'g1u7y667z64x2h7vc6fmpcprgey4ck233jaww9zq' -args '10000000' -broadcast -chainid=tendermint_test test1 stdout 'OK!' -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 2000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 5000000 -args '' -broadcast -chainid=tendermint_test test1 stdout 'Total supply..: 24691356' stdout 'Known accounts..: 2' # should be 3 once we can use test2 stdout 'OK!' # XXX: use test3 instead (depends on https://github.com/gnolang/gno/issues/1269#issuecomment-1806386069) -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Withdraw -args 10000000 -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Withdraw -args 10000000 -gas-fee 1000000ugnot -gas-wanted 5000000 -broadcast -chainid=tendermint_test test1 stdout 'OK!' -gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 2000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/wugnot -func Render -gas-fee 1000000ugnot -gas-wanted 5000000 -args '' -broadcast -chainid=tendermint_test test1 stdout 'Total supply..: 14691356' stdout 'Known accounts..: 2' # should be 3 once we can use test2 stdout 'OK!' diff --git a/gno.land/cmd/gnoweb/CONTRIBUTING.md b/gno.land/cmd/gnoweb/CONTRIBUTING.md deleted file mode 100644 index 7d7663e8bf7..00000000000 --- a/gno.land/cmd/gnoweb/CONTRIBUTING.md +++ /dev/null @@ -1,20 +0,0 @@ -# gno.land Website - -The gno.land website has 3 main dependencies: - -1. [UmbrellaJs](https://umbrellajs.com/) for DOM operations -2. [MarkedJs](https://marked.js.org/) for Markdown to html compilation -3. [HighlightJs](https://highlightjs.org/) for golang syntax highlighting -4. [DOMPurify](https://github.com/cure53/DOMPurify) to sanitize html (and avoid xss) - -Some security considerations: -| | Umbrella Js | Marked Js | HighlightJs | DOMPurify | -|---|---|---|---|---| -| dependencies | 0 | 0 | 0 | 0 | -| sanitize content | | [no](https://marked.js.org/#usage) | [throws an error](https://github.com/highlightjs/highlight.js/blob/7addd66c19036eccd7c602af61f1ed84d215c77d/src/highlight.js#L741) | [yes](https://github.com/cure53/DOMPurify#readme) | - -Best Practices: - -- **When using MarkedJs**: Always run the output of the marked compiler inside `DOMPurify.sanitize` before inserting it in the dom with `.innerHtml = `. -- **When using DOMPurify**: Preferably use `{ USE_PROFILES: { html: true } }` option to allow html only. Content passed in the sanitizer must not be modified afterwards, and must directly be inserted in the DOM with innerHtml. Do not call `DOMPurify.sanitize` with the output of a previous `DOMPurify.sanitize` to avoid any mutation XSS risks. -- **When using HighlightJs**: always configure it before with `hljs.configure({throwUnescapedHTML: true})` to throw before inserting html in the page if any unexpected html children are detected. The check is done [here](https://github.com/highlightjs/highlight.js/blob/7addd66c19036eccd7c602af61f1ed84d215c77d/src/highlight.js#L741). diff --git a/gno.land/cmd/gnoweb/README.md b/gno.land/cmd/gnoweb/README.md index 6379d3f6c43..ccd538c8f70 100644 --- a/gno.land/cmd/gnoweb/README.md +++ b/gno.land/cmd/gnoweb/README.md @@ -2,12 +2,4 @@ The gno.land web interface. -Live demo: https://gno.land/ - -## Install `gnoweb` - -Install and run a local [`gnoland`](../gnoland) instance first. - - $> git clone git@github.com:gnolang/gno.git - $> cd ./gno/gno.land - $> make install.gnoweb +Live demo: [https://gno.land/](https://gno.land/) or using `gnodev` from the directory [gnodev](../../../contribs/gnodev). diff --git a/gno.land/cmd/gnoweb/main.go b/gno.land/cmd/gnoweb/main.go index 5cec7257ebe..6500e44fcc4 100644 --- a/gno.land/cmd/gnoweb/main.go +++ b/gno.land/cmd/gnoweb/main.go @@ -1,61 +1,196 @@ package main import ( + "context" "flag" "fmt" + "net" "net/http" "os" "time" - // for static files "github.com/gnolang/gno/gno.land/pkg/gnoweb" "github.com/gnolang/gno/gno.land/pkg/log" + "github.com/gnolang/gno/tm2/pkg/commands" + "go.uber.org/zap" "go.uber.org/zap/zapcore" - // for error types - // "github.com/gnolang/gno/tm2/pkg/sdk" // for baseapp (info, status) ) +type webCfg struct { + chainid string + remote string + remoteHelp string + bind string + faucetURL string + assetsDir string + analytics bool + json bool + html bool + verbose bool +} + +var defaultWebOptions = webCfg{ + chainid: "dev", + remote: "127.0.0.1:26657", + bind: ":8888", +} + func main() { - err := runMain(os.Args[1:]) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "%+v\n", err) - os.Exit(1) - } + var cfg webCfg + + stdio := commands.NewDefaultIO() + cmd := commands.NewCommand( + commands.Metadata{ + Name: "gnoweb", + ShortUsage: "gnoweb [flags] [path ...]", + ShortHelp: "runs gno.land web interface", + LongHelp: `gnoweb web interface`, + }, + &cfg, + func(ctx context.Context, args []string) error { + run, err := setupWeb(&cfg, args, stdio) + if err != nil { + return err + } + + return run() + }) + + cmd.Execute(context.Background(), os.Args[1:]) +} + +func (c *webCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &c.remote, + "remote", + defaultWebOptions.remote, + "remote gno.land node address", + ) + + fs.StringVar( + &c.remoteHelp, + "help-remote", + defaultWebOptions.remoteHelp, + "help page's remote address", + ) + + fs.StringVar( + &c.assetsDir, + "assets-dir", + defaultWebOptions.assetsDir, + "if not empty, will be use as assets directory", + ) + + fs.StringVar( + &c.chainid, + "help-chainid", + defaultWebOptions.chainid, + "Deprecated: use `chainid` instead", + ) + + fs.StringVar( + &c.chainid, + "chainid", + defaultWebOptions.chainid, + "target chain id", + ) + + fs.StringVar( + &c.bind, + "bind", + defaultWebOptions.bind, + "gnoweb listener", + ) + + fs.StringVar( + &c.faucetURL, + "faucet-url", + defaultWebOptions.faucetURL, + "The faucet URL will redirect the user when they access `/faucet`.", + ) + + fs.BoolVar( + &c.json, + "json", + defaultWebOptions.json, + "display log in json format", + ) + + fs.BoolVar( + &c.html, + "html", + defaultWebOptions.html, + "enable unsafe html", + ) + + fs.BoolVar( + &c.analytics, + "with-analytics", + defaultWebOptions.analytics, + "nable privacy-first analytics", + ) + + fs.BoolVar( + &c.verbose, + "v", + defaultWebOptions.verbose, + "verbose logging mode", + ) } -func runMain(args []string) error { - var ( - fs = flag.NewFlagSet("gnoweb", flag.ContinueOnError) - cfg = gnoweb.NewDefaultConfig() - bindAddress string - ) - fs.StringVar(&cfg.RemoteAddr, "remote", cfg.RemoteAddr, "remote gnoland node address") - fs.StringVar(&cfg.CaptchaSite, "captcha-site", cfg.CaptchaSite, "recaptcha site key (if empty, captcha are disabled)") - fs.StringVar(&cfg.FaucetURL, "faucet-url", cfg.FaucetURL, "faucet server URL") - fs.StringVar(&cfg.ViewsDir, "views-dir", cfg.ViewsDir, "views directory location") // XXX: replace with goembed - fs.StringVar(&cfg.HelpChainID, "help-chainid", cfg.HelpChainID, "help page's chainid") - fs.StringVar(&cfg.HelpRemote, "help-remote", cfg.HelpRemote, "help page's remote addr") - fs.BoolVar(&cfg.WithAnalytics, "with-analytics", cfg.WithAnalytics, "enable privacy-first analytics") - fs.StringVar(&bindAddress, "bind", "127.0.0.1:8888", "server listening address") - fs.BoolVar(&cfg.WithHTML, "with-html", cfg.WithHTML, "Enable HTML parsing in markdown rendering") - - if err := fs.Parse(args); err != nil { - return err +func setupWeb(cfg *webCfg, _ []string, io commands.IO) (func() error, error) { + // Setup logger + level := zapcore.InfoLevel + if cfg.verbose { + level = zapcore.DebugLevel + } + + var zapLogger *zap.Logger + if cfg.json { + zapLogger = log.NewZapJSONLogger(io.Out(), level) + } else { + zapLogger = log.NewZapConsoleLogger(io.Out(), level) } + defer zapLogger.Sync() - zapLogger := log.NewZapConsoleLogger(os.Stdout, zapcore.DebugLevel) logger := log.ZapLoggerToSlog(zapLogger) - logger.Info("Running", "listener", "http://"+bindAddress) + appcfg := gnoweb.NewDefaultAppConfig() + appcfg.ChainID = cfg.chainid + appcfg.NodeRemote = cfg.remote + appcfg.RemoteHelp = cfg.remoteHelp + 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) + } + + 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()) + server := &http.Server{ - Addr: bindAddress, + Handler: app, + Addr: bindaddr.String(), ReadHeaderTimeout: 60 * time.Second, - Handler: gnoweb.MakeApp(logger, cfg).Router, } - if err := server.ListenAndServe(); err != nil { - logger.Error("HTTP server stopped", " error:", err) - } + return func() error { + if err := server.ListenAndServe(); err != nil { + logger.Error("HTTP server stopped", " error:", err) + return commands.ExitCodeError(1) + } - return zapLogger.Sync() + return nil + }, nil } diff --git a/gno.land/cmd/gnoweb/main_test.go b/gno.land/cmd/gnoweb/main_test.go index 640c4763140..37006c18c93 100644 --- a/gno.land/cmd/gnoweb/main_test.go +++ b/gno.land/cmd/gnoweb/main_test.go @@ -1,14 +1,25 @@ package main import ( - "errors" - "flag" + "os" "testing" + + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/stretchr/testify/require" ) -func TestFlagHelp(t *testing.T) { - err := runMain([]string{"-h"}) - if !errors.Is(err, flag.ErrHelp) { - t.Errorf("should display usage") - } +func TestSetupWeb(t *testing.T) { + opts := defaultWebOptions + opts.bind = "127.0.0.1:0" // random port + stdio := commands.NewDefaultIO() + + // Open /dev/null as a write-only file + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0o644) + require.NoError(t, err) + defer devNull.Close() + + stdio.SetOut(devNull) + + _, err = setupWeb(&opts, []string{}, stdio) + require.NoError(t, err) } diff --git a/gno.land/pkg/gnoclient/integration_test.go b/gno.land/pkg/gnoclient/integration_test.go index 0a06eb4756a..4b70fb60c49 100644 --- a/gno.land/pkg/gnoclient/integration_test.go +++ b/gno.land/pkg/gnoclient/integration_test.go @@ -39,8 +39,8 @@ func TestCallSingle_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -92,8 +92,8 @@ func TestCallMultiple_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -154,8 +154,8 @@ func TestSendSingle_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -218,8 +218,8 @@ func TestSendMultiple_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -290,8 +290,8 @@ func TestRunSingle_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -358,8 +358,8 @@ func TestRunMultiple_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 13000000, + GasFee: ugnot.ValueString(2300000), + GasWanted: 23000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -451,8 +451,8 @@ func TestAddPackageSingle_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", @@ -536,8 +536,8 @@ func TestAddPackageMultiple_Integration(t *testing.T) { // Make Tx config baseCfg := BaseTxCfg{ - GasFee: ugnot.ValueString(10000), - GasWanted: 8000000, + GasFee: ugnot.ValueString(2100000), + GasWanted: 21000000, AccountNumber: 0, SequenceNumber: 0, Memo: "", diff --git a/gno.land/pkg/gnoland/app.go b/gno.land/pkg/gnoland/app.go index e0c93f6194f..9e8f2163441 100644 --- a/gno.land/pkg/gnoland/app.go +++ b/gno.land/pkg/gnoland/app.go @@ -89,16 +89,18 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { baseApp.MountStoreWithDB(baseKey, dbadapter.StoreConstructor, cfg.DB) // Construct keepers. - acctKpr := auth.NewAccountKeeper(mainKey, ProtoGnoAccount) - bankKpr := bank.NewBankKeeper(acctKpr) paramsKpr := params.NewParamsKeeper(mainKey, "vm") + acctKpr := auth.NewAccountKeeper(mainKey, paramsKpr, ProtoGnoAccount) + gpKpr := auth.NewGasPriceKeeper(mainKey) + bankKpr := bank.NewBankKeeper(acctKpr) + vmk := vm.NewVMKeeper(baseKey, mainKey, acctKpr, bankKpr, paramsKpr) vmk.Output = cfg.VMOutput // Set InitChainer icc := cfg.InitChainerConfig icc.baseApp = baseApp - icc.acctKpr, icc.bankKpr, icc.vmKpr, icc.paramsKpr = acctKpr, bankKpr, vmk, paramsKpr + icc.acctKpr, icc.bankKpr, icc.vmKpr, icc.paramsKpr, icc.gpKpr = acctKpr, bankKpr, vmk, paramsKpr, gpKpr baseApp.SetInitChainer(icc.InitChainer) // Set AnteHandler @@ -112,9 +114,11 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { func(ctx sdk.Context, tx std.Tx, simulate bool) ( newCtx sdk.Context, res sdk.Result, abort bool, ) { + // Add last gas price in the context + ctx = ctx.WithValue(auth.GasPriceContextKey{}, gpKpr.LastGasPrice(ctx)) + // Override auth params. - ctx = ctx. - WithValue(auth.AuthParamsContextKey{}, auth.DefaultParams()) + ctx = ctx.WithValue(auth.AuthParamsContextKey{}, acctKpr.GetParams(ctx)) // Continue on with default auth ante handler. newCtx, res, abort = authAnteHandler(ctx, tx, simulate) return @@ -145,6 +149,8 @@ func NewAppWithOptions(cfg *AppOptions) (abci.Application, error) { baseApp.SetEndBlocker( EndBlocker( c, + acctKpr, + gpKpr, vmk, baseApp, ), @@ -236,6 +242,7 @@ type InitChainerConfig struct { acctKpr auth.AccountKeeperI bankKpr bank.BankKeeperI paramsKpr params.ParamsKeeperI + gpKpr auth.GasPriceKeeperI } // InitChainer is the function that can be used as a [sdk.InitChainer]. @@ -293,6 +300,10 @@ func (cfg InitChainerConfig) loadAppState(ctx sdk.Context, appState any) ([]abci if !ok { return nil, fmt.Errorf("invalid AppState of type %T", appState) } + cfg.acctKpr.InitGenesis(ctx, state.Auth) + params := cfg.acctKpr.GetParams(ctx) + ctx = ctx.WithValue(auth.AuthParamsContextKey{}, params) + auth.InitChainer(ctx, cfg.gpKpr.(auth.GasPriceKeeper), params.InitialGasPrice) // Apply genesis balances. for _, bal := range state.Balances { @@ -370,6 +381,8 @@ type endBlockerApp interface { // validator set changes func EndBlocker( collector *collector[validatorUpdate], + acctKpr auth.AccountKeeperI, + gpKpr auth.GasPriceKeeperI, vmk vm.VMKeeperI, app endBlockerApp, ) func( @@ -377,6 +390,14 @@ func EndBlocker( req abci.RequestEndBlock, ) abci.ResponseEndBlock { return func(ctx sdk.Context, _ abci.RequestEndBlock) abci.ResponseEndBlock { + // set the auth params value in the ctx. The EndBlocker will use InitialGasPrice in + // the params to calculate the updated gas price. + if acctKpr != nil { + ctx = ctx.WithValue(auth.AuthParamsContextKey{}, acctKpr.GetParams(ctx)) + } + if acctKpr != nil && gpKpr != nil { + auth.EndBlocker(ctx, gpKpr) + } // Check if there was a valset change if len(collector.getEvents()) == 0 { // No valset updates diff --git a/gno.land/pkg/gnoland/app_test.go b/gno.land/pkg/gnoland/app_test.go index 999e04b2c4b..375602cfa4a 100644 --- a/gno.land/pkg/gnoland/app_test.go +++ b/gno.land/pkg/gnoland/app_test.go @@ -19,6 +19,10 @@ import ( "github.com/gnolang/gno/tm2/pkg/events" "github.com/gnolang/gno/tm2/pkg/log" "github.com/gnolang/gno/tm2/pkg/sdk" + "github.com/gnolang/gno/tm2/pkg/sdk/auth" + "github.com/gnolang/gno/tm2/pkg/sdk/bank" + "github.com/gnolang/gno/tm2/pkg/sdk/params" + "github.com/gnolang/gno/tm2/pkg/sdk/testutils" "github.com/gnolang/gno/tm2/pkg/std" "github.com/gnolang/gno/tm2/pkg/store" "github.com/gnolang/gno/tm2/pkg/store/dbadapter" @@ -38,6 +42,36 @@ func TestNewAppWithOptions(t *testing.T) { assert.Equal(t, "gnoland", bapp.Name()) addr := crypto.AddressFromPreimage([]byte("test1")) + + appState := DefaultGenState() + appState.Balances = []Balance{ + { + Address: addr, + Amount: []std.Coin{{Amount: 1e15, Denom: "ugnot"}}, + }, + } + appState.Txs = []TxWithMetadata{ + { + Tx: std.Tx{ + Msgs: []std.Msg{vm.NewMsgAddPackage(addr, "gno.land/r/demo", []*gnovm.MemFile{ + { + Name: "demo.gno", + Body: "package demo; func Hello() string { return `hello`; }", + }, + })}, + Fee: std.Fee{GasWanted: 1e6, GasFee: std.Coin{Amount: 1e6, Denom: "ugnot"}}, + Signatures: []std.Signature{{}}, // one empty signature + }, + }, + } + appState.Params = []Param{ + {key: "foo", kind: "string", value: "hello"}, + {key: "foo", kind: "int64", value: int64(-42)}, + {key: "foo", kind: "uint64", value: uint64(1337)}, + {key: "foo", kind: "bool", value: true}, + {key: "foo", kind: "bytes", value: []byte{0x48, 0x69, 0x21}}, + } + resp := bapp.InitChain(abci.RequestInitChain{ Time: time.Now(), ChainID: "dev", @@ -45,35 +79,7 @@ func TestNewAppWithOptions(t *testing.T) { Block: defaultBlockParams(), }, Validators: []abci.ValidatorUpdate{}, - AppState: GnoGenesisState{ - Balances: []Balance{ - { - Address: addr, - Amount: []std.Coin{{Amount: 1e15, Denom: "ugnot"}}, - }, - }, - Txs: []TxWithMetadata{ - { - Tx: std.Tx{ - Msgs: []std.Msg{vm.NewMsgAddPackage(addr, "gno.land/r/demo", []*gnovm.MemFile{ - { - Name: "demo.gno", - Body: "package demo; func Hello() string { return `hello`; }", - }, - })}, - Fee: std.Fee{GasWanted: 1e6, GasFee: std.Coin{Amount: 1e6, Denom: "ugnot"}}, - Signatures: []std.Signature{{}}, // one empty signature - }, - }, - }, - Params: []Param{ - {key: "foo", kind: "string", value: "hello"}, - {key: "foo", kind: "int64", value: int64(-42)}, - {key: "foo", kind: "uint64", value: uint64(1337)}, - {key: "foo", kind: "bool", value: true}, - {key: "foo", kind: "bytes", value: []byte{0x48, 0x69, 0x21}}, - }, - }, + AppState: appState, }) require.True(t, resp.IsOK(), "InitChain response: %v", resp) @@ -142,7 +148,7 @@ func TestNewApp(t *testing.T) { }, }, Validators: []abci.ValidatorUpdate{}, - AppState: GnoGenesisState{}, + AppState: DefaultGenState(), }) assert.True(t, resp.IsOK(), "resp is not OK: %v", resp) } @@ -212,8 +218,12 @@ func testInitChainerLoadStdlib(t *testing.T, cached bool) { //nolint:thelper vmKpr: mock, CacheStdlibLoad: cached, } + // Construct keepers. + paramsKpr := params.NewParamsKeeper(iavlCapKey, "") + cfg.acctKpr = auth.NewAccountKeeper(iavlCapKey, paramsKpr, ProtoGnoAccount) + cfg.gpKpr = auth.NewGasPriceKeeper(iavlCapKey) cfg.InitChainer(testCtx, abci.RequestInitChain{ - AppState: GnoGenesisState{}, + AppState: DefaultGenState(), }) // assert number of calls @@ -485,7 +495,7 @@ func TestEndBlocker(t *testing.T) { c := newCollector[validatorUpdate](&mockEventSwitch{}, noFilter) // Create the EndBlocker - eb := EndBlocker(c, nil, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, nil, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}, abci.RequestEndBlock{}) @@ -525,7 +535,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(gnostdlibs.GnoEvent{}) // Create the EndBlocker - eb := EndBlocker(c, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}, abci.RequestEndBlock{}) @@ -568,7 +578,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(gnostdlibs.GnoEvent{}) // Create the EndBlocker - eb := EndBlocker(c, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}, abci.RequestEndBlock{}) @@ -636,7 +646,7 @@ func TestEndBlocker(t *testing.T) { mockEventSwitch.FireEvent(txEvent) // Create the EndBlocker - eb := EndBlocker(c, mockVMKeeper, &mockEndBlockerApp{}) + eb := EndBlocker(c, nil, nil, mockVMKeeper, &mockEndBlockerApp{}) // Run the EndBlocker res := eb(sdk.Context{}, abci.RequestEndBlock{}) @@ -651,3 +661,338 @@ func TestEndBlocker(t *testing.T) { } }) } + +func TestGasPriceUpdate(t *testing.T) { + app := newGasPriceTestApp(t) + + // with default initial gas price 0.1 ugnot per gas + gnoGen := gnoGenesisState(t) + + // abci inintChain + app.InitChain(abci.RequestInitChain{ + AppState: gnoGen, + ChainID: "test-chain", + ConsensusParams: &abci.ConsensusParams{ + Block: &abci.BlockParams{ + MaxGas: 10000, + }, + }, + }) + baseApp := app.(*sdk.BaseApp) + require.Equal(t, int64(0), baseApp.LastBlockHeight()) + // Case 1 + // CheckTx failed because the GasFee is less than the initial gas price. + + tx := newCounterTx(100) + tx.Fee = std.Fee{ + GasWanted: 100, + GasFee: sdk.Coin{ + Amount: 9, + Denom: "ugnot", + }, + } + txBytes, err := amino.Marshal(tx) + require.NoError(t, err) + r := app.CheckTx(abci.RequestCheckTx{Tx: txBytes}) + assert.False(t, r.IsOK(), fmt.Sprintf("%v", r)) + + // Case 2: + // A previously successful CheckTx failed after the block gas price increased. + // Check Tx Ok + tx2 := newCounterTx(100) + tx2.Fee = std.Fee{ + GasWanted: 1000, + GasFee: sdk.Coin{ + Amount: 100, + Denom: "ugnot", + }, + } + txBytes2, err := amino.Marshal(tx2) + require.NoError(t, err) + r = app.CheckTx(abci.RequestCheckTx{Tx: txBytes2}) + assert.True(t, r.IsOK(), fmt.Sprintf("%v", r)) + + // After replaying a block, the gas price increased. + header := &bft.Header{ChainID: "test-chain", Height: 1} + app.BeginBlock(abci.RequestBeginBlock{Header: header}) + // Delvier Tx consumes more than that target block gas 6000. + + tx6001 := newCounterTx(6001) + tx6001.Fee = std.Fee{ + GasWanted: 20000, + GasFee: sdk.Coin{ + Amount: 200, + Denom: "ugnot", + }, + } + txBytes6001, err := amino.Marshal(tx6001) + require.NoError(t, err) + res := app.DeliverTx(abci.RequestDeliverTx{Tx: txBytes6001}) + require.True(t, res.IsOK(), fmt.Sprintf("%v", res)) + app.EndBlock(abci.RequestEndBlock{}) + app.Commit() + + // CheckTx failed because gas price increased + r = app.CheckTx(abci.RequestCheckTx{Tx: txBytes2}) + assert.False(t, r.IsOK(), fmt.Sprintf("%v", r)) + + // Case 3: + // A previously failed CheckTx successed after block gas price reduced. + + // CheckTx Failed + r = app.CheckTx(abci.RequestCheckTx{Tx: txBytes2}) + assert.False(t, r.IsOK(), fmt.Sprintf("%v", r)) + // Replayed a Block, the gas price decrease + header = &bft.Header{ChainID: "test-chain", Height: 2} + app.BeginBlock(abci.RequestBeginBlock{Header: header}) + // Delvier Tx consumes less than that target block gas 6000. + + tx200 := newCounterTx(200) + tx200.Fee = std.Fee{ + GasWanted: 20000, + GasFee: sdk.Coin{ + Amount: 200, + Denom: "ugnot", + }, + } + txBytes200, err := amino.Marshal(tx200) + require.NoError(t, err) + + res = app.DeliverTx(abci.RequestDeliverTx{Tx: txBytes200}) + require.True(t, res.IsOK(), fmt.Sprintf("%v", res)) + + app.EndBlock(abci.RequestEndBlock{}) + app.Commit() + + // CheckTx earlier failed tx, now is OK + r = app.CheckTx(abci.RequestCheckTx{Tx: txBytes2}) + assert.True(t, r.IsOK(), fmt.Sprintf("%v", r)) + + // Case 4 + // require matching expected GasPrice after three blocks ( increase case) + replayBlock(t, baseApp, 8000, 3) + replayBlock(t, baseApp, 8000, 4) + replayBlock(t, baseApp, 6000, 5) + + key := []byte("gasPrice") + query := abci.RequestQuery{ + Path: ".store/main/key", + Data: key, + } + qr := app.Query(query) + var gp std.GasPrice + err = amino.Unmarshal(qr.Value, &gp) + require.NoError(t, err) + require.Equal(t, "108ugnot", gp.Price.String()) + + // Case 5, + // require matching expected GasPrice after low gas blocks ( decrease below initial gas price case) + + replayBlock(t, baseApp, 5000, 6) + replayBlock(t, baseApp, 5000, 7) + replayBlock(t, baseApp, 5000, 8) + + qr = app.Query(query) + err = amino.Unmarshal(qr.Value, &gp) + require.NoError(t, err) + require.Equal(t, "102ugnot", gp.Price.String()) + + replayBlock(t, baseApp, 5000, 9) + + qr = app.Query(query) + err = amino.Unmarshal(qr.Value, &gp) + require.NoError(t, err) + require.Equal(t, "100ugnot", gp.Price.String()) +} + +func newGasPriceTestApp(t *testing.T) abci.Application { + t.Helper() + cfg := TestAppOptions(memdb.NewMemDB()) + cfg.EventSwitch = events.NewEventSwitch() + + // Capabilities keys. + mainKey := store.NewStoreKey("main") + baseKey := store.NewStoreKey("base") + + baseApp := sdk.NewBaseApp("gnoland", cfg.Logger, cfg.DB, baseKey, mainKey) + baseApp.SetAppVersion("test") + + // Set mounts for BaseApp's MultiStore. + baseApp.MountStoreWithDB(mainKey, iavl.StoreConstructor, cfg.DB) + baseApp.MountStoreWithDB(baseKey, dbadapter.StoreConstructor, cfg.DB) + + // Construct keepers. + paramsKpr := params.NewParamsKeeper(mainKey, "") + acctKpr := auth.NewAccountKeeper(mainKey, paramsKpr, ProtoGnoAccount) + gpKpr := auth.NewGasPriceKeeper(mainKey) + bankKpr := bank.NewBankKeeper(acctKpr) + vmk := vm.NewVMKeeper(baseKey, mainKey, acctKpr, bankKpr, paramsKpr) + + // Set InitChainer + icc := cfg.InitChainerConfig + icc.baseApp = baseApp + icc.acctKpr, icc.bankKpr, icc.vmKpr, icc.gpKpr = acctKpr, bankKpr, vmk, gpKpr + baseApp.SetInitChainer(icc.InitChainer) + + // Set AnteHandler + baseApp.SetAnteHandler( + // Override default AnteHandler with custom logic. + func(ctx sdk.Context, tx std.Tx, simulate bool) ( + newCtx sdk.Context, res sdk.Result, abort bool, + ) { + // Add last gas price in the context + ctx = ctx.WithValue(auth.GasPriceContextKey{}, gpKpr.LastGasPrice(ctx)) + + // Override auth params. + ctx = ctx.WithValue(auth.AuthParamsContextKey{}, acctKpr.GetParams(ctx)) + // Continue on with default auth ante handler. + if ctx.IsCheckTx() { + res := auth.EnsureSufficientMempoolFees(ctx, tx.Fee) + if !res.IsOK() { + return ctx, res, true + } + } + + newCtx = auth.SetGasMeter(false, ctx, tx.Fee.GasWanted) + + count := getTotalCount(tx) + + newCtx.GasMeter().ConsumeGas(count, "counter-ante") + res = sdk.Result{ + GasWanted: getTotalCount(tx), + } + return + }, + ) + + // Set up the event collector + c := newCollector[validatorUpdate]( + cfg.EventSwitch, // global event switch filled by the node + validatorEventFilter, // filter fn that keeps the collector valid + ) + + // Set EndBlocker + baseApp.SetEndBlocker( + EndBlocker( + c, + acctKpr, + gpKpr, + nil, + baseApp, + ), + ) + + // Set a handler Route. + baseApp.Router().AddRoute("auth", auth.NewHandler(acctKpr)) + baseApp.Router().AddRoute("bank", bank.NewHandler(bankKpr)) + baseApp.Router().AddRoute( + testutils.RouteMsgCounter, + newTestHandler( + func(ctx sdk.Context, msg sdk.Msg) sdk.Result { return sdk.Result{} }, + ), + ) + + baseApp.Router().AddRoute("vm", vm.NewHandler(vmk)) + + // Load latest version. + if err := baseApp.LoadLatestVersion(); err != nil { + t.Fatalf("failed to load the lastest state: %v", err) + } + + // Initialize the VMKeeper. + ms := baseApp.GetCacheMultiStore() + vmk.Initialize(cfg.Logger, ms) + ms.MultiWrite() // XXX why was't this needed? + + return baseApp +} + +// newTx constructs a tx with multiple counter messages. +// we can use the counter as the gas used for the message. + +func newCounterTx(counters ...int64) sdk.Tx { + msgs := make([]sdk.Msg, len(counters)) + + for i, c := range counters { + msgs[i] = testutils.MsgCounter{Counter: c} + } + tx := sdk.Tx{Msgs: msgs} + return tx +} + +func getTotalCount(tx sdk.Tx) int64 { + var c int64 + for _, m := range tx.Msgs { + c = +m.(testutils.MsgCounter).Counter + } + return c +} + +func gnoGenesisState(t *testing.T) GnoGenesisState { + t.Helper() + gen := GnoGenesisState{} + genBytes := []byte(`{ + "@type": "/gno.GenesisState", + "auth": { + "params": { + "gas_price_change_compressor": "8", + "initial_gasprice": { + "gas": "1000", + "price": "100ugnot" + }, + "max_memo_bytes": "65536", + "sig_verify_cost_ed25519": "590", + "sig_verify_cost_secp256k1": "1000", + "target_gas_ratio": "60", + "tx_sig_limit": "7", + "tx_size_cost_per_byte": "10" + } + } + }`) + err := amino.UnmarshalJSON(genBytes, &gen) + if err != nil { + t.Fatalf("failed to create genesis state: %v", err) + } + return gen +} + +func replayBlock(t *testing.T, app *sdk.BaseApp, gas int64, hight int64) { + t.Helper() + tx := newCounterTx(gas) + tx.Fee = std.Fee{ + GasWanted: 20000, + GasFee: sdk.Coin{ + Amount: 1000, + Denom: "ugnot", + }, + } + txBytes, err := amino.Marshal(tx) + require.NoError(t, err) + + header := &bft.Header{ChainID: "test-chain", Height: hight} + app.BeginBlock(abci.RequestBeginBlock{Header: header}) + // consume gas in the block + res := app.DeliverTx(abci.RequestDeliverTx{Tx: txBytes}) + require.True(t, res.IsOK(), fmt.Sprintf("%v", res)) + app.EndBlock(abci.RequestEndBlock{}) + app.Commit() +} + +type testHandler struct { + process func(sdk.Context, sdk.Msg) sdk.Result + query func(sdk.Context, abci.RequestQuery) abci.ResponseQuery +} + +func (th testHandler) Process(ctx sdk.Context, msg sdk.Msg) sdk.Result { + return th.process(ctx, msg) +} + +func (th testHandler) Query(ctx sdk.Context, req abci.RequestQuery) abci.ResponseQuery { + return th.query(ctx, req) +} + +func newTestHandler(proc func(sdk.Context, sdk.Msg) sdk.Result) sdk.Handler { + return testHandler{ + process: proc, + } +} diff --git a/gno.land/pkg/gnoland/genesis.go b/gno.land/pkg/gnoland/genesis.go index 778121d59ed..ccc3369766d 100644 --- a/gno.land/pkg/gnoland/genesis.go +++ b/gno.land/pkg/gnoland/genesis.go @@ -12,10 +12,13 @@ import ( bft "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/crypto" osm "github.com/gnolang/gno/tm2/pkg/os" + "github.com/gnolang/gno/tm2/pkg/sdk/auth" "github.com/gnolang/gno/tm2/pkg/std" "github.com/pelletier/go-toml" ) +const initGasPrice = "10ugnot/100gas" + // LoadGenesisBalancesFile loads genesis balances from the provided file path. func LoadGenesisBalancesFile(path string) ([]Balance, error) { // each balance is in the form: g1xxxxxxxxxxxxxxxx=100000ugnot @@ -187,3 +190,20 @@ func LoadPackage(pkg gnomod.Pkg, creator bft.Address, fee std.Fee, deposit std.C return tx, nil } + +func DefaultGenState() GnoGenesisState { + authGen := auth.DefaultGenesisState() + gp, err := std.ParseGasPrice(initGasPrice) + if err != nil { + panic(err) + } + authGen.Params.InitialGasPrice = gp + + gs := GnoGenesisState{ + Balances: []Balance{}, + Txs: []TxWithMetadata{}, + Auth: authGen, + } + + return gs +} diff --git a/gno.land/pkg/gnoland/types.go b/gno.land/pkg/gnoland/types.go index a5f76fdcef7..ed35c4141f4 100644 --- a/gno.land/pkg/gnoland/types.go +++ b/gno.land/pkg/gnoland/types.go @@ -8,6 +8,7 @@ import ( "os" "github.com/gnolang/gno/tm2/pkg/amino" + "github.com/gnolang/gno/tm2/pkg/sdk/auth" "github.com/gnolang/gno/tm2/pkg/std" ) @@ -25,9 +26,10 @@ func ProtoGnoAccount() std.Account { } type GnoGenesisState struct { - Balances []Balance `json:"balances"` - Txs []TxWithMetadata `json:"txs"` - Params []Param `json:"params"` + Balances []Balance `json:"balances"` + Txs []TxWithMetadata `json:"txs"` + Params []Param `json:"params"` + Auth auth.GenesisState `json:"auth"` } type TxWithMetadata struct { diff --git a/gno.land/pkg/gnoweb/.gitignore b/gno.land/pkg/gnoweb/.gitignore new file mode 100644 index 00000000000..dd09eb49099 --- /dev/null +++ b/gno.land/pkg/gnoweb/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +tmp/ +.cache diff --git a/gno.land/pkg/gnoweb/Makefile b/gno.land/pkg/gnoweb/Makefile new file mode 100644 index 00000000000..61397fef54f --- /dev/null +++ b/gno.land/pkg/gnoweb/Makefile @@ -0,0 +1,100 @@ +# Configurable arguments +DEV_REMOTE ?= 127.0.0.1:26657 +CHAIN_ID ?= test3 +PUBLIC_DIR ?= public + +# Variable Declarations +tools_run := go run -modfile ./tools/go.mod +run_reflex := $(tools_run) github.com/cespare/reflex +run_logname := go -C ./tools run ./cmd/logname + +# css config +input_css := frontend/css/input.css +output_css := $(PUBLIC_DIR)/styles.css +tw_version := 3.4.14 +tw_config_path := frontend/css/tx.config.js + +# static config +src_dir_static := frontend/static +out_dir_static := $(PUBLIC_DIR) +input_static := $(shell find $(src_dir_static) -type f) +output_static := $(patsubst $(src_dir_static)/%, $(out_dir_static)/%, $(input_static)) + +# esbuild config +src_dir_js := frontend/js +out_dir_js := $(PUBLIC_DIR)/js +input_js := $(shell find $(src_dir_js) -name '*.ts') +output_js := $(patsubst $(src_dir_js)/%.ts,$(out_dir_js)/%.js,$(input_js)) +esbuild_version := 0.24.0 + +# cache +cache_dir := .cache + +############# +# Targets +############# +.PHONY: all generate fmt css ts + +# Install dependencies +all: generate + +# Generate process +generate: css ts static + +css: $(output_css) +$(output_css): $(input_css) + npx -y tailwindcss@$(tw_version) -c $(tw_config_path) -i $< -o $@ --minify # tailwind + touch $@ + +ts: $(output_js) +$(out_dir_js)/%.js: $(src_dir_js)/%.ts + npx -y esbuild $< --log-level=error --bundle --outdir=$(out_dir_js) --format=esm --minify + +# Rule to copy static files while preserving directory structure +static: $(output_static) +$(out_dir_static)/%: $(src_dir_static)/% + @mkdir -p $(dir $@) + @cp -v $< $@ + +# Format process +fmt: + go fmt ./... + + ############################### + # Developments + ############################### +.PHONY: dev dev.server dev.css dev.ts deps + +# Run the development dependencies in parallel +dev: + @echo "-- starting development tools" + @PUBLIC_DIR=$(cache_dir)/public $(MAKE) -j 3 \ + dev.gnoweb \ + dev.ts \ + dev.css + +# Go server in development mode +dev.gnoweb: generate + $(run_reflex) -s -r '.*\.go(html)?' -- \ + go run ../../cmd/gnoweb -assets-dir=${PUBLIC_DIR} -chainid=${CHAIN_ID} -remote=${DEV_REMOTE} \ + 2>&1 | $(run_logname) gnoweb + +# Tailwind CSS in development mode +dev.css: generate | $(PUBLIC_DIR) + npx -y tailwindcss@$(tw_version) -c $(tw_config_path) --verbose -i $(input_css) -o $(output_css) --watch \ + 2>&1 | $(run_logname) tailwind + +# XXX: add versioning on esbuild +# TS in development mode +dev.ts: generate | $(PUBLIC_DIR) + npx -y esbuild@$(esbuild_version) $(input_js) --bundle --outdir=$(out_dir_js) --sourcemap --format=esm --watch \ + 2>&1 | $(run_logname) esbuild + +# Cleanup +clean: + rm -rf $(cache_dir) tmp +fclean: clean + rm -rf $(PUBLIC_DIR) + +# Dirs +$(PUBLIC_DIR):; mkdir -p $@ diff --git a/gno.land/pkg/gnoweb/README.md b/gno.land/pkg/gnoweb/README.md new file mode 100644 index 00000000000..287279538d8 --- /dev/null +++ b/gno.land/pkg/gnoweb/README.md @@ -0,0 +1,45 @@ +# gnoweb + +`gnoweb` is a universal web frontend for the gno.land blockchain. + +This README provides instructions on how to set up and run `gnoweb` for development purposes. + +## Prerequisites + +Before you begin, ensure you have the following software installed on your machine: + +- **Node.js**: Required for running JavaScript and CSS build tools. +- **Go**: Required for building `gnoweb` + +## Development + +To start the development environment, which runs multiple development tools in parallel, +use the following command: + +```sh +make dev +``` + +This will: + +- Start a Go server in development mode and watch for any Go files change (targeting [localhost](http://localhost:8888)). +- Enable Tailwind CSS in watch mode to automatically compile CSS changes. +- Use esbuild in watch mode to automatically transpile and bundle TypeScript changes. + +You can customize the behavior of the Go server using the `DEV_REMOTE` and +`CHAIN_ID` environment variables. For example, to use `portal-loop` as the +target, run: + +```sh +CHAIN_ID=portal-loop DEV_REMOTE=https://rpc.gno.land make dev +``` + +## Generate + +To generate the public assets for the project, including static assets (fonts, CSS and JavaScript... +files), run the following command. This should be used while editing CSS, JS, or +any asset files: + +```sh +make generate +``` diff --git a/gno.land/pkg/gnoweb/alias.go b/gno.land/pkg/gnoweb/alias.go index 7fb28d5cbc3..06bb3941e41 100644 --- a/gno.land/pkg/gnoweb/alias.go +++ b/gno.land/pkg/gnoweb/alias.go @@ -1,6 +1,12 @@ package gnoweb -// realm aliases +import ( + "net/http" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" +) + +// Aliases are gnoweb paths that are rewritten using [AliasAndRedirectMiddleware]. var Aliases = map[string]string{ "/": "/r/gnoland/home", "/about": "/r/gnoland/pages:p/about", @@ -14,7 +20,7 @@ var Aliases = map[string]string{ "/events": "/r/gnoland/events", } -// http redirects +// Redirect are gnoweb paths that are redirected using [AliasAndRedirectMiddleware]. var Redirects = map[string]string{ "/r/demo/boards:gnolang/6": "/r/demo/boards:gnolang/3", // XXX: temporary "/blog": "/r/gnoland/blog", @@ -23,5 +29,29 @@ var Redirects = map[string]string{ "/grants": "/partners", "/language": "/gnolang", "/getting-started": "/start", - "/gophercon24": "https://docs.gno.land", +} + +// AliasAndRedirectMiddleware redirects all incoming requests whose path matches +// any of the [Redirects] to the corresponding URL; and rewrites the URL path +// for incoming requests which match any of the [Aliases]. +func AliasAndRedirectMiddleware(next http.Handler, analytics bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check if the request path matches a redirect + if newPath, ok := Redirects[r.URL.Path]; ok { + http.Redirect(w, r, newPath, http.StatusFound) + components.RenderRedirectComponent(w, components.RedirectData{ + To: newPath, + WithAnalytics: analytics, + }) + return + } + + // Check if the request path matches an alias + if newPath, ok := Aliases[r.URL.Path]; ok { + r.URL.Path = newPath + } + + // Call the next handler + next.ServeHTTP(w, r) + }) } diff --git a/gno.land/pkg/gnoweb/app.go b/gno.land/pkg/gnoweb/app.go new file mode 100644 index 00000000000..dc13253468e --- /dev/null +++ b/gno.land/pkg/gnoweb/app.go @@ -0,0 +1,152 @@ +package gnoweb + +import ( + "fmt" + "log/slog" + "net/http" + "path" + "strings" + + "github.com/alecthomas/chroma/v2" + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/styles" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/yuin/goldmark" + mdhtml "github.com/yuin/goldmark/renderer/html" +) + +// AppConfig contains configuration for the gnoweb. +type AppConfig struct { + // UnsafeHTML, if enabled, allows to use HTML in the markdown. + UnsafeHTML bool + // Analytics enables SimpleAnalytics. + Analytics bool + // NodeRemote is the remote address of the gno.land node. + NodeRemote string + // RemoteHelp is the remote of the gno.land node, as used in the help page. + RemoteHelp string + // ChainID is the chain id, used for constructing the help page. + 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 + AssetsDir string + // FaucetURL, if specified, will be the URL to which `/faucet` redirects. + FaucetURL 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/", + } +} + +var chromaStyle = mustGetStyle("friendly") + +func mustGetStyle(name string) *chroma.Style { + s := styles.Get(name) + if s == nil { + panic("unable to get chroma style") + } + return s +} + +// NewRouter initializes the gnoweb router, with the given logger and config. +func NewRouter(logger *slog.Logger, cfg *AppConfig) (http.Handler, error) { + chromaOptions := []chromahtml.Option{ + chromahtml.WithLineNumbers(true), + chromahtml.WithLinkableLineNumbers(true, "L"), + chromahtml.WithClasses(true), + chromahtml.ClassPrefix("chroma-"), + } + + mdopts := []goldmark.Option{ + goldmark.WithExtensions( + markdown.NewHighlighting( + markdown.WithFormatOptions(chromaOptions...), + ), + ), + } + 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) + } + webcli := NewWebClient(logger, client, md) + + formatter := chromahtml.New(chromaOptions...) + 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 main handler + webhandler := NewWebHandler(logger, webConfig) + + mux := http.NewServeMux() + + // Setup Webahndler along Alias Middleware + mux.Handle("/", AliasAndRedirectMiddleware(webhandler, cfg.Analytics)) + + // Register faucet URL to `/faucet` if specified + if cfg.FaucetURL != "" { + mux.Handle("/faucet", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, cfg.FaucetURL, http.StatusFound) + components.RenderRedirectComponent(w, components.RedirectData{ + To: cfg.FaucetURL, + WithAnalytics: cfg.Analytics, + }) + })) + } + + // setup assets + 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) + http.NotFound(w, r) + } + })) + + // Normalize assets path + assetsBase := "/" + strings.Trim(cfg.AssetsPath, "/") + "/" + + // Handle assets path + if cfg.AssetsDir != "" { + logger.Debug("using assets dir instead of embed assets", "dir", cfg.AssetsDir) + mux.Handle(assetsBase, DevAssetHandler(assetsBase, cfg.AssetsDir)) + } else { + mux.Handle(assetsBase, AssetHandler()) + } + + // Handle status page + mux.Handle("/status.json", handlerStatusJSON(logger, client)) + + return mux, nil +} diff --git a/gno.land/pkg/gnoweb/gnoweb_test.go b/gno.land/pkg/gnoweb/app_test.go similarity index 67% rename from gno.land/pkg/gnoweb/gnoweb_test.go rename to gno.land/pkg/gnoweb/app_test.go index 99eb86ea07e..78fe197a134 100644 --- a/gno.land/pkg/gnoweb/gnoweb_test.go +++ b/gno.land/pkg/gnoweb/app_test.go @@ -4,13 +4,13 @@ import ( "fmt" "net/http" "net/http/httptest" - "strings" "testing" "github.com/gnolang/gno/gno.land/pkg/integration" "github.com/gnolang/gno/gnovm/pkg/gnoenv" "github.com/gnolang/gno/tm2/pkg/log" - "github.com/gotuna/gotuna/test/assert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestRoutes(t *testing.T) { @@ -27,12 +27,12 @@ func TestRoutes(t *testing.T) { {"/", ok, "Welcome"}, // assert / gives 200 (OK). assert / contains "Welcome". {"/about", ok, "blockchain"}, {"/r/gnoland/blog", ok, ""}, // whatever content - {"/r/gnoland/blog$help", ok, "exposed"}, + {"/r/gnoland/blog$help", ok, "AdminSetAdminAddr"}, {"/r/gnoland/blog/", ok, "admin.gno"}, - {"/r/gnoland/blog/admin.gno", ok, "func "}, - {"/r/gnoland/blog$help&func=Render", ok, "Render(...)"}, - {"/r/gnoland/blog$help&func=Render&path=foo/bar", ok, `input type="text" value="foo/bar"`}, - {"/r/gnoland/blog$help&func=NonExisting", ok, "NonExisting not found"}, + {"/r/gnoland/blog/admin.gno", ok, ">func<"}, + {"/r/gnoland/blog$help&func=Render", ok, "Render(path)"}, + {"/r/gnoland/blog$help&func=Render&path=foo/bar", ok, `value="foo/bar"`}, + // {"/r/gnoland/blog$help&func=NonExisting", ok, "NonExisting not found"}, // XXX(TODO) {"/r/demo/users:administrator", ok, "address"}, {"/r/demo/users", ok, "moul"}, {"/r/demo/users/users.gno", ok, "// State"}, @@ -40,18 +40,18 @@ func TestRoutes(t *testing.T) { {"/r/demo/deep/very/deep?arg1=val1&arg2=val2", ok, "hi ?arg1=val1&arg2=val2"}, {"/r/demo/deep/very/deep:bob", ok, "hi bob"}, {"/r/demo/deep/very/deep:bob?arg1=val1&arg2=val2", ok, "hi bob?arg1=val1&arg2=val2"}, - {"/r/demo/deep/very/deep$help", ok, "exposed"}, + {"/r/demo/deep/very/deep$help", ok, "Render"}, {"/r/demo/deep/very/deep/", ok, "render.gno"}, - {"/r/demo/deep/very/deep/render.gno", ok, "func Render("}, + {"/r/demo/deep/very/deep/render.gno", ok, ">package<"}, {"/contribute", ok, "Game of Realms"}, {"/game-of-realms", found, "/contribute"}, {"/gor", found, "/contribute"}, {"/blog", found, "/r/gnoland/blog"}, - {"/404-not-found", notFound, "/404-not-found"}, - {"/아스키문자가아닌경로", notFound, "/아스키문자가아닌경로"}, - {"/%ED%85%8C%EC%8A%A4%ED%8A%B8", notFound, "/테스트"}, - {"/グノー", notFound, "/グノー"}, - {"/⚛️", notFound, "/⚛️"}, + {"/404/not/found/", notFound, ""}, + {"/아스키문자가아닌경로", notFound, ""}, + {"/%ED%85%8C%EC%8A%A4%ED%8A%B8", notFound, ""}, + {"/グノー", notFound, ""}, + {"/⚛️", notFound, ""}, {"/p/demo/flow/LICENSE", ok, "BSD 3-Clause"}, } @@ -61,20 +61,21 @@ func TestRoutes(t *testing.T) { node, remoteAddr := integration.TestingInMemoryNode(t, log.NewTestingLogger(t), config) defer node.Stop() - cfg := NewDefaultConfig() + cfg := NewDefaultAppConfig() + cfg.NodeRemote = remoteAddr logger := log.NewTestingLogger(t) // set the `remoteAddr` of the client to the listening address of the // node, which is randomly assigned. - cfg.RemoteAddr = remoteAddr - app := MakeApp(logger, cfg) + router, err := NewRouter(logger, cfg) + require.NoError(t, err) for _, r := range routes { t.Run(fmt.Sprintf("test route %s", r.route), func(t *testing.T) { request := httptest.NewRequest(http.MethodGet, r.route, nil) response := httptest.NewRecorder() - app.Router.ServeHTTP(response, request) + router.ServeHTTP(response, request) assert.Equal(t, r.status, response.Code) assert.Contains(t, response.Body.String(), r.substring) }) @@ -110,34 +111,39 @@ func TestAnalytics(t *testing.T) { node, remoteAddr := integration.TestingInMemoryNode(t, log.NewTestingLogger(t), config) defer node.Stop() - cfg := NewDefaultConfig() - cfg.RemoteAddr = remoteAddr - - logger := log.NewTestingLogger(t) - - t.Run("with", func(t *testing.T) { + t.Run("enabled", func(t *testing.T) { for _, route := range routes { t.Run(route, func(t *testing.T) { - ccfg := cfg // clone config - ccfg.WithAnalytics = true - app := MakeApp(logger, ccfg) + cfg := NewDefaultAppConfig() + cfg.NodeRemote = remoteAddr + cfg.Analytics = true + logger := log.NewTestingLogger(t) + + router, err := NewRouter(logger, cfg) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, route, nil) response := httptest.NewRecorder() - app.Router.ServeHTTP(response, request) + router.ServeHTTP(response, request) + fmt.Println("HELLO:", response.Body.String()) assert.Contains(t, response.Body.String(), "sa.gno.services") }) } }) - t.Run("without", func(t *testing.T) { + t.Run("disabled", func(t *testing.T) { for _, route := range routes { t.Run(route, func(t *testing.T) { - ccfg := cfg // clone config - ccfg.WithAnalytics = false - app := MakeApp(logger, ccfg) + cfg := NewDefaultAppConfig() + cfg.NodeRemote = remoteAddr + cfg.Analytics = false + logger := log.NewTestingLogger(t) + router, err := NewRouter(logger, cfg) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodGet, route, nil) response := httptest.NewRecorder() - app.Router.ServeHTTP(response, request) - assert.Equal(t, strings.Contains(response.Body.String(), "sa.gno.services"), false) + router.ServeHTTP(response, request) + assert.NotContains(t, response.Body.String(), "sa.gno.services") }) } }) diff --git a/gno.land/pkg/gnoweb/components/breadcrumb.go b/gno.land/pkg/gnoweb/components/breadcrumb.go new file mode 100644 index 00000000000..9e7a97b2fae --- /dev/null +++ b/gno.land/pkg/gnoweb/components/breadcrumb.go @@ -0,0 +1,18 @@ +package components + +import ( + "io" +) + +type BreadcrumbPart struct { + Name string + Path string +} + +type BreadcrumbData struct { + Parts []BreadcrumbPart +} + +func RenderBreadcrumpComponent(w io.Writer, data BreadcrumbData) error { + return tmpl.ExecuteTemplate(w, "Breadcrumb", data) +} diff --git a/gno.land/pkg/gnoweb/components/breadcrumb.gohtml b/gno.land/pkg/gnoweb/components/breadcrumb.gohtml new file mode 100644 index 00000000000..a3301cb037e --- /dev/null +++ b/gno.land/pkg/gnoweb/components/breadcrumb.gohtml @@ -0,0 +1,12 @@ +{{ define "breadcrumb" }} +
    + {{- range $index, $part := .Parts }} + {{- if $index }} +
  1. + {{- else }} +
  2. + {{- end }} + {{ $part.Name }}
  3. + {{- end }} +
+{{ end }} \ No newline at end of file diff --git a/gno.land/pkg/gnoweb/components/directory.go b/gno.land/pkg/gnoweb/components/directory.go new file mode 100644 index 00000000000..6e47db3b2c4 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/directory.go @@ -0,0 +1,15 @@ +package components + +import ( + "io" +) + +type DirData struct { + PkgPath string + Files []string + FileCounter int +} + +func RenderDirectoryComponent(w io.Writer, data DirData) error { + return tmpl.ExecuteTemplate(w, "renderDir", data) +} diff --git a/gno.land/pkg/gnoweb/components/directory.gohtml b/gno.land/pkg/gnoweb/components/directory.gohtml new file mode 100644 index 00000000000..4cdeff12a38 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/directory.gohtml @@ -0,0 +1,39 @@ +{{ define "renderDir" }} +
+
+ + + {{ $pkgpath := .PkgPath }} +
+
+
+

{{ $pkgpath }}

+
+
+ Directory · {{ .FileCounter }} Files +
+
+ +
+ +
+
+
+ +
+{{ end }} + diff --git a/gno.land/pkg/gnoweb/components/help.go b/gno.land/pkg/gnoweb/components/help.go new file mode 100644 index 00000000000..e819705006b --- /dev/null +++ b/gno.land/pkg/gnoweb/components/help.go @@ -0,0 +1,51 @@ +package components + +import ( + "html/template" + "io" + "strings" + + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types +) + +type HelpData struct { + // Selected function + SelectedFunc string + SelectedArgs map[string]string + + RealmName string + Functions []vm.FunctionSignature + ChainId string + Remote string + PkgPath string +} + +func registerHelpFuncs(funcs template.FuncMap) { + funcs["helpFuncSignature"] = func(fsig vm.FunctionSignature) (string, error) { + var fsigStr strings.Builder + + fsigStr.WriteString(fsig.FuncName) + fsigStr.WriteRune('(') + for i, param := range fsig.Params { + if i > 0 { + fsigStr.WriteString(", ") + } + fsigStr.WriteString(param.Name) + } + fsigStr.WriteRune(')') + + return fsigStr.String(), nil + } + + funcs["getSelectedArgValue"] = func(data HelpData, param vm.NamedType) (string, error) { + if data.SelectedArgs == nil { + return "", nil + } + + return data.SelectedArgs[param.Name], nil + } +} + +func RenderHelpComponent(w io.Writer, data HelpData) error { + return tmpl.ExecuteTemplate(w, "renderHelp", data) +} diff --git a/gno.land/pkg/gnoweb/components/help.gohtml b/gno.land/pkg/gnoweb/components/help.gohtml new file mode 100644 index 00000000000..1ea8ba1927a --- /dev/null +++ b/gno.land/pkg/gnoweb/components/help.gohtml @@ -0,0 +1,110 @@ +{{ define "renderHelp" }} + {{ $data := . }} +
+
+
+
+

{{ .RealmName }}

+
+
+
+ + + + +
+
+ + +
+
+
+ +
+ + {{ range .Functions }} +
+

{{ .FuncName }}

+
+
+

Params

+
+ {{ $funcName := .FuncName }} + {{ range .Params }} +
+
+ + +
+
+ {{ end }} +
+
+
+
+

Command

+
+ +
gnokey maketx call -pkgpath "{{ $.PkgPath }}" -func "{{ .FuncName }}" -gas-fee 1000000ugnot -gas-wanted 2000000 -broadcast -chainid "{{ $.ChainId }}"{{ range .Params }} -args ""{{ end }} -remote "{{ $.Remote }}" ADDRESS
+
+
+
+ {{ end }} + +
+
+
+{{ end }} diff --git a/gno.land/pkg/gnoweb/components/index.go b/gno.land/pkg/gnoweb/components/index.go new file mode 100644 index 00000000000..0cc020ae261 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/index.go @@ -0,0 +1,47 @@ +package components + +import ( + "context" + "html/template" + "io" + "net/url" +) + +type HeadData struct { + Title string + Description string + Canonical string + Image string + URL string + ChromaPath string + AssetsPath string + Analytics bool +} + +type HeaderData struct { + RealmPath string + Breadcrumb BreadcrumbData + WebQuery url.Values +} + +type FooterData struct { + Analytics bool + AssetsPath string +} + +type IndexData struct { + HeadData + HeaderData + FooterData + Body template.HTML +} + +func IndexComponent(data IndexData) Component { + return func(ctx context.Context, tmpl *template.Template, w io.Writer) error { + return tmpl.ExecuteTemplate(w, "index", data) + } +} + +func RenderIndexComponent(w io.Writer, data IndexData) error { + return tmpl.ExecuteTemplate(w, "index", data) +} diff --git a/gno.land/pkg/gnoweb/components/index.gohtml b/gno.land/pkg/gnoweb/components/index.gohtml new file mode 100644 index 00000000000..215e1a4e5a9 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/index.gohtml @@ -0,0 +1,159 @@ +{{ define "index" }} + + + {{ template "head" .HeadData }} + + {{ template "spritesvg" }} + + + {{ template "header" .HeaderData }} + + + {{ template "main" .Body }} + + + {{ template "footer" .FooterData }} + + +{{ end }} + +{{ define "head" }} + + + + {{ .Title }} + + + + + + + + + + + {{ if .Canonical }} + + {{ end }} + + + + + + + + + + + + + + + + + + + + + + + + + + +{{ end }} + +{{ define "header" }} +
+ +
+{{ end }} + +{{ define "main" }} + {{ . }} +{{ end }} + +{{ define "footer" }} + + +{{- if .Analytics -}} {{- template "analytics" }} {{- end -}} + +{{- end }} + +{{- define "analytics" -}} + + + +{{- end -}} diff --git a/gno.land/pkg/gnoweb/components/logosvg.gohtml b/gno.land/pkg/gnoweb/components/logosvg.gohtml new file mode 100644 index 00000000000..5ebe6460ee3 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/logosvg.gohtml @@ -0,0 +1,21 @@ +{{ define "logosvg" }} + + + + + + + + + + + + + + + + + + + +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/realm.go b/gno.land/pkg/gnoweb/components/realm.go new file mode 100644 index 00000000000..027760bb382 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/realm.go @@ -0,0 +1,32 @@ +package components + +import ( + "context" + "html/template" + "io" + + "github.com/gnolang/gno/gno.land/pkg/gnoweb/markdown" +) + +type RealmTOCData struct { + Items []*markdown.TocItem +} + +func RealmTOCComponent(data *RealmTOCData) Component { + return func(ctx context.Context, tmpl *template.Template, w io.Writer) error { + return tmpl.ExecuteTemplate(w, "renderRealmToc", data) + } +} + +func RenderRealmTOCComponent(w io.Writer, data *RealmTOCData) error { + return tmpl.ExecuteTemplate(w, "renderRealmToc", data) +} + +type RealmData struct { + Content template.HTML + TocItems *RealmTOCData +} + +func RenderRealmComponent(w io.Writer, data RealmData) error { + return tmpl.ExecuteTemplate(w, "renderRealm", data) +} diff --git a/gno.land/pkg/gnoweb/components/realm.gohtml b/gno.land/pkg/gnoweb/components/realm.gohtml new file mode 100644 index 00000000000..55f39ef36d7 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/realm.gohtml @@ -0,0 +1,41 @@ +{{ define "renderRealmToc" }} + +{{ end }} + +{{ define "renderRealm" }} +
+
+ +
+ + {{ .Content }} +
+
+
+{{ end }} diff --git a/gno.land/pkg/gnoweb/components/redirect.go b/gno.land/pkg/gnoweb/components/redirect.go new file mode 100644 index 00000000000..873ddf56ff5 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/redirect.go @@ -0,0 +1,12 @@ +package components + +import "io" + +type RedirectData struct { + To string + WithAnalytics bool +} + +func RenderRedirectComponent(w io.Writer, data RedirectData) error { + return tmpl.ExecuteTemplate(w, "renderRedirect", data) +} diff --git a/gno.land/pkg/gnoweb/components/redirect.gohtml b/gno.land/pkg/gnoweb/components/redirect.gohtml new file mode 100644 index 00000000000..45dac0981cd --- /dev/null +++ b/gno.land/pkg/gnoweb/components/redirect.gohtml @@ -0,0 +1,16 @@ +{{- define "renderRedirect" -}} + + + + + + + + Redirecting to {{.To}} + + + {{.To}} + {{- if .WithAnalytics -}} {{- template "analytics" }} {{- end -}} + + +{{- end -}} \ No newline at end of file diff --git a/gno.land/pkg/gnoweb/components/source.go b/gno.land/pkg/gnoweb/components/source.go new file mode 100644 index 00000000000..23170776657 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/source.go @@ -0,0 +1,20 @@ +package components + +import ( + "html/template" + "io" +) + +type SourceData struct { + PkgPath string + Files []string + FileName string + FileSize string + FileLines int + FileCounter int + FileSource template.HTML +} + +func RenderSourceComponent(w io.Writer, data SourceData) error { + return tmpl.ExecuteTemplate(w, "renderSource", data) +} diff --git a/gno.land/pkg/gnoweb/components/source.gohtml b/gno.land/pkg/gnoweb/components/source.gohtml new file mode 100644 index 00000000000..20e710ca29b --- /dev/null +++ b/gno.land/pkg/gnoweb/components/source.gohtml @@ -0,0 +1,57 @@ +{{ define "renderSource" }} +
+
+
+
+

{{ .FileName }}

+
+
+ {{ .FileSize }} · {{ .FileLines }} lines + +
+
+ + +
+
+ {{ .FileSource }} +
+
+
+
+{{ end }} diff --git a/gno.land/pkg/gnoweb/components/spritesvg.gohtml b/gno.land/pkg/gnoweb/components/spritesvg.gohtml new file mode 100644 index 00000000000..c061e97bf58 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/spritesvg.gohtml @@ -0,0 +1,125 @@ +{{ define "spritesvg" }} + + + Search + + + + + + + Apps + + + + Documentation + + + + Source + + + + Content + + + + File + + + + Folder + + + + + + + + + + + Download + + + + Copy + + + + + + + + + + +{{ end }} diff --git a/gno.land/pkg/gnoweb/components/status.gohtml b/gno.land/pkg/gnoweb/components/status.gohtml new file mode 100644 index 00000000000..2321d1110bd --- /dev/null +++ b/gno.land/pkg/gnoweb/components/status.gohtml @@ -0,0 +1,12 @@ +{{ define "status" }} +
+
+
+ gno land +

Error: {{ .Message }}

+

Something went wrong. Let’s find our way back!

+ Go Back Home +
+
+
+{{ end }} diff --git a/gno.land/pkg/gnoweb/components/template.go b/gno.land/pkg/gnoweb/components/template.go new file mode 100644 index 00000000000..9c08703f460 --- /dev/null +++ b/gno.land/pkg/gnoweb/components/template.go @@ -0,0 +1,77 @@ +package components + +import ( + "bytes" + "context" + "embed" + "html/template" + "io" + "net/url" +) + +//go:embed *.gohtml +var gohtml embed.FS + +var funcMap = template.FuncMap{ + // NOTE: this method does NOT escape HTML, use with caution + "noescape_string": func(in string) template.HTML { + return template.HTML(in) //nolint:gosec + }, + // NOTE: this method does NOT escape HTML, use with caution + "noescape_bytes": func(in []byte) template.HTML { + return template.HTML(in) //nolint:gosec + }, + "queryHas": func(vals url.Values, key string) bool { + if vals == nil { + return false + } + + return vals.Has(key) + }, +} + +var tmpl = template.New("web").Funcs(funcMap) + +func init() { + registerHelpFuncs(funcMap) + tmpl.Funcs(funcMap) + + var err error + tmpl, err = tmpl.ParseFS(gohtml, "*.gohtml") + if err != nil { + panic("unable to parse embed tempalates: " + err.Error()) + } +} + +type Component func(ctx context.Context, tmpl *template.Template, w io.Writer) error + +func (c Component) Render(ctx context.Context, w io.Writer) error { + return RenderComponent(ctx, w, c) +} + +func RenderComponent(ctx context.Context, w io.Writer, c Component) error { + var render *template.Template + funcmap := template.FuncMap{ + "render": func(cf Component) (string, error) { + var buf bytes.Buffer + if err := cf(ctx, render, &buf); err != nil { + return "", err + } + + return buf.String(), nil + }, + } + + render = tmpl.Funcs(funcmap) + return c(ctx, render, w) +} + +type StatusData struct { + Message string +} + +func RenderStatusComponent(w io.Writer, message string) error { + return tmpl.ExecuteTemplate(w, "status", StatusData{ + Message: message, + }) +} diff --git a/gno.land/pkg/gnoweb/formatter.go b/gno.land/pkg/gnoweb/formatter.go new file mode 100644 index 00000000000..e172afe9e21 --- /dev/null +++ b/gno.land/pkg/gnoweb/formatter.go @@ -0,0 +1,25 @@ +package gnoweb + +import ( + "io" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/formatters/html" +) + +type Formatter interface { + Format(w io.Writer, iterator chroma.Iterator) error +} + +type formatterWithStyle struct { + *html.Formatter + style *chroma.Style +} + +func newFormatterWithStyle(formater *html.Formatter, style *chroma.Style) Formatter { + return &formatterWithStyle{Formatter: formater, style: style} +} + +func (f *formatterWithStyle) Format(w io.Writer, iterator chroma.Iterator) error { + return f.Formatter.Format(w, f.style, iterator) +} diff --git a/gno.land/pkg/gnoweb/frontend/css/input.css b/gno.land/pkg/gnoweb/frontend/css/input.css new file mode 100644 index 00000000000..d54d30123b7 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/css/input.css @@ -0,0 +1,346 @@ +@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-style: oblique 0deg 10deg; + src: url("./fonts/intervar/Intervar.woff2") format("woff2"); +} + +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { + @apply font-interVar text-gray-600 bg-light text-200; + font-feature-settings: "kern" on, "liga" on, "calt" on, "zero" on; + -webkit-font-feature-settings: "kern" on, "liga" on, "calt" on, "zero" on; + 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 { + @apply max-w-full max-h-full; + } + + form { + @apply my-0; + } + + .realm-content { + @apply text-200 break-words pt-10; + } + + .realm-content > *:first-child { + @apply !mt-0; + } + + .realm-content a { + @apply text-green-600 font-medium hover:underline; + } + + .realm-content h1, + .realm-content h2, + .realm-content h3, + .realm-content h4 { + @apply text-gray-900 mt-12 leading-tight; + } + + .realm-content h2, + .realm-content h2 * { + @apply font-bold; + } + + .realm-content h3, + .realm-content h3 *, + .realm-content h4, + .realm-content h4 * { + @apply font-semibold; + } + + .realm-content h1 + h2, + .realm-content h2 + h3, + .realm-content h3 + h4 { + @apply mt-4; + } + + .realm-content h1 { + @apply text-800 font-bold; + } + + .realm-content h2 { + @apply text-600; + } + + .realm-content h3 { + @apply text-400 text-gray-600 mt-10; + } + + .realm-content h4 { + @apply text-300 text-gray-600 font-medium my-6; + } + + .realm-content p { + @apply my-5; + } + + .realm-content strong { + @apply font-bold text-gray-900; + } + + .realm-content strong * { + @apply font-bold; + } + + .realm-content em { + @apply italic-subtle; + } + + .realm-content blockquote { + @apply border-l-4 border-gray-300 pl-4 text-gray-600 italic-subtle my-4; + } + + .realm-content ul, + .realm-content ol { + @apply pl-4 my-6; + } + + .realm-content ul li, + .realm-content ol li { + @apply mb-2; + } + + .realm-content img { + @apply max-w-full my-8; + } + + .realm-content figure { + @apply my-6 text-center; + } + + .realm-content figcaption { + @apply text-100 text-gray-600; + } + + .realm-content :not(pre) > code { + @apply bg-gray-100 px-1 py-0.5 rounded-sm text-100 font-mono; + } + + .realm-content pre { + @apply bg-gray-50 p-4 rounded overflow-x-auto font-mono; + } + + .realm-content hr { + @apply border-t border-gray-100 my-10; + } + + .realm-content table { + @apply w-full border-collapse my-8; + } + + .realm-content th, + .realm-content td { + @apply border border-gray-300 px-4 py-2; + } + + .realm-content th { + @apply bg-gray-100 font-bold; + } + + .realm-content caption { + @apply mt-2 text-100 text-gray-600 text-left; + } + + .realm-content q { + @apply quotes; + } + + .realm-content q::before { + content: open-quote; + } + + .realm-content q::after { + content: close-quote; + } + + .realm-content ul ul, + .realm-content ul ol, + .realm-content ol ul, + .realm-content ol ol { + @apply mt-3 mb-2 pl-4; + } + + .realm-content ul { + @apply list-disc; + } + + .realm-content ol { + @apply list-decimal; + } + + .realm-content table th:first-child, + .realm-content td:first-child { + @apply pl-0; + } + + .realm-content table th:last-child, + .realm-content td:last-child { + @apply pr-0; + } + + .realm-content abbr[title] { + @apply border-b border-dotted cursor-help; + } + + .realm-content details { + @apply my-5; + } + + .realm-content summary { + @apply font-bold cursor-pointer; + } + + .realm-content a code { + @apply text-inherit; + } + + .realm-content video { + @apply max-w-full my-8; + } + + .realm-content math { + @apply font-mono; + } + + .realm-content small { + @apply text-100; + } + + .realm-content del { + @apply line-through; + } + + .realm-content sub { + @apply text-50 align-sub; + } + + .realm-content sup { + @apply text-50 align-super; + } + + .realm-content input, + .realm-content button { + @apply px-4 py-2 border border-gray-300; + } + + main :is(h1, h2, h3, h4) { + @apply scroll-mt-24; + } + + ::-moz-selection { + @apply bg-green-600 text-light; + } + ::selection { + @apply bg-green-600 text-light; + } +} + +@layer components { + /* header */ + .sidemenu .peer:checked + label > svg { + @apply text-green-600; + } + + /* toc */ + .toc-expend-btn:has(#toc-expend:checked) + nav { + @apply block; + } + .toc-expend-btn:has(#toc-expend:checked) .toc-expend-btn_ico { + @apply rotate-180; + } + + /* sidebar */ + .main-header:has(#sidemenu-summary:checked) + main #sidebar #sidebar-summary, + .main-header:has(#sidemenu-source:checked) + main #sidebar #sidebar-source, + .main-header:has(#sidemenu-docs:checked) + main #sidebar #sidebar-docs, + .main-header:has(#sidemenu-meta:checked) + main #sidebar #sidebar-meta { + @apply block; + } + + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) + main .realm-content, + :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; + } + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) + main #sidebar, + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) .sidemenu { + @apply md:col-span-4; + } + :is(.main-header:has(#sidemenu-source:checked), .main-header:has(#sidemenu-docs:checked), .main-header:has(#sidemenu-meta:checked)) + main #sidebar::before { + @apply absolute block content-[''] top-0 w-[50vw] h-full -left-7 bg-gray-100 z-min; + } + + /* chroma */ + 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-content > pre a { + @apply hover:no-underline; + } + + main :is(.realm-content, .source-code) > pre .chroma-ln:target { + @apply !bg-transparent; + } + main :is(.realm-content, .source-code) > pre .chroma-line:has(.chroma-ln:target), + main :is(.realm-content, .source-code) > pre .chroma-line:has(.chroma-lnlinks:hover), + main :is(.realm-content, .source-code) > pre .chroma-line:has(.chroma-ln:target) .chroma-cl, + main :is(.realm-content, .source-code) > pre .chroma-line:has(.chroma-lnlinks:hover) .chroma-cl { + @apply !bg-gray-100 rounded; + } + main :is(.realm-content, .source-code) > pre .chroma-ln { + @apply scroll-mt-24; + } +} + +@layer utilities { + .italic-subtle { + font-style: oblique 10deg; + } + + .quotes { + @apply italic-subtle text-[#555] border-l-4 border-l-[#ccc] pl-4 my-6 [quotes:"“"_"”"_"‘"_"’"]; + } + + .quotes::before, + .quotes::after { + @apply [content:open-quote] text-600 text-gray-300 mr-1 [vertical-align:-0.4rem]; + } + + .quotes::after { + @apply [content:close-quote]; + } + + .text-stroke { + -webkit-text-stroke: currentColor; + -webkit-text-stroke-width: 0.6px; + } + + .no-scrollbar::-webkit-scrollbar { + display: none; + } + .no-scrollbar { + -ms-overflow-style: none; + scrollbar-width: none; + } +} diff --git a/gno.land/pkg/gnoweb/frontend/css/tx.config.js b/gno.land/pkg/gnoweb/frontend/css/tx.config.js new file mode 100644 index 00000000000..21b6a101dd6 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/css/tx.config.js @@ -0,0 +1,72 @@ +const pxToRem = (px) => px / 16; + +export default { + content: ["./components/**/*.{gohtml,ts}"], + theme: { + screens: { + xs: `${pxToRem(360)}rem`, + sm: `${pxToRem(480)}rem`, + md: `${pxToRem(640)}rem`, + lg: `${pxToRem(820)}rem`, + xl: `${pxToRem(1020)}rem`, + xxl: `${pxToRem(1366)}rem`, + max: `${pxToRem(1580)}rem`, + }, + zIndex: { + min: "-1", + 1: "1", + 2: "2", + 100: "100", + max: "9999", + }, + container: { + center: true, + padding: `${pxToRem(40)}rem`, + }, + borderRadius: { + sm: `${pxToRem(4)}rem`, + DEFAULT: `${pxToRem(6)}rem`, + }, + colors: { + light: "#FFFFFF", + gray: { + 50: "#F0F0F0", // Background color + 100: "#E2E2E2", // Title dark color + 200: "#BDBDBD", // Content dark color + 300: "#999999", // Muted color + 400: "#7C7C7C", // Border color + 600: "#54595D", // Content color + 800: "#131313", // Background dark color + 900: "#080809", // Title color + }, + green: { + 400: "#2D8D72", // Primary dark color + 600: "#226C57", // Primary light color + }, + transparent: "transparent", + current: "currentColor", + inherit: "inherit", + }, + fontFamily: { + 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', + ], + }, + fontSize: { + 0: "0", + 50: `${pxToRem(12)}rem`, + 100: `${pxToRem(14)}rem`, + 200: `${pxToRem(16)}rem`, + 300: `${pxToRem(18)}rem`, + 400: `${pxToRem(20)}rem`, + 500: `${pxToRem(22)}rem`, + 600: `${pxToRem(24)}rem`, + 700: `${pxToRem(32)}rem`, + 800: `${pxToRem(38)}rem`, + 900: `${pxToRem(42)}rem`, + }, + }, + plugins: [], +}; diff --git a/gno.land/pkg/gnoweb/frontend/js/copy.ts b/gno.land/pkg/gnoweb/frontend/js/copy.ts new file mode 100644 index 00000000000..f3e5c725783 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/copy.ts @@ -0,0 +1,105 @@ +class Copy { + private DOM: { + el: HTMLElement | null; + }; + private static FEEDBACK_DELAY = 750; + + private btnClicked: HTMLElement | null = null; + private btnClickedIcons: HTMLElement[] = []; + private isAnimationRunning: boolean = false; + + private static SELECTORS = { + button: "[data-copy-btn]", + icon: `[data-copy-icon] > use`, + content: (id: string) => `[data-copy-content="${id}"]`, + }; + + constructor() { + this.DOM = { + el: document.querySelector("main"), + }; + + if (this.DOM.el) { + this.init(); + } else { + console.warn("Copy: Main container not found."); + } + } + + private init(): void { + this.bindEvents(); + } + + private bindEvents(): void { + this.DOM.el?.addEventListener("click", this.handleClick.bind(this)); + } + + private handleClick(event: Event): void { + const target = event.target as HTMLElement; + const button = target.closest(Copy.SELECTORS.button); + + if (!button) return; + + this.btnClicked = button; + this.btnClickedIcons = Array.from(button.querySelectorAll(Copy.SELECTORS.icon)); + + const contentId = button.getAttribute("data-copy-btn"); + if (!contentId) { + console.warn("Copy: No content ID found on the button."); + return; + } + + const codeBlock = this.DOM.el?.querySelector(Copy.SELECTORS.content(contentId)); + if (codeBlock) { + this.copyToClipboard(codeBlock, this.btnClickedIcons); + } else { + console.warn(`Copy: No content found for ID "${contentId}".`); + } + } + + private sanitizeContent(codeBlock: HTMLElement): string { + const html = codeBlock.innerHTML.replace(/]*class="chroma-ln"[^>]*>[\s\S]*?<\/span>/g, ""); + + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = html; + + return tempDiv.textContent?.trim() || ""; + } + + private toggleIcons(icons: HTMLElement[]): void { + icons.forEach((icon) => { + icon.classList.toggle("hidden"); + }); + } + + private showFeedback(icons: HTMLElement[]): void { + if (!this.btnClicked || this.isAnimationRunning === true) return; + + this.isAnimationRunning = true; + this.toggleIcons(icons); + window.setTimeout(() => { + this.toggleIcons(icons); + this.isAnimationRunning = false; + }, Copy.FEEDBACK_DELAY); + } + + private async copyToClipboard(codeBlock: HTMLElement, icons: HTMLElement[]): Promise { + const sanitizedText = this.sanitizeContent(codeBlock); + + if (!navigator.clipboard) { + console.error("Copy: Clipboard API is not supported in this browser."); + this.showFeedback(icons); + return; + } + + try { + await navigator.clipboard.writeText(sanitizedText); + this.showFeedback(icons); + } catch (err) { + console.error("Copy: Error while copying text.", err); + this.showFeedback(icons); + } + } +} + +export default () => new Copy(); diff --git a/gno.land/pkg/gnoweb/frontend/js/index.ts b/gno.land/pkg/gnoweb/frontend/js/index.ts new file mode 100644 index 00000000000..3927f794b94 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/index.ts @@ -0,0 +1,42 @@ +(() => { + interface Module { + selector: string; + path: string; + } + + const modules: Record = { + copy: { + selector: "[data-copy-btn]", + path: "/public/js/copy.js", + }, + help: { + selector: "#help", + path: "/public/js/realmhelp.js", + }, + searchBar: { + selector: "#header-searchbar", + path: "/public/js/searchbar.js", + }, + }; + + const loadModuleIfExists = async ({ selector, path }: Module): Promise => { + const element = document.querySelector(selector); + if (element) { + try { + const module = await import(path); + module.default(); + } catch (err) { + console.error(`Error while loading script ${path}:`, err); + } + } else { + console.warn(`Module not loaded: no element matches selector "${selector}"`); + } + }; + + const initModules = async (): Promise => { + const promises = Object.values(modules).map((module) => loadModuleIfExists(module)); + await Promise.all(promises); + }; + + document.addEventListener("DOMContentLoaded", initModules); +})(); diff --git a/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts b/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts new file mode 100644 index 00000000000..d72102e2a2e --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/realmhelp.ts @@ -0,0 +1,169 @@ +import { debounce } from "./utils"; + +class Help { + private DOM: { + el: HTMLElement | null; + funcs: HTMLElement[]; + addressInput: HTMLInputElement | null; + cmdModeSelect: HTMLSelectElement | null; + }; + + private funcList: HelpFunc[]; + + private static SELECTORS = { + container: "#help", + func: "[data-func]", + addressInput: "[data-role='help-input-addr']", + cmdModeSelect: "[data-role='help-select-mode']", + }; + + constructor() { + this.DOM = { + el: document.querySelector(Help.SELECTORS.container), + funcs: [], + addressInput: null, + cmdModeSelect: null, + }; + + this.funcList = []; + + if (this.DOM.el) { + this.init(); + } else { + console.warn("Help: Main container not found."); + } + } + + private init(): void { + const { el } = this.DOM; + if (!el) return; + + this.DOM.funcs = Array.from(el.querySelectorAll(Help.SELECTORS.func)); + this.DOM.addressInput = el.querySelector(Help.SELECTORS.addressInput); + this.DOM.cmdModeSelect = el.querySelector(Help.SELECTORS.cmdModeSelect); + + this.funcList = this.DOM.funcs.map((funcEl) => new HelpFunc(funcEl)); + + this.restoreAddress(); + this.bindEvents(); + } + + private restoreAddress(): void { + const { addressInput } = this.DOM; + if (addressInput) { + const storedAddress = localStorage.getItem("helpAddressInput"); + if (storedAddress) { + addressInput.value = storedAddress; + this.funcList.forEach((func) => func.updateAddr(storedAddress)); + } + } + } + + private bindEvents(): void { + const { addressInput, cmdModeSelect } = this.DOM; + + const debouncedUpdate = debounce((addressInput: HTMLInputElement) => { + const address = addressInput.value; + + localStorage.setItem("helpAddressInput", address); + this.funcList.forEach((func) => func.updateAddr(address)); + }); + addressInput?.addEventListener("input", () => debouncedUpdate(addressInput)); + + cmdModeSelect?.addEventListener("change", (e) => { + const target = e.target as HTMLSelectElement; + this.funcList.forEach((func) => func.updateMode(target.value)); + }); + } +} + +class HelpFunc { + private DOM: { + el: HTMLElement; + addrs: HTMLElement[]; + args: HTMLElement[]; + modes: HTMLElement[]; + paramInputs: HTMLInputElement[]; + }; + + private funcName: string | null; + + private static SELECTORS = { + address: "[data-role='help-code-address']", + args: "[data-role='help-code-args']", + mode: "[data-code-mode]", + paramInput: "[data-role='help-param-input']", + }; + + constructor(el: HTMLElement) { + this.DOM = { + el, + addrs: Array.from(el.querySelectorAll(HelpFunc.SELECTORS.address)), + args: Array.from(el.querySelectorAll(HelpFunc.SELECTORS.args)), + modes: Array.from(el.querySelectorAll(HelpFunc.SELECTORS.mode)), + paramInputs: Array.from(el.querySelectorAll(HelpFunc.SELECTORS.paramInput)), + }; + + this.funcName = el.dataset.func || null; + + this.initializeArgs(); + this.bindEvents(); + } + + private static sanitizeArgsInput(input: HTMLInputElement) { + const paramName = input.dataset.param || ""; + const paramValue = input.value.trim(); + + if (!paramName) { + console.warn("sanitizeArgsInput: param is missing in arg input dataset."); + } + + return { paramName, paramValue }; + } + + private bindEvents(): void { + const debouncedUpdate = debounce((paramName: string, paramValue: string) => { + if (paramName) this.updateArg(paramName, paramValue); + }); + + this.DOM.el.addEventListener("input", (e) => { + const target = e.target as HTMLInputElement; + if (target.dataset.role === "help-param-input") { + const { paramName, paramValue } = HelpFunc.sanitizeArgsInput(target); + debouncedUpdate(paramName, paramValue); + } + }); + } + + private initializeArgs(): void { + this.DOM.paramInputs.forEach((input) => { + const { paramName, paramValue } = HelpFunc.sanitizeArgsInput(input); + if (paramName) this.updateArg(paramName, paramValue); + }); + } + + public updateArg(paramName: string, paramValue: string): void { + this.DOM.args + .filter((arg) => arg.dataset.arg === paramName) + .forEach((arg) => { + arg.textContent = paramValue || ""; + }); + } + + public updateAddr(addr: string): void { + this.DOM.addrs.forEach((DOMaddr) => { + DOMaddr.textContent = addr.trim() || "ADDRESS"; + }); + } + + public updateMode(mode: string): void { + this.DOM.modes.forEach((cmd) => { + const isVisible = cmd.dataset.codeMode === mode; + cmd.classList.toggle("inline", isVisible); + cmd.classList.toggle("hidden", !isVisible); + cmd.dataset.copyContent = isVisible ? `help-cmd-${this.funcName}` : ""; + }); + } +} + +export default () => new Help(); diff --git a/gno.land/pkg/gnoweb/frontend/js/searchbar.ts b/gno.land/pkg/gnoweb/frontend/js/searchbar.ts new file mode 100644 index 00000000000..6cca444aa0f --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/searchbar.ts @@ -0,0 +1,74 @@ +class SearchBar { + private DOM: { + el: HTMLElement | null; + inputSearch: HTMLInputElement | null; + breadcrumb: HTMLElement | null; + }; + + private baseUrl: string; + + private static SELECTORS = { + container: "#header-searchbar", + inputSearch: "[data-role='header-input-search']", + breadcrumb: "[data-role='header-breadcrumb-search']", + }; + + constructor() { + this.DOM = { + el: document.querySelector(SearchBar.SELECTORS.container), + inputSearch: null, + breadcrumb: null, + }; + + this.baseUrl = window.location.origin; + + if (this.DOM.el) { + this.init(); + } else { + console.warn("SearchBar: Main container not found."); + } + } + + private init(): void { + const { el } = this.DOM; + + this.DOM.inputSearch = el?.querySelector(SearchBar.SELECTORS.inputSearch) ?? null; + this.DOM.breadcrumb = el?.querySelector(SearchBar.SELECTORS.breadcrumb) ?? null; + + if (!this.DOM.inputSearch) { + console.warn("SearchBar: Input element for search not found."); + } + + this.bindEvents(); + } + + private bindEvents(): void { + this.DOM.el?.addEventListener("submit", (e) => { + e.preventDefault(); + this.searchUrl(); + }); + } + + public searchUrl(): void { + const input = this.DOM.inputSearch?.value.trim(); + + if (input) { + let url = input; + + // Check if the URL has a proper scheme + if (!/^https?:\/\//i.test(url)) { + url = `${this.baseUrl}${url.startsWith("/") ? "" : "/"}${url}`; + } + + try { + window.location.href = new URL(url).href; + } catch (error) { + console.error("SearchBar: Invalid URL. Please enter a valid URL starting with http:// or https://."); + } + } else { + console.error("SearchBar: Please enter a URL to search."); + } + } +} + +export default () => new SearchBar(); diff --git a/gno.land/pkg/gnoweb/frontend/js/utils.ts b/gno.land/pkg/gnoweb/frontend/js/utils.ts new file mode 100644 index 00000000000..83de509efa5 --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/js/utils.ts @@ -0,0 +1,12 @@ +export function debounce void>(func: T, delay: number = 250): (...args: Parameters) => void { + let timeoutId: ReturnType | undefined; + + return function (this: any, ...args: Parameters) { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + timeoutId = setTimeout(() => { + func.apply(this, args); + }, delay); + }; +} diff --git a/gno.land/pkg/gnoweb/static/img/favicon.ico b/gno.land/pkg/gnoweb/frontend/static/favicon.ico similarity index 100% rename from gno.land/pkg/gnoweb/static/img/favicon.ico rename to gno.land/pkg/gnoweb/frontend/static/favicon.ico diff --git a/gno.land/pkg/gnoweb/frontend/static/fonts/intervar/Intervar.woff2 b/gno.land/pkg/gnoweb/frontend/static/fonts/intervar/Intervar.woff2 new file mode 100644 index 00000000000..891fc5cc567 Binary files /dev/null and b/gno.land/pkg/gnoweb/frontend/static/fonts/intervar/Intervar.woff2 differ diff --git a/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff b/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff new file mode 100644 index 00000000000..2c58fe2d6d7 Binary files /dev/null and b/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff differ diff --git a/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff2 b/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff2 new file mode 100644 index 00000000000..53d081f3a53 Binary files /dev/null and b/gno.land/pkg/gnoweb/frontend/static/fonts/roboto/roboto-mono-normal.woff2 differ diff --git a/gno.land/pkg/gnoweb/frontend/static/imgs/gnoland.svg b/gno.land/pkg/gnoweb/frontend/static/imgs/gnoland.svg new file mode 100644 index 00000000000..30d2f3ef56a --- /dev/null +++ b/gno.land/pkg/gnoweb/frontend/static/imgs/gnoland.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gno.land/pkg/gnoweb/gnoweb.go b/gno.land/pkg/gnoweb/gnoweb.go deleted file mode 100644 index 40d027d84b9..00000000000 --- a/gno.land/pkg/gnoweb/gnoweb.go +++ /dev/null @@ -1,608 +0,0 @@ -package gnoweb - -import ( - "bytes" - "embed" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "log/slog" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "runtime" - "strings" - "time" - - "github.com/gnolang/gno/gnovm" - "github.com/gnolang/gno/tm2/pkg/amino" - abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" - "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" - "github.com/gorilla/mux" - "github.com/gotuna/gotuna" - - // for static files - "github.com/gnolang/gno/gno.land/pkg/gnoweb/static" - "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types - // "github.com/gnolang/gno/tm2/pkg/sdk" // for baseapp (info, status) -) - -const ( - qFileStr = "vm/qfile" - gnowebArgsSeparator = "$" - urlQuerySeparator = "?" -) - -//go:embed views/* -var defaultViewsFiles embed.FS - -type Config struct { - RemoteAddr string - CaptchaSite string - FaucetURL string - ViewsDir string - HelpChainID string - HelpRemote string - WithAnalytics bool - WithHTML bool -} - -func NewDefaultConfig() Config { - return Config{ - RemoteAddr: "127.0.0.1:26657", - CaptchaSite: "", - FaucetURL: "http://localhost:5050", - ViewsDir: "", - HelpChainID: "dev", - HelpRemote: "127.0.0.1:26657", - WithAnalytics: false, - WithHTML: false, - } -} - -func MakeApp(logger *slog.Logger, cfg Config) gotuna.App { - var viewFiles fs.FS - - // Get specific views directory if specified - if cfg.ViewsDir != "" { - viewFiles = os.DirFS(cfg.ViewsDir) - } else { - // Get embed views - var err error - viewFiles, err = fs.Sub(defaultViewsFiles, "views") - if err != nil { - panic("unable to get views directory from embed fs: " + err.Error()) - } - } - - app := gotuna.App{ - ViewFiles: viewFiles, - Router: gotuna.NewMuxRouter(), - Static: static.EmbeddedStatic, - } - - for from, to := range Aliases { - app.Router.Handle(from, handlerRealmAlias(logger, app, &cfg, to)) - } - - for from, to := range Redirects { - app.Router.Handle(from, handlerRedirect(logger, app, &cfg, to)) - } - // realm routes - // NOTE: see rePathPart. - app.Router.Handle("/r/{rlmname:[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)+}/{filename:(?:(?:.*\\.(?:gno|md|txt|mod)$)|(?:LICENSE$))?}", handlerRealmFile(logger, app, &cfg)) - app.Router.Handle("/r/{rlmname:[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)+}{args:(?:\\$.*)?}", handlerRealmMain(logger, app, &cfg)) - app.Router.Handle("/r/{rlmname:[a-z][a-z0-9_]*(?:/[a-z][a-z0-9_]*)+}:{querystr:[^$]*}{args:(?:\\$.*)?}", handlerRealmRender(logger, app, &cfg)) - app.Router.Handle("/p/{filepath:.*}", handlerPackageFile(logger, app, &cfg)) - - // other - app.Router.Handle("/faucet", handlerFaucet(logger, app, &cfg)) - app.Router.Handle("/static/{path:.+}", handlerStaticFile(logger, app, &cfg)) - app.Router.Handle("/favicon.ico", handlerFavicon(logger, app, &cfg)) - - // api - app.Router.Handle("/status.json", handlerStatusJSON(logger, app, &cfg)) - - app.Router.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := r.RequestURI - handleNotFound(logger, app, &cfg, path, w, r) - }) - return app -} - -var ( - inlineCodePattern = regexp.MustCompile("`[^`]*`") - htmlTagPattern = regexp.MustCompile(`<\/?\w+[^>]*?>`) -) - -func sanitizeContent(cfg *Config, content string) string { - if cfg.WithHTML { - return content - } - - placeholders := map[string]string{} - contentWithPlaceholders := inlineCodePattern.ReplaceAllStringFunc(content, func(match string) string { - placeholder := fmt.Sprintf("__GNOMDCODE_%d__", len(placeholders)) - placeholders[placeholder] = match - return placeholder - }) - - sanitizedContent := htmlTagPattern.ReplaceAllString(contentWithPlaceholders, "") - - if len(placeholders) > 0 { - for placeholder, code := range placeholders { - sanitizedContent = strings.ReplaceAll(sanitizedContent, placeholder, code) - } - } - - return sanitizedContent -} - -// handlerRealmAlias is used to render official pages from realms. -// url is intended to be shorter. -// UX is intended to be more minimalistic. -// A link to the realm realm is added. -func handlerRealmAlias(logger *slog.Logger, app gotuna.App, cfg *Config, rlmpath string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rlmfullpath := "gno.land" + rlmpath - querystr := "" // XXX: "?gnoweb-alias=1" - parts := strings.Split(rlmpath, ":") - switch len(parts) { - case 1: // continue - case 2: // r/realm:querystr - rlmfullpath = "gno.land" + parts[0] - querystr = parts[1] + querystr - default: - panic("should not happen") - } - rlmname := strings.TrimPrefix(rlmfullpath, "gno.land/r/") - qpath := "vm/qrender" - data := []byte(fmt.Sprintf("%s:%s", rlmfullpath, querystr)) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, fmt.Errorf("gnoweb failed to query gnoland: %w", err)) - return - } - - queryParts := strings.Split(querystr, "/") - pathLinks := []pathLink{} - for i, part := range queryParts { - pathLinks = append(pathLinks, pathLink{ - URL: "/r/" + rlmname + ":" + strings.Join(queryParts[:i+1], "/"), - Text: part, - }) - } - - tmpl := app.NewTemplatingEngine() - // XXX: extract title from realm's output - // XXX: extract description from realm's output - tmpl.Set("RealmName", rlmname) - tmpl.Set("RealmPath", rlmpath) - tmpl.Set("Query", querystr) - tmpl.Set("PathLinks", pathLinks) - tmpl.Set("Contents", sanitizeContent(cfg, string(res.Data))) - tmpl.Set("Config", cfg) - tmpl.Set("IsAlias", true) - tmpl.Render(w, r, "realm_render.html", "funcs.html") - }) -} - -func handlerFaucet(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - app.NewTemplatingEngine(). - Set("Config", cfg). - Render(w, r, "faucet.html", "funcs.html") - }) -} - -func handlerStatusJSON(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - startedAt := time.Now() - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var ret struct { - Gnoland struct { - Connected bool `json:"connected"` - Error *string `json:"error,omitempty"` - Height *int64 `json:"height,omitempty"` - // processed txs - // active connections - - Version *string `json:"version,omitempty"` - // Uptime *float64 `json:"uptime-seconds,omitempty"` - // Goarch *string `json:"goarch,omitempty"` - // Goos *string `json:"goos,omitempty"` - // GoVersion *string `json:"go-version,omitempty"` - // NumCPU *int `json:"num_cpu,omitempty"` - } `json:"gnoland"` - Website struct { - // Version string `json:"version"` - Uptime float64 `json:"uptime-seconds"` - Goarch string `json:"goarch"` - Goos string `json:"goos"` - GoVersion string `json:"go-version"` - NumCPU int `json:"num_cpu"` - } `json:"website"` - } - ret.Website.Uptime = time.Since(startedAt).Seconds() - ret.Website.Goarch = runtime.GOARCH - ret.Website.Goos = runtime.GOOS - ret.Website.NumCPU = runtime.NumCPU() - ret.Website.GoVersion = runtime.Version() - - ret.Gnoland.Connected = true - res, err := makeRequest(logger, cfg, ".app/version", []byte{}) - if err != nil { - ret.Gnoland.Connected = false - errmsg := err.Error() - ret.Gnoland.Error = &errmsg - } else { - version := string(res.Value) - ret.Gnoland.Version = &version - ret.Gnoland.Height = &res.Height - } - - out, _ := json.MarshalIndent(ret, "", " ") - w.Header().Set("Content-Type", "application/json") - w.Write(out) - }) -} - -func handlerRedirect(logger *slog.Logger, app gotuna.App, cfg *Config, to string) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, to, http.StatusFound) - tmpl := app.NewTemplatingEngine() - tmpl.Set("To", to) - tmpl.Set("Config", cfg) - tmpl.Render(w, r, "redirect.html", "funcs.html") - }) -} - -func handlerRealmMain(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - args, err := parseGnowebArgs(r.RequestURI) - if err != nil { - writeError(logger, w, err) - return - } - - vars := mux.Vars(r) - rlmname := vars["rlmname"] - rlmpath := "gno.land/r/" + rlmname - - logger.Info("handling", "name", rlmname, "path", rlmpath) - if args.Has("help") { - // Render function helper. - funcName := args.Get("func") - qpath := "vm/qfuncs" - data := []byte(rlmpath) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, fmt.Errorf("request failed: %w", err)) - return - } - var fsigs vm.FunctionSignatures - amino.MustUnmarshalJSON(res.Data, &fsigs) - // Fill fsigs with query parameters. - for i := range fsigs { - fsig := &(fsigs[i]) - for j := range fsig.Params { - param := &(fsig.Params[j]) - value := args.Get(param.Name) - param.Value = value - } - } - // Render template. - tmpl := app.NewTemplatingEngine() - tmpl.Set("FuncName", funcName) - tmpl.Set("RealmPath", rlmpath) - tmpl.Set("DirPath", pathOf(rlmpath)) - tmpl.Set("FunctionSignatures", fsigs) - tmpl.Set("Config", cfg) - tmpl.Render(w, r, "realm_help.html", "funcs.html") - } else { - // Ensure realm exists. TODO optimize. - qpath := qFileStr - data := []byte(rlmpath) - _, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, errors.New("error querying realm package")) - return - } - // Render blank query path, /r/REALM:. - handleRealmRender(logger, app, cfg, w, r) - } - }) -} - -type pathLink struct { - URL string - Text string -} - -func handlerRealmRender(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - handleRealmRender(logger, app, cfg, w, r) - }) -} - -func handleRealmRender(logger *slog.Logger, app gotuna.App, cfg *Config, w http.ResponseWriter, r *http.Request) { - gnowebArgs, err := parseGnowebArgs(r.RequestURI) - if err != nil { - writeError(logger, w, err) - return - } - - queryArgs, err := parseQueryArgs(r.RequestURI) - if err != nil { - writeError(logger, w, err) - return - } - - var urlQuery, gnowebQuery string - if len(queryArgs) > 0 { - urlQuery = urlQuerySeparator + queryArgs.Encode() - } - if len(gnowebArgs) > 0 { - gnowebQuery = gnowebArgsSeparator + gnowebArgs.Encode() - } - - vars := mux.Vars(r) - rlmname := vars["rlmname"] - rlmpath := "gno.land/r/" + rlmname - querystr := vars["querystr"] - if r.URL.Path == "/r/"+rlmname+":" { - // Redirect to /r/REALM if querypath is empty. - http.Redirect(w, r, "/r/"+rlmname+urlQuery+gnowebQuery, http.StatusFound) - return - } - - qpath := "vm/qrender" - data := []byte(fmt.Sprintf("%s:%s", rlmpath, querystr+urlQuery)) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - // XXX hack - if strings.Contains(err.Error(), "Render not declared") { - res = &abci.ResponseQuery{} - res.Data = []byte("realm package has no Render() function") - } else { - writeError(logger, w, err) - return - } - } - - dirdata := []byte(rlmpath) - dirres, err := makeRequest(logger, cfg, qFileStr, dirdata) - if err != nil { - writeError(logger, w, err) - return - } - hasReadme := bytes.Contains(append(dirres.Data, '\n'), []byte("README.md\n")) - - // linkify querystr. - queryParts := strings.Split(querystr, "/") - pathLinks := []pathLink{} - for i, part := range queryParts { - rlmpath := strings.Join(queryParts[:i+1], "/") - - // Add URL query arguments to the last breadcrumb item's URL - if i+1 == len(queryParts) { - rlmpath = rlmpath + urlQuery + gnowebQuery - } - - pathLinks = append(pathLinks, pathLink{ - URL: "/r/" + rlmname + ":" + rlmpath, - Text: part, - }) - } - - // Render template. - tmpl := app.NewTemplatingEngine() - // XXX: extract title from realm's output - // XXX: extract description from realm's output - tmpl.Set("RealmName", rlmname) - tmpl.Set("RealmPath", rlmpath) - tmpl.Set("Query", querystr) - tmpl.Set("PathLinks", pathLinks) - tmpl.Set("Contents", sanitizeContent(cfg, string(res.Data))) - tmpl.Set("Config", cfg) - tmpl.Set("HasReadme", hasReadme) - tmpl.Render(w, r, "realm_render.html", "funcs.html") -} - -func handlerRealmFile(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - diruri := "gno.land/r/" + vars["rlmname"] - filename := vars["filename"] - renderPackageFile(logger, app, cfg, w, r, diruri, filename) - }) -} - -func handlerPackageFile(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - pkgpath := "gno.land/p/" + vars["filepath"] - diruri, filename := gnovm.SplitFilepath(pkgpath) - if filename == "" && diruri == pkgpath { - // redirect to diruri + "/" - http.Redirect(w, r, "/p/"+vars["filepath"]+"/", http.StatusFound) - return - } - renderPackageFile(logger, app, cfg, w, r, diruri, filename) - }) -} - -func renderPackageFile(logger *slog.Logger, app gotuna.App, cfg *Config, w http.ResponseWriter, r *http.Request, diruri string, filename string) { - if filename == "" { - // Request is for a folder. - qpath := qFileStr - data := []byte(diruri) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, err) - return - } - files := strings.Split(string(res.Data), "\n") - // Render template. - tmpl := app.NewTemplatingEngine() - tmpl.Set("DirURI", diruri) - tmpl.Set("DirPath", pathOf(diruri)) - tmpl.Set("Files", files) - tmpl.Set("Config", cfg) - tmpl.Render(w, r, "package_dir.html", "funcs.html") - } else { - // Request is for a file. - filepath := diruri + "/" + filename - qpath := qFileStr - data := []byte(filepath) - res, err := makeRequest(logger, cfg, qpath, data) - if err != nil { - writeError(logger, w, err) - return - } - // Render template. - tmpl := app.NewTemplatingEngine() - tmpl.Set("DirURI", diruri) - tmpl.Set("DirPath", pathOf(diruri)) - tmpl.Set("FileName", filename) - tmpl.Set("FileContents", string(res.Data)) - tmpl.Set("Config", cfg) - tmpl.Render(w, r, "package_file.html", "funcs.html") - } -} - -func makeRequest(log *slog.Logger, cfg *Config, qpath string, data []byte) (res *abci.ResponseQuery, err error) { - opts2 := client.ABCIQueryOptions{ - // Height: height, XXX - // Prove: false, XXX - } - remote := cfg.RemoteAddr - cli, err := client.NewHTTPClient(remote) - if err != nil { - return nil, fmt.Errorf("unable to create HTTP client, %w", err) - } - - qres, err := cli.ABCIQueryWithOptions( - qpath, data, opts2) - if err != nil { - log.Error("request error", "path", qpath, "error", err) - return nil, fmt.Errorf("unable to query path %q: %w", qpath, err) - } - if qres.Response.Error != nil { - log.Error("response error", "path", qpath, "log", qres.Response.Log) - return nil, qres.Response.Error - } - return &qres.Response, nil -} - -func handlerStaticFile(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - fs := http.FS(app.Static) - fileapp := http.StripPrefix("/static", http.FileServer(fs)) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - fpath := filepath.Clean(vars["path"]) - f, err := fs.Open(fpath) - if os.IsNotExist(err) { - handleNotFound(logger, app, cfg, fpath, w, r) - return - } - stat, err := f.Stat() - if err != nil || stat.IsDir() { - handleNotFound(logger, app, cfg, fpath, w, r) - return - } - - // TODO: ModTime doesn't work for embed? - // w.Header().Set("ETag", fmt.Sprintf("%x", stat.ModTime().UnixNano())) - // w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%s", "31536000")) - fileapp.ServeHTTP(w, r) - }) -} - -func handlerFavicon(logger *slog.Logger, app gotuna.App, cfg *Config) http.Handler { - fs := http.FS(app.Static) - - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fpath := "img/favicon.ico" - f, err := fs.Open(fpath) - if os.IsNotExist(err) { - handleNotFound(logger, app, cfg, fpath, w, r) - return - } - w.Header().Set("Content-Type", "image/x-icon") - w.Header().Set("Cache-Control", "public, max-age=604800") // 7d - io.Copy(w, f) - }) -} - -func handleNotFound(logger *slog.Logger, app gotuna.App, cfg *Config, path string, w http.ResponseWriter, r *http.Request) { - // decode path for non-ascii characters - decodedPath, err := url.PathUnescape(path) - if err != nil { - logger.Error("failed to decode path", "error", err) - decodedPath = path - } - w.WriteHeader(http.StatusNotFound) - app.NewTemplatingEngine(). - Set("title", "Not found"). - Set("path", decodedPath). - Set("Config", cfg). - Render(w, r, "404.html", "funcs.html") -} - -func writeError(logger *slog.Logger, w http.ResponseWriter, err error) { - if details := errors.Unwrap(err); details != nil { - logger.Error("handler", "error", err, "details", details) - } else { - logger.Error("handler", "error", err) - } - - // XXX: writeError should return an error page template. - w.WriteHeader(500) - w.Write([]byte(err.Error())) -} - -func pathOf(diruri string) string { - parts := strings.Split(diruri, "/") - if parts[0] == "gno.land" { - return "/" + strings.Join(parts[1:], "/") - } - - panic(fmt.Sprintf("invalid dir-URI %q", diruri)) -} - -// parseQueryArgs parses URL query arguments that are not specific to gnoweb. -// These are the standard arguments that comes after the "?" symbol and before -// the special "$" symbol. The "$" symbol can be used within public query -// arguments by using its encoded representation "%24". -func parseQueryArgs(rawURL string) (url.Values, error) { - if i := strings.Index(rawURL, gnowebArgsSeparator); i != -1 { - rawURL = rawURL[:i] - } - - u, err := url.Parse(rawURL) - if err != nil { - return url.Values{}, fmt.Errorf("invalid query arguments: %w", err) - } - return u.Query(), nil -} - -// parseGnowebArgs parses URL query arguments that are specific to gnoweb. -// These arguments are indicated by using the "$" symbol followed by a query -// string with the arguments. -func parseGnowebArgs(rawURL string) (url.Values, error) { - i := strings.Index(rawURL, gnowebArgsSeparator) - if i == -1 { - return url.Values{}, nil - } - - values, err := url.ParseQuery(rawURL[i+1:]) - if err != nil { - return url.Values{}, fmt.Errorf("invalid gnoweb arguments: %w", err) - } - return values, nil -} diff --git a/gno.land/pkg/gnoweb/handler.go b/gno.land/pkg/gnoweb/handler.go new file mode 100644 index 00000000000..b3a9fcd143c --- /dev/null +++ b/gno.land/pkg/gnoweb/handler.go @@ -0,0 +1,381 @@ +package gnoweb + +import ( + "bytes" + "errors" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/gnolang/gno/gno.land/pkg/gnoweb/components" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" // for error types +) + +const DefaultChainDomain = "gno.land" + +type StaticMetadata struct { + AssetsPath string + ChromaPath string + RemoteHelp string + ChainId string + Analytics bool +} + +type WebHandlerConfig struct { + Meta StaticMetadata + RenderClient *WebClient + Formatter Formatter +} + +type WebHandler struct { + formatter Formatter + + logger *slog.Logger + static StaticMetadata + webcli *WebClient +} + +func NewWebHandler(logger *slog.Logger, cfg WebHandlerConfig) *WebHandler { + if cfg.RenderClient == nil { + logger.Error("no renderer has been defined") + } + + return &WebHandler{ + formatter: cfg.Formatter, + webcli: cfg.RenderClient, + logger: logger, + static: cfg.Meta, + } +} + +func (h *WebHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.logger.Debug("receiving request", "method", r.Method, "path", r.URL.Path) + + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + h.Get(w, r) +} + +func (h *WebHandler) Get(w http.ResponseWriter, r *http.Request) { + var body bytes.Buffer + + start := time.Now() + defer func() { + h.logger.Debug("request completed", + "url", r.URL.String(), + "elapsed", time.Since(start).String()) + }() + + var indexData components.IndexData + indexData.HeadData.AssetsPath = h.static.AssetsPath + indexData.HeadData.ChromaPath = h.static.ChromaPath + indexData.FooterData.Analytics = h.static.Analytics + indexData.FooterData.AssetsPath = h.static.AssetsPath + + // Render the page body into the buffer + var status int + gnourl, err := ParseGnoURL(r.URL) + if err != nil { + h.logger.Warn("page not found", "path", r.URL.Path, "err", err) + status, err = http.StatusNotFound, components.RenderStatusComponent(&body, "page not found") + } else { + // TODO: real data (title & description) + indexData.HeadData.Title = "gno.land - " + gnourl.Path + + // Header + indexData.HeaderData.RealmPath = gnourl.Path + indexData.HeaderData.Breadcrumb.Parts = generateBreadcrumbPaths(gnourl.Path) + indexData.HeaderData.WebQuery = gnourl.WebQuery + + // Render + switch gnourl.Kind() { + case KindRealm, KindPure: + status, err = h.renderPackage(&body, gnourl) + default: + h.logger.Debug("invalid page kind", "kind", gnourl.Kind) + status, err = http.StatusNotFound, components.RenderStatusComponent(&body, "page not found") + } + } + + if err != nil { + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(status) + + // NOTE: HTML escaping should have already been done by markdown rendering package + indexData.Body = template.HTML(body.String()) //nolint:gosec + + // Render the final page with the rendered body + if err = components.RenderIndexComponent(w, indexData); err != nil { + h.logger.Error("failed to render index component", "err", err) + } + + return +} + +func (h *WebHandler) renderPackage(w io.Writer, gnourl *GnoURL) (status int, err error) { + h.logger.Info("component render", "path", gnourl.Path, "args", gnourl.Args) + + kind := gnourl.Kind() + + // Display realm help page? + if kind == KindRealm && gnourl.WebQuery.Has("help") { + return h.renderRealmHelp(w, gnourl) + } + + // Display package source page? + switch { + case gnourl.WebQuery.Has("source"): + return h.renderRealmSource(w, gnourl) + case kind == KindPure, + strings.HasSuffix(gnourl.Path, "/"), + isFile(gnourl.Path): + i := strings.LastIndexByte(gnourl.Path, '/') + if i < 0 { + return http.StatusInternalServerError, fmt.Errorf("unable to get ending slash for %q", gnourl.Path) + } + + // Fill webquery with file infos + gnourl.WebQuery.Set("source", "") // set source + + file := gnourl.Path[i+1:] + if file == "" { + return h.renderRealmDirectory(w, gnourl) + } + + gnourl.WebQuery.Set("file", file) + gnourl.Path = gnourl.Path[:i] + + return h.renderRealmSource(w, gnourl) + } + + // Render content into the content buffer + var content bytes.Buffer + meta, err := h.webcli.Render(&content, gnourl.Path, gnourl.EncodeArgs()) + if err != nil { + if errors.Is(err, vm.InvalidPkgPathError{}) { + return http.StatusNotFound, components.RenderStatusComponent(w, "not found") + } + + h.logger.Error("unable to render markdown", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + err = components.RenderRealmComponent(w, components.RealmData{ + TocItems: &components.RealmTOCData{ + Items: meta.Items, + }, + // NOTE: `content` should have already been escaped by + Content: template.HTML(content.String()), //nolint:gosec + }) + if err != nil { + h.logger.Error("unable to render template", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + // Write the rendered content to the response writer + return http.StatusOK, nil +} + +func (h *WebHandler) renderRealmHelp(w io.Writer, gnourl *GnoURL) (status int, err error) { + fsigs, err := h.webcli.Functions(gnourl.Path) + if err != nil { + h.logger.Error("unable to fetch path functions", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + var selArgs map[string]string + var selFn string + if selFn = gnourl.WebQuery.Get("func"); selFn != "" { + for _, fn := range fsigs { + if selFn != fn.FuncName { + continue + } + + selArgs = make(map[string]string) + for _, param := range fn.Params { + selArgs[param.Name] = gnourl.WebQuery.Get(param.Name) + } + + fsigs = []vm.FunctionSignature{fn} + break + } + } + + // Catch last name of the path + // XXX: we should probably add a helper within the template + realmName := filepath.Base(gnourl.Path) + err = components.RenderHelpComponent(w, components.HelpData{ + SelectedFunc: selFn, + SelectedArgs: selArgs, + RealmName: realmName, + ChainId: h.static.ChainId, + // TODO: get chain domain and use that. + PkgPath: filepath.Join(DefaultChainDomain, gnourl.Path), + Remote: h.static.RemoteHelp, + Functions: fsigs, + }) + if err != nil { + h.logger.Error("unable to render helper", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + return http.StatusOK, nil +} + +func (h *WebHandler) renderRealmSource(w io.Writer, gnourl *GnoURL) (status int, err error) { + pkgPath := gnourl.Path + + files, err := h.webcli.Sources(pkgPath) + if err != nil { + h.logger.Error("unable to list sources file", "path", gnourl.Path, "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + if len(files) == 0 { + h.logger.Debug("no files available", "path", gnourl.Path) + return http.StatusOK, components.RenderStatusComponent(w, "no files available") + } + + var fileName string + file := gnourl.WebQuery.Get("file") + if file == "" { + fileName = files[0] + } else if slices.Contains(files, file) { + fileName = file + } else { + h.logger.Error("unable to render source", "file", file, "err", "file does not exist") + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + source, err := h.webcli.SourceFile(pkgPath, fileName) + if err != nil { + h.logger.Error("unable to get source file", "file", fileName, "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + // XXX: we should either do this on the front or in the markdown parsing side + fileLines := strings.Count(string(source), "\n") + fileSizeKb := float64(len(source)) / 1024.0 + fileSizeStr := fmt.Sprintf("%.2f Kb", fileSizeKb) + + // Highlight code source + hsource, err := h.highlightSource(fileName, source) + if err != nil { + h.logger.Error("unable to highlight source file", "file", fileName, "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + err = components.RenderSourceComponent(w, components.SourceData{ + PkgPath: gnourl.Path, + Files: files, + FileName: fileName, + FileCounter: len(files), + FileLines: fileLines, + FileSize: fileSizeStr, + FileSource: template.HTML(hsource), //nolint:gosec + }) + if err != nil { + h.logger.Error("unable to render helper", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + return http.StatusOK, nil +} + +func (h *WebHandler) renderRealmDirectory(w io.Writer, gnourl *GnoURL) (status int, err error) { + pkgPath := gnourl.Path + + files, err := h.webcli.Sources(pkgPath) + if err != nil { + h.logger.Error("unable to list sources file", "path", gnourl.Path, "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + if len(files) == 0 { + h.logger.Debug("no files available", "path", gnourl.Path) + return http.StatusOK, components.RenderStatusComponent(w, "no files available") + } + + err = components.RenderDirectoryComponent(w, components.DirData{ + PkgPath: gnourl.Path, + Files: files, + FileCounter: len(files), + }) + if err != nil { + h.logger.Error("unable to render directory", "err", err) + return http.StatusInternalServerError, components.RenderStatusComponent(w, "internal error") + } + + return http.StatusOK, nil +} + +func (h *WebHandler) highlightSource(fileName string, src []byte) ([]byte, error) { + var lexer chroma.Lexer + + 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") // file kind not supported, fallback on `.txt` + } + + if lexer == nil { + return nil, fmt.Errorf("unsupported lexer for file %q", fileName) + } + + iterator, err := lexer.Tokenise(nil, string(src)) + if err != nil { + h.logger.Error("unable to ", "fileName", fileName, "err", err) + } + + var buff bytes.Buffer + if err := h.formatter.Format(&buff, iterator); err != nil { + return nil, fmt.Errorf("unable to format source file %q: %w", fileName, err) + } + + return buff.Bytes(), nil +} + +func generateBreadcrumbPaths(path string) []components.BreadcrumbPart { + split := strings.Split(path, "/") + parts := []components.BreadcrumbPart{} + + var name string + for i := range split { + if name = split[i]; name == "" { + continue + } + + parts = append(parts, components.BreadcrumbPart{ + Name: name, + Path: strings.Join(split[:i+1], "/"), + }) + } + + return parts +} + +// IsFile checks if the last element of the path is a file (has an extension) +func isFile(path string) bool { + base := filepath.Base(path) + ext := filepath.Ext(base) + return ext != "" +} diff --git a/gno.land/pkg/gnoweb/markdown/highlighting.go b/gno.land/pkg/gnoweb/markdown/highlighting.go new file mode 100644 index 00000000000..51c66674df1 --- /dev/null +++ b/gno.land/pkg/gnoweb/markdown/highlighting.go @@ -0,0 +1,588 @@ +// This file was copied from https://github.com/yuin/goldmark-highlighting +// +// package highlighting is an extension for the goldmark(http://github.com/yuin/goldmark). +// +// This extension adds syntax-highlighting to the fenced code blocks using +// chroma(https://github.com/alecthomas/chroma). +package markdown + +import ( + "bytes" + "io" + "strconv" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" + + "github.com/alecthomas/chroma/v2" + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" +) + +// ImmutableAttributes is a read-only interface for ast.Attributes. +type ImmutableAttributes interface { + // Get returns (value, true) if an attribute associated with given + // name exists, otherwise (nil, false) + Get(name []byte) (interface{}, bool) + + // GetString returns (value, true) if an attribute associated with given + // name exists, otherwise (nil, false) + GetString(name string) (interface{}, bool) + + // All returns all attributes. + All() []ast.Attribute +} + +type immutableAttributes struct { + n ast.Node +} + +func (a *immutableAttributes) Get(name []byte) (interface{}, bool) { + return a.n.Attribute(name) +} + +func (a *immutableAttributes) GetString(name string) (interface{}, bool) { + return a.n.AttributeString(name) +} + +func (a *immutableAttributes) All() []ast.Attribute { + if a.n.Attributes() == nil { + return []ast.Attribute{} + } + return a.n.Attributes() +} + +// CodeBlockContext holds contextual information of code highlighting. +type CodeBlockContext interface { + // Language returns (language, true) if specified, otherwise (nil, false). + Language() ([]byte, bool) + + // Highlighted returns true if this code block can be highlighted, otherwise false. + Highlighted() bool + + // Attributes return attributes of the code block. + Attributes() ImmutableAttributes +} + +type codeBlockContext struct { + language []byte + highlighted bool + attributes ImmutableAttributes +} + +func newCodeBlockContext(language []byte, highlighted bool, attrs ImmutableAttributes) CodeBlockContext { + return &codeBlockContext{ + language: language, + highlighted: highlighted, + attributes: attrs, + } +} + +func (c *codeBlockContext) Language() ([]byte, bool) { + if c.language != nil { + return c.language, true + } + return nil, false +} + +func (c *codeBlockContext) Highlighted() bool { + return c.highlighted +} + +func (c *codeBlockContext) Attributes() ImmutableAttributes { + return c.attributes +} + +// WrapperRenderer renders wrapper elements like div, pre, etc. +type WrapperRenderer func(w util.BufWriter, context CodeBlockContext, entering bool) + +// CodeBlockOptions creates Chroma options per code block. +type CodeBlockOptions func(ctx CodeBlockContext) []chromahtml.Option + +// Config struct holds options for the extension. +type Config struct { + html.Config + + // Style is a highlighting style. + // Supported styles are defined under https://github.com/alecthomas/chroma/tree/master/formatters. + Style string + + // Pass in a custom Chroma style. If this is not nil, the Style string will be ignored + CustomStyle *chroma.Style + + // If set, will try to guess language if none provided. + // If the guessing fails, we will fall back to a text lexer. + // Note that while Chroma's API supports language guessing, the implementation + // is not there yet, so you will currently always get the basic text lexer. + GuessLanguage bool + + // FormatOptions is a option related to output formats. + // See https://github.com/alecthomas/chroma#the-html-formatter for details. + FormatOptions []chromahtml.Option + + // CSSWriter is an io.Writer that will be used as CSS data output buffer. + // If WithClasses() is enabled, you can get CSS data corresponds to the style. + CSSWriter io.Writer + + // CodeBlockOptions allows set Chroma options per code block. + CodeBlockOptions CodeBlockOptions + + // WrapperRenderer allows you to change wrapper elements. + WrapperRenderer WrapperRenderer +} + +// NewConfig returns a new Config with defaults. +func NewConfig() Config { + return Config{ + Config: html.NewConfig(), + Style: "github", + FormatOptions: []chromahtml.Option{}, + CSSWriter: nil, + WrapperRenderer: nil, + CodeBlockOptions: nil, + } +} + +// SetOption implements renderer.SetOptioner. +func (c *Config) SetOption(name renderer.OptionName, value interface{}) { + switch name { + case optStyle: + c.Style = value.(string) + case optCustomStyle: + c.CustomStyle = value.(*chroma.Style) + case optFormatOptions: + if value != nil { + c.FormatOptions = value.([]chromahtml.Option) + } + case optCSSWriter: + c.CSSWriter = value.(io.Writer) + case optWrapperRenderer: + c.WrapperRenderer = value.(WrapperRenderer) + case optCodeBlockOptions: + c.CodeBlockOptions = value.(CodeBlockOptions) + case optGuessLanguage: + c.GuessLanguage = value.(bool) + default: + c.Config.SetOption(name, value) + } +} + +// Option interface is a functional option interface for the extension. +type Option interface { + renderer.Option + // SetHighlightingOption sets given option to the extension. + SetHighlightingOption(*Config) +} + +type withHTMLOptions struct { + value []html.Option +} + +func (o *withHTMLOptions) SetConfig(c *renderer.Config) { + if o.value != nil { + for _, v := range o.value { + v.(renderer.Option).SetConfig(c) + } + } +} + +func (o *withHTMLOptions) SetHighlightingOption(c *Config) { + if o.value != nil { + for _, v := range o.value { + v.SetHTMLOption(&c.Config) + } + } +} + +// WithHTMLOptions is functional option that wraps goldmark HTMLRenderer options. +func WithHTMLOptions(opts ...html.Option) Option { + return &withHTMLOptions{opts} +} + +const ( + optStyle renderer.OptionName = "HighlightingStyle" + optCustomStyle renderer.OptionName = "HighlightingCustomStyle" +) + +var highlightLinesAttrName = []byte("hl_lines") + +var ( + styleAttrName = []byte("hl_style") + nohlAttrName = []byte("nohl") + linenosAttrName = []byte("linenos") + linenosTableAttrValue = []byte("table") + linenosInlineAttrValue = []byte("inline") + linenostartAttrName = []byte("linenostart") +) + +type withStyle struct { + value string +} + +func (o *withStyle) SetConfig(c *renderer.Config) { + c.Options[optStyle] = o.value +} + +func (o *withStyle) SetHighlightingOption(c *Config) { + c.Style = o.value +} + +// WithStyle is a functional option that changes highlighting style. +func WithStyle(style string) Option { + return &withStyle{style} +} + +type withCustomStyle struct { + value *chroma.Style +} + +func (o *withCustomStyle) SetConfig(c *renderer.Config) { + c.Options[optCustomStyle] = o.value +} + +func (o *withCustomStyle) SetHighlightingOption(c *Config) { + c.CustomStyle = o.value +} + +// WithStyle is a functional option that changes highlighting style. +func WithCustomStyle(style *chroma.Style) Option { + return &withCustomStyle{style} +} + +const optCSSWriter renderer.OptionName = "HighlightingCSSWriter" + +type withCSSWriter struct { + value io.Writer +} + +func (o *withCSSWriter) SetConfig(c *renderer.Config) { + c.Options[optCSSWriter] = o.value +} + +func (o *withCSSWriter) SetHighlightingOption(c *Config) { + c.CSSWriter = o.value +} + +// WithCSSWriter is a functional option that sets io.Writer for CSS data. +func WithCSSWriter(w io.Writer) Option { + return &withCSSWriter{w} +} + +const optGuessLanguage renderer.OptionName = "HighlightingGuessLanguage" + +type withGuessLanguage struct { + value bool +} + +func (o *withGuessLanguage) SetConfig(c *renderer.Config) { + c.Options[optGuessLanguage] = o.value +} + +func (o *withGuessLanguage) SetHighlightingOption(c *Config) { + c.GuessLanguage = o.value +} + +// WithGuessLanguage is a functional option that toggles language guessing +// if none provided. +func WithGuessLanguage(b bool) Option { + return &withGuessLanguage{value: b} +} + +const optWrapperRenderer renderer.OptionName = "HighlightingWrapperRenderer" + +type withWrapperRenderer struct { + value WrapperRenderer +} + +func (o *withWrapperRenderer) SetConfig(c *renderer.Config) { + c.Options[optWrapperRenderer] = o.value +} + +func (o *withWrapperRenderer) SetHighlightingOption(c *Config) { + c.WrapperRenderer = o.value +} + +// WithWrapperRenderer is a functional option that sets WrapperRenderer that +// renders wrapper elements like div, pre, etc. +func WithWrapperRenderer(w WrapperRenderer) Option { + return &withWrapperRenderer{w} +} + +const optCodeBlockOptions renderer.OptionName = "HighlightingCodeBlockOptions" + +type withCodeBlockOptions struct { + value CodeBlockOptions +} + +func (o *withCodeBlockOptions) SetConfig(c *renderer.Config) { + c.Options[optCodeBlockOptions] = o.value +} + +func (o *withCodeBlockOptions) SetHighlightingOption(c *Config) { + c.CodeBlockOptions = o.value +} + +// WithCodeBlockOptions is a functional option that sets CodeBlockOptions that +// allows setting Chroma options per code block. +func WithCodeBlockOptions(c CodeBlockOptions) Option { + return &withCodeBlockOptions{value: c} +} + +const optFormatOptions renderer.OptionName = "HighlightingFormatOptions" + +type withFormatOptions struct { + value []chromahtml.Option +} + +func (o *withFormatOptions) SetConfig(c *renderer.Config) { + if _, ok := c.Options[optFormatOptions]; !ok { + c.Options[optFormatOptions] = []chromahtml.Option{} + } + c.Options[optFormatOptions] = append(c.Options[optFormatOptions].([]chromahtml.Option), o.value...) +} + +func (o *withFormatOptions) SetHighlightingOption(c *Config) { + c.FormatOptions = append(c.FormatOptions, o.value...) +} + +// WithFormatOptions is a functional option that wraps chroma HTML formatter options. +func WithFormatOptions(opts ...chromahtml.Option) Option { + return &withFormatOptions{opts} +} + +// HTMLRenderer struct is a renderer.NodeRenderer implementation for the extension. +type HTMLRenderer struct { + Config +} + +// NewHTMLRenderer builds a new HTMLRenderer with given options and returns it. +func NewHTMLRenderer(opts ...Option) renderer.NodeRenderer { + r := &HTMLRenderer{ + Config: NewConfig(), + } + for _, opt := range opts { + opt.SetHighlightingOption(&r.Config) + } + return r +} + +// RegisterFuncs implements NodeRenderer.RegisterFuncs. +func (r *HTMLRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock) +} + +func getAttributes(node *ast.FencedCodeBlock, infostr []byte) ImmutableAttributes { + if node.Attributes() != nil { + return &immutableAttributes{node} + } + if infostr != nil { + attrStartIdx := -1 + + for idx, char := range infostr { + if char == '{' { + attrStartIdx = idx + break + } + } + if attrStartIdx > 0 { + n := ast.NewTextBlock() // dummy node for storing attributes + attrStr := infostr[attrStartIdx:] + if attrs, hasAttr := parser.ParseAttributes(text.NewReader(attrStr)); hasAttr { + for _, attr := range attrs { + n.SetAttribute(attr.Name, attr.Value) + } + return &immutableAttributes{n} + } + } + } + return nil +} + +func (r *HTMLRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.FencedCodeBlock) + if !entering { + return ast.WalkContinue, nil + } + language := n.Language(source) + + chromaFormatterOptions := make([]chromahtml.Option, 0, len(r.FormatOptions)) + for _, opt := range r.FormatOptions { + chromaFormatterOptions = append(chromaFormatterOptions, opt) + } + + style := r.CustomStyle + if style == nil { + style = styles.Get(r.Style) + } + nohl := false + + var info []byte + if n.Info != nil { + info = n.Info.Segment.Value(source) + } + attrs := getAttributes(n, info) + if attrs != nil { + baseLineNumber := 1 + if linenostartAttr, ok := attrs.Get(linenostartAttrName); ok { + if linenostart, ok := linenostartAttr.(float64); ok { + baseLineNumber = int(linenostart) + chromaFormatterOptions = append( + chromaFormatterOptions, chromahtml.BaseLineNumber(baseLineNumber), + ) + } + } + if linesAttr, hasLinesAttr := attrs.Get(highlightLinesAttrName); hasLinesAttr { + if lines, ok := linesAttr.([]interface{}); ok { + var hlRanges [][2]int + for _, l := range lines { + if ln, ok := l.(float64); ok { + hlRanges = append(hlRanges, [2]int{int(ln) + baseLineNumber - 1, int(ln) + baseLineNumber - 1}) + } + if rng, ok := l.([]uint8); ok { + slices := strings.Split(string(rng), "-") + lhs, err := strconv.Atoi(slices[0]) + if err != nil { + continue + } + rhs := lhs + if len(slices) > 1 { + rhs, err = strconv.Atoi(slices[1]) + if err != nil { + continue + } + } + hlRanges = append(hlRanges, [2]int{lhs + baseLineNumber - 1, rhs + baseLineNumber - 1}) + } + } + chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.HighlightLines(hlRanges)) + } + } + if styleAttr, hasStyleAttr := attrs.Get(styleAttrName); hasStyleAttr { + if st, ok := styleAttr.([]uint8); ok { + styleStr := string(st) + style = styles.Get(styleStr) + } + } + if _, hasNohlAttr := attrs.Get(nohlAttrName); hasNohlAttr { + nohl = true + } + + if linenosAttr, ok := attrs.Get(linenosAttrName); ok { + switch v := linenosAttr.(type) { + case bool: + chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.WithLineNumbers(v)) + case []uint8: + if v != nil { + chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.WithLineNumbers(true)) + } + if bytes.Equal(v, linenosTableAttrValue) { + chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.LineNumbersInTable(true)) + } else if bytes.Equal(v, linenosInlineAttrValue) { + chromaFormatterOptions = append(chromaFormatterOptions, chromahtml.LineNumbersInTable(false)) + } + } + } + } + + var lexer chroma.Lexer + if language != nil { + lexer = lexers.Get(string(language)) + } + if !nohl && (lexer != nil || r.GuessLanguage) { + if style == nil { + style = styles.Fallback + } + var buffer bytes.Buffer + l := n.Lines().Len() + for i := 0; i < l; i++ { + line := n.Lines().At(i) + buffer.Write(line.Value(source)) + } + + if lexer == nil { + lexer = lexers.Analyse(buffer.String()) + if lexer == nil { + lexer = lexers.Fallback + } + language = []byte(strings.ToLower(lexer.Config().Name)) + } + lexer = chroma.Coalesce(lexer) + + iterator, err := lexer.Tokenise(nil, buffer.String()) + if err == nil { + c := newCodeBlockContext(language, true, attrs) + + if r.CodeBlockOptions != nil { + chromaFormatterOptions = append(chromaFormatterOptions, r.CodeBlockOptions(c)...) + } + formatter := chromahtml.New(chromaFormatterOptions...) + if r.WrapperRenderer != nil { + r.WrapperRenderer(w, c, true) + } + _ = formatter.Format(w, style, iterator) == nil + if r.WrapperRenderer != nil { + r.WrapperRenderer(w, c, false) + } + if r.CSSWriter != nil { + _ = formatter.WriteCSS(r.CSSWriter, style) + } + return ast.WalkContinue, nil + } + } + + var c CodeBlockContext + if r.WrapperRenderer != nil { + c = newCodeBlockContext(language, false, attrs) + r.WrapperRenderer(w, c, true) + } else { + _, _ = w.WriteString("
')
+	}
+	l := n.Lines().Len()
+	for i := 0; i < l; i++ {
+		line := n.Lines().At(i)
+		r.Writer.RawWrite(w, line.Value(source))
+	}
+	if r.WrapperRenderer != nil {
+		r.WrapperRenderer(w, c, false)
+	} else {
+		_, _ = w.WriteString("
\n") + } + return ast.WalkContinue, nil +} + +type highlighting struct { + options []Option +} + +// Highlighting is a goldmark.Extender implementation. +var Highlighting = &highlighting{ + options: []Option{}, +} + +// NewHighlighting returns a new extension with given options. +func NewHighlighting(opts ...Option) goldmark.Extender { + return &highlighting{ + options: opts, + } +} + +// Extend implements goldmark.Extender. +func (e *highlighting) Extend(m goldmark.Markdown) { + m.Renderer().AddOptions(renderer.WithNodeRenderers( + util.Prioritized(NewHTMLRenderer(e.options...), 200), + )) +} diff --git a/gno.land/pkg/gnoweb/markdown/highlighting_test.go b/gno.land/pkg/gnoweb/markdown/highlighting_test.go new file mode 100644 index 00000000000..25bc4fedd61 --- /dev/null +++ b/gno.land/pkg/gnoweb/markdown/highlighting_test.go @@ -0,0 +1,568 @@ +// This file was copied from https://github.com/yuin/goldmark-highlighting + +package markdown + +import ( + "bytes" + "fmt" + "strings" + "testing" + + "github.com/alecthomas/chroma/v2" + chromahtml "github.com/alecthomas/chroma/v2/formatters/html" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/testutil" + "github.com/yuin/goldmark/util" +) + +func TestHighlighting(t *testing.T) { + var css bytes.Buffer + markdown := goldmark.New( + goldmark.WithExtensions( + NewHighlighting( + WithStyle("monokai"), + WithCSSWriter(&css), + WithFormatOptions( + chromahtml.WithClasses(true), + chromahtml.WithLineNumbers(false), + ), + WithWrapperRenderer(func(w util.BufWriter, c CodeBlockContext, entering bool) { + _, ok := c.Language() + if entering { + if !ok { + w.WriteString("
")
+							return
+						}
+						w.WriteString(`
`) + } else { + if !ok { + w.WriteString("
") + return + } + w.WriteString(`
`) + } + }), + WithCodeBlockOptions(func(c CodeBlockContext) []chromahtml.Option { + if language, ok := c.Language(); ok { + // Turn on line numbers for Go only. + if string(language) == "go" { + return []chromahtml.Option{ + chromahtml.WithLineNumbers(true), + } + } + } + return nil + }), + ), + ), + ) + var buffer bytes.Buffer + if err := markdown.Convert([]byte(` +Title +======= +`+"``` go\n"+`func main() { + fmt.Println("ok") +} +`+"```"+` +`), &buffer); err != nil { + t.Fatal(err) + } + + if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` +

Title

+
1func main() {
+2    fmt.Println("ok")
+3}
+
+`) { + t.Errorf("failed to render HTML\n%s", buffer.String()) + } + + expected := strings.TrimSpace(`/* Background */ .bg { color: #f8f8f2; background-color: #272822; } +/* PreWrapper */ .chroma { color: #f8f8f2; background-color: #272822; } +/* LineNumbers targeted by URL anchor */ .chroma .ln:target { color: #f8f8f2; background-color: #3c3d38 } +/* LineNumbersTable targeted by URL anchor */ .chroma .lnt:target { color: #f8f8f2; background-color: #3c3d38 } +/* Error */ .chroma .err { color: #960050; background-color: #1e0010 } +/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } +/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } +/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } +/* LineHighlight */ .chroma .hl { background-color: #3c3d38 } +/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } +/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #7f7f7f } +/* Line */ .chroma .line { display: flex; } +/* Keyword */ .chroma .k { color: #66d9ef } +/* KeywordConstant */ .chroma .kc { color: #66d9ef } +/* KeywordDeclaration */ .chroma .kd { color: #66d9ef } +/* KeywordNamespace */ .chroma .kn { color: #f92672 } +/* KeywordPseudo */ .chroma .kp { color: #66d9ef } +/* KeywordReserved */ .chroma .kr { color: #66d9ef } +/* KeywordType */ .chroma .kt { color: #66d9ef } +/* NameAttribute */ .chroma .na { color: #a6e22e } +/* NameClass */ .chroma .nc { color: #a6e22e } +/* NameConstant */ .chroma .no { color: #66d9ef } +/* NameDecorator */ .chroma .nd { color: #a6e22e } +/* NameException */ .chroma .ne { color: #a6e22e } +/* NameFunction */ .chroma .nf { color: #a6e22e } +/* NameOther */ .chroma .nx { color: #a6e22e } +/* NameTag */ .chroma .nt { color: #f92672 } +/* Literal */ .chroma .l { color: #ae81ff } +/* LiteralDate */ .chroma .ld { color: #e6db74 } +/* LiteralString */ .chroma .s { color: #e6db74 } +/* LiteralStringAffix */ .chroma .sa { color: #e6db74 } +/* LiteralStringBacktick */ .chroma .sb { color: #e6db74 } +/* LiteralStringChar */ .chroma .sc { color: #e6db74 } +/* LiteralStringDelimiter */ .chroma .dl { color: #e6db74 } +/* LiteralStringDoc */ .chroma .sd { color: #e6db74 } +/* LiteralStringDouble */ .chroma .s2 { color: #e6db74 } +/* LiteralStringEscape */ .chroma .se { color: #ae81ff } +/* LiteralStringHeredoc */ .chroma .sh { color: #e6db74 } +/* LiteralStringInterpol */ .chroma .si { color: #e6db74 } +/* LiteralStringOther */ .chroma .sx { color: #e6db74 } +/* LiteralStringRegex */ .chroma .sr { color: #e6db74 } +/* LiteralStringSingle */ .chroma .s1 { color: #e6db74 } +/* LiteralStringSymbol */ .chroma .ss { color: #e6db74 } +/* LiteralNumber */ .chroma .m { color: #ae81ff } +/* LiteralNumberBin */ .chroma .mb { color: #ae81ff } +/* LiteralNumberFloat */ .chroma .mf { color: #ae81ff } +/* LiteralNumberHex */ .chroma .mh { color: #ae81ff } +/* LiteralNumberInteger */ .chroma .mi { color: #ae81ff } +/* LiteralNumberIntegerLong */ .chroma .il { color: #ae81ff } +/* LiteralNumberOct */ .chroma .mo { color: #ae81ff } +/* Operator */ .chroma .o { color: #f92672 } +/* OperatorWord */ .chroma .ow { color: #f92672 } +/* Comment */ .chroma .c { color: #75715e } +/* CommentHashbang */ .chroma .ch { color: #75715e } +/* CommentMultiline */ .chroma .cm { color: #75715e } +/* CommentSingle */ .chroma .c1 { color: #75715e } +/* CommentSpecial */ .chroma .cs { color: #75715e } +/* CommentPreproc */ .chroma .cp { color: #75715e } +/* CommentPreprocFile */ .chroma .cpf { color: #75715e } +/* GenericDeleted */ .chroma .gd { color: #f92672 } +/* GenericEmph */ .chroma .ge { font-style: italic } +/* GenericInserted */ .chroma .gi { color: #a6e22e } +/* GenericStrong */ .chroma .gs { font-weight: bold } +/* GenericSubheading */ .chroma .gu { color: #75715e }`) + + gotten := strings.TrimSpace(css.String()) + + if expected != gotten { + diff := testutil.DiffPretty([]byte(expected), []byte(gotten)) + t.Errorf("incorrect CSS.\n%s", string(diff)) + } +} + +func TestHighlighting2(t *testing.T) { + markdown := goldmark.New( + goldmark.WithExtensions( + Highlighting, + ), + ) + var buffer bytes.Buffer + if err := markdown.Convert([]byte(` +Title +======= +`+"```"+` +func main() { + fmt.Println("ok") +} +`+"```"+` +`), &buffer); err != nil { + t.Fatal(err) + } + + if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` +

Title

+
func main() {
+    fmt.Println("ok")
+}
+
+`) { + t.Error("failed to render HTML") + } +} + +func TestHighlighting3(t *testing.T) { + markdown := goldmark.New( + goldmark.WithExtensions( + Highlighting, + ), + ) + var buffer bytes.Buffer + if err := markdown.Convert([]byte(` +Title +======= + +`+"```"+`cpp {hl_lines=[1,2]} +#include +int main() { + std::cout<< "hello" << std::endl; +} +`+"```"+` +`), &buffer); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` +

Title

+
#include <iostream>
+int main() {
+    std::cout<< "hello" << std::endl;
+}
+
+`) { + t.Errorf("failed to render HTML:\n%s", buffer.String()) + } +} + +func TestHighlightingCustom(t *testing.T) { + custom := chroma.MustNewStyle("custom", chroma.StyleEntries{ + chroma.Background: "#cccccc bg:#1d1d1d", + chroma.Comment: "#999999", + chroma.CommentSpecial: "#cd0000", + chroma.Keyword: "#cc99cd", + chroma.KeywordDeclaration: "#cc99cd", + chroma.KeywordNamespace: "#cc99cd", + chroma.KeywordType: "#cc99cd", + chroma.Operator: "#67cdcc", + chroma.OperatorWord: "#cdcd00", + chroma.NameClass: "#f08d49", + chroma.NameBuiltin: "#f08d49", + chroma.NameFunction: "#f08d49", + chroma.NameException: "bold #666699", + chroma.NameVariable: "#00cdcd", + chroma.LiteralString: "#7ec699", + chroma.LiteralNumber: "#f08d49", + chroma.LiteralStringBoolean: "#f08d49", + chroma.GenericHeading: "bold #000080", + chroma.GenericSubheading: "bold #800080", + chroma.GenericDeleted: "#e2777a", + chroma.GenericInserted: "#cc99cd", + chroma.GenericError: "#e2777a", + chroma.GenericEmph: "italic", + chroma.GenericStrong: "bold", + chroma.GenericPrompt: "bold #000080", + chroma.GenericOutput: "#888", + chroma.GenericTraceback: "#04D", + chroma.GenericUnderline: "underline", + chroma.Error: "border:#e2777a", + }) + + var css bytes.Buffer + markdown := goldmark.New( + goldmark.WithExtensions( + NewHighlighting( + WithStyle("monokai"), // to make sure it is overrided even if present + WithCustomStyle(custom), + WithCSSWriter(&css), + WithFormatOptions( + chromahtml.WithClasses(true), + chromahtml.WithLineNumbers(false), + ), + WithWrapperRenderer(func(w util.BufWriter, c CodeBlockContext, entering bool) { + _, ok := c.Language() + if entering { + if !ok { + w.WriteString("
")
+							return
+						}
+						w.WriteString(`
`) + } else { + if !ok { + w.WriteString("
") + return + } + w.WriteString(`
`) + } + }), + WithCodeBlockOptions(func(c CodeBlockContext) []chromahtml.Option { + if language, ok := c.Language(); ok { + // Turn on line numbers for Go only. + if string(language) == "go" { + return []chromahtml.Option{ + chromahtml.WithLineNumbers(true), + } + } + } + return nil + }), + ), + ), + ) + var buffer bytes.Buffer + if err := markdown.Convert([]byte(` +Title +======= +`+"``` go\n"+`func main() { + fmt.Println("ok") +} +`+"```"+` +`), &buffer); err != nil { + t.Fatal(err) + } + + if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` +

Title

+
1func main() {
+2    fmt.Println("ok")
+3}
+
+`) { + t.Error("failed to render HTML", buffer.String()) + } + + expected := strings.TrimSpace(`/* Background */ .bg { color: #cccccc; background-color: #1d1d1d; } +/* PreWrapper */ .chroma { color: #cccccc; background-color: #1d1d1d; } +/* LineNumbers targeted by URL anchor */ .chroma .ln:target { color: #cccccc; background-color: #333333 } +/* LineNumbersTable targeted by URL anchor */ .chroma .lnt:target { color: #cccccc; background-color: #333333 } +/* Error */ .chroma .err { } +/* LineLink */ .chroma .lnlinks { outline: none; text-decoration: none; color: inherit } +/* LineTableTD */ .chroma .lntd { vertical-align: top; padding: 0; margin: 0; border: 0; } +/* LineTable */ .chroma .lntable { border-spacing: 0; padding: 0; margin: 0; border: 0; } +/* LineHighlight */ .chroma .hl { background-color: #333333 } +/* LineNumbersTable */ .chroma .lnt { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #666666 } +/* LineNumbers */ .chroma .ln { white-space: pre; -webkit-user-select: none; user-select: none; margin-right: 0.4em; padding: 0 0.4em 0 0.4em;color: #666666 } +/* Line */ .chroma .line { display: flex; } +/* Keyword */ .chroma .k { color: #cc99cd } +/* KeywordConstant */ .chroma .kc { color: #cc99cd } +/* KeywordDeclaration */ .chroma .kd { color: #cc99cd } +/* KeywordNamespace */ .chroma .kn { color: #cc99cd } +/* KeywordPseudo */ .chroma .kp { color: #cc99cd } +/* KeywordReserved */ .chroma .kr { color: #cc99cd } +/* KeywordType */ .chroma .kt { color: #cc99cd } +/* NameBuiltin */ .chroma .nb { color: #f08d49 } +/* NameClass */ .chroma .nc { color: #f08d49 } +/* NameException */ .chroma .ne { color: #666699; font-weight: bold } +/* NameFunction */ .chroma .nf { color: #f08d49 } +/* NameVariable */ .chroma .nv { color: #00cdcd } +/* LiteralString */ .chroma .s { color: #7ec699 } +/* LiteralStringAffix */ .chroma .sa { color: #7ec699 } +/* LiteralStringBacktick */ .chroma .sb { color: #7ec699 } +/* LiteralStringChar */ .chroma .sc { color: #7ec699 } +/* LiteralStringDelimiter */ .chroma .dl { color: #7ec699 } +/* LiteralStringDoc */ .chroma .sd { color: #7ec699 } +/* LiteralStringDouble */ .chroma .s2 { color: #7ec699 } +/* LiteralStringEscape */ .chroma .se { color: #7ec699 } +/* LiteralStringHeredoc */ .chroma .sh { color: #7ec699 } +/* LiteralStringInterpol */ .chroma .si { color: #7ec699 } +/* LiteralStringOther */ .chroma .sx { color: #7ec699 } +/* LiteralStringRegex */ .chroma .sr { color: #7ec699 } +/* LiteralStringSingle */ .chroma .s1 { color: #7ec699 } +/* LiteralStringSymbol */ .chroma .ss { color: #7ec699 } +/* LiteralNumber */ .chroma .m { color: #f08d49 } +/* LiteralNumberBin */ .chroma .mb { color: #f08d49 } +/* LiteralNumberFloat */ .chroma .mf { color: #f08d49 } +/* LiteralNumberHex */ .chroma .mh { color: #f08d49 } +/* LiteralNumberInteger */ .chroma .mi { color: #f08d49 } +/* LiteralNumberIntegerLong */ .chroma .il { color: #f08d49 } +/* LiteralNumberOct */ .chroma .mo { color: #f08d49 } +/* Operator */ .chroma .o { color: #67cdcc } +/* OperatorWord */ .chroma .ow { color: #cdcd00 } +/* Comment */ .chroma .c { color: #999999 } +/* CommentHashbang */ .chroma .ch { color: #999999 } +/* CommentMultiline */ .chroma .cm { color: #999999 } +/* CommentSingle */ .chroma .c1 { color: #999999 } +/* CommentSpecial */ .chroma .cs { color: #cd0000 } +/* CommentPreproc */ .chroma .cp { color: #999999 } +/* CommentPreprocFile */ .chroma .cpf { color: #999999 } +/* GenericDeleted */ .chroma .gd { color: #e2777a } +/* GenericEmph */ .chroma .ge { font-style: italic } +/* GenericError */ .chroma .gr { color: #e2777a } +/* GenericHeading */ .chroma .gh { color: #000080; font-weight: bold } +/* GenericInserted */ .chroma .gi { color: #cc99cd } +/* GenericOutput */ .chroma .go { color: #888888 } +/* GenericPrompt */ .chroma .gp { color: #000080; font-weight: bold } +/* GenericStrong */ .chroma .gs { font-weight: bold } +/* GenericSubheading */ .chroma .gu { color: #800080; font-weight: bold } +/* GenericTraceback */ .chroma .gt { color: #0044dd } +/* GenericUnderline */ .chroma .gl { text-decoration: underline }`) + + gotten := strings.TrimSpace(css.String()) + + if expected != gotten { + diff := testutil.DiffPretty([]byte(expected), []byte(gotten)) + t.Errorf("incorrect CSS.\n%s", string(diff)) + } +} + +func TestHighlightingHlLines(t *testing.T) { + markdown := goldmark.New( + goldmark.WithExtensions( + NewHighlighting( + WithFormatOptions( + chromahtml.WithClasses(true), + ), + ), + ), + ) + + for i, test := range []struct { + attributes string + expect []int + }{ + {`hl_lines=["2"]`, []int{2}}, + {`hl_lines=["2-3",5],linenostart=5`, []int{2, 3, 5}}, + {`hl_lines=["2-3"]`, []int{2, 3}}, + {`hl_lines=["2-3",5],linenostart="5"`, []int{2, 3}}, // linenostart must be a number. string values are ignored + } { + t.Run(fmt.Sprint(i), func(t *testing.T) { + var buffer bytes.Buffer + codeBlock := fmt.Sprintf(`bash {%s} +LINE1 +LINE2 +LINE3 +LINE4 +LINE5 +LINE6 +LINE7 +LINE8 +`, test.attributes) + + if err := markdown.Convert([]byte(` +`+"```"+codeBlock+"```"+` +`), &buffer); err != nil { + t.Fatal(err) + } + + for _, line := range test.expect { + expectStr := fmt.Sprintf("LINE%d\n", line) + if !strings.Contains(buffer.String(), expectStr) { + t.Fatal("got\n", buffer.String(), "\nexpected\n", expectStr) + } + } + }) + } +} + +type nopPreWrapper struct{} + +// Start is called to write a start
 element.
+func (nopPreWrapper) Start(code bool, styleAttr string) string { return "" }
+
+// End is called to write the end 
element. +func (nopPreWrapper) End(code bool) string { return "" } + +func TestHighlightingLinenos(t *testing.T) { + outputLineNumbersInTable := `
+ +
+1 + +LINE1 +
+
` + + for i, test := range []struct { + attributes string + lineNumbers bool + lineNumbersInTable bool + expect string + }{ + {`linenos=true`, false, false, `1LINE1 +`}, + {`linenos=false`, false, false, `LINE1 +`}, + {``, true, false, `1LINE1 +`}, + {``, true, true, outputLineNumbersInTable}, + {`linenos=inline`, true, true, `1LINE1 +`}, + {`linenos=foo`, false, false, `1LINE1 +`}, + {`linenos=table`, false, false, outputLineNumbersInTable}, + } { + t.Run(fmt.Sprint(i), func(t *testing.T) { + markdown := goldmark.New( + goldmark.WithExtensions( + NewHighlighting( + WithFormatOptions( + chromahtml.WithLineNumbers(test.lineNumbers), + chromahtml.LineNumbersInTable(test.lineNumbersInTable), + chromahtml.WithPreWrapper(nopPreWrapper{}), + chromahtml.WithClasses(true), + ), + ), + ), + ) + + var buffer bytes.Buffer + codeBlock := fmt.Sprintf(`bash {%s} +LINE1 +`, test.attributes) + + content := "```" + codeBlock + "```" + + if err := markdown.Convert([]byte(content), &buffer); err != nil { + t.Fatal(err) + } + + s := strings.TrimSpace(buffer.String()) + + if s != test.expect { + t.Fatal("got\n", s, "\nexpected\n", test.expect) + } + }) + } +} + +func TestHighlightingGuessLanguage(t *testing.T) { + markdown := goldmark.New( + goldmark.WithExtensions( + NewHighlighting( + WithGuessLanguage(true), + WithFormatOptions( + chromahtml.WithClasses(true), + chromahtml.WithLineNumbers(true), + ), + ), + ), + ) + var buffer bytes.Buffer + if err := markdown.Convert([]byte("```"+` +LINE +`+"```"), &buffer); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` +
1LINE
+
+`) { + t.Errorf("render mismatch, got\n%s", buffer.String()) + } +} + +func TestCoalesceNeeded(t *testing.T) { + markdown := goldmark.New( + goldmark.WithExtensions( + NewHighlighting( + // WithGuessLanguage(true), + WithFormatOptions( + chromahtml.WithClasses(true), + chromahtml.WithLineNumbers(true), + ), + ), + ), + ) + var buffer bytes.Buffer + if err := markdown.Convert([]byte("```http"+` +GET /foo HTTP/1.1 +Content-Type: application/json +User-Agent: foo + +{ + "hello": "world" +} +`+"```"), &buffer); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(buffer.String()) != strings.TrimSpace(` +
1GET /foo HTTP/1.1
+2Content-Type: application/json
+3User-Agent: foo
+4
+5{
+6  "hello": "world"
+7}
+
+`) { + t.Errorf("render mismatch, got\n%s", buffer.String()) + } +} diff --git a/gno.land/pkg/gnoweb/markdown/toc.go b/gno.land/pkg/gnoweb/markdown/toc.go new file mode 100644 index 00000000000..59d4941fabf --- /dev/null +++ b/gno.land/pkg/gnoweb/markdown/toc.go @@ -0,0 +1,137 @@ +// This file is a minimal version of https://github.com/abhinav/goldmark-toc + +package markdown + +import ( + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/util" +) + +const MaxDepth = 6 + +type Toc struct { + Items []*TocItem +} + +type TocItem struct { + // Title of this item in the table of contents. + // + // This may be blank for items that don't refer to a heading, and only + // have sub-items. + Title []byte + + // ID is the identifier for the heading that this item refers to. This + // is the fragment portion of the link without the "#". + // + // This may be blank if the item doesn't have an id assigned to it, or + // if it doesn't have a title. + // + // Enable AutoHeadingID in your parser if you expected these to be set + // but they weren't. + ID []byte + + // Items references children of this item. + // + // For a heading at level 3, Items, contains the headings at level 4 + // under that section. + Items []*TocItem +} + +func (i TocItem) Anchor() string { + return "#" + string(i.ID) +} + +type TocOptions struct { + MinDepth, MaxDepth int +} + +func TocInspect(n ast.Node, src []byte, opts TocOptions) (*Toc, error) { + // Appends an empty subitem to the given node + // and returns a reference to it. + appendChild := func(n *TocItem) *TocItem { + child := new(TocItem) + n.Items = append(n.Items, child) + return child + } + + // Returns the last subitem of the given node, + // creating it if necessary. + lastChild := func(n *TocItem) *TocItem { + if len(n.Items) > 0 { + return n.Items[len(n.Items)-1] + } + return appendChild(n) + } + + var root TocItem + + stack := []*TocItem{&root} // inv: len(stack) >= 1 + err := ast.Walk(n, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + + // Skip non-heading node + heading, ok := n.(*ast.Heading) + if !ok { + return ast.WalkContinue, nil + } + + if opts.MinDepth > 0 && heading.Level < opts.MinDepth { + return ast.WalkSkipChildren, nil + } + + if opts.MaxDepth > 0 && heading.Level > opts.MaxDepth { + return ast.WalkSkipChildren, nil + } + + // The heading is deeper than the current depth. + // Append empty items to match the heading's level. + for len(stack) < heading.Level { + parent := stack[len(stack)-1] + stack = append(stack, lastChild(parent)) + } + + // The heading is shallower than the current depth. + // Move back up the stack until we reach the heading's level. + if len(stack) > heading.Level { + stack = stack[:heading.Level] + } + + parent := stack[len(stack)-1] + target := lastChild(parent) + if len(target.Title) > 0 || len(target.Items) > 0 { + target = appendChild(parent) + } + + target.Title = util.UnescapePunctuations(heading.Text(src)) + if id, ok := n.AttributeString("id"); ok { + target.ID, _ = id.([]byte) + } + + return ast.WalkSkipChildren, nil + }) + + root.Items = compactItems(root.Items) + + return &Toc{Items: root.Items}, err +} + +// compactItems removes items with no titles +// from the given list of items. +// +// Children of removed items will be promoted to the parent item. +func compactItems(items []*TocItem) []*TocItem { + result := make([]*TocItem, 0) + for _, item := range items { + if len(item.Title) == 0 { + result = append(result, compactItems(item.Items)...) + continue + } + + item.Items = compactItems(item.Items) + result = append(result, item) + } + + return result +} diff --git a/gno.land/pkg/gnoweb/public/favicon.ico b/gno.land/pkg/gnoweb/public/favicon.ico new file mode 100644 index 00000000000..528c362c44a Binary files /dev/null and b/gno.land/pkg/gnoweb/public/favicon.ico differ diff --git a/gno.land/pkg/gnoweb/public/fonts/intervar/Intervar.woff2 b/gno.land/pkg/gnoweb/public/fonts/intervar/Intervar.woff2 new file mode 100644 index 00000000000..891fc5cc567 Binary files /dev/null and b/gno.land/pkg/gnoweb/public/fonts/intervar/Intervar.woff2 differ diff --git a/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff b/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff new file mode 100644 index 00000000000..2c58fe2d6d7 Binary files /dev/null and b/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff differ diff --git a/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff2 b/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff2 new file mode 100644 index 00000000000..53d081f3a53 Binary files /dev/null and b/gno.land/pkg/gnoweb/public/fonts/roboto/roboto-mono-normal.woff2 differ diff --git a/gno.land/pkg/gnoweb/public/imgs/gnoland.svg b/gno.land/pkg/gnoweb/public/imgs/gnoland.svg new file mode 100644 index 00000000000..30d2f3ef56a --- /dev/null +++ b/gno.land/pkg/gnoweb/public/imgs/gnoland.svg @@ -0,0 +1,4 @@ + + + + diff --git a/gno.land/pkg/gnoweb/public/js/copy.js b/gno.land/pkg/gnoweb/public/js/copy.js new file mode 100644 index 00000000000..918a30b1ca3 --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/copy.js @@ -0,0 +1 @@ +var s=class o{DOM;static FEEDBACK_DELAY=750;btnClicked=null;btnClickedIcons=[];isAnimationRunning=!1;static SELECTORS={button:"[data-copy-btn]",icon:"[data-copy-icon] > use",content:t=>`[data-copy-content="${t}"]`};constructor(){this.DOM={el:document.querySelector("main")},this.DOM.el?this.init():console.warn("Copy: Main container not found.")}init(){this.bindEvents()}bindEvents(){this.DOM.el?.addEventListener("click",this.handleClick.bind(this))}handleClick(t){let e=t.target.closest(o.SELECTORS.button);if(!e)return;this.btnClicked=e,this.btnClickedIcons=Array.from(e.querySelectorAll(o.SELECTORS.icon));let i=e.getAttribute("data-copy-btn");if(!i){console.warn("Copy: No content ID found on the button.");return}let r=this.DOM.el?.querySelector(o.SELECTORS.content(i));r?this.copyToClipboard(r,this.btnClickedIcons):console.warn(`Copy: No content found for ID "${i}".`)}sanitizeContent(t){let n=t.innerHTML.replace(/]*class="chroma-ln"[^>]*>[\s\S]*?<\/span>/g,""),e=document.createElement("div");return e.innerHTML=n,e.textContent?.trim()||""}toggleIcons(t){t.forEach(n=>{n.classList.toggle("hidden")})}showFeedback(t){!this.btnClicked||this.isAnimationRunning===!0||(this.isAnimationRunning=!0,this.toggleIcons(t),window.setTimeout(()=>{this.toggleIcons(t),this.isAnimationRunning=!1},o.FEEDBACK_DELAY))}async copyToClipboard(t,n){let e=this.sanitizeContent(t);if(!navigator.clipboard){console.error("Copy: Clipboard API is not supported in this browser."),this.showFeedback(n);return}try{await navigator.clipboard.writeText(e),this.showFeedback(n)}catch(i){console.error("Copy: Error while copying text.",i),this.showFeedback(n)}}},a=()=>new s;export{a as default}; diff --git a/gno.land/pkg/gnoweb/public/js/index.js b/gno.land/pkg/gnoweb/public/js/index.js new file mode 100644 index 00000000000..e990dd91f5f --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/index.js @@ -0,0 +1 @@ +(()=>{let s={copy:{selector:"[data-copy-btn]",path:"/public/js/copy.js"},help:{selector:"#help",path:"/public/js/realmhelp.js"},searchBar:{selector:"#header-searchbar",path:"/public/js/searchbar.js"}},r=async({selector:e,path:o})=>{if(document.querySelector(e))try{(await import(o)).default()}catch(t){console.error(`Error while loading script ${o}:`,t)}else console.warn(`Module not loaded: no element matches selector "${e}"`)},l=async()=>{let e=Object.values(s).map(o=>r(o));await Promise.all(e)};document.addEventListener("DOMContentLoaded",l)})(); diff --git a/gno.land/pkg/gnoweb/public/js/realmhelp.js b/gno.land/pkg/gnoweb/public/js/realmhelp.js new file mode 100644 index 00000000000..5d4a3feeba6 --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/realmhelp.js @@ -0,0 +1 @@ +function d(s,e=250){let t;return function(...a){t!==void 0&&clearTimeout(t),t=setTimeout(()=>{s.apply(this,a)},e)}}var l=class s{DOM;funcList;static SELECTORS={container:"#help",func:"[data-func]",addressInput:"[data-role='help-input-addr']",cmdModeSelect:"[data-role='help-select-mode']"};constructor(){this.DOM={el:document.querySelector(s.SELECTORS.container),funcs:[],addressInput:null,cmdModeSelect:null},this.funcList=[],this.DOM.el?this.init():console.warn("Help: Main container not found.")}init(){let{el:e}=this.DOM;e&&(this.DOM.funcs=Array.from(e.querySelectorAll(s.SELECTORS.func)),this.DOM.addressInput=e.querySelector(s.SELECTORS.addressInput),this.DOM.cmdModeSelect=e.querySelector(s.SELECTORS.cmdModeSelect),this.funcList=this.DOM.funcs.map(t=>new o(t)),this.restoreAddress(),this.bindEvents())}restoreAddress(){let{addressInput:e}=this.DOM;if(e){let t=localStorage.getItem("helpAddressInput");t&&(e.value=t,this.funcList.forEach(a=>a.updateAddr(t)))}}bindEvents(){let{addressInput:e,cmdModeSelect:t}=this.DOM,a=d(r=>{let n=r.value;localStorage.setItem("helpAddressInput",n),this.funcList.forEach(i=>i.updateAddr(n))});e?.addEventListener("input",()=>a(e)),t?.addEventListener("change",r=>{let n=r.target;this.funcList.forEach(i=>i.updateMode(n.value))})}},o=class s{DOM;funcName;static SELECTORS={address:"[data-role='help-code-address']",args:"[data-role='help-code-args']",mode:"[data-code-mode]",paramInput:"[data-role='help-param-input']"};constructor(e){this.DOM={el:e,addrs:Array.from(e.querySelectorAll(s.SELECTORS.address)),args:Array.from(e.querySelectorAll(s.SELECTORS.args)),modes:Array.from(e.querySelectorAll(s.SELECTORS.mode)),paramInputs:Array.from(e.querySelectorAll(s.SELECTORS.paramInput))},this.funcName=e.dataset.func||null,this.initializeArgs(),this.bindEvents()}static sanitizeArgsInput(e){let t=e.dataset.param||"",a=e.value.trim();return t||console.warn("sanitizeArgsInput: param is missing in arg input dataset."),{paramName:t,paramValue:a}}bindEvents(){let e=d((t,a)=>{t&&this.updateArg(t,a)});this.DOM.el.addEventListener("input",t=>{let a=t.target;if(a.dataset.role==="help-param-input"){let{paramName:r,paramValue:n}=s.sanitizeArgsInput(a);e(r,n)}})}initializeArgs(){this.DOM.paramInputs.forEach(e=>{let{paramName:t,paramValue:a}=s.sanitizeArgsInput(e);t&&this.updateArg(t,a)})}updateArg(e,t){this.DOM.args.filter(a=>a.dataset.arg===e).forEach(a=>{a.textContent=t||""})}updateAddr(e){this.DOM.addrs.forEach(t=>{t.textContent=e.trim()||"ADDRESS"})}updateMode(e){this.DOM.modes.forEach(t=>{let a=t.dataset.codeMode===e;t.classList.toggle("inline",a),t.classList.toggle("hidden",!a),t.dataset.copyContent=a?`help-cmd-${this.funcName}`:""})}},p=()=>new l;export{p as default}; diff --git a/gno.land/pkg/gnoweb/public/js/searchbar.js b/gno.land/pkg/gnoweb/public/js/searchbar.js new file mode 100644 index 00000000000..e8012b9b6d9 --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/searchbar.js @@ -0,0 +1 @@ +var n=class r{DOM;baseUrl;static SELECTORS={container:"#header-searchbar",inputSearch:"[data-role='header-input-search']",breadcrumb:"[data-role='header-breadcrumb-search']"};constructor(){this.DOM={el:document.querySelector(r.SELECTORS.container),inputSearch:null,breadcrumb:null},this.baseUrl=window.location.origin,this.DOM.el?this.init():console.warn("SearchBar: Main container not found.")}init(){let{el:e}=this.DOM;this.DOM.inputSearch=e?.querySelector(r.SELECTORS.inputSearch)??null,this.DOM.breadcrumb=e?.querySelector(r.SELECTORS.breadcrumb)??null,this.DOM.inputSearch||console.warn("SearchBar: Input element for search not found."),this.bindEvents()}bindEvents(){this.DOM.el?.addEventListener("submit",e=>{e.preventDefault(),this.searchUrl()})}searchUrl(){let e=this.DOM.inputSearch?.value.trim();if(e){let t=e;/^https?:\/\//i.test(t)||(t=`${this.baseUrl}${t.startsWith("/")?"":"/"}${t}`);try{window.location.href=new URL(t).href}catch{console.error("SearchBar: Invalid URL. Please enter a valid URL starting with http:// or https://.")}}else console.error("SearchBar: Please enter a URL to search.")}},i=()=>new n;export{i as default}; diff --git a/gno.land/pkg/gnoweb/public/js/utils.js b/gno.land/pkg/gnoweb/public/js/utils.js new file mode 100644 index 00000000000..e27fb93bc1c --- /dev/null +++ b/gno.land/pkg/gnoweb/public/js/utils.js @@ -0,0 +1 @@ +function r(t,n=250){let e;return function(...i){e!==void 0&&clearTimeout(e),e=setTimeout(()=>{t.apply(this,i)},n)}}export{r as debounce}; diff --git a/gno.land/pkg/gnoweb/public/styles.css b/gno.land/pkg/gnoweb/public/styles.css new file mode 100644 index 00000000000..50db20831bd --- /dev/null +++ b/gno.land/pkg/gnoweb/public/styles.css @@ -0,0 +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-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-content{overflow-wrap:break-word;padding-top:2.5rem;font-size:1rem}.realm-content>:first-child{margin-top:0!important}.realm-content a{font-weight:500;--tw-text-opacity:1;color:rgb(34 108 87/var(--tw-text-opacity))}.realm-content a:hover{text-decoration-line:underline}.realm-content h1,.realm-content h2,.realm-content h3,.realm-content h4{margin-top:3rem;line-height:1.25;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-content h2,.realm-content h2 *{font-weight:700}.realm-content h3,.realm-content h3 *,.realm-content h4,.realm-content h4 *{font-weight:600}.realm-content h1+h2,.realm-content h2+h3,.realm-content h3+h4{margin-top:1rem}.realm-content h1{font-size:2.375rem;font-weight:700}.realm-content h2{font-size:1.5rem}.realm-content h3{margin-top:2.5rem;font-size:1.25rem}.realm-content h3,.realm-content h4{--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-content h4{margin-top:1.5rem;margin-bottom:1.5rem;font-size:1.125rem;font-weight:500}.realm-content p{margin-top:1.25rem;margin-bottom:1.25rem}.realm-content strong{font-weight:700;--tw-text-opacity:1;color:rgb(8 8 9/var(--tw-text-opacity))}.realm-content strong *{font-weight:700}.realm-content em{font-style:oblique 10deg}.realm-content 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 10deg}.realm-content ol,.realm-content ul{margin-top:1.5rem;margin-bottom:1.5rem;padding-left:1rem}.realm-content ol li,.realm-content ul li{margin-bottom:.5rem}.realm-content img{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-content figure{margin-top:1.5rem;margin-bottom:1.5rem;text-align:center}.realm-content figcaption{font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-content :not(pre)>code{border-radius:.25rem;background-color:rgb(226 226 226/var(--tw-bg-opacity));padding:.125rem .25rem;font-size:.875rem}.realm-content :not(pre)>code,.realm-content pre{--tw-bg-opacity:1;font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-content pre{overflow-x:auto;border-radius:.375rem;background-color:rgb(240 240 240/var(--tw-bg-opacity));padding:1rem}.realm-content 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-content table{margin-top:2rem;margin-bottom:2rem;width:100%;border-collapse:collapse}.realm-content td,.realm-content th{border-width:1px;--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity));padding:.5rem 1rem}.realm-content th{--tw-bg-opacity:1;background-color:rgb(226 226 226/var(--tw-bg-opacity));font-weight:700}.realm-content caption{margin-top:.5rem;text-align:left;font-size:.875rem;--tw-text-opacity:1;color:rgb(84 89 93/var(--tw-text-opacity))}.realm-content q{margin-top:1.5rem;margin-bottom:1.5rem;border-left-width:4px;--tw-border-opacity:1;border-left-color:rgb(204 204 204/var(--tw-border-opacity));padding-left:1rem;--tw-text-opacity:1;color:rgb(85 85 85/var(--tw-text-opacity));font-style:oblique 10deg;quotes:"“" "”" "‘" "’"}.realm-content q:after,.realm-content 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-content q:after{content:close-quote}.realm-content q:before{content:open-quote}.realm-content q:after{content:close-quote}.realm-content ol ol,.realm-content ol ul,.realm-content ul ol,.realm-content ul ul{margin-top:.75rem;margin-bottom:.5rem;padding-left:1rem}.realm-content ul{list-style-type:disc}.realm-content ol{list-style-type:decimal}.realm-content table th:first-child,.realm-content td:first-child{padding-left:0}.realm-content table th:last-child,.realm-content td:last-child{padding-right:0}.realm-content abbr[title]{cursor:help;border-bottom-width:1px;border-style:dotted}.realm-content details{margin-top:1.25rem;margin-bottom:1.25rem}.realm-content summary{cursor:pointer;font-weight:700}.realm-content a code{color:inherit}.realm-content video{margin-top:2rem;margin-bottom:2rem;max-width:100%}.realm-content math{font-family:Roboto,Menlo,Consolas,Ubuntu Mono,Roboto Mono,DejaVu Sans Mono,monospace;}.realm-content small{font-size:.875rem}.realm-content del{text-decoration-line:line-through}.realm-content sub{vertical-align:sub;font-size:.75rem}.realm-content sup{vertical-align:super;font-size:.75rem}.realm-content button,.realm-content 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))}.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-content{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)}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-content>pre a:hover{text-decoration-line:none}main :is(.realm-content,.source-code)>pre .chroma-ln:target{background-color:transparent!important}main :is(.realm-content,.source-code)>pre .chroma-line:has(.chroma-ln:target),main :is(.realm-content,.source-code)>pre .chroma-line:has(.chroma-ln:target) .chroma-cl,main :is(.realm-content,.source-code)>pre .chroma-line:has(.chroma-lnlinks:hover),main :is(.realm-content,.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-content,.source-code)>pre .chroma-ln{scroll-margin-top:6rem}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.bottom-1{bottom:.25rem}.left-0{left:0}.right-2{right:.5rem}.right-3{right:.75rem}.top-0{top:0}.top-1\/2{top:50%}.top-14{top:3.5rem}.top-2{top:.5rem}.z-max{z-index:9999}.col-span-1{grid-column:span 1/span 1}.col-span-10{grid-column:span 10/span 10}.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}.row-start-1{grid-row-start:1}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-8{margin-bottom:2rem}.mr-10{margin-right:2.5rem}.mt-1{margin-top:.25rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.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}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-full{height:100%}.max-h-screen{max-height:100vh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-full{width:100%}.min-w-2{min-width:.5rem}.min-w-48{min-width:12rem}.max-w-screen-max{max-width:98.75rem}.shrink-0{flex-shrink:0}.grow-\[2\]{flex-grow:2}.-translate-y-1\/2{--tw-translate-y:-50%;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))}.cursor-pointer{cursor:pointer}.list-none{list-style-type:none}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.grid-flow-dense{grid-auto-flow:dense}.auto-rows-min{grid-auto-rows:min-content}.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}.items-start{align-items:flex-start}.items-center{align-items:center}.items-stretch{align-items:stretch}.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-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-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-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.375rem}.rounded-sm{border-radius:.25rem}.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))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(226 226 226/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-1\.5{padding:.375rem}.p-2{padding:.5rem}.p-4{padding:1rem}.px-1{padding-left:.25rem;padding-right:.25rem}.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-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}.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-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-tight{line-height:1.25}.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))}.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}.\*\:pl-0>*{padding-left:0}.before\:px-\[0\.18rem\]:before{content:var(--tw-content);padding-left:.18rem;padding-right:.18rem}.before\:text-gray-300:before{content:var(--tw-content);--tw-text-opacity:1;color:rgb(153 153 153/var(--tw-text-opacity))}.before\:content-\[\'\/\'\]:before{--tw-content:"/";content:var(--tw-content)}.before\:content-\[\'open\'\]:before{--tw-content:"open";content:var(--tw-content)}.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\:block:after{content:var(--tw-content);display:block}.after\:h-1:after{content:var(--tw-content);height:.25rem}.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-green-600:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(34 108 87/var(--tw-bg-opacity))}.first\:border-t:first-child{border-top-width:1px}.hover\:border-gray-300:hover{--tw-border-opacity:1;border-color:rgb(153 153 153/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-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\: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))}.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\:before\:content-\[\'close\'\]:before{--tw-content:"close";content:var(--tw-content)}.peer:focus-within~.peer-focus-within\:hidden{display:none}.has-\[ul\:empty\]\:hidden:has(ul:empty){display:none}.has-\[\:focus-within\]\:border-gray-300:has(:focus-within){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}.has-\[\:focus\]\:border-gray-300:has(:focus){--tw-border-opacity:1;border-color:rgb(153 153 153/var(--tw-border-opacity))}@media (min-width:30rem){.sm\:gap-6{gap:1.5rem}}@media (min-width:40rem){.md\:col-span-3{grid-column:span 3/span 3}.md\:mb-0{margin-bottom:0}.md\:h-4{height:1rem}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.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}}@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\:row-start-2{grid-row-start:2}.lg\:mb-4{margin-bottom:1rem}.lg\:mt-0{margin-top:0}.lg\:mt-10{margin-top:2.5rem}.lg\:block{display:block}.lg\:hidden{display:none}.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\:font-semibold{font-weight:600}.lg\:hover\:bg-transparent:hover{background-color:transparent}}@media (min-width:63.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-6{gap:1.5rem}.xl\:pt-0{padding-top:0}}@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/static.go b/gno.land/pkg/gnoweb/static.go new file mode 100644 index 00000000000..7900dcd7891 --- /dev/null +++ b/gno.land/pkg/gnoweb/static.go @@ -0,0 +1,28 @@ +package gnoweb + +import ( + "embed" + "net/http" +) + +//go:embed public/* +var assets embed.FS + +func disableCache(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + next.ServeHTTP(w, r) + }) +} + +// AssetHandler returns the handler to serve static assets. If cache is true, +// these will be served using the static files embedded in the binary; otherwise +// they will served from the filesystem. +func AssetHandler() http.Handler { + return http.FileServer(http.FS(assets)) +} + +func DevAssetHandler(path, dir string) http.Handler { + handler := http.StripPrefix(path, http.FileServer(http.Dir(dir))) + return disableCache(handler) +} diff --git a/gno.land/pkg/gnoweb/static/css/app.css b/gno.land/pkg/gnoweb/static/css/app.css deleted file mode 100644 index c10fc8ec0e0..00000000000 --- a/gno.land/pkg/gnoweb/static/css/app.css +++ /dev/null @@ -1,862 +0,0 @@ -/**** ROBOTO ****/ - -@font-face { - font-family: "Roboto Mono"; - font-style: normal; - font-weight: normal; - font-display: swap; - src: local("Roboto Mono Regular"), url("/static/font/roboto/RobotoMono-Regular.woff") format("woff"); - } - - @font-face { - font-family: "Roboto Mono"; - font-style: italic; - font-weight: normal; - font-display: swap; - src: local("Roboto Mono Italic"), url("/static/font/roboto/RobotoMono-Italic.woff") format("woff"); - } - - @font-face { - font-family: "Roboto Mono Bold"; - font-style: normal; - font-weight: 700; - font-display: swap; - src: local("Roboto Mono Bold"), url("/static/font/roboto/RobotoMono-Bold.woff") format("woff"); - } - - @font-face { - font-family: "Roboto Mono"; - font-style: italic; - font-weight: 700; - font-display: swap; - src: local("Roboto Mono Bold Italic"), url("/static/font/roboto/RobotoMono-BoldItalic.woff") format("woff"); - } - - -/*** DARK/LIGHT THEME COLORS ***/ - -html:not([data-theme="dark"]), -html[data-theme="light"] { - --background-color: #eee; - --input-background-color: #eee; - --text-color: #000; - --link-color: #25172a; - --muted-color: #757575; - --border-color: #d7d9db; - --icon-color: #000; - - --quote-background: #ddd; - --quote-2-background: #aaa4; - --code-background: #d7d9db; - --header-background: #373737; - --header-forground: #ffffff; - --logo-hat: #ffffff; - --logo-beard: #808080; - - --realm-help-background-color: #d7d9db9e; - --realm-help-odd-background-color: #d7d9db45; - --realm-help-code-color: #5d5d5d; - - --highlight-color: #2f3337; - --highlight-bg: #f6f6f6; - --highlight-color: #2f3337; - --highlight-comment: #656e77; - --highlight-keyword: #015692; - --highlight-attribute: #015692; - --highlight-symbol: #803378; - --highlight-namespace: #b75501; - --highlight-keyword: #015692; - --highlight-variable: #54790d; - --highlight-keyword: #015692; - --highlight-literal: #b75501; - --highlight-punctuation: #535a60; - --highlight-variable: #54790d; - --highlight-deletion: #c02d2e; - --highlight-addition: #2f6f44; -} - -html[data-theme="dark"] { - --background-color: #1e1e1e; - --input-background-color: #393939; - --text-color: #c7c7c7; - --link-color: #c7c7c7; - --muted-color: #737373; - --border-color: #606060; - --icon-color: #dddddd; - - --quote-background: #404040; - --quote-2-background: #555555; - --code-background: #606060; - --header-background: #373737; - --header-forground: #ffffff; - --logo-hat: #ffffff; - --logo-beard: #808080; - - --realm-help-background-color: #45454545; - --realm-help-odd-background-color: #4545459e; - --realm-help-code-color: #b6b6b6; - - --highlight-color: #ffffff; - --highlight-bg: #1c1b1b; - --highlight-color: #ffffff; - --highlight-comment: #999999; - --highlight-keyword: #88aece; - --highlight-attribute: #88aece; - --highlight-symbol: #c59bc1; - --highlight-namespace: #f08d49; - --highlight-keyword: #88aece; - --highlight-variable: #b5bd68; - --highlight-keyword: #88aece; - --highlight-literal: #f08d49; - --highlight-punctuation: #cccccc; - --highlight-variable: #b5bd68; - --highlight-deletion: #de7176; - --highlight-addition: #76c490; -} - -.logo-wording path {fill: var(--header-forground, #ffffff); } -.logo-beard { fill: var(--logo-beard, #808080); } -.logo-hat {fill: var(--logo-hat, #ffffff); } - -#theme-toggle { - cursor: pointer; - display: inline-block; - padding: 0; - color: var(--header-forground, #ffffff); -} - -html[data-theme="dark"] #theme-toggle-moon, -html[data-theme="light"] #theme-toggle-sun { - display: none; -} - -/*** BASE HTML ELEMENTS ***/ - -* { - box-sizing: border-box; -} - -html { - font-feature-settings: "kern" on, "liga" on, "calt" on, "zero" on; - -webkit-font-feature-settings: "kern" on, "liga" on, "calt" on, "zero" on; - text-size-adjust: 100%; - -moz-osx-font-smoothing: grayscale; - font-smoothing: antialiased; - font-variant-ligatures: contextual common-ligatures; - font-kerning: normal; - text-rendering: optimizeLegibility; - -moz-text-size-adjust: none; - -webkit-text-size-adjust: none; - text-size-adjust: none; -} - -html, -body { - padding: 0; - margin: 0; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; background-color: var(--background-color, #eee); - color: var(--text-color, #000); - font-size: 15px; - transition: 0.25s all ease; -} - -h1, -h2, -h3, -h4, -nav { - - font-weight: 600; - letter-spacing: 0.08rem; -} - -:is(h1, h2, h3, h4) a { - text-decoration: none; -} - -h1 { - text-align: center; - font-size: 2rem; - margin-block: 4.2rem 2rem; -} - -h2 { - font-size: 1.625rem; - margin-block: 3.4rem 1.2rem; - line-height: 1.4; -} - -h3 { - font-size: 1.467rem; - margin-block: 2.6rem 1rem; -} - -p { - font-size: 1rem; - margin-block: 1.2rem; - line-height: 1.4; -} - -p:last-child:has(a:only-child) { - margin-block-start: 0.8rem; -} -.stack > p:last-child:has(a:only-child) { - margin-block-start: 0; -} - -hr { - border: none; - height: 1px; - background: var(--border-color, #d7d9db); - width: 100%; - margin-block: 1.5rem 2rem; -} - -nav { - font-weight: 400; -} - -button { - color: var(--text-color, #000); -} - -body { - height: 100%; - width: 100%; -} - -input { - -webkit-appearance: none; - -moz-appearance: none; - appearance: none; -} - -a { - color: var(--link-color, #25172a); -} - -a[href="#"] { - color: var(--muted-color, #757575); -} - -.gno-tmpl-section ul { - padding: 0; -} - -.gno-tmpl-section li , -#header li , -.footer li { - list-style: none; -} - -.gno-tmpl-section blockquote { - margin-inline: 0; -} - -li { - margin-bottom: 0.4rem; -} - -li > * { - vertical-align: middle; -} - -input { - background-color: var(--input-background-color, #eee); - border: 1px solid var(--border-color); - color: var(--text-color, #000); - width: 25em; - padding: 0.4rem 0.5rem; - max-width: 100%;x -} - -blockquote { - background-color: var(--quote-background, #ddd); -} - -blockquote blockquote { - margin: 0; - background-color: var(--quote-2-background, #aaa4); -} - -pre, code { - font-family: "Roboto Mono", "Courier New", "sans-serif"; -} -pre { - background-color: var(--code-background, #d7d9db); - margin: 0; - padding: 0.5rem; -} - -label { - margin-block-end: 0.8rem; - display: block; -} - -label > img { - margin-inline-end: 0.8rem; -} - -code { - white-space: pre-wrap; - overflow-wrap: anywhere; -} -/*** COMPOSITION ***/ -.container { - width: 100%; - max-width: 63.75rem; - margin: auto; - padding: 1.25rem; -} - -.container p > img:only-child { - max-width: 100%; -} -.gno-tmpl-page p img:only-child { - margin-inline: auto; - display: block; - max-width: 100%; -} - -.inline-list { - padding: 1rem; - display: flex; - justify-content: space-between; -} - - - -.stack, -.stack > p { - display: flex; - flex-direction: column; -} - -.stack > p { - margin: 0; -} - -.stack > a, -.stack > p > a{ - margin-block-end: 0.4rem; -} - -.column > h1, -.column > h2, -.column > h3, -.column > h4, -.column > h5, -.column > h6 { - margin-block-start: 0; -} - -.columns-2, -.columns-3 { - display: grid; - grid-template-columns: repeat(1, 1fr); - grid-gap: 3.75rem; - margin: 3.75rem auto; -} - -.footer { - text-align: center; - margin-block-start: 2rem; - background-color: var(--header-background, #d7d9db); - border-top: 1px solid var(--border-color); -} - -.footer > .logo { - display: inline-block; - margin: 1rem; - height: 1.2rem; -} - -/** 51.2rem **/ -@media screen and (min-width: 68.75rem) { - .stack, - .stack > p { - flex-direction: row; - } - .stack *:not(:first-child) { - margin-left: 3.75rem; - } - .stack > a, - .stack > p > a{ - margin-block-end: 0; - } - .columns-2 { - grid-template-columns: repeat(2, 1fr); - } - .columns-3 { - grid-template-columns: repeat(3, 1fr); - } -} - -/*** UTILITIES ***/ - -.is-hidden { - display: none; -} - -.is-muted { - color: var(--muted-color, #757575); -} - -.is-finished { - text-decoration: line-through; -} - -.is-underline { - text-decoration: underline; -} - -/*** BLOCKS ***/ -.tabs button { - border: none; - cursor: pointer; - text-decoration: underline; - padding: 0; - background: none; - color: var(--text-color, #000); -} - -.tabs button[aria-selected="true"] { - font-weight: 700; -} - -.tabs + .jumbotron { - margin-top: 2.5rem; -} -.tabs > .columns-2, -.tabs > .columns-3 { - margin-bottom: 2.5rem; -} - -.accordion-trigger { - display: block; - border: none; - cursor: pointer; - padding: 0.4rem 0; - font-size: 1.125rem; - font-weight: 700; - text-align: left; - background: none; -} - -.accordion-trigger ~ div { - padding: 0.875rem 0 2.2rem; -} - -.accordion > p { - margin-block: 0; -} -/** 51.2rem **/ -@media screen and (min-width: 68.75rem) { - .accordion .accordion-trigger ~ div { - padding: 0.875rem 0 2.2rem 2rem; - } -} - -.gor-accordion button::first-letter { - font-size: 1.5em; - color: var(--text-color, #000); -} - -.jumbotron { - border: 1px solid var(--border-color, #d7d9db); - padding: 1.4rem; - margin: 3.75rem auto; -} - -.jumbotron h1 { - text-align: left; -} - -.jumbotron > *:first-child, -.jumbotron > * > *:first-child { - margin-block-start: 0; -} - -.jumbotron > *:last-child, -.jumbotron > * > *:last-child { - margin-block-end: 0; -} - -/** 68.75rem**/ -@media screen and (min-width: 68.75rem) { - .jumbotron { - margin: 3.75rem -3.5rem; - padding: 3.5rem; - } -} - -#root { - display: flex; - flex-direction: column; - border: 1px solid var(--header-background, #d7d9db); - margin: 20px; - overflow: hidden; - /* height: calc(100vh - 40px); */ -} - -#header { - position: relative; - background-color: var(--header-background, #d7d9db); - padding: 1.333rem; - display: flex; - align-items: center; - justify-content: space-between; -} - -#header > nav { - flex-grow: 2; -} - -#header .logo { - display: flex; - align-items: center; - color: var(--link-color, #25172a); - position: absolute; - height: 2.4rem; - z-index: 2; -} - -.logo > svg { - height: 100%; -} - -#logo_path a { - text-decoration: none; -} - -#logo_path { - padding-right: 0.8rem; -} - -#logo_path a:hover { - text-decoration: underline; -} - -#realm_links a { - font-size: 0.8rem; -} - -#header_buttons { - position: relative; - width: 100%; - height: 3rem; -} - -#header_buttons nav { - height: 100%; - display: flex; - justify-content: flex-end; - align-items: center; -} - -/* enabled conditionally with