Init commit
Some checks failed
CI/CD Pipeline / Build Docker Image (push) Has been cancelled
CI/CD Pipeline / Test (push) Has been cancelled
CI/CD Pipeline / Build (arm64, windows, linkbeam-windows-arm64.exe) (push) Has been cancelled
CI/CD Pipeline / Build (386, linux, linkbeam-linux-386) (push) Has been cancelled
CI/CD Pipeline / Lint (push) Has been cancelled
CI/CD Pipeline / Build (amd64, linux, linkbeam-linux-amd64) (push) Has been cancelled
CI/CD Pipeline / Build (arm, 7, linux, linkbeam-linux-armv7) (push) Has been cancelled
CI/CD Pipeline / Build (386, windows, linkbeam-windows-386.exe) (push) Has been cancelled
CI/CD Pipeline / Build (amd64, windows, linkbeam-windows-amd64.exe) (push) Has been cancelled
CI/CD Pipeline / Build (arm64, darwin, linkbeam-darwin-arm64) (push) Has been cancelled
CI/CD Pipeline / Build (arm64, linux, linkbeam-linux-arm64) (push) Has been cancelled
CI/CD Pipeline / Build (amd64, darwin, linkbeam-darwin-amd64) (push) Has been cancelled
CI/CD Pipeline / Create Release (push) Has been cancelled
Some checks failed
CI/CD Pipeline / Build Docker Image (push) Has been cancelled
CI/CD Pipeline / Test (push) Has been cancelled
CI/CD Pipeline / Build (arm64, windows, linkbeam-windows-arm64.exe) (push) Has been cancelled
CI/CD Pipeline / Build (386, linux, linkbeam-linux-386) (push) Has been cancelled
CI/CD Pipeline / Lint (push) Has been cancelled
CI/CD Pipeline / Build (amd64, linux, linkbeam-linux-amd64) (push) Has been cancelled
CI/CD Pipeline / Build (arm, 7, linux, linkbeam-linux-armv7) (push) Has been cancelled
CI/CD Pipeline / Build (386, windows, linkbeam-windows-386.exe) (push) Has been cancelled
CI/CD Pipeline / Build (amd64, windows, linkbeam-windows-amd64.exe) (push) Has been cancelled
CI/CD Pipeline / Build (arm64, darwin, linkbeam-darwin-arm64) (push) Has been cancelled
CI/CD Pipeline / Build (arm64, linux, linkbeam-linux-arm64) (push) Has been cancelled
CI/CD Pipeline / Build (amd64, darwin, linkbeam-darwin-amd64) (push) Has been cancelled
CI/CD Pipeline / Create Release (push) Has been cancelled
This commit is contained in:
111
internal/config/config.go
Normal file
111
internal/config/config.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// config.go
|
||||
|
||||
/*
|
||||
* Copyright (c) - All Rights Reserved.
|
||||
*
|
||||
* See the LICENSE file for more information.
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Name string `yaml:"name"`
|
||||
Bio string `yaml:"bio"`
|
||||
Avatar string `yaml:"avatar"`
|
||||
Theme string `yaml:"theme"`
|
||||
FontAwesomeCDN string `yaml:"font_awesome_cdn"`
|
||||
Footer []FooterBlock `yaml:"footer"`
|
||||
Links []Link `yaml:"links"`
|
||||
Socials []Social `yaml:"socials"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
Icon string `yaml:"icon"`
|
||||
}
|
||||
|
||||
type Social struct {
|
||||
Platform string `yaml:"platform"`
|
||||
URL string `yaml:"url"`
|
||||
Icon string `yaml:"icon"`
|
||||
}
|
||||
|
||||
type FooterBlock struct {
|
||||
Text string `yaml:"text"`
|
||||
}
|
||||
|
||||
func (c *Config) LinksCount() int {
|
||||
return len(c.Links)
|
||||
}
|
||||
|
||||
// GetAvailableThemes discovers available themes from the themes directory.
|
||||
// It returns a list of theme names (without the .css extension).
|
||||
func GetAvailableThemes(themesDir string) ([]string, error) {
|
||||
entries, err := os.ReadDir(themesDir)
|
||||
if err != nil {
|
||||
// If themes directory doesn't exist, return empty list
|
||||
if os.IsNotExist(err) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var themes []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if strings.HasSuffix(name, ".css") {
|
||||
themeName := strings.TrimSuffix(name, ".css")
|
||||
themes = append(themes, themeName)
|
||||
}
|
||||
}
|
||||
return themes, nil
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
return c.ValidateWithThemes("")
|
||||
}
|
||||
|
||||
// ValidateWithThemes validates the config with theme discovery.
|
||||
// If themesDir is empty, theme validation is skipped.
|
||||
func (c *Config) ValidateWithThemes(themesDir string) error {
|
||||
if c.Name == "" {
|
||||
return errors.New("name cannot be empty")
|
||||
}
|
||||
|
||||
// Skip theme validation if no themes directory provided
|
||||
if themesDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
availableThemes, err := GetAvailableThemes(themesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If no themes found, skip validation
|
||||
if len(availableThemes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the configured theme is available
|
||||
validThemes := make(map[string]bool)
|
||||
for _, theme := range availableThemes {
|
||||
validThemes[theme] = true
|
||||
}
|
||||
|
||||
if !validThemes[c.Theme] {
|
||||
return errors.New("theme '" + c.Theme + "' not found. Available themes: " + strings.Join(availableThemes, ", "))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
98
internal/config/config_test.go
Normal file
98
internal/config/config_test.go
Normal file
@@ -0,0 +1,98 @@
|
||||
// config_test.go
|
||||
|
||||
/*
|
||||
* Copyright (c) - All Rights Reserved.
|
||||
*
|
||||
* See the LICENSE file for more information.
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadTestConfig(t *testing.T) *Config {
|
||||
t.Helper()
|
||||
cfg, err := Load("testdata/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("load config: %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
cfg := loadTestConfig(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got interface{}
|
||||
want interface{}
|
||||
}{
|
||||
{"name", cfg.Name, "Ada Lovelace"},
|
||||
{"links count", len(cfg.Links), 2},
|
||||
{"first link URL", cfg.Links[0].URL, "https://ada.blog"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s: got %v, want %v", tt.name, tt.got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeValidation(t *testing.T) {
|
||||
cfg := loadTestConfig(t)
|
||||
if cfg.Theme != "light" && cfg.Theme != "dark" && cfg.Theme != "auto" {
|
||||
t.Errorf("invalid theme: %s", cfg.Theme)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinksCountMethod(t *testing.T) {
|
||||
cfg := loadTestConfig(t)
|
||||
if got, want := cfg.LinksCount(), 2; got != want {
|
||||
t.Errorf("LinksCount() = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterBlocks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "with footer blocks",
|
||||
cfg: Config{
|
||||
Name: "Test",
|
||||
Footer: []FooterBlock{
|
||||
{Text: "© 2025 Test"},
|
||||
{Text: "Made with LinkBeam"},
|
||||
},
|
||||
},
|
||||
want: 2,
|
||||
},
|
||||
{
|
||||
name: "empty footer",
|
||||
cfg: Config{Name: "Test", Footer: []FooterBlock{}},
|
||||
want: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := len(tt.cfg.Footer); got != tt.want {
|
||||
t.Errorf("Footer length = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterBlockText(t *testing.T) {
|
||||
block := FooterBlock{Text: "Test Footer"}
|
||||
want := "Test Footer"
|
||||
|
||||
if block.Text != want {
|
||||
t.Errorf("FooterBlock.Text = %q, want %q", block.Text, want)
|
||||
}
|
||||
}
|
||||
29
internal/config/load.go
Normal file
29
internal/config/load.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// load.go
|
||||
|
||||
/*
|
||||
* Copyright (c) - All Rights Reserved.
|
||||
*
|
||||
* See the LICENCE file for more information.
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, cfg.Validate()
|
||||
}
|
||||
34
internal/config/load_test.go
Normal file
34
internal/config/load_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// load_test.go
|
||||
|
||||
/*
|
||||
* Copyright (c) - All Rights Reserved.
|
||||
*
|
||||
* See the LICENCE file for more information.
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad_Validate(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
badConfigPath := filepath.Join(tmpDir, "bad.yaml")
|
||||
|
||||
// Write invalid config (empty name, invalid theme)
|
||||
content := []byte(`
|
||||
name: ""
|
||||
theme: "unknown"
|
||||
`)
|
||||
if err := os.WriteFile(badConfigPath, content, 0644); err != nil {
|
||||
t.Fatalf("failed to write bad config: %v", err)
|
||||
}
|
||||
|
||||
_, err := Load(badConfigPath)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid config, got nil")
|
||||
}
|
||||
}
|
||||
23
internal/config/testdata/config-catppuccin-frappe.yaml
vendored
Normal file
23
internal/config/testdata/config-catppuccin-frappe.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# config-catppuccin-frappe.yaml - Test config with Catppuccin Frappé theme
|
||||
|
||||
---
|
||||
name: "Test User Catppuccin"
|
||||
bio: "Testing Catppuccin Frappé theme"
|
||||
avatar: "static/logo.png"
|
||||
theme: "catppuccin-frappe"
|
||||
footer:
|
||||
- text: "© 2025 Test User"
|
||||
- text: "Catppuccin Frappé Theme"
|
||||
|
||||
links:
|
||||
- title: "My Website"
|
||||
url: "https://example.com"
|
||||
icon: "fas fa-globe"
|
||||
- title: "GitHub"
|
||||
url: "https://github.com/user"
|
||||
icon: "fab fa-github"
|
||||
|
||||
socials:
|
||||
- platform: "Twitter"
|
||||
url: "https://twitter.com/user"
|
||||
icon: "fab fa-twitter"
|
||||
23
internal/config/testdata/config-catppuccin-latte.yaml
vendored
Normal file
23
internal/config/testdata/config-catppuccin-latte.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# config-catppuccin-latte.yaml - Test config with Catppuccin Latte theme
|
||||
|
||||
---
|
||||
name: "Test User Catppuccin"
|
||||
bio: "Testing Catppuccin Latte theme"
|
||||
avatar: "static/logo.png"
|
||||
theme: "catppuccin-latte"
|
||||
footer:
|
||||
- text: "© 2025 Test User"
|
||||
- text: "Catppuccin Latte Theme"
|
||||
|
||||
links:
|
||||
- title: "My Website"
|
||||
url: "https://example.com"
|
||||
icon: "fas fa-globe"
|
||||
- title: "GitHub"
|
||||
url: "https://github.com/user"
|
||||
icon: "fab fa-github"
|
||||
|
||||
socials:
|
||||
- platform: "Twitter"
|
||||
url: "https://twitter.com/user"
|
||||
icon: "fab fa-twitter"
|
||||
23
internal/config/testdata/config-catppuccin-macchiato.yaml
vendored
Normal file
23
internal/config/testdata/config-catppuccin-macchiato.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# config-catppuccin-macchiato.yaml - Test config with Catppuccin Macchiato theme
|
||||
|
||||
---
|
||||
name: "Test User Catppuccin"
|
||||
bio: "Testing Catppuccin Macchiato theme"
|
||||
avatar: "static/logo.png"
|
||||
theme: "catppuccin-macchiato"
|
||||
footer:
|
||||
- text: "© 2025 Test User"
|
||||
- text: "Catppuccin Macchiato Theme"
|
||||
|
||||
links:
|
||||
- title: "My Website"
|
||||
url: "https://example.com"
|
||||
icon: "fas fa-globe"
|
||||
- title: "GitHub"
|
||||
url: "https://github.com/user"
|
||||
icon: "fab fa-github"
|
||||
|
||||
socials:
|
||||
- platform: "Twitter"
|
||||
url: "https://twitter.com/user"
|
||||
icon: "fab fa-twitter"
|
||||
23
internal/config/testdata/config-catppuccin-mocha.yaml
vendored
Normal file
23
internal/config/testdata/config-catppuccin-mocha.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# config-catppuccin.yaml - Test config with Catppuccin theme
|
||||
|
||||
---
|
||||
name: "Test User Catppuccin"
|
||||
bio: "Testing Catppuccin Mocha theme"
|
||||
avatar: "static/logo.png"
|
||||
theme: "catppuccin-mocha"
|
||||
footer:
|
||||
- text: "© 2025 Test User"
|
||||
- text: "Catppuccin Mocha Theme"
|
||||
|
||||
links:
|
||||
- title: "My Website"
|
||||
url: "https://example.com"
|
||||
icon: "fas fa-globe"
|
||||
- title: "GitHub"
|
||||
url: "https://github.com/user"
|
||||
icon: "fab fa-github"
|
||||
|
||||
socials:
|
||||
- platform: "Twitter"
|
||||
url: "https://twitter.com/user"
|
||||
icon: "fab fa-twitter"
|
||||
23
internal/config/testdata/config-gruvbox.yaml
vendored
Normal file
23
internal/config/testdata/config-gruvbox.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# config-gruvbox.yaml - Test config with Gruvbox theme
|
||||
|
||||
---
|
||||
name: "Test User Gruvbox"
|
||||
bio: "Testing Gruvbox theme with light/dark variants"
|
||||
avatar: "static/logo.png"
|
||||
theme: "gruvbox"
|
||||
footer:
|
||||
- text: "© 2025 Test User"
|
||||
- text: "Gruvbox Theme"
|
||||
|
||||
links:
|
||||
- title: "My Website"
|
||||
url: "https://example.com"
|
||||
icon: "fas fa-globe"
|
||||
- title: "GitHub"
|
||||
url: "https://github.com/user"
|
||||
icon: "fab fa-github"
|
||||
|
||||
socials:
|
||||
- platform: "Twitter"
|
||||
url: "https://twitter.com/user"
|
||||
icon: "fab fa-twitter"
|
||||
12
internal/config/testdata/config-invalid-theme.yaml
vendored
Normal file
12
internal/config/testdata/config-invalid-theme.yaml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# config-invalid-theme.yaml - Test config with invalid theme (for error testing)
|
||||
|
||||
---
|
||||
name: "Test User Invalid"
|
||||
bio: "Testing invalid theme error handling"
|
||||
avatar: "static/logo.png"
|
||||
theme: "nonexistent"
|
||||
|
||||
links:
|
||||
- title: "My Website"
|
||||
url: "https://example.com"
|
||||
icon: "fas fa-globe"
|
||||
23
internal/config/testdata/config-nord.yaml
vendored
Normal file
23
internal/config/testdata/config-nord.yaml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# config-nord.yaml - Test config with Nord theme
|
||||
|
||||
---
|
||||
name: "Test User Nord"
|
||||
bio: "Testing Nord theme with light/dark variants"
|
||||
avatar: "static/logo.png"
|
||||
theme: "nord"
|
||||
footer:
|
||||
- text: "© 2025 Test User"
|
||||
- text: "Nord Theme"
|
||||
|
||||
links:
|
||||
- title: "My Website"
|
||||
url: "https://example.com"
|
||||
icon: "fas fa-globe"
|
||||
- title: "GitHub"
|
||||
url: "https://github.com/user"
|
||||
icon: "fab fa-github"
|
||||
|
||||
socials:
|
||||
- platform: "Twitter"
|
||||
url: "https://twitter.com/user"
|
||||
icon: "fab fa-twitter"
|
||||
128
internal/config/validate_test.go
Normal file
128
internal/config/validate_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// validate_test.go
|
||||
|
||||
/*
|
||||
* Copyright (c) - All Rights Reserved.
|
||||
*
|
||||
* See the LICENCE file for more information.
|
||||
*/
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantError bool
|
||||
}{
|
||||
{"missing name", Config{Name: "", Theme: "auto"}, true},
|
||||
{"valid config", Config{Name: "Ada", Theme: "auto"}, false},
|
||||
{"valid config with any theme", Config{Name: "Ada", Theme: "invalid"}, false}, // No theme validation without themesDir
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("Validate() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWithThemes(t *testing.T) {
|
||||
// Create a temporary themes directory for testing
|
||||
tmpDir := t.TempDir()
|
||||
themesDir := filepath.Join(tmpDir, "themes")
|
||||
if err := os.MkdirAll(themesDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create some test theme files
|
||||
for _, theme := range []string{"auto", "nord", "gruvbox", "catppuccin"} {
|
||||
if err := os.WriteFile(filepath.Join(themesDir, theme+".css"), []byte("/* test */"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
themesDir string
|
||||
wantError bool
|
||||
}{
|
||||
{"missing name", Config{Name: "", Theme: "auto"}, themesDir, true},
|
||||
{"invalid theme", Config{Name: "Ada", Theme: "invalid"}, themesDir, true},
|
||||
{"valid auto theme", Config{Name: "Ada", Theme: "auto"}, themesDir, false},
|
||||
{"valid nord theme", Config{Name: "Ada", Theme: "nord"}, themesDir, false},
|
||||
{"valid gruvbox theme", Config{Name: "Ada", Theme: "gruvbox"}, themesDir, false},
|
||||
{"valid catppuccin theme", Config{Name: "Ada", Theme: "catppuccin"}, themesDir, false},
|
||||
{"no themes dir", Config{Name: "Ada", Theme: "anything"}, "", false}, // Skip validation if no dir
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.ValidateWithThemes(tt.themesDir)
|
||||
if (err != nil) != tt.wantError {
|
||||
t.Errorf("ValidateWithThemes() error = %v, wantError %v", err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableThemes(t *testing.T) {
|
||||
// Create a temporary themes directory for testing
|
||||
tmpDir := t.TempDir()
|
||||
themesDir := filepath.Join(tmpDir, "themes")
|
||||
if err := os.MkdirAll(themesDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create some test theme files
|
||||
testThemes := []string{"auto", "nord", "gruvbox"}
|
||||
for _, theme := range testThemes {
|
||||
if err := os.WriteFile(filepath.Join(themesDir, theme+".css"), []byte("/* test */"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a non-CSS file (should be ignored)
|
||||
if err := os.WriteFile(filepath.Join(themesDir, "readme.txt"), []byte("readme"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
themes, err := GetAvailableThemes(themesDir)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAvailableThemes() error = %v", err)
|
||||
}
|
||||
|
||||
if len(themes) != len(testThemes) {
|
||||
t.Errorf("GetAvailableThemes() got %d themes, want %d", len(themes), len(testThemes))
|
||||
}
|
||||
|
||||
// Check that all expected themes are present
|
||||
themeMap := make(map[string]bool)
|
||||
for _, theme := range themes {
|
||||
themeMap[theme] = true
|
||||
}
|
||||
|
||||
for _, expectedTheme := range testThemes {
|
||||
if !themeMap[expectedTheme] {
|
||||
t.Errorf("Expected theme %q not found in available themes", expectedTheme)
|
||||
}
|
||||
}
|
||||
|
||||
// Test non-existent directory
|
||||
themes, err = GetAvailableThemes(filepath.Join(tmpDir, "nonexistent"))
|
||||
if err != nil {
|
||||
t.Errorf("GetAvailableThemes() should not error on non-existent dir, got %v", err)
|
||||
}
|
||||
if len(themes) != 0 {
|
||||
t.Errorf("GetAvailableThemes() should return empty list for non-existent dir, got %d themes", len(themes))
|
||||
}
|
||||
}
|
||||
186
internal/generator/generator.go
Normal file
186
internal/generator/generator.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// generator.go
|
||||
|
||||
/*
|
||||
* Copyright (c) - All Rights Reserved.
|
||||
*
|
||||
* See the LICENCE file for more information.
|
||||
*/
|
||||
|
||||
package generator
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"linkbeam/internal/config"
|
||||
)
|
||||
|
||||
func GenerateSite(cfg *config.Config, templatePath, outPath string) error {
|
||||
tmpl, err := template.ParseFiles(templatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse template: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
|
||||
return fmt.Errorf("create output dir: %w", err)
|
||||
}
|
||||
|
||||
outFile, err := os.Create(outPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create output file: %w", err)
|
||||
}
|
||||
defer func() { _ = outFile.Close() }()
|
||||
|
||||
if err := tmpl.Execute(outFile, cfg); err != nil {
|
||||
return fmt.Errorf("execute template: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderUserPage renders a plain text representation of the user's page.
|
||||
func RenderUserPage(cfg *config.Config) string {
|
||||
var sb strings.Builder
|
||||
fmt.Fprintf(&sb, "Name: %s\n", cfg.Name)
|
||||
fmt.Fprintf(&sb, "Bio: %s\n", cfg.Bio)
|
||||
sb.WriteString("Links:\n")
|
||||
for _, link := range cfg.Links {
|
||||
fmt.Fprintf(&sb, "- %s (%s)\n", link.Title, link.URL)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// CopyAssets copies theme and static assets to the output directory.
|
||||
func CopyAssets(distDir string, themeDirs ...string) error {
|
||||
themesDir := filepath.Join(distDir, "themes")
|
||||
if err := os.MkdirAll(themesDir, 0755); err != nil {
|
||||
return fmt.Errorf("create themes dir: %w", err)
|
||||
}
|
||||
|
||||
for _, themeDir := range themeDirs {
|
||||
if err := copyThemeFiles(themeDir, themesDir); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyThemeFiles(srcDir, dstDir string) error {
|
||||
entries, err := os.ReadDir(srcDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read theme dir %s: %w", srcDir, err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
src := filepath.Join(srcDir, entry.Name())
|
||||
dst := filepath.Join(dstDir, entry.Name())
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return fmt.Errorf("copy %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies a single file from src to dst.
|
||||
func copyFile(src, dst string) error {
|
||||
srcFile, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = srcFile.Close() }()
|
||||
|
||||
dstFile, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = dstFile.Close() }()
|
||||
|
||||
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return dstFile.Sync()
|
||||
}
|
||||
|
||||
// CopyAvatar copies the avatar file to the dist directory if it's a local file.
|
||||
// It skips URLs (http:// or https://) and doesn't fail if the file doesn't exist.
|
||||
func CopyAvatar(cfg *config.Config, distDir string) error {
|
||||
if cfg.Avatar == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip if avatar is a URL
|
||||
if strings.HasPrefix(cfg.Avatar, "http://") || strings.HasPrefix(cfg.Avatar, "https://") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the source file exists
|
||||
if _, err := os.Stat(cfg.Avatar); os.IsNotExist(err) {
|
||||
// Don't fail if file doesn't exist, just skip
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create the destination path, preserving directory structure
|
||||
dstPath := filepath.Join(distDir, cfg.Avatar)
|
||||
|
||||
// Create destination directory if needed
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||
return fmt.Errorf("create avatar dir: %w", err)
|
||||
}
|
||||
|
||||
// Copy the file
|
||||
if err := copyFile(cfg.Avatar, dstPath); err != nil {
|
||||
return fmt.Errorf("copy avatar: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopyStaticFiles copies the static directory to the dist directory.
|
||||
// It recursively copies all files and subdirectories from static/ to dist/static/.
|
||||
func CopyStaticFiles(distDir string) error {
|
||||
staticSrc := "static"
|
||||
staticDst := filepath.Join(distDir, "static")
|
||||
|
||||
// Check if static directory exists
|
||||
if _, err := os.Stat(staticSrc); os.IsNotExist(err) {
|
||||
// Don't fail if static directory doesn't exist, just skip
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create destination static directory
|
||||
if err := os.MkdirAll(staticDst, 0755); err != nil {
|
||||
return fmt.Errorf("create static dir: %w", err)
|
||||
}
|
||||
|
||||
// Walk through the static directory and copy all files
|
||||
return filepath.Walk(staticSrc, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get relative path from static source
|
||||
relPath, err := filepath.Rel(staticSrc, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create destination path
|
||||
dstPath := filepath.Join(staticDst, relPath)
|
||||
|
||||
// If it's a directory, create it
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, 0755)
|
||||
}
|
||||
|
||||
// If it's a file, copy it
|
||||
return copyFile(path, dstPath)
|
||||
})
|
||||
}
|
||||
343
internal/generator/generator_test.go
Normal file
343
internal/generator/generator_test.go
Normal file
@@ -0,0 +1,343 @@
|
||||
// generator_test.go
|
||||
|
||||
/*
|
||||
* Copyright (c) - All Rights Reserved.
|
||||
*
|
||||
* See the LICENCE file for more information.
|
||||
*/
|
||||
|
||||
package generator
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"linkbeam/internal/config"
|
||||
)
|
||||
|
||||
func TestGenerateSite(t *testing.T) {
|
||||
cfg, err := config.Load("../config/testdata/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
outPath := tmpDir + "/index.html"
|
||||
|
||||
templatePath := "../../templates/base.html"
|
||||
if err = GenerateSite(cfg, templatePath, outPath); err != nil {
|
||||
t.Fatalf("failed to generate site: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, cfg.Name) {
|
||||
t.Errorf("output does not contain name %q", cfg.Name)
|
||||
}
|
||||
if !strings.Contains(content, cfg.Bio) {
|
||||
t.Errorf("output does not contain bio %q", cfg.Bio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSiteWithFooter(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
outPath := tmpDir + "/index.html"
|
||||
|
||||
footerText := "© 2025 Test User"
|
||||
cfg := &config.Config{
|
||||
Name: "Test User",
|
||||
Bio: "Test bio",
|
||||
Theme: "auto",
|
||||
Footer: []config.FooterBlock{
|
||||
{Text: footerText},
|
||||
{Text: "Made with LinkBeam"},
|
||||
},
|
||||
}
|
||||
|
||||
templatePath := "../../templates/base.html"
|
||||
if err := GenerateSite(cfg, templatePath, outPath); err != nil {
|
||||
t.Fatalf("failed to generate site: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read output: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, footerText) {
|
||||
t.Errorf("output does not contain footer text %q", footerText)
|
||||
}
|
||||
if !strings.Contains(content, "Made with LinkBeam") {
|
||||
t.Errorf("output does not contain footer text %q", "Made with LinkBeam")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderUserPage_EmptyConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
output := RenderUserPage(cfg)
|
||||
|
||||
for _, want := range []string{"Name:", "Bio:", "Links:"} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("output missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAssets(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create a temporary themes directory with test files
|
||||
tmpThemesDir := tmpDir + "/themes_src"
|
||||
if err := os.MkdirAll(tmpThemesDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create temp themes dir: %v", err)
|
||||
}
|
||||
|
||||
// Create test CSS files
|
||||
testCSS := "body { color: red; }"
|
||||
if err := os.WriteFile(tmpThemesDir+"/test.css", []byte(testCSS), 0644); err != nil {
|
||||
t.Fatalf("failed to write test CSS: %v", err)
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
distDir := tmpDir + "/dist"
|
||||
|
||||
// Copy assets
|
||||
if err := CopyAssets(distDir, tmpThemesDir); err != nil {
|
||||
t.Fatalf("CopyAssets failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file was copied
|
||||
copiedFile := distDir + "/themes/test.css"
|
||||
data, err := os.ReadFile(copiedFile)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read copied file: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != testCSS {
|
||||
t.Errorf("copied file content mismatch: got %q, want %q", string(data), testCSS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyStaticFiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupStatic bool
|
||||
files []string
|
||||
subdirs []string
|
||||
wantErr bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "copy single file",
|
||||
setupStatic: true,
|
||||
files: []string{"logo.png"},
|
||||
subdirs: []string{},
|
||||
wantErr: false,
|
||||
description: "should copy a single static file",
|
||||
},
|
||||
{
|
||||
name: "copy multiple files",
|
||||
setupStatic: true,
|
||||
files: []string{"logo.png", "favicon.ico", "style.css"},
|
||||
subdirs: []string{},
|
||||
wantErr: false,
|
||||
description: "should copy multiple static files",
|
||||
},
|
||||
{
|
||||
name: "copy files with subdirectories",
|
||||
setupStatic: true,
|
||||
files: []string{"logo.png", "images/banner.jpg", "css/custom.css"},
|
||||
subdirs: []string{"images", "css"},
|
||||
wantErr: false,
|
||||
description: "should copy files preserving subdirectory structure",
|
||||
},
|
||||
{
|
||||
name: "no static directory",
|
||||
setupStatic: false,
|
||||
files: []string{},
|
||||
subdirs: []string{},
|
||||
wantErr: false,
|
||||
description: "should not fail when static directory doesn't exist",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Change to temp directory to simulate project root
|
||||
oldWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Chdir(oldWd); err != nil {
|
||||
t.Errorf("failed to restore working directory: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := os.Chdir(tmpDir); err != nil {
|
||||
t.Fatalf("failed to change to temp directory: %v", err)
|
||||
}
|
||||
|
||||
// Setup static directory and files if needed
|
||||
if tt.setupStatic {
|
||||
staticDir := tmpDir + "/static"
|
||||
if err := os.MkdirAll(staticDir, 0755); err != nil {
|
||||
t.Fatalf("failed to create static dir: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectories
|
||||
for _, subdir := range tt.subdirs {
|
||||
subdirPath := staticDir + "/" + subdir
|
||||
if err := os.MkdirAll(subdirPath, 0755); err != nil {
|
||||
t.Fatalf("failed to create subdir %s: %v", subdir, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create test files
|
||||
for _, file := range tt.files {
|
||||
filePath := staticDir + "/" + file
|
||||
testContent := []byte("test content for " + file)
|
||||
if err := os.WriteFile(filePath, testContent, 0644); err != nil {
|
||||
t.Fatalf("failed to write test file %s: %v", file, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
distDir := tmpDir + "/dist"
|
||||
|
||||
// Execute CopyStaticFiles
|
||||
err = CopyStaticFiles(distDir)
|
||||
|
||||
// Check error expectation
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("%s: CopyStaticFiles() error = %v, wantErr %v", tt.description, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify copied files
|
||||
if tt.setupStatic {
|
||||
for _, file := range tt.files {
|
||||
copiedPath := distDir + "/static/" + file
|
||||
if _, err := os.Stat(copiedPath); os.IsNotExist(err) {
|
||||
t.Errorf("%s: expected file at %s but it doesn't exist", tt.description, copiedPath)
|
||||
} else {
|
||||
// Verify content
|
||||
data, err := os.ReadFile(copiedPath)
|
||||
if err != nil {
|
||||
t.Errorf("%s: failed to read copied file %s: %v", tt.description, file, err)
|
||||
}
|
||||
expectedContent := "test content for " + file
|
||||
if string(data) != expectedContent {
|
||||
t.Errorf("%s: file %s content mismatch: got %q, want %q",
|
||||
tt.description, file, string(data), expectedContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyAvatar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
avatar string
|
||||
setupFile bool
|
||||
wantErr bool
|
||||
expectCopy bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "local file",
|
||||
avatar: "static/avatar.png",
|
||||
setupFile: true,
|
||||
wantErr: false,
|
||||
expectCopy: true,
|
||||
description: "should copy local avatar file",
|
||||
},
|
||||
{
|
||||
name: "http URL",
|
||||
avatar: "http://example.com/avatar.png",
|
||||
setupFile: false,
|
||||
wantErr: false,
|
||||
expectCopy: false,
|
||||
description: "should skip http:// URLs",
|
||||
},
|
||||
{
|
||||
name: "https URL",
|
||||
avatar: "https://example.com/avatar.png",
|
||||
setupFile: false,
|
||||
wantErr: false,
|
||||
expectCopy: false,
|
||||
description: "should skip https:// URLs",
|
||||
},
|
||||
{
|
||||
name: "missing file",
|
||||
avatar: "static/nonexistent.png",
|
||||
setupFile: false,
|
||||
wantErr: false,
|
||||
expectCopy: false,
|
||||
description: "should not fail when file doesn't exist",
|
||||
},
|
||||
{
|
||||
name: "empty avatar",
|
||||
avatar: "",
|
||||
setupFile: false,
|
||||
wantErr: false,
|
||||
expectCopy: false,
|
||||
description: "should handle empty avatar gracefully",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
distDir := tmpDir + "/dist"
|
||||
|
||||
// Setup test file if needed
|
||||
if tt.setupFile {
|
||||
avatarPath := tmpDir + "/" + tt.avatar
|
||||
if err := os.MkdirAll(strings.TrimSuffix(avatarPath, "/avatar.png"), 0755); err != nil {
|
||||
t.Fatalf("failed to create avatar dir: %v", err)
|
||||
}
|
||||
testContent := []byte("fake image data")
|
||||
if err := os.WriteFile(avatarPath, testContent, 0644); err != nil {
|
||||
t.Fatalf("failed to write test avatar: %v", err)
|
||||
}
|
||||
// Update avatar path to use the temp directory
|
||||
tt.avatar = avatarPath
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Name: "Test User",
|
||||
Theme: "dark",
|
||||
Avatar: tt.avatar,
|
||||
}
|
||||
|
||||
// Execute CopyAvatar
|
||||
err := CopyAvatar(cfg, distDir)
|
||||
|
||||
// Check error expectation
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("%s: CopyAvatar() error = %v, wantErr %v", tt.description, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if file was copied
|
||||
if tt.expectCopy {
|
||||
copiedPath := distDir + "/" + tt.avatar
|
||||
if _, err := os.Stat(copiedPath); os.IsNotExist(err) {
|
||||
t.Errorf("%s: expected file at %s but it doesn't exist", tt.description, copiedPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user