# WordPress BuildTarget — Go Builder Implementation Plan

> **For agentic workers:** TDD, bite-sized steps, frequent commits. Run `go test ./...` after each task. Baseline must stay green — the WebsiteTarget extraction must not change existing website-build behavior.

**Goal:** Introduce an extensible `BuildTarget` abstraction in the Go builder so it can generate WordPress FSE block themes alongside the current React/Vite output, selected by the `output_type` field Laravel now sends on `/api/run`.

**Architecture:** A `BuildTarget` interface with one implementation per output kind, resolved from a registry by `output_type` (default `website`). Today's React behavior is extracted into `WebsiteTarget`; `WordPressTarget` adds FSE-specific template handling, a `theme.json` design emitter, validation (no npm), and theme-directory packaging. Plumbing: `RunRequest.OutputType` → `Session` → `Runner` → `Executor`.

**Tech stack:** Go 1.26, standard `testing`, `t.TempDir()` fixtures.

**Key files (from architecture map):**
- `internal/models/api.go` — `RunRequest`, `DesignSystem`
- `internal/api/handlers.go` — `handleRun` (~248-320 sets session props), `serveBuildOutput` (~937)
- `internal/agent/session.go` — session state + setters (~707-754)
- `internal/agent/runner.go` — extracts session props to executor (~460-470), playbook injection (~677)
- `internal/agent/prompt.go` — `BuildSystemPrompt` (~102), `processTemplate`
- `internal/executor/executor.go` — tool dispatch + overlay call (~434)
- `internal/executor/build.go` — `VerifyBuild`
- `internal/executor/design_overlay.go` — `ApplyDesignOverlay`
- `internal/executor/template.go` — `UseTemplate`

---

### Task 1: `RunRequest.OutputType` field + default

**Files:** Modify `internal/models/api.go`; Test `internal/models/api_output_type_test.go`

- [ ] **Step 1: Failing test** — unmarshal a `/api/run` body with `"output_type":"wordpress_theme"` into `RunRequest`, assert the field; and assert a body without it leaves the zero value `""` (the consumer defaults to `website`).

```go
package models

import (
	"encoding/json"
	"testing"
)

func TestRunRequest_OutputType(t *testing.T) {
	var r RunRequest
	if err := json.Unmarshal([]byte(`{"goal":"x","workspace_id":"w","webhook_url":"u","config":{},"output_type":"wordpress_theme"}`), &r); err != nil {
		t.Fatal(err)
	}
	if r.OutputType != "wordpress_theme" {
		t.Fatalf("got %q", r.OutputType)
	}

	var r2 RunRequest
	_ = json.Unmarshal([]byte(`{"goal":"x","workspace_id":"w","webhook_url":"u","config":{}}`), &r2)
	if r2.OutputType != "" {
		t.Fatalf("expected empty default, got %q", r2.OutputType)
	}
}
```

- [ ] **Step 2:** `go test ./internal/models/ -run TestRunRequest_OutputType` → FAIL (unknown field is ignored, so the first assert fails: `got ""`).

- [ ] **Step 3:** Add to `RunRequest` (after `DesignSystem`):

```go
	OutputType          string               `json:"output_type,omitempty"` // "website" (default) | "wordpress_theme"
```

- [ ] **Step 4:** `go test ./internal/models/ -run TestRunRequest_OutputType` → PASS.

- [ ] **Step 5:** Commit: `feat(models): add RunRequest.OutputType`.

---

### Task 2: `BuildTarget` interface + registry + constants

**Files:** Create `internal/buildtarget/buildtarget.go`, `internal/buildtarget/registry.go`; Test `internal/buildtarget/registry_test.go`

- [ ] **Step 1: Failing test**

```go
package buildtarget

import "testing"

func TestRegistry_DefaultsToWebsite(t *testing.T) {
	if Resolve("").Key() != "website" {
		t.Fatal("empty should resolve to website")
	}
	if Resolve("website").Key() != "website" {
		t.Fatal("website")
	}
	if Resolve("wordpress_theme").Key() != "wordpress_theme" {
		t.Fatal("wordpress_theme")
	}
	if Resolve("nonsense").Key() != "website" {
		t.Fatal("unknown should fall back to website")
	}
}
```

- [ ] **Step 2:** `go test ./internal/buildtarget/` → FAIL (package missing).

- [ ] **Step 3:** Create `buildtarget.go`:

