[pkgsite] internal/api: allow doc=json for package documentation

0 views
Skip to first unread message

Hyang-Ah Hana Kim (Gerrit)

unread,
Apr 28, 2026, 8:05:11 PM (9 hours ago) Apr 28
to goph...@pubsubhelper.golang.org, Hyang-Ah Hana Kim, golang-co...@googlegroups.com

Hyang-Ah Hana Kim has uploaded the change for review

Commit message

internal/api: allow doc=json for package documentation

Once golang/go#34293 lands, we will need to adjust the output
json to match for consistency.
Change-Id: I1cbb796fae1a7b392ee92f09effd37d39815eae7

Change diff

diff --git a/cmd/internal/pkgsite-cli/client/types_gen.go b/cmd/internal/pkgsite-cli/client/types_gen.go
index 22a7ddd..4a7661e 100644
--- a/cmd/internal/pkgsite-cli/client/types_gen.go
+++ b/cmd/internal/pkgsite-cli/client/types_gen.go
@@ -125,3 +125,50 @@
ModulePath string `json:"modulePath"`
PackagePath string `json:"packagePath"`
}
+
+// PackageJSON represents the structured documentation of a package.
+// TODO: This structure is based on the proposal in Go issue #34293 (go doc -json).
+// Once that issue lands, we should try to match its output JSON structure.
+type PackageJSON struct {
+ Doc string `json:"doc,omitempty"`
+ Consts []ValueJSON `json:"consts,omitempty"`
+ Vars []ValueJSON `json:"vars,omitempty"`
+ Funcs []FuncJSON `json:"funcs,omitempty"`
+ Types []TypeJSON `json:"types,omitempty"`
+ Examples []ExampleJSON `json:"examples,omitempty"`
+}
+
+// ValueJSON represents documentation for a group of constants or variables.
+type ValueJSON struct {
+ Names []string `json:"names"`
+ Doc string `json:"doc,omitempty"`
+ Decl string `json:"decl"`
+}
+
+// FuncJSON represents documentation for a function or method.
+type FuncJSON struct {
+ Name string `json:"name"`
+ Doc string `json:"doc,omitempty"`
+ Decl string `json:"decl"`
+ Examples []ExampleJSON `json:"examples,omitempty"`
+}
+
+// TypeJSON represents documentation for a type and its associated symbols.
+type TypeJSON struct {
+ Name string `json:"name"`
+ Doc string `json:"doc,omitempty"`
+ Decl string `json:"decl"`
+ Consts []ValueJSON `json:"consts,omitempty"`
+ Vars []ValueJSON `json:"vars,omitempty"`
+ Funcs []FuncJSON `json:"funcs,omitempty"`
+ Methods []FuncJSON `json:"methods,omitempty"`
+ Examples []ExampleJSON `json:"examples,omitempty"`
+}
+
+// ExampleJSON represents documentation for an example.
+type ExampleJSON struct {
+ Doc string `json:"doc,omitempty"`
+ Suffix string `json:"suffix,omitempty"`
+ Code string `json:"code"`
+ Output string `json:"output,omitempty"`
+}
diff --git a/cmd/internal/pkgsite-cli/main_test.go b/cmd/internal/pkgsite-cli/main_test.go
index 097eaa8..98bba30 100644
--- a/cmd/internal/pkgsite-cli/main_test.go
+++ b/cmd/internal/pkgsite-cli/main_test.go
@@ -262,3 +262,26 @@
}
}
}
+
+func TestRunPackage_DocJSON(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if got := r.URL.Query().Get("doc"); got != "json" {
+ t.Errorf("doc query param = %q, want json", got)
+ }
+ json.NewEncoder(w).Encode(client.Package{
+ Path: "encoding/json",
+ ModulePath: "std",
+ Docs: `{"doc": "Package docs in JSON"}`,
+ })
+ }))
+ defer srv.Close()
+
+ var stdout, stderr bytes.Buffer
+ code := run([]string{"package", "--server=" + srv.URL, "-doc=json", "encoding/json"}, &stdout, &stderr)
+ if code != 0 {
+ t.Fatalf("exit code = %d, stderr = %s", code, stderr.String())
+ }
+ if !strings.Contains(stdout.String(), `{"doc": "Package docs in JSON"}`) {
+ t.Errorf("output missing expected JSON doc:\n%s", stdout.String())
+ }
+}
diff --git a/cmd/internal/pkgsite-cli/package.go b/cmd/internal/pkgsite-cli/package.go
index 9874811..6b7910f 100644
--- a/cmd/internal/pkgsite-cli/package.go
+++ b/cmd/internal/pkgsite-cli/package.go
@@ -99,7 +99,7 @@

func (f *packageFlags) register(fs *flag.FlagSet) {
f.commonFlags.register(fs)
- fs.StringVar(&f.doc, "doc", "", "render docs in format: text, md, html")
+ fs.StringVar(&f.doc, "doc", "", "render docs in format: text, md, html, json")
fs.BoolVar(&f.examples, "examples", false, "include examples (requires -doc)")
fs.BoolVar(&f.imports, "imports", false, "list imported packages")
fs.BoolVar(&f.importedBy, "imported-by", false, "list reverse dependencies")
diff --git a/internal/api/api.go b/internal/api/api.go
index d7ff45b..9f65e46 100644
--- a/internal/api/api.go
+++ b/internal/api/api.go
@@ -769,8 +769,10 @@
r = newMarkdownRenderer(gpkg.Fset, &sb)
case "html":
r = newHTMLRenderer(gpkg.Fset, &sb)
+ case "json":
+ return renderJSONDocumentation(dpkg, gpkg.Fset, examples)
default:
- return "", BadRequest("bad doc format: need one of 'text', 'md', 'markdown' or 'html'")
+ return "", BadRequest("bad doc format: need one of 'text', 'md', 'markdown', 'html' or 'json'")
}
if err := renderDoc(dpkg, r, examples); err != nil {
return "", fmt.Errorf("renderDoc: %w", err)
diff --git a/internal/api/api_test.go b/internal/api/api_test.go
index b65c673..f74ad5e 100644
--- a/internal/api/api_test.go
+++ b/internal/api/api_test.go
@@ -5,12 +5,16 @@
package api

import (
+ "context"
"encoding/json"
+ "go/parser"
+ "go/token"
"net/http"
"net/http/httptest"
"testing"

"golang.org/x/pkgsite/internal"
+ "golang.org/x/pkgsite/internal/godoc"
"golang.org/x/pkgsite/internal/osv"
"golang.org/x/pkgsite/internal/testing/fakedatasource"
"golang.org/x/pkgsite/internal/vuln"
@@ -138,3 +142,85 @@
})
}
}
+
+func TestServePackage_JSONDoc(t *testing.T) {
+ ds := fakedatasource.New()
+ const modulePath = "example.com/foo"
+ const v = "v1.0.0"
+
+ // Create a fake Go file
+ src := `package foo
+// Bar is a constant.
+const Bar = 42
+
+// Baz is a function.
+func Baz() {}
+`
+ fset := token.NewFileSet()
+ f, err := parser.ParseFile(fset, "foo.go", src, parser.ParseComments)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ docPkg := godoc.NewPackage(fset, nil)
+ docPkg.AddFile(f, true)
+ encodedSrc, err := docPkg.Encode(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ ds.MustInsertModule(t, &internal.Module{
+ ModuleInfo: internal.ModuleInfo{
+ ModulePath: modulePath,
+ Version: v,
+ },
+ Units: []*internal.Unit{{
+ UnitMeta: internal.UnitMeta{
+ Path: modulePath,
+ Name: "foo",
+ ModuleInfo: internal.ModuleInfo{
+ ModulePath: modulePath,
+ Version: v,
+ },
+ },
+ Documentation: []*internal.Documentation{{
+ GOOS: internal.All,
+ GOARCH: internal.All,
+ Synopsis: "Package foo is a fake package.",
+ Source: encodedSrc,
+ }},
+ }},
+ })
+
+ r := httptest.NewRequest("GET", "/v1/package/"+modulePath+"?doc=json", nil)
+ w := httptest.NewRecorder()
+
+ if err := ServePackage(w, r, ds); err != nil {
+ t.Fatal(err)
+ }
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", w.Code, http.StatusOK)
+ }
+
+ var resp Package
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("json.Unmarshal: %v", err)
+ }
+
+ if resp.Docs == "" {
+ t.Fatal("docs is empty")
+ }
+
+ var pkgJSON PackageJSON
+ if err := json.Unmarshal([]byte(resp.Docs), &pkgJSON); err != nil {
+ t.Fatalf("json.Unmarshal(Docs): %v", err)
+ }
+
+ if len(pkgJSON.Consts) != 1 || pkgJSON.Consts[0].Names[0] != "Bar" {
+ t.Errorf("expected Const Bar, got %v", pkgJSON.Consts)
+ }
+ if len(pkgJSON.Funcs) != 1 || pkgJSON.Funcs[0].Name != "Baz" {
+ t.Errorf("expected Func Baz, got %v", pkgJSON.Funcs)
+ }
+}
diff --git a/internal/api/render.go b/internal/api/render.go
index 4001f6c..3b88cf8 100644
--- a/internal/api/render.go
+++ b/internal/api/render.go
@@ -5,6 +5,7 @@
package api

