From 91037a94430547dea48e5d0ce014dad10a3f712f Mon Sep 17 00:00:00 2001 From: Virgil Date: Wed, 1 Apr 2026 11:10:27 +0000 Subject: [PATCH] feat(build): add mkdocs docs builder --- cmd/build/cmd_project.go | 4 +- docs/architecture.md | 2 + docs/index.md | 5 +- pkg/build/builders/docs.go | 214 ++++++++++++++++++++++++++++++++ pkg/build/builders/docs_test.go | 112 +++++++++++++++++ pkg/release/release.go | 2 + 6 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 pkg/build/builders/docs.go create mode 100644 pkg/build/builders/docs_test.go diff --git a/cmd/build/cmd_project.go b/cmd/build/cmd_project.go index f176561..b64ccad 100644 --- a/cmd/build/cmd_project.go +++ b/cmd/build/cmd_project.go @@ -1,6 +1,6 @@ // cmd_project.go implements the main project build logic. // -// This handles auto-detection of project types (Go, Wails, Node, PHP, Docker, LinuxKit, Taskfile) +// This handles auto-detection of project types (Go, Wails, Node, PHP, Docs, Docker, LinuxKit, Taskfile) // and orchestrates the build process including signing, archiving, and checksums. package buildcmd @@ -392,6 +392,8 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) { return builders.NewNodeBuilder(), nil case build.ProjectTypePHP: return builders.NewPHPBuilder(), nil + case build.ProjectTypeDocs: + return builders.NewDocsBuilder(), nil default: return nil, coreerr.E("build.getBuilder", "unsupported project type: "+string(projectType), nil) } diff --git a/docs/architecture.md b/docs/architecture.md index b966c3e..185bc78 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,6 +19,7 @@ Detection order: 2. `go.mod` -- `ProjectTypeGo` 3. `package.json` -- `ProjectTypeNode` 4. `composer.json` -- `ProjectTypePHP` +5. `mkdocs.yml` -- `ProjectTypeDocs` Docker (`Dockerfile`), LinuxKit (`linuxkit.yml` or `.core/linuxkit/*.yml`), C++ (`CMakeLists.txt`), and Taskfile (`Taskfile.yml`) are detected by their respective builders' `Detect()` methods rather than the central discovery function. @@ -55,6 +56,7 @@ type Artifact struct { | **WailsBuilder** | `wails.json` | Checks `go.mod` for Wails v3 vs v2. V3 delegates to TaskfileBuilder; V2 runs `wails build -platform` then copies from `build/bin/` to `dist/`. | | **NodeBuilder** | `package.json` | Detects the active package manager from lockfiles, runs the build script once per target, and collects artifacts from `dist/{os}_{arch}/`. | | **PHPBuilder** | `composer.json` | Runs `composer install`, then `composer run-script build` when present. Falls back to a deterministic zip bundle in `dist/{os}_{arch}/`. | +| **DocsBuilder** | `mkdocs.yml` | Runs `mkdocs build --clean --site-dir` and packages the generated `site/` tree into a zip bundle per target. | | **DockerBuilder** | `Dockerfile` | Validates `docker` and `buildx`, builds multi-platform images with `docker buildx build --platform`. Supports `--push` or local load/OCI tarball. | | **LinuxKitBuilder** | `linuxkit.yml` or `.core/linuxkit/*.yml` | Validates `linuxkit` CLI, runs `linuxkit build --format --name --dir --arch`. Outputs qcow2, iso, raw, vmdk, vhd, or cloud images. Linux-only targets. | | **CPPBuilder** | `CMakeLists.txt` | Validates `make`, runs `make configure` then `make build` then `make package` for host builds. Cross-compilation uses Conan profile targets (e.g. `make gcc-linux-armv8`). Finds artifacts in `build/packages/` or `build/release/src/`. | diff --git a/docs/index.md b/docs/index.md index f93c322..de92cc7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ description: Build system, release pipeline, and SDK generation for the Core eco `forge.lthn.ai/core/go-build` is the build, release, and SDK generation toolkit for Core projects. It provides: -- **Auto-detecting builders** for Go, Wails, Node, PHP, Docker, LinuxKit, C++, and Taskfile projects +- **Auto-detecting builders** for Go, Wails, Node, PHP, Docs, Docker, LinuxKit, C++, and Taskfile projects - **Cross-compilation** with per-target archiving (tar.gz, tar.xz, zip) and SHA-256 checksums - **Code signing** -- macOS codesign with notarisation, GPG detached signatures, Windows signtool - **Release automation** -- semantic versioning from git tags, conventional-commit changelogs, multi-target publishing @@ -43,6 +43,7 @@ The builder is chosen by marker-file priority: | `go.mod` | Go | | `package.json` | Node | | `composer.json` | PHP | +| `mkdocs.yml` | Docs | | `CMakeLists.txt` | C++ | | `Dockerfile` | Docker | | `linuxkit.yml` | LinuxKit | @@ -96,7 +97,7 @@ forge.lthn.ai/core/go-build/ | +-- pkg/ |-- build/ Core build types, config loading, discovery, archiving, checksums - | |-- builders/ Builder implementations (Go, Wails, Node, PHP, Docker, LinuxKit, C++, Taskfile) +| |-- builders/ Builder implementations (Go, Wails, Node, PHP, Docs, Docker, LinuxKit, C++, Taskfile) | +-- signing/ Code-signing implementations (macOS codesign, GPG, Windows stub) | |-- release/ Release orchestration, versioning, changelog, config diff --git a/pkg/build/builders/docs.go b/pkg/build/builders/docs.go new file mode 100644 index 0000000..7f6d7f7 --- /dev/null +++ b/pkg/build/builders/docs.go @@ -0,0 +1,214 @@ +// 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) + } + + if !cfg.FS.IsFile(ax.Join(cfg.ProjectDir, "mkdocs.yml")) { + return nil, coreerr.E("DocsBuilder.Build", "mkdocs.yml 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) + } + + args := []string{"build", "--clean", "--site-dir", siteDir} + output, err := ax.CombinedOutput(ctx, cfg.ProjectDir, nil, 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 +} + +// 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 + + 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) diff --git a/pkg/build/builders/docs_test.go b/pkg/build/builders/docs_test.go new file mode 100644 index 0000000..55ebafd --- /dev/null +++ b/pkg/build/builders/docs_test.go @@ -0,0 +1,112 @@ +package builders + +import ( + "archive/zip" + "context" + stdio "io" + "os" + "runtime" + "testing" + + "dappco.re/go/core/build/internal/ax" + "dappco.re/go/core/build/pkg/build" + "dappco.re/go/core/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDocs_DocsBuilderName_Good(t *testing.T) { + builder := NewDocsBuilder() + assert.Equal(t, "docs", builder.Name()) +} + +func TestDocs_DocsBuilderDetect_Good(t *testing.T) { + fs := io.Local + + t.Run("detects mkdocs.yml", func(t *testing.T) { + dir := t.TempDir() + err := ax.WriteFile(ax.Join(dir, "mkdocs.yml"), []byte("site_name: Demo\n"), 0o644) + require.NoError(t, err) + + builder := NewDocsBuilder() + detected, err := builder.Detect(fs, dir) + require.NoError(t, err) + assert.True(t, detected) + }) + + t.Run("returns false without mkdocs.yml", func(t *testing.T) { + builder := NewDocsBuilder() + detected, err := builder.Detect(fs, t.TempDir()) + require.NoError(t, err) + assert.False(t, detected) + }) +} + +func TestDocs_DocsBuilderBuild_Good(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("mkdocs test fixture uses a shell script") + } + + dir := t.TempDir() + require.NoError(t, ax.WriteFile(ax.Join(dir, "mkdocs.yml"), []byte("site_name: Demo\n"), 0o644)) + + binDir := t.TempDir() + mkdocsPath := ax.Join(binDir, "mkdocs") + script := "#!/bin/sh\nset -eu\nsite_dir=\"\"\nwhile [ $# -gt 0 ]; do\n if [ \"$1\" = \"--site-dir\" ]; then\n shift\n site_dir=\"$1\"\n fi\n shift\ndone\nmkdir -p \"$site_dir\"\nprintf '%s' 'demo docs' > \"$site_dir/index.html\"\n" + require.NoError(t, ax.WriteFile(mkdocsPath, []byte(script), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cfg := &build.Config{ + FS: io.Local, + ProjectDir: dir, + OutputDir: ax.Join(dir, "dist"), + Name: "demo-site", + } + + builder := NewDocsBuilder() + artifacts, err := builder.Build(context.Background(), cfg, []build.Target{{OS: "linux", Arch: "amd64"}}) + require.NoError(t, err) + require.Len(t, artifacts, 1) + + artifact := artifacts[0] + assert.Equal(t, "linux", artifact.OS) + assert.Equal(t, "amd64", artifact.Arch) + assert.FileExists(t, artifact.Path) + + reader, err := zip.OpenReader(artifact.Path) + require.NoError(t, err) + defer func() { _ = reader.Close() }() + + require.Len(t, reader.File, 1) + assert.Equal(t, "index.html", reader.File[0].Name) + + file, err := reader.File[0].Open() + require.NoError(t, err) + defer func() { _ = file.Close() }() + + data, err := stdio.ReadAll(file) + require.NoError(t, err) + assert.Equal(t, "demo docs", string(data)) +} + +func TestDocs_DocsBuilderBuild_Bad(t *testing.T) { + builder := NewDocsBuilder() + + t.Run("returns error when config is nil", func(t *testing.T) { + artifacts, err := builder.Build(context.Background(), nil, []build.Target{{OS: "linux", Arch: "amd64"}}) + require.Error(t, err) + assert.Nil(t, artifacts) + }) + + t.Run("returns error when mkdocs.yml is missing", func(t *testing.T) { + cfg := &build.Config{ + FS: io.Local, + ProjectDir: t.TempDir(), + OutputDir: t.TempDir(), + } + + artifacts, err := builder.Build(context.Background(), cfg, []build.Target{{OS: "linux", Arch: "amd64"}}) + require.Error(t, err) + assert.Nil(t, artifacts) + }) +} diff --git a/pkg/release/release.go b/pkg/release/release.go index 368526c..0166e1a 100644 --- a/pkg/release/release.go +++ b/pkg/release/release.go @@ -331,6 +331,8 @@ func getBuilder(projectType build.ProjectType) (build.Builder, error) { return builders.NewNodeBuilder(), nil case build.ProjectTypePHP: return builders.NewPHPBuilder(), nil + case build.ProjectTypeDocs: + return builders.NewDocsBuilder(), nil default: return nil, coreerr.E("release.getBuilder", "unsupported project type: "+string(projectType), nil) }