From ed04ff40179788ff2a7e78d55f7f5d5e904fa950 Mon Sep 17 00:00:00 2001 From: Julien Bisconti Date: Fri, 27 Feb 2026 23:22:16 +0100 Subject: [PATCH] feat: add website builder using goldmark (replaces build.js) Co-Authored-By: Claude Opus 4.6 --- go.mod | 1 + go.sum | 2 + internal/builder/builder.go | 51 ++++++++++++++ internal/builder/builder_test.go | 113 +++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 internal/builder/builder.go create mode 100644 internal/builder/builder_test.go diff --git a/go.mod b/go.mod index f608100..b72fbff 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/yuin/goldmark v1.7.16 // indirect golang.org/x/oauth2 v0.35.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 8c787c0..0c38e50 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= diff --git a/internal/builder/builder.go b/internal/builder/builder.go new file mode 100644 index 0000000..872072e --- /dev/null +++ b/internal/builder/builder.go @@ -0,0 +1,51 @@ +package builder + +import ( + "bytes" + "fmt" + "os" + "strings" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" +) + +// Build converts a Markdown file to HTML using a template. +// The template must contain a placeholder element that will be replaced with the rendered content. +func Build(markdownPath, templatePath, outputPath string) error { + md, err := os.ReadFile(markdownPath) + if err != nil { + return fmt.Errorf("read markdown: %w", err) + } + + tmpl, err := os.ReadFile(templatePath) + if err != nil { + return fmt.Errorf("read template: %w", err) + } + + // Convert markdown to HTML + gm := goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions(html.WithUnsafe()), + ) + var buf bytes.Buffer + if err := gm.Convert(md, &buf); err != nil { + return fmt.Errorf("convert markdown: %w", err) + } + + // Inject into template — support both placeholder formats + output := string(tmpl) + replacements := []struct{ old, new string }{ + {`
`, `
` + buf.String() + `
`}, + {`
`, `
` + buf.String() + `
`}, + } + for _, r := range replacements { + output = strings.Replace(output, r.old, r.new, 1) + } + + if err := os.WriteFile(outputPath, []byte(output), 0644); err != nil { + return fmt.Errorf("write output: %w", err) + } + return nil +} diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go new file mode 100644 index 0000000..cdf992d --- /dev/null +++ b/internal/builder/builder_test.go @@ -0,0 +1,113 @@ +package builder + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuild(t *testing.T) { + dir := t.TempDir() + + md := "# Test List\n\n- [Example](https://example.com) - A test entry.\n" + mdPath := filepath.Join(dir, "README.md") + if err := os.WriteFile(mdPath, []byte(md), 0644); err != nil { + t.Fatal(err) + } + + tmpl := ` + + +
+ +` + tmplPath := filepath.Join(dir, "template.html") + if err := os.WriteFile(tmplPath, []byte(tmpl), 0644); err != nil { + t.Fatal(err) + } + + outPath := filepath.Join(dir, "index.html") + if err := Build(mdPath, tmplPath, outPath); err != nil { + t.Fatalf("Build failed: %v", err) + } + + content, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + + html := string(content) + if !strings.Contains(html, "Test List") { + t.Error("expected 'Test List' in output") + } + if !strings.Contains(html, "https://example.com") { + t.Error("expected link in output") + } +} + +func TestBuildWithSectionPlaceholder(t *testing.T) { + dir := t.TempDir() + + md := "# Hello\n\nWorld.\n" + mdPath := filepath.Join(dir, "README.md") + if err := os.WriteFile(mdPath, []byte(md), 0644); err != nil { + t.Fatal(err) + } + + // This matches the actual template format + tmpl := ` + + +
+ +` + tmplPath := filepath.Join(dir, "template.html") + if err := os.WriteFile(tmplPath, []byte(tmpl), 0644); err != nil { + t.Fatal(err) + } + + outPath := filepath.Join(dir, "index.html") + if err := Build(mdPath, tmplPath, outPath); err != nil { + t.Fatalf("Build failed: %v", err) + } + + content, err := os.ReadFile(outPath) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(content), "Hello") { + t.Error("expected 'Hello' in output") + } + if !strings.Contains(string(content), `class="main-content"`) { + t.Error("expected section class preserved") + } +} + +func TestBuildRealREADME(t *testing.T) { + mdPath := "../../README.md" + tmplPath := "../../config/website.tmpl.html" + if _, err := os.Stat(mdPath); err != nil { + t.Skip("README.md not found") + } + if _, err := os.Stat(tmplPath); err != nil { + t.Skip("website template not found") + } + + dir := t.TempDir() + outPath := filepath.Join(dir, "index.html") + + if err := Build(mdPath, tmplPath, outPath); err != nil { + t.Fatalf("Build failed: %v", err) + } + + info, err := os.Stat(outPath) + if err != nil { + t.Fatal(err) + } + if info.Size() < 10000 { + t.Errorf("output too small: %d bytes", info.Size()) + } + t.Logf("Generated %d bytes", info.Size()) +}