import (
+ "encoding/json"
"fmt"
"go/ast"
"go/doc"
@@ -419,3 +420,97 @@
r.endSection()
}
}
+
+// renderJSONDocumentation renders the documentation for dpkg to JSON.
+func renderJSONDocumentation(dpkg *doc.Package, fset *token.FileSet, examples bool) (string, error) {
+ pkgJSON := PackageJSON{
+ Doc: dpkg.Doc,
+ }
+
+ nodeToString := func(n ast.Node) string {
+ var sb strings.Builder
+ if err := format.Node(&sb, fset, n); err != nil {
+ return fmt.Sprintf("error formatting node: %v", err)
+ }
+ return sb.String()
+ }
+
+ convertExamples := func(exs []*doc.Example) []ExampleJSON {
+ var res []ExampleJSON
+ for _, ex := range exs {
+ res = append(res, ExampleJSON{
+ Doc: ex.Doc,
+ Suffix: ex.Suffix,
+ Code: nodeToString(ex.Code),
+ Output: ex.Output,
+ })
+ }
+ return res
+ }
+
+ if examples {
+ pkgJSON.Examples = convertExamples(dpkg.Examples)
+ }
+
+ convertValues := func(vals []*doc.Value) []ValueJSON {
+ var res []ValueJSON
+ for _, v := range vals {
+ if slices.IndexFunc(v.Names, ast.IsExported) >= 0 {
+ res = append(res, ValueJSON{
+ Names: v.Names,
+ Doc: v.Doc,
+ Decl: nodeToString(v.Decl),
+ })
+ }
+ }
+ return res
+ }
+
+ pkgJSON.Consts = convertValues(dpkg.Consts)
+ pkgJSON.Vars = convertValues(dpkg.Vars)
+
+ convertFuncs := func(funcs []*doc.Func) []FuncJSON {
+ var res []FuncJSON
+ for _, f := range funcs {
+ if ast.IsExported(f.Name) {
+ fj := FuncJSON{
+ Name: f.Name,
+ Doc: f.Doc,
+ Decl: nodeToString(f.Decl),
+ }
+ if examples {
+ fj.Examples = convertExamples(f.Examples)
+ }
+ res = append(res, fj)
+ }
+ }
+ return res
+ }
+
+ pkgJSON.Funcs = convertFuncs(dpkg.Funcs)
+
+ for _, t := range dpkg.Types {
+ if !ast.IsExported(t.Name) {
+ continue
+ }
+ tJSON := TypeJSON{
+ Name: t.Name,
+ Doc: t.Doc,
+ Decl: nodeToString(t.Decl),
+ Consts: convertValues(t.Consts),
+ Vars: convertValues(t.Vars),
+ Funcs: convertFuncs(t.Funcs),
+ Methods: convertFuncs(t.Methods),
+ }
+ if examples {
+ tJSON.Examples = convertExamples(t.Examples)
+ }
+ pkgJSON.Types = append(pkgJSON.Types, tJSON)
+ }
+
+ b, err := json.Marshal(pkgJSON)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
diff --git a/internal/api/types.go b/internal/api/types.go
index e765a13..35cfc24 100644
--- a/internal/api/types.go
+++ b/internal/api/types.go
@@ -154,3 +154,50 @@
ModulePath string `json:"modulePath"`
PackagePath string `json:"packagePath"`
}
+
+// PackageJSON represents the structured documentation of a package.
+// TODO: This structure is based on the proposal in Go issue #34293 (go doc -json).
+// Once that issue lands, we should try to match its output JSON structure.
+type PackageJSON struct {
+ Doc string `json:"doc,omitempty"`
+ Consts []ValueJSON `json:"consts,omitempty"`
+ Vars []ValueJSON `json:"vars,omitempty"`
+ Funcs []FuncJSON `json:"funcs,omitempty"`
+ Types []TypeJSON `json:"types,omitempty"`
+ Examples []ExampleJSON `json:"examples,omitempty"`
+}
+
+// ValueJSON represents documentation for a group of constants or variables.
+type ValueJSON struct {
+ Names []string `json:"names"`
+ Doc string `json:"doc,omitempty"`
+ Decl string `json:"decl"`
+}
+
+// FuncJSON represents documentation for a function or method.
+type FuncJSON struct {
+ Name string `json:"name"`
+ Doc string `json:"doc,omitempty"`
+ Decl string `json:"decl"`
+ Examples []ExampleJSON `json:"examples,omitempty"`
+}
+
+// TypeJSON represents documentation for a type and its associated symbols.
+type TypeJSON struct {
+ Name string `json:"name"`
+ Doc string `json:"doc,omitempty"`
+ Decl string `json:"decl"`
+ Consts []ValueJSON `json:"consts,omitempty"`
+ Vars []ValueJSON `json:"vars,omitempty"`
+ Funcs []FuncJSON `json:"funcs,omitempty"`
+ Methods []FuncJSON `json:"methods,omitempty"`
+ Examples []ExampleJSON `json:"examples,omitempty"`
+}
+
+// ExampleJSON represents documentation for an example.
+type ExampleJSON struct {
+ Doc string `json:"doc,omitempty"`
+ Suffix string `json:"suffix,omitempty"`
+ Code string `json:"code"`
+ Output string `json:"output,omitempty"`
+}
diff --git a/internal/tests/api/api_test.go b/internal/tests/api/api_test.go
index 7c7fd90..4c3adac 100644
--- a/internal/tests/api/api_test.go
+++ b/internal/tests/api/api_test.go
@@ -353,7 +353,7 @@
wantStatus: http.StatusBadRequest,
want: &api.Error{
Code: http.StatusBadRequest,
- Message: "bad doc format: need one of 'text', 'md', 'markdown' or 'html'",
+ Message: "bad doc format: need one of 'text', 'md', 'markdown', 'html' or 'json'",
},
},
{

Change information

Files:
  • M cmd/internal/pkgsite-cli/client/types_gen.go
  • M cmd/internal/pkgsite-cli/main_test.go
  • M cmd/internal/pkgsite-cli/package.go
  • M internal/api/api.go
  • M internal/api/api_test.go
  • M internal/api/render.go
  • M internal/api/types.go
  • M internal/tests/api/api_test.go
Change size: L
Delta: 8 files changed, 303 insertions(+), 3 deletions(-)
Open in Gerrit

Related details

Attention set is empty
Submit Requirements:
  • requirement is not satisfiedCode-Review
  • requirement satisfiedNo-Unresolved-Comments
  • requirement is not satisfiedReview-Enforcement
  • requirement is not satisfiedTryBots-Pass
  • requirement is not satisfiedkokoro-CI-Passes
Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
Gerrit-MessageType: newchange
Gerrit-Project: pkgsite
Gerrit-Branch: master
Gerrit-Change-Id: I1cbb796fae1a7b392ee92f09effd37d39815eae7
Gerrit-Change-Number: 771820
Gerrit-PatchSet: 1
Gerrit-Owner: Hyang-Ah Hana Kim <hya...@gmail.com>
Gerrit-Reviewer: Hyang-Ah Hana Kim <hya...@gmail.com>
unsatisfied_requirement
satisfied_requirement
open
diffy

kokoro (Gerrit)

unread,
Apr 28, 2026, 8:31:42 PM (9 hours ago) Apr 28
to Hyang-Ah Hana Kim, goph...@pubsubhelper.golang.org, golang...@luci-project-accounts.iam.gserviceaccount.com, golang-co...@googlegroups.com
Attention needed from Hyang-Ah Hana Kim

kokoro voted kokoro-CI+1

Kokoro presubmit build finished with status: SUCCESS
Logs at: https://source.cloud.google.com/results/invocations/7730947b-9c2a-45b2-b47d-d52916394ed4

kokoro-CI+1
Open in Gerrit

Related details

Attention is currently required from:
  • Hyang-Ah Hana Kim
Submit Requirements:
    • requirement is not satisfiedCode-Review
    • requirement satisfiedNo-Unresolved-Comments
    • requirement is not satisfiedReview-Enforcement
    • requirement satisfiedTryBots-Pass
    • requirement satisfiedkokoro-CI-Passes
    Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. DiffyGerrit
    Gerrit-MessageType: comment
    Gerrit-Project: pkgsite
    Gerrit-Branch: master
    Gerrit-Change-Id: I1cbb796fae1a7b392ee92f09effd37d39815eae7
    Gerrit-Change-Number: 771820
    Gerrit-PatchSet: 1
    Gerrit-Owner: Hyang-Ah Hana Kim <hya...@gmail.com>
    Gerrit-Reviewer: Hyang-Ah Hana Kim <hya...@gmail.com>
    Gerrit-Reviewer: kokoro <noreply...@google.com>
    Gerrit-CC: kokoro <noreply...@google.com>
    Gerrit-Attention: Hyang-Ah Hana Kim <hya...@gmail.com>
    Gerrit-Comment-Date: Wed, 29 Apr 2026 00:31:39 +0000
    Gerrit-HasComments: No
    Gerrit-Has-Labels: Yes
    unsatisfied_requirement
    satisfied_requirement
    open
    diffy
    Reply all
    Reply to author
    Forward
    0 new messages