```go
// Package buildtarget defines the output-kind strategy (website vs WordPress
// theme) so the builder can generate different artifacts from one agent loop.
package buildtarget

const (
	KeyWebsite        = "website"
	KeyWordPressTheme = "wordpress_theme"
)

// BuildTarget is the per-output-kind strategy. Implementations are pure value
// types (no state) so they are safe to share across sessions.
type BuildTarget interface {
	Key() string
	// SystemPromptFile returns the prompt filename (relative to the prompts dir).
	SystemPromptFile(compact bool) string
	// OutputDir is the directory (relative to the workspace) that gets packaged.
	OutputDir() string
	// NeedsNodeBuild reports whether VerifyBuild should run npm.
	NeedsNodeBuild() bool
	// InjectsBaseTag reports whether index.html gets a <base> tag on packaging.
	InjectsBaseTag() bool
}
```

- [ ] **Step 4:** Create `registry.go`:

```go
package buildtarget

// Resolve maps an output_type string to a BuildTarget, defaulting to website
// for empty/unknown values (back-compat: pre-WordPress Laravel sends nothing).
func Resolve(outputType string) BuildTarget {
	switch outputType {
	case KeyWordPressTheme:
		return WordPressTarget{}
	default:
		return WebsiteTarget{}
	}
}
```

- [ ] **Step 5:** Create `website.go` + `wordpress.go` implementations:

```go
// website.go
package buildtarget

type WebsiteTarget struct{}

func (WebsiteTarget) Key() string                       { return KeyWebsite }
func (WebsiteTarget) SystemPromptFile(compact bool) string {
	if compact {
		return "compact.md"
	}
	return "system.md"
}
func (WebsiteTarget) OutputDir() string    { return "dist" }
func (WebsiteTarget) NeedsNodeBuild() bool { return true }
func (WebsiteTarget) InjectsBaseTag() bool { return true }
```

```go
// wordpress.go
package buildtarget

type WordPressTarget struct{}

func (WordPressTarget) Key() string { return KeyWordPressTheme }
func (WordPressTarget) SystemPromptFile(compact bool) string {
	if compact {
		return "compact-wordpress.md"
	}
	return "system-wordpress.md"
}

// The whole workspace IS the theme; package it directly (no build artifact).
func (WordPressTarget) OutputDir() string    { return "." }
func (WordPressTarget) NeedsNodeBuild() bool { return false }
func (WordPressTarget) InjectsBaseTag() bool { return false }
```

- [ ] **Step 6:** `go test ./internal/buildtarget/` → PASS. Commit: `feat(buildtarget): add BuildTarget interface + registry`.

---

### Task 3: `theme.json` emitter (the design-system reuse core)

**Files:** Create `internal/executor/wordpress_overlay.go`; Test `internal/executor/wordpress_overlay_test.go`

This translates a resolved `DesignSystem` (tokens + accent + fonts) into FSE `theme.json` — the deterministic mirror of `ApplyDesignOverlay`.

- [ ] **Step 1: Failing golden-style test**

```go
package executor

import (
	"encoding/json"
	"strings"
	"testing"

	"webby-builder/internal/models"
)

func TestBuildThemeJSON_MapsAccentAndFonts(t *testing.T) {
	ds := &models.DesignSystem{
		Slug:        "substrate",
		Accent:      "indigo",
		AccentLight: map[string]string{"primary": "250 54% 56%", "background": "0 0% 100%"},
		Fonts:       `<link href="https://fonts.googleapis.com/css2?family=Geist&display=swap" rel="stylesheet" />`,
		Playbook:    "# Substrate",
	}

	out, err := BuildThemeJSON(ds)
	if err != nil {
		t.Fatal(err)
	}

	// Valid JSON with the FSE schema version + a color palette entry derived from the accent.
	var parsed map[string]any
	if err := json.Unmarshal([]byte(out), &parsed); err != nil {
		t.Fatalf("theme.json is not valid JSON: %v", err)
	}
	if parsed["version"] == nil {
		t.Fatal("missing version")
	}
	if !strings.Contains(out, "primary") {
		t.Fatal("expected primary color slug in palette")
	}
	// Font family derived from the Geist <link>.
	if !strings.Contains(strings.ToLower(out), "geist") {
		t.Fatal("expected Geist font family")
	}
}

func TestBuildThemeJSON_NilIsEmpty(t *testing.T) {
	out, err := BuildThemeJSON(nil)
	if err != nil || out != "" {
		t.Fatalf("nil ds should yield empty string, got %q err %v", out, err)
	}
}
```

- [ ] **Step 2:** `go test ./internal/executor/ -run TestBuildThemeJSON` → FAIL.

- [ ] **Step 3:** Implement `BuildThemeJSON`:

```go
package executor

import (
	"encoding/json"
	"fmt"
	"regexp"
	"strings"

	"webby-builder/internal/models"
)

// hslToHex converts an "H S% L%" token (the design-system color format) to a
// CSS hex string for theme.json (which prefers concrete color values).
func hslTokenToCSS(v string) string {
	// theme.json accepts any CSS color; wrap the HSL triple as hsl(...).
	v = strings.TrimSpace(v)
	if v == "" {
		return ""
	}
	parts := strings.Fields(v)
	if len(parts) == 3 {
		return fmt.Sprintf("hsl(%s %s %s)", parts[0], parts[1], parts[2])
	}
	return v
}

var fontFamilyRe = regexp.MustCompile(`family=([A-Za-z0-9+]+)`)

// extractFontFamilies pulls human font names out of Google Fonts <link>s.
func extractFontFamilies(fontsHTML string) []string {
	var out []string
	for _, m := range fontFamilyRe.FindAllStringSubmatch(fontsHTML, -1) {
		name := strings.ReplaceAll(m[1], "+", " ")
		out = append(out, name)
	}
	return out
}

// BuildThemeJSON translates a resolved design system into an FSE theme.json
// (schema v3). Returns "" for a nil/empty system (Automatic with no resolution).
func BuildThemeJSON(ds *models.DesignSystem) (string, error) {
	if ds == nil {
		return "", nil
	}

	palette := []map[string]string{}
	for slug, val := range ds.AccentLight {
		css := hslTokenToCSS(val)
		if css == "" {
			continue
		}
		palette = append(palette, map[string]string{
			"slug":  slug,
			"name":  strings.Title(slug),
			"color": css,
		})
	}

	fontFamilies := []map[string]any{}
	for _, fam := range extractFontFamilies(ds.Fonts) {
		slug := strings.ToLower(strings.ReplaceAll(fam, " ", "-"))
		fontFamilies = append(fontFamilies, map[string]any{
			"fontFamily": fmt.Sprintf("\"%s\", sans-serif", fam),
			"name":       fam,
			"slug":       slug,
		})
	}

	theme := map[string]any{
		"$schema": "https://schemas.wp.org/trunk/theme.json",
		"version": 3,
		"settings": map[string]any{
			"color":      map[string]any{"palette": palette},
			"typography": map[string]any{"fontFamilies": fontFamilies},
		},
	}

	b, err := json.MarshalIndent(theme, "", "\t")
	if err != nil {
		return "", err
	}
	return string(b), nil
}
```

- [ ] **Step 4:** `go test ./internal/executor/ -run TestBuildThemeJSON` → PASS.

- [ ] **Step 5:** Commit: `feat(executor): add theme.json emitter for WordPress design overlay`.

---

### Task 4: WordPress theme validator (replaces npm build)

**Files:** Create `internal/executor/wordpress_verify.go`; Test `internal/executor/wordpress_verify_test.go`

- [ ] **Step 1: Failing test** — seed a temp workspace with/without required files (`style.css` with a `Theme Name:` header, `templates/index.html`, valid `theme.json`), assert `ValidateWordPressTheme(ws)` returns success only when valid, and a helpful message listing what's missing otherwise.

```go
package executor

import (
	"os"
	"path/filepath"
	"testing"
)

func writeFile(t *testing.T, ws, rel, content string) {
	t.Helper()
	p := filepath.Join(ws, rel)
	_ = os.MkdirAll(filepath.Dir(p), 0o755)
	if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
}

func TestValidateWordPressTheme(t *testing.T) {
	ws := t.TempDir()
	if res := ValidateWordPressTheme(ws); res.Success {
		t.Fatal("empty workspace should fail validation")
	}

	writeFile(t, ws, "style.css", "/*\nTheme Name: Demo\n*/")
	writeFile(t, ws, "templates/index.html", "<!-- wp:paragraph --><p>hi</p><!-- /wp:paragraph -->")
	writeFile(t, ws, "theme.json", `{"version":3}`)

	res := ValidateWordPressTheme(ws)
	if !res.Success {
		t.Fatalf("valid theme should pass: %s", res.Content)
	}

	// Broken theme.json must fail.
	writeFile(t, ws, "theme.json", `{not json`)
	if ValidateWordPressTheme(ws).Success {
		t.Fatal("invalid theme.json should fail")
	}
}
```

- [ ] **Step 2:** FAIL. **Step 3:** Implement `ValidateWordPressTheme(workspacePath string) ToolResult` — check `style.css` exists and contains `Theme Name:`; `templates/index.html` exists; if `theme.json` exists it must parse as JSON. Return `ToolResult{Success, Content}` with a message listing any missing/invalid items. (Match the `ToolResult` shape used in `build.go`.) **Step 4:** PASS. **Step 5:** Commit `feat(executor): add WordPress theme validator`.

---

### Task 5: Session + Runner + Executor plumbing for OutputType

**Files:** Modify `internal/agent/session.go`, `internal/api/handlers.go`, `internal/agent/runner.go`, `internal/executor/executor.go`

- [ ] **Step 1:** Add `outputType string` to `Session` + `SetOutputType(string)` / `GetOutputType() string` (mirror `SetDesignSystem`). Add a unit test in `session_test.go` if one exists; else a small standalone test.
- [ ] **Step 2:** In `handleRun` (~line 280, alongside `SetDesignSystem`), add `session.SetOutputType(req.OutputType)`.
- [ ] **Step 3:** Add `outputTarget buildtarget.BuildTarget` to the `Executor` + `SetOutputTarget(buildtarget.BuildTarget)` (default `WebsiteTarget{}` in the constructor so existing behavior is unchanged).
- [ ] **Step 4:** In `runner.go` (~line 470, alongside design-system extraction), add `r.executor.SetOutputTarget(buildtarget.Resolve(session.GetOutputType()))`.
- [ ] **Step 5:** `go build ./... && go test ./...` → green. Commit `feat(builder): plumb output_type session→runner→executor`.

---

### Task 6: Branch VerifyBuild on the target

**Files:** Modify `internal/executor/build.go`

- [ ] **Step 1:** In `VerifyBuild`, early-branch: `if !e.outputTarget.NeedsNodeBuild() { return ValidateWordPressTheme(e.workspacePath), nil }` (guard nil → default website). Add a test driving a `BuildExecutor` whose target is `WordPressTarget{}` over a valid theme workspace → success without npm.
- [ ] **Step 2:** `go test ./internal/executor/` green. Commit `feat(executor): WordPress verify path skips npm`.

---

### Task 7: Branch design overlay on the target

**Files:** Modify `internal/executor/executor.go` (~line 434)

- [ ] **Step 1:** Where `ApplyDesignOverlay` is called after `useTemplate`, branch: for the WordPress target, write `BuildThemeJSON(e.designSystem)` to `<workspace>/theme.json` (when non-empty), set `e.designPlaybook = e.designSystem.Playbook`, and skip the React overlay. Keep the website path unchanged.
- [ ] **Step 2:** `go test ./...` green. Commit `feat(executor): apply theme.json overlay for WordPress target`.

---

### Task 8: Branch system-prompt selection on the target

**Files:** Modify `internal/agent/prompt.go` (`PromptConfig` + `BuildSystemPrompt`), wire the target/output_type through.

- [ ] **Step 1:** Add `OutputType string` to `PromptConfig`; in `BuildSystemPrompt`, choose the template file via `buildtarget.Resolve(cfg.OutputType).SystemPromptFile(cfg.Compact)`. Ensure callers pass `OutputType` (from session). Add a test asserting the WordPress output_type selects `system-wordpress.md` (create a minimal `prompts/system-wordpress.md` fixture/file).
- [ ] **Step 2:** green. Commit `feat(agent): select WordPress system prompt by output_type`.

---

### Task 9: Branch build-output packaging on the target

**Files:** Modify `internal/api/handlers.go` (`serveBuildOutput` ~937)

- [ ] **Step 1:** Resolve the target for the session; package `target.OutputDir()` (`.` for WordPress → the theme dir, excluding `node_modules`/`.git`) instead of hard-coded `dist`; skip `injectBaseTag` when `!target.InjectsBaseTag()`. Filename `theme.zip` for WordPress vs `dist.zip`.
- [ ] **Step 2:** `go build ./... && go test ./...` green. Commit `feat(api): package WordPress theme directory on build output`.

---

## Out of this plan (content + preview — separate efforts)

- **`prompts/system-wordpress.md`** — the full WordPress block-theme agent playbook (block markup grammar, FSE template hierarchy, theme.json, pattern registration). A minimal stub is created in Task 8; authoring the production prompt is its own task.
- **FSE starter-theme zips** — the WordPress `Template` family (valid block themes) seeded as `templates.output_target = 'wordpress_theme'` rows + zips. Content authoring.
- **Plan 3 — WordPress Playground (WASM) preview** — Laravel/frontend: a preview strategy that boots `@wp-playground/client` in the iframe, installs the theme zip + demo content via a Blueprint, renders the front page. View-only in v1.

## Self-review

- Plumbing (Tasks 1, 5) defaults to `website`/`WebsiteTarget` everywhere → existing builds unaffected.
- Deterministic core (Tasks 3, 4) is fully unit-tested.
- Branch points (Tasks 6-9) each guard against a nil target by defaulting to website.
- Naming: `output_type` (wire) → `Session.outputType` → `buildtarget.Resolve(...)` → `BuildTarget`.
