From 0f438ccc7dbe2f891f5f97594f7b6510e490c742 Mon Sep 17 00:00:00 2001 From: Jonathan Amsterdam Date: Sat, 21 Mar 2026 16:52:49 -0400 Subject: internal/api: render markdown documentation Change-Id: I8adf1e62d19cf592cc8204264d99953e12da5843 Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/760260 LUCI-TryBot-Result: Go LUCI kokoro-CI: kokoro Reviewed-by: Ethan Lee --- internal/api/api.go | 8 +-- internal/api/render.go | 74 ++++++++++++++++++++++++ internal/api/render_test.go | 31 ++++++---- internal/api/testdata/markdown.golden | 105 ++++++++++++++++++++++++++++++++++ internal/api/testdata/pkg.go | 6 ++ internal/api/testdata/text.golden | 6 ++ 6 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 internal/api/testdata/markdown.golden diff --git a/internal/api/api.go b/internal/api/api.go index edd758e1..b2be2327 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -106,19 +106,19 @@ func ServePackage(w http.ResponseWriter, r *http.Request, ds internal.DataSource if err != nil { return err } - var tr renderer + var r renderer var sb strings.Builder switch params.Doc { case "text": - tr = &textRenderer{fset: gpkg.Fset, w: &sb} + r = &textRenderer{fset: gpkg.Fset, w: &sb} case "md", "markdown": - return errors.New("unimplemented") + r = &markdownRenderer{fset: gpkg.Fset, w: &sb} case "html": return errors.New("unimplemented") default: return serveErrorJSON(w, http.StatusBadRequest, "bad doc format: need one of 'text', 'md', 'markdown' or 'html'", nil) } - if err := renderDoc(dpkg, tr); err != nil { + if err := renderDoc(dpkg, r); err != nil { return serveErrorJSON(w, http.StatusInternalServerError, err.Error(), nil) } docs = sb.String() diff --git a/internal/api/render.go b/internal/api/render.go index 47b157af..656a8405 100644 --- a/internal/api/render.go +++ b/internal/api/render.go @@ -14,6 +14,9 @@ import ( "io" "slices" "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" ) // A renderer prints symbol documentation for a package. @@ -101,6 +104,77 @@ func (r *textRenderer) printf(format string, args ...any) { } } +type markdownRenderer struct { + fset *token.FileSet + w io.Writer + pkg *doc.Package + parser *comment.Parser + printer *comment.Printer + caser cases.Caser + err error +} + +func (r *markdownRenderer) start(pkg *doc.Package) { + r.pkg = pkg + r.parser = pkg.Parser() + r.printer = pkg.Printer() + r.printer.HeadingLevel = 3 + r.caser = cases.Title(language.English) + + r.printf("# package %s\n", pkg.Name) + if pkg.Doc != "" { + r.printf("\n") + _, err := r.w.Write(r.printer.Markdown(r.parser.Parse(pkg.Doc))) + if err != nil { + r.err = err + } + } + r.printf("\n") +} + +func (r *markdownRenderer) end() error { return r.err } + +func (r *markdownRenderer) startSection(name string) { + if name == "" { + return + } + r.printf("## %s\n\n", r.caser.String(name)) +} + +func (r *markdownRenderer) endSection() {} + +func (r *markdownRenderer) emit(comment string, node ast.Node) { + if r.err != nil { + return + } + r.printf("```\n") + err := format.Node(r.w, r.fset, node) + if err != nil { + r.err = err + return + } + r.printf("\n```\n") + formatted := r.printer.Markdown(r.parser.Parse(comment)) + if len(formatted) > 0 { + _, err = r.w.Write(formatted) + if err != nil { + r.err = err + return + } + } + r.printf("\n") +} + +func (r *markdownRenderer) printf(format string, args ...any) { + if r.err != nil { + return + } + _, err := fmt.Fprintf(r.w, format, args...) + if err != nil { + r.err = err + } +} + // renderDoc renders the documentation for dpkg using the given renderer. // TODO(jba): support examples. func renderDoc(dpkg *doc.Package, r renderer) error { diff --git a/internal/api/render_test.go b/internal/api/render_test.go index d77518d9..d163b26d 100644 --- a/internal/api/render_test.go +++ b/internal/api/render_test.go @@ -45,17 +45,24 @@ func TestRenderDoc(t *testing.T) { } var sb strings.Builder - tr := &textRenderer{fset: decoded.Fset, w: &sb} - if err := renderDoc(dpkg, tr); err != nil { - t.Fatal(err) - } - got := strings.TrimSpace(sb.String()) - wantBytes, err := os.ReadFile(filepath.FromSlash("testdata/text.golden")) - if err != nil { - t.Fatal(err) - } - want := strings.TrimSpace(string(wantBytes)) - if diff := cmp.Diff(want, got); diff != "" { - t.Errorf("mismatch (-want +got):\n%s", diff) + check := func(t *testing.T, name string, r renderer) { + sb.Reset() + t.Run(name, func(t *testing.T) { + if err := renderDoc(dpkg, r); err != nil { + t.Fatal(err) + } + got := strings.TrimSpace(sb.String()) + wantBytes, err := os.ReadFile(filepath.FromSlash("testdata/" + name + ".golden")) + if err != nil { + t.Fatal(err) + } + want := strings.TrimSpace(string(wantBytes)) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + }) } + + check(t, "text", &textRenderer{fset: decoded.Fset, w: &sb}) + check(t, "markdown", &markdownRenderer{fset: decoded.Fset, w: &sb}) } diff --git a/internal/api/testdata/markdown.golden b/internal/api/testdata/markdown.golden new file mode 100644 index 00000000..205546d0 --- /dev/null +++ b/internal/api/testdata/markdown.golden @@ -0,0 +1,105 @@ +# package pkg + +Package pkg has every form of declaration. + +### Heading {#hdr-Heading} + +Search [Google](https://google.com) for details. + +### Links {#hdr-Links} + + - pkgsite repo, [https://go.googlesource.com/pkgsite](https://go.googlesource.com/pkgsite) + - Play with Go, [https://play-with-go.dev](https://play-with-go.dev) + +## Constants + +``` +const ( + X = 1 + Y = 2 +) +``` +Several constants. + +``` +const C = 1 +``` +C is a shorthand for 1. + +## Variables + +``` +var V = 2 +``` +V is a variable. + +## Functions + +``` +func Add(x int) int +``` +Add adds 1 to x. + +``` +func F() +``` +F is a function. + +## Types + +``` +type A int +``` + +``` +type B bool +``` + +``` +type I1 interface { + M1() +} +``` +I1 is an interface. + +``` +type I2 interface { + I1 + M2() +} +``` + +``` +type S1 struct { + F int // field +} +``` +S1 is a struct. + +``` +type S2 struct { + S1 + G int +} +``` +S2 is another struct. + +``` +type T int +``` +T is a type. + +``` +const CT T = 3 +``` +CT is a typed constant. They appear after their type. + +``` +func TF() T +``` +TF is a constructor for T. + +``` +func (T) M() +``` +M is a method of T. BUG(xxx): this verifies that notes are rendered. diff --git a/internal/api/testdata/pkg.go b/internal/api/testdata/pkg.go index b9650bb8..6d62abad 100644 --- a/internal/api/testdata/pkg.go +++ b/internal/api/testdata/pkg.go @@ -4,10 +4,16 @@ // Package pkg has every form of declaration. // +// # Heading +// +// Search [Google] for details. +// // # Links // // - pkgsite repo, https://go.googlesource.com/pkgsite // - Play with Go, https://play-with-go.dev +// +// [Google]: https://google.com package pkg // C is a shorthand for 1. diff --git a/internal/api/testdata/text.golden b/internal/api/testdata/text.golden index 6497759c..75998ec5 100644 --- a/internal/api/testdata/text.golden +++ b/internal/api/testdata/text.golden @@ -2,11 +2,17 @@ package pkg Package pkg has every form of declaration. +# Heading + +Search Google for details. + # Links - pkgsite repo, https://go.googlesource.com/pkgsite - Play with Go, https://play-with-go.dev +[Google]: https://google.com + CONSTANTS const ( -- cgit v1.3