Skip to content

Commit b70604c

Browse files
committed
feat: PRSDM-10268 embed themes in binary
1 parent 13a732d commit b70604c

File tree

5 files changed

+209
-25
lines changed

5 files changed

+209
-25
lines changed

Makefile

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
FILENAME=presidium
22
DOCSDIR=docs
33
.DEFAULT_GOAL=help
4-
.PHONY: build test dist clean fmt vet tidy coverage_report help
4+
.PHONY: build test dist clean fmt vet tidy coverage_report help update-themes prepare-themes restore-themes lint checks serve-docs
55

66
help: ## Display available targets
77
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-18s %s\n", $$1, $$2}'
@@ -38,17 +38,17 @@ restore-themes: ## Restore theme go.mod files to original names
3838
done
3939

4040
build: prepare-themes ## Build the presidium binary
41-
go build -tags extended -o $(FILENAME) . ; status=$$? ; $(MAKE) restore-themes ; exit $$status
41+
go build -tags extended -o $(FILENAME) . ; status=$$? ; $(MAKE) restore-themes ; rstatus=$$? ; if [ $$status -eq 0 ]; then status=$$rstatus; fi ; exit $$status
4242

4343
test: prepare-themes ## Run tests with coverage
4444
@mkdir -p reports
45-
go test -race -timeout 120s ./... -coverprofile=reports/tests-cov.out ; status=$$? ; $(MAKE) restore-themes ; exit $$status
45+
go test -race -timeout 120s ./... -coverprofile=reports/tests-cov.out ; status=$$? ; $(MAKE) restore-themes ; rstatus=$$? ; if [ $$status -eq 0 ]; then status=$$rstatus; fi ; exit $$status
4646

4747
fmt: ## Format Go source files
4848
go fmt ./...
4949

5050
vet: prepare-themes ## Run go vet
51-
go vet ./... ; status=$$? ; $(MAKE) restore-themes ; exit $$status
51+
go vet ./... ; status=$$? ; $(MAKE) restore-themes ; rstatus=$$? ; if [ $$status -eq 0 ]; then status=$$rstatus; fi ; exit $$status
5252

5353
tidy: ## Tidy and verify module dependencies
5454
go mod tidy && go mod verify
@@ -60,12 +60,10 @@ coverage_report: ## Open coverage report in browser
6060
@go tool cover -html=reports/tests-cov.out
6161

6262
dist: prepare-themes ## Build distribution binary
63-
mkdir -p "dist" && go build -trimpath -o "dist/presidium" --tags extended ; status=$$? ; $(MAKE) restore-themes ; exit $$status
64-
65-
checks: clean tidy fmt vet lint test build
63+
mkdir -p "dist" && go build -trimpath -o "dist/presidium" --tags extended ; status=$$? ; $(MAKE) restore-themes ; rstatus=$$? ; if [ $$status -eq 0 ]; then status=$$rstatus; fi ; exit $$status
6664

6765
serve-docs:
6866
cd $(DOCSDIR) && make serve
6967

7068
lint: prepare-themes ## Run golangci-lint
71-
golangci-lint run --timeout 10m ; status=$$? ; $(MAKE) restore-themes ; exit $$status
69+
golangci-lint run --timeout 10m ; status=$$? ; $(MAKE) restore-themes ; rstatus=$$? ; if [ $$status -eq 0 ]; then status=$$rstatus; fi ; exit $$status

docs/.hugo_build.lock

Whitespace-only changes.

pkg/domain/service/hugo/hugo_test.go

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,13 @@ package hugo
22

33
import (
44
"os"
5+
"reflect"
56
"testing"
7+
"unsafe"
68

79
"github.com/SPANDigital/presidium-hugo/pkg/domain/service/themes"
810
)
911

