feat(build): add mkdocs docs builder
This commit is contained in:
parent
83a50b684d
commit
91037a9443
6 changed files with 336 additions and 3 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/`. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
214
pkg/build/builders/docs.go
Normal file
214
pkg/build/builders/docs.go
Normal file
|
|
@ -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)
|
||||
112
pkg/build/builders/docs_test.go
Normal file
112
pkg/build/builders/docs_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue