go-build/pkg/build/builders/docs.go
Virgil ed7b1ef083 feat(build): add env parity to docs builds
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:39:54 +00:00

236 lines
6.8 KiB
Go

// Package builders provides build implementations for different project types.
package builders
import (
"archive/zip"
"context"
stdio "io"
"runtime"
"sort"
"strings"
"dappco.re/go/core"
"dappco.re/go/core/build/internal/ax"
"dappco.re/go/core/build/pkg/build"
"dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
)
// DocsBuilder builds MkDocs projects.
//
// b := builders.NewDocsBuilder()
type DocsBuilder struct{}
// NewDocsBuilder creates a new DocsBuilder instance.
//
// b := builders.NewDocsBuilder()
func NewDocsBuilder() *DocsBuilder {
return &DocsBuilder{}
}
// Name returns the builder's identifier.
//
// name := b.Name() // → "docs"
func (b *DocsBuilder) Name() string {
return "docs"
}
// Detect checks if this builder can handle the project in the given directory.
//
// ok, err := b.Detect(io.Local, ".")
func (b *DocsBuilder) Detect(fs io.Medium, dir string) (bool, error) {
return build.IsMkDocsProject(fs, dir), nil
}
// Build runs mkdocs build and packages the generated site into a zip archive.
//
// artifacts, err := b.Build(ctx, cfg, []build.Target{{OS: "linux", Arch: "amd64"}})
func (b *DocsBuilder) Build(ctx context.Context, cfg *build.Config, targets []build.Target) ([]build.Artifact, error) {
if cfg == nil {
return nil, coreerr.E("DocsBuilder.Build", "config is nil", nil)
}
if len(targets) == 0 {
targets = []build.Target{{OS: runtime.GOOS, Arch: runtime.GOARCH}}
}
outputDir := cfg.OutputDir
if outputDir == "" {
outputDir = ax.Join(cfg.ProjectDir, "dist")
}
if err := cfg.FS.EnsureDir(outputDir); err != nil {
return nil, coreerr.E("DocsBuilder.Build", "failed to create output directory", err)
}
configPath := b.resolveMkDocsConfigPath(cfg.FS, cfg.ProjectDir)
if configPath == "" {
return nil, coreerr.E("DocsBuilder.Build", "mkdocs.yml or mkdocs.yaml not found", nil)
}
mkdocsCommand, err := b.resolveMkDocsCli()
if err != nil {
return nil, err
}
var artifacts []build.Artifact
for _, target := range targets {
platformDir := ax.Join(outputDir, core.Sprintf("%s_%s", target.OS, target.Arch))
if err := cfg.FS.EnsureDir(platformDir); err != nil {
return artifacts, coreerr.E("DocsBuilder.Build", "failed to create platform directory", err)
}
siteDir := ax.Join(platformDir, "site")
if err := cfg.FS.EnsureDir(siteDir); err != nil {
return artifacts, coreerr.E("DocsBuilder.Build", "failed to create site directory", err)
}
env := appendConfiguredEnv(cfg.Env,
core.Sprintf("GOOS=%s", target.OS),
core.Sprintf("GOARCH=%s", target.Arch),
core.Sprintf("TARGET_OS=%s", target.OS),
core.Sprintf("TARGET_ARCH=%s", target.Arch),
core.Sprintf("OUTPUT_DIR=%s", outputDir),
core.Sprintf("TARGET_DIR=%s", platformDir),
)
if cfg.Name != "" {
env = append(env, core.Sprintf("NAME=%s", cfg.Name))
}
if cfg.Version != "" {
env = append(env, core.Sprintf("VERSION=%s", cfg.Version))
}
args := []string{"build", "--clean", "--site-dir", siteDir, "--config-file", configPath}
output, err := ax.CombinedOutput(ctx, cfg.ProjectDir, env, mkdocsCommand, args...)
if err != nil {
return artifacts, coreerr.E("DocsBuilder.Build", "mkdocs build failed: "+output, err)
}
bundlePath := ax.Join(platformDir, b.bundleName(cfg)+".zip")
if err := b.bundleSite(cfg.FS, siteDir, bundlePath); err != nil {
return artifacts, err
}
artifacts = append(artifacts, build.Artifact{
Path: bundlePath,
OS: target.OS,
Arch: target.Arch,
})
}
return artifacts, nil
}
// resolveMkDocsConfigPath returns the MkDocs config file path if present.
func (b *DocsBuilder) resolveMkDocsConfigPath(fs io.Medium, projectDir string) string {
return build.ResolveMkDocsConfigPath(fs, projectDir)
}
// resolveMkDocsCli returns the executable path for the mkdocs CLI.
func (b *DocsBuilder) resolveMkDocsCli(paths ...string) (string, error) {
if len(paths) == 0 {
paths = []string{
"/usr/local/bin/mkdocs",
"/opt/homebrew/bin/mkdocs",
}
}
command, err := ax.ResolveCommand("mkdocs", paths...)
if err != nil {
return "", coreerr.E("DocsBuilder.resolveMkDocsCli", "mkdocs CLI not found. Install it with: pip install mkdocs", err)
}
return command, nil
}
// bundleName returns the bundle filename stem.
func (b *DocsBuilder) bundleName(cfg *build.Config) string {
if cfg.Name != "" {
return cfg.Name
}
if cfg.ProjectDir != "" {
return ax.Base(cfg.ProjectDir)
}
return "docs-site"
}
// bundleSite creates a zip bundle containing the generated MkDocs site.
func (b *DocsBuilder) bundleSite(fs io.Medium, siteDir, bundlePath string) error {
if err := fs.EnsureDir(ax.Dir(bundlePath)); err != nil {
return coreerr.E("DocsBuilder.bundleSite", "failed to create bundle directory", err)
}
file, err := fs.Create(bundlePath)
if err != nil {
return coreerr.E("DocsBuilder.bundleSite", "failed to create bundle file", err)
}
defer func() { _ = file.Close() }()
writer := zip.NewWriter(file)
defer func() { _ = writer.Close() }()
return b.writeZipTree(fs, writer, siteDir, siteDir)
}
// writeZipTree walks a directory and writes files into the zip bundle.
func (b *DocsBuilder) writeZipTree(fs io.Medium, writer *zip.Writer, rootDir, currentDir string) error {
entries, err := fs.List(currentDir)
if err != nil {
return coreerr.E("DocsBuilder.writeZipTree", "failed to list directory", err)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
entryPath := ax.Join(currentDir, entry.Name())
if entry.IsDir() {
if err := b.writeZipTree(fs, writer, rootDir, entryPath); err != nil {
return err
}
continue
}
relPath, err := ax.Rel(rootDir, entryPath)
if err != nil {
return coreerr.E("DocsBuilder.writeZipTree", "failed to relativise bundle path", err)
}
info, err := fs.Stat(entryPath)
if err != nil {
return coreerr.E("DocsBuilder.writeZipTree", "failed to stat bundle entry", err)
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return coreerr.E("DocsBuilder.writeZipTree", "failed to create zip header", err)
}
header.Name = strings.ReplaceAll(relPath, ax.DS(), "/")
header.Method = zip.Deflate
header.SetModTime(deterministicZipTime)
zipEntry, err := writer.CreateHeader(header)
if err != nil {
return coreerr.E("DocsBuilder.writeZipTree", "failed to create zip entry", err)
}
source, err := fs.Open(entryPath)
if err != nil {
return coreerr.E("DocsBuilder.writeZipTree", "failed to open bundle entry", err)
}
if _, err := stdio.Copy(zipEntry, source); err != nil {
_ = source.Close()
return coreerr.E("DocsBuilder.writeZipTree", "failed to write bundle entry", err)
}
if err := source.Close(); err != nil {
return coreerr.E("DocsBuilder.writeZipTree", "failed to close bundle entry", err)
}
}
return nil
}
// Ensure DocsBuilder implements the Builder interface.
var _ build.Builder = (*DocsBuilder)(nil)