feat: add website builder using goldmark (replaces build.js)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Bisconti
2026-02-27 23:22:16 +01:00
parent 804da83d7b
commit ed04ff4017
4 changed files with 167 additions and 0 deletions

1
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed // indirect
github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf // indirect
github.com/spf13/pflag v1.0.9 // 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 golang.org/x/oauth2 v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

2
go.sum
View File

@@ -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/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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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= 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 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=

View File

@@ -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 }{
{`<div id="md"></div>`, `<div id="md">` + buf.String() + `</div>`},
{`<section id="md" class="main-content"></section>`, `<section id="md" class="main-content">` + buf.String() + `</section>`},
}
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
}

View File

@@ -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 := `<!DOCTYPE html>
<html>
<body>
<div id="md"></div>
</body>
</html>`
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 := `<!DOCTYPE html>
<html>
<body>
<section id="md" class="main-content"></section>
</body>
</html>`
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())
}