10-
func TestNew(t *testing.T) {
11-
svc := New()
12-
// Service is intentionally a zero-sized struct (stateless)
13-
// This test just verifies New() doesn't panic
14-
_ = svc
15-
}
16-
1712
func TestExecute_WithoutThemes(t *testing.T) {
1813
// Save original themesFS
1914
originalFS := themes.GetThemesFS()
@@ -122,15 +117,12 @@ func TestExecute_PropagatesErrors(t *testing.T) {
122117

123118
func TestService_IsZeroSized(t *testing.T) {
124119
// Verify that Service struct has no fields (stateless)
125-
svc := New()
126-
127-
// This is mainly a documentation test - Service should remain stateless
128-
if testing.Short() {
129-
t.Skip("Skipping structural test in short mode")
120+
if numFields := reflect.TypeOf(Service{}).NumField(); numFields != 0 {
121+
t.Errorf("Service should be a zero-field struct, but has %d fields", numFields)
122+
}
123+
if size := unsafe.Sizeof(Service{}); size != 0 {
124+
t.Errorf("Service should be zero-sized, but has size %d", size)
130125
}
131-
132-
// Service should be empty struct
133-
_ = svc // Just verify it exists and is usable
134126
}
135127

136128
// TestExecute_Integration is an integration test that requires a real Hugo project

pkg/domain/service/themes/themes.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"io/fs"
66
"os"
77
"path/filepath"
8+
"sort"
89
"strings"
910

1011
"github.com/SPANDigital/presidium-hugo/pkg/filesystem"
@@ -84,9 +85,16 @@ func (s Service) Extract() (tmpDir string, replacements string, err error) {
8485
return "", "", fmt.Errorf("creating temp directory: %w", err)
8586
}
8687

87-
// Extract each theme
88+
// Extract each theme (sorted for deterministic output)
89+
modulePaths := make([]string, 0, len(moduleMap))
90+
for modulePath := range moduleMap {
91+
modulePaths = append(modulePaths, modulePath)
92+
}
93+
sort.Strings(modulePaths)
94+
8895
var replacementPairs []string
89-
for modulePath, themeName := range moduleMap {
96+
for _, modulePath := range modulePaths {
97+
themeName := moduleMap[modulePath]
9098
// Create the theme directory in temp
9199
themeDir := filepath.Join(tmpDir, themeName)
92100
if err := os.MkdirAll(themeDir, 0755); err != nil {
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package themes
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
"testing/fstest"
9+
)
10+
11+
func TestNew_WithEmbeddedFS(t *testing.T) {
12+
// Create a mock embedded FS with a themes directory
13+
mockFS := fstest.MapFS{
14+
"themes/presidium-styling-base/config.yml": &fstest.MapFile{Data: []byte("name: test")},
15+
}
16+
SetFS(mockFS)
17+
defer SetFS(nil)
18+
19+
svc, err := New()
20+
if err != nil {
21+
t.Fatalf("New() returned unexpected error: %v", err)
22+
}
23+
if svc.themes == nil {
24+
t.Fatal("New() returned service with nil themes FS")
25+
}
26+
}
27+
28+
func TestNew_WithNilFS_NoModuleRoot(t *testing.T) {
29+
// Ensure themesFS is nil
30+
originalFS := GetThemesFS()
31+
SetFS(nil)
32+
defer func() {
33+
if originalFS != nil {
34+
SetFS(originalFS)
35+
}
36+
}()
37+
38+
// Change to a temp dir with no go.mod above it
39+
origDir, _ := os.Getwd()
40+
tmpDir := t.TempDir()
41+
if err := os.Chdir(tmpDir); err != nil {
42+
t.Fatal(err)
43+
}
44+
defer func() { _ = os.Chdir(origDir) }()
45+
46+
_, err := New()
47+
if err == nil {
48+
t.Fatal("New() should return error when no go.mod found and no embedded FS")
49+
}
50+
if !strings.Contains(err.Error(), "no go.mod found") {
51+
t.Errorf("expected 'no go.mod found' error, got: %v", err)
52+
}
53+
}
54+
55+
func TestExtract_WritesFilesAndProducesReplacements(t *testing.T) {
56+
// Create a mock FS with theme files including go.mod.tmpl and go.sum.tmpl
57+
mockFS := fstest.MapFS{
58+
"presidium-styling-base/config.yml": &fstest.MapFile{Data: []byte("name: styling")},
59+
"presidium-styling-base/go.mod.tmpl": &fstest.MapFile{Data: []byte("module github.com/spandigital/presidium-styling-base")},
60+
"presidium-styling-base/go.sum.tmpl": &fstest.MapFile{Data: []byte("some-dep v1.0.0 h1:abc=")},
61+
"presidium-layouts-base/config.yml": &fstest.MapFile{Data: []byte("name: layouts-base")},
62+
"presidium-layouts-base/go.mod.tmpl": &fstest.MapFile{Data: []byte("module github.com/spandigital/presidium-layouts-base")},
63+
"presidium-layouts-blog/config.yml": &fstest.MapFile{Data: []byte("name: layouts-blog")},
64+
"presidium-layouts-blog/go.mod.tmpl": &fstest.MapFile{Data: []byte("module github.com/spandigital/presidium-layouts-blog")},
65+
}
66+
67+
svc := Service{themes: mockFS}
68+
69+
tmpDir, replacements, err := svc.Extract()
70+
if err != nil {
71+
t.Fatalf("Extract() returned unexpected error: %v", err)
72+
}
73+
defer os.RemoveAll(tmpDir)
74+
75+
// Verify go.mod.tmpl was renamed to go.mod on disk
76+
goModPath := filepath.Join(tmpDir, "presidium-styling-base", "go.mod")
77+
if _, err := os.Stat(goModPath); os.IsNotExist(err) {
78+
t.Error("Expected go.mod.tmpl to be extracted as go.mod, but file does not exist")
79+
}
80+
81+
// Verify go.sum.tmpl was renamed to go.sum on disk
82+
goSumPath := filepath.Join(tmpDir, "presidium-styling-base", "go.sum")
83+
if _, err := os.Stat(goSumPath); os.IsNotExist(err) {
84+
t.Error("Expected go.sum.tmpl to be extracted as go.sum, but file does not exist")
85+
}
86+
87+
// Verify config.yml was extracted
88+
configPath := filepath.Join(tmpDir, "presidium-styling-base", "config.yml")
89+
if _, err := os.Stat(configPath); os.IsNotExist(err) {
90+
t.Error("Expected config.yml to be extracted, but file does not exist")
91+
}
92+
93+
// Verify replacements string contains all three modules
94+
for _, mod := range []string{
95+
"github.com/spandigital/presidium-styling-base",
96+
"github.com/spandigital/presidium-layouts-base",
97+
"github.com/spandigital/presidium-layouts-blog",
98+
} {
99+
if !strings.Contains(replacements, mod) {
100+
t.Errorf("Expected replacements to contain %q, got: %s", mod, replacements)
101+
}
102+
}
103+
104+
// Verify replacements are in sorted order (deterministic)
105+
parts := strings.Split(replacements, ",")
106+
if len(parts) != 3 {
107+
t.Fatalf("Expected 3 replacement pairs, got %d: %s", len(parts), replacements)
108+
}
109+
for i := 1; i < len(parts); i++ {
110+
if parts[i-1] > parts[i] {
111+
t.Errorf("Replacement pairs are not sorted: %q > %q", parts[i-1], parts[i])
112+
}
113+
}
114+
}
115+
116+
func TestExtract_EmptyTheme(t *testing.T) {
117+
// Test with a theme that has no files (just a directory entry)
118+
mockFS := fstest.MapFS{
119+
"presidium-styling-base/config.yml": &fstest.MapFile{Data: []byte("name: test")},
120+
"presidium-layouts-base/config.yml": &fstest.MapFile{Data: []byte("name: test")},
121+
"presidium-layouts-blog/config.yml": &fstest.MapFile{Data: []byte("name: test")},
122+
}
123+
124+
svc := Service{themes: mockFS}
125+
126+
tmpDir, replacements, err := svc.Extract()
127+
if err != nil {
128+
t.Fatalf("Extract() returned unexpected error: %v", err)
129+
}
130+
defer os.RemoveAll(tmpDir)
131+
132+
if replacements == "" {
133+
t.Error("Expected non-empty replacements string")
134+
}
135+
}
136+
137+
func TestFindModuleRoot(t *testing.T) {
138+
// Save and restore working directory
139+
origDir, _ := os.Getwd()
140+
defer func() { _ = os.Chdir(origDir) }()
141+
142+
// Create a temp directory structure with go.mod
143+
tmpDir := t.TempDir()
144+
subDir := filepath.Join(tmpDir, "a", "b", "c")
145+
if err := os.MkdirAll(subDir, 0755); err != nil {
146+
t.Fatal(err)
147+
}
148+
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0644); err != nil {
149+
t.Fatal(err)
150+
}
151+
152+
// Change to the deeply nested directory
153+
if err := os.Chdir(subDir); err != nil {
154+
t.Fatal(err)
155+
}
156+
157+
root, err := findModuleRoot()
158+
if err != nil {
159+
t.Fatalf("findModuleRoot() returned unexpected error: %v", err)
160+
}
161+
// Resolve symlinks for comparison (macOS /var -> /private/var)
162+
wantResolved, _ := filepath.EvalSymlinks(tmpDir)
163+
gotResolved, _ := filepath.EvalSymlinks(root)
164+
if gotResolved != wantResolved {
165+
t.Errorf("findModuleRoot() = %q, want %q", gotResolved, wantResolved)
166+
}
167+
}
168+
169+
func TestFindModuleRoot_NotFound(t *testing.T) {
170+
origDir, _ := os.Getwd()
171+
defer func() { _ = os.Chdir(origDir) }()
172+
173+
// Use a temp directory with no go.mod anywhere above
174+
tmpDir := t.TempDir()
175+
if err := os.Chdir(tmpDir); err != nil {
176+
t.Fatal(err)
177+
}
178+
179+
_, err := findModuleRoot()
180+
if err == nil {
181+
t.Fatal("findModuleRoot() should return error when no go.mod found")
182+
}
183+
if !strings.Contains(err.Error(), "no go.mod found") {
184+
t.Errorf("expected 'no go.mod found' error, got: %v", err)
185+
}
186+
}

0 commit comments

Comments
 (0)