Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ jobs:
steps:
- uses: actions/checkout@v5

- uses: actions/setup-go@v6
with:
go-version: '1.27'

- uses: actions/setup-node@v6
with:
node-version: 24
Expand Down Expand Up @@ -66,4 +70,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v4
35 changes: 35 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Documentation checks

on:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
docs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version: '1.27'
cache-dependency-path: |
go.sum
reference/go.sum
- uses: actions/setup-node@v6
with:
node-version: '24'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Test cookbook
run: go test ./...
- name: Install site dependencies
run: npm ci --ignore-scripts
working-directory: site
- name: Check source reference and build site
run: npm run build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,8 @@ vendor
.superpowers/
.serena/
.playwright-mcp/
.cache/
site/src/generated/
site/dist/
site/dist-next/
site/node_modules/
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,20 @@ the runnable cookbook recipes the docs reference.
| ----------- | --------------------------------------------------------------------------------------------------- |
| `site/` | The docs site — [Astro](https://astro.build) + [Starlight](https://starlight.astro.build). Content lives in `site/src/content/docs/`. |
| `cookbook/` | Standalone, runnable Go example apps referenced from the docs. |
| `reference/` | Source-owned middleware examples and the config-field extractor used by the site build. |
| `docs/` | Internal design specs. |

## Documentation site

Requires [Node.js](https://nodejs.org) (LTS).
Requires [Node.js](https://nodejs.org) (LTS), Go 1.27, and Git. The build
publishes the stable docs at `/` and a next preview at `/next/`, each with its
own search index and source revision. Those revisions are pinned in
`site/echo-source.json` and `site/next-source.json`. The build compiles the
`reference/` examples against both, extracts middleware fields and function
signatures, and checks stable against `site/reference-baseline.json` and next
against `site/next-reference-baseline.json`. JWT,
Prometheus, and OpenTelemetry are extracted from their own pinned modules in
`site/external-sources.json` and checked against `site/external-baseline.json`.

```bash
cd site
Expand All @@ -24,6 +33,33 @@ npm run build # production build to site/dist
npm run preview # preview the production build
```

The first build fetches pinned source into `.cache/`. To test a proposed next
Echo checkout, set `ECHO_SOURCE_DIR` to its absolute path when running
`npm run build`. The build reports changed API facts and stops. Review the
affected pages and behavior, then prepare and accept the **next** baseline:

```bash
DOCS_CHANNEL=next ECHO_SOURCE_DIR=/absolute/path/to/echo npm run source:prepare
DOCS_CHANNEL=next ECHO_SOURCE_DIR=/absolute/path/to/echo npm run source:accept
```

Review the baseline diff, update `site/next-source.json` to the proposed
revision, and rerun the full build. The release baseline remains independent.
The generated files in `site/src/generated/` are never edited or committed.
`npm run site:check` checks routes, local links and fragments, image text,
search assets, and locale coverage. `npm run performance:check` catches large
HTML or first-load asset growth on representative stable and next pages.

Generated field descriptions come from the pinned Go source comments and remain
in English on localized pages; the surrounding task guidance is authored per
locale. `npm run translations:status` identifies changed sections in the Spanish,
Japanese, Portuguese, and Chinese Request Logger, Static, and middleware task
pages. Translate and review the affected section, then record that page with
`npm run translations:accept -- es logger` (replace locale and page) and review
the baseline diff. This tracks edits to both English and localized text; it
does not judge translation quality. The API tables are generated from source,
so translators focus on explanations, task guidance, examples, and safety notes.

Content is Markdown/MDX under `site/src/content/docs/` (`guide/`, `middleware/`,
`cookbook/`). To add a page, drop a file in the right folder — the sidebar is
generated from each page's `sidebar.order` frontmatter. Every page needs a
Expand Down
24 changes: 24 additions & 0 deletions docs/api-reference-tool-trial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# API reference tool trial

The first trial used `gomarkdoc` v1.1.0 against the pinned Echo checkout's
`middleware` package, which includes CORS, Request Logger, and Static, and
against the external `github.com/labstack/echo-jwt/v5` package from the current
`echox` module. The package output was 2,422 lines for core middleware and 389
lines for JWT. `gomarkdoc --check --output ...` successfully detected that the
unmodified generated core file matched its source. The output included
`EnablePathUnescaping` and excluded the removed `RequestLoggerConfig.LogError`.

`--embed` supports marked regions within authored Markdown. A check against an
unmarked, fully generated file failed because embed mode would append a second
generated block. This confirms that an existing page must add embed markers
before using that mode. Templates can change generated Markdown, but this
package-wide output is too broad for individual middleware pages and is not a
machine-readable field manifest. Those two requirements motivate the small
`config-fields` extractor in `reference/`. It extracts only fields, types,
deprecation markers, and source positions; it does not infer defaults or
rewrite authored pages.

The site currently renders field tables from that extractor. A targeted
`gomarkdoc` template and marked embedding on a real page remain to be evaluated
for signatures and other reference sections. This trial does not justify
generating behavior explanations.
163 changes: 163 additions & 0 deletions reference/cmd/config-fields/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: MIT

// config-fields emits source-backed middleware API facts for the website.
// It does not claim to determine runtime defaults or behavior.
package main

import (
"bytes"
"encoding/json"
"flag"
"fmt"
"go/ast"
"go/format"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
)

type field struct {
Name string `json:"name"`
Type string `json:"type"`
Doc string `json:"doc,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
Line int `json:"line"`
}

type config struct {
Name string `json:"name"`
File string `json:"file"`
Line int `json:"line"`
Fields []field `json:"fields"`
}

type function struct {
Name string `json:"name"`
Signature string `json:"signature"`
File string `json:"file"`
Line int `json:"line"`
}

type manifest struct {
Module string `json:"module"`
Revision string `json:"revision,omitempty"`
Configs []config `json:"configs"`
Functions []function `json:"functions"`
}

func main() {
root := flag.String("root", "", "Echo repository root")
revision := flag.String("revision", "", "exact Echo commit or tag used for this build")
module := flag.String("module", "github.com/labstack/echo/v5", "module path containing the source")
directory := flag.String("directory", "middleware", "package directory relative to the module root")
packageName := flag.String("package", "middleware", "Go package name")
flag.Parse()
if *root == "" {
fmt.Fprintln(os.Stderr, "-root is required")
os.Exit(2)
}

result, err := extractPackage(*root, *revision, *module, *directory, *packageName)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(result); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

func extract(root, revision string) (manifest, error) {
return extractPackage(root, revision, "github.com/labstack/echo/v5", "middleware", "middleware")
}

func extractPackage(root, revision, module, directory, packageName string) (manifest, error) {
root, err := filepath.Abs(root)
if err != nil {
return manifest{}, err
}
fs := token.NewFileSet()
packageDir := filepath.Join(root, directory)
packages, err := parser.ParseDir(fs, packageDir, func(info os.FileInfo) bool {
return strings.HasSuffix(info.Name(), ".go") && !strings.HasSuffix(info.Name(), "_test.go")
}, parser.ParseComments)
if err != nil {
return manifest{}, err
}
pkg, ok := packages[packageName]
if !ok {
return manifest{}, fmt.Errorf("package %s not found in %s", packageName, packageDir)
}

result := manifest{Module: module, Revision: revision, Configs: []config{}, Functions: []function{}}
for filename, source := range pkg.Files {
for _, declaration := range source.Decls {
if fn, ok := declaration.(*ast.FuncDecl); ok && fn.Recv == nil && ast.IsExported(fn.Name.Name) {
var rendered bytes.Buffer
if err := format.Node(&rendered, fs, fn.Type); err != nil {
return manifest{}, err
}
result.Functions = append(result.Functions, function{
Name: fn.Name.Name,
Signature: strings.Replace(rendered.String(), "func(", "func "+fn.Name.Name+"(", 1),
File: filepath.ToSlash(strings.TrimPrefix(filename, root+string(filepath.Separator))),
Line: fs.Position(fn.Pos()).Line,
})
continue
}
group, ok := declaration.(*ast.GenDecl)
if !ok || group.Tok != token.TYPE {
continue
}
for _, item := range group.Specs {
typeSpec := item.(*ast.TypeSpec)
structure, ok := typeSpec.Type.(*ast.StructType)
if !ok || !ast.IsExported(typeSpec.Name.Name) || !strings.HasSuffix(typeSpec.Name.Name, "Config") {
continue
}
entry := config{
Name: typeSpec.Name.Name,
File: filepath.ToSlash(strings.TrimPrefix(filename, root+string(filepath.Separator))),
Line: fs.Position(typeSpec.Pos()).Line,
Fields: []field{},
}
for _, sourceField := range structure.Fields.List {
var rendered bytes.Buffer
if err := format.Node(&rendered, fs, sourceField.Type); err != nil {
return manifest{}, err
}
fieldDoc := comment(sourceField.Doc)
for _, name := range sourceField.Names {
if !ast.IsExported(name.Name) {
continue
}
entry.Fields = append(entry.Fields, field{
Name: name.Name,
Type: rendered.String(),
Doc: fieldDoc,
Deprecated: strings.Contains(fieldDoc, "Deprecated:"),
Line: fs.Position(name.Pos()).Line,
})
}
}
result.Configs = append(result.Configs, entry)
}
}
}
sort.Slice(result.Configs, func(i, j int) bool { return result.Configs[i].Name < result.Configs[j].Name })
sort.Slice(result.Functions, func(i, j int) bool { return result.Functions[i].Name < result.Functions[j].Name })
return result, nil
}

func comment(group *ast.CommentGroup) string {
if group == nil {
return ""
}
return strings.TrimSpace(group.Text())
}
50 changes: 50 additions & 0 deletions reference/cmd/config-fields/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: MIT

package main

import (
"os"
"path/filepath"
"testing"
)

func TestExtractOnlyExportedConfigFields(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "middleware")
if err := os.Mkdir(dir, 0755); err != nil {
t.Fatal(err)
}
source := `package middleware
type ExampleConfig struct {
// Deprecated: use New instead.
Old bool
New string
private int
}
type hiddenConfig struct { Visible bool }
type OtherType struct { Visible bool }
func ExampleWithConfig(c ExampleConfig) bool { return c.New != "" }
func privateFunction() {}
`
if err := os.WriteFile(filepath.Join(dir, "example.go"), []byte(source), 0644); err != nil {
t.Fatal(err)
}

got, err := extract(root, "abc123")
if err != nil {
t.Fatal(err)
}
if got.Revision != "abc123" || len(got.Configs) != 1 {
t.Fatalf("unexpected manifest: %#v", got)
}
fields := got.Configs[0].Fields
if got.Configs[0].File != "middleware/example.go" || len(fields) != 2 {
t.Fatalf("unexpected config: %#v", got.Configs[0])
}
if fields[0].Name != "Old" || fields[0].Type != "bool" || fields[0].Doc != "Deprecated: use New instead." || !fields[0].Deprecated || fields[1].Name != "New" {
t.Fatalf("unexpected fields: %#v", fields)
}
if len(got.Functions) != 1 || got.Functions[0].Name != "ExampleWithConfig" || got.Functions[0].Signature != "func ExampleWithConfig(c ExampleConfig) bool" || got.Functions[0].File != "middleware/example.go" {
t.Fatalf("unexpected functions: %#v", got.Functions)
}
}
7 changes: 7 additions & 0 deletions reference/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module github.com/labstack/echox/reference

go 1.25.0

require github.com/labstack/echo/v5 v5.3.0

require golang.org/x/time v0.15.0 // indirect
16 changes: 16 additions & 0 deletions reference/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/labstack/echo/v5 v5.3.0 h1:KT74Mprk053PQEHwSZdeCDIz1BigTZOZhavMD0c9Fjs=
github.com/labstack/echo/v5 v5.3.0/go.mod h1:Q3j2+clBRgJr0O3DDONQeXNsM7RHgSwUhcuo47unqm8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Loading
Loading