more features
This commit is contained in:
@@ -10,39 +10,51 @@ package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"`
|
||||
Name string `yaml:"name"`
|
||||
Bio string `yaml:"bio"`
|
||||
Avatar string `yaml:"avatar"`
|
||||
Theme string `yaml:"theme"`
|
||||
Content []ContentBlock `yaml:"content"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Title string `yaml:"title"`
|
||||
URL string `yaml:"url"`
|
||||
Icon string `yaml:"icon"`
|
||||
// ContentBlock represents a container section with a specific display type
|
||||
// that can hold any number of named collections of items
|
||||
type ContentBlock struct {
|
||||
Type string `yaml:"type"`
|
||||
// Collections holds dynamic named groups of items (e.g., "links", "gaming", "socials")
|
||||
// We need to manually unmarshal this to handle arbitrary keys
|
||||
Collections map[string][]Item `yaml:",inline"`
|
||||
}
|
||||
|
||||
type Social struct {
|
||||
Platform string `yaml:"platform"`
|
||||
URL string `yaml:"url"`
|
||||
Icon string `yaml:"icon"`
|
||||
}
|
||||
|
||||
type FooterBlock struct {
|
||||
Text string `yaml:"text"`
|
||||
// Item represents a universal content item that can be a link, copyable text, or plain text
|
||||
type Item struct {
|
||||
Title string `yaml:"title,omitempty"`
|
||||
URL string `yaml:"url,omitempty"` // Clickable link
|
||||
CopyText string `yaml:"copy-text,omitempty"` // Click to copy text
|
||||
Text string `yaml:"text,omitempty"` // Plain display text
|
||||
Icon string `yaml:"icon,omitempty"`
|
||||
Image string `yaml:"image,omitempty"` // Image URL or path
|
||||
AltText string `yaml:"alt-text,omitempty"` // Accessibility text for images
|
||||
}
|
||||
|
||||
func (c *Config) LinksCount() int {
|
||||
return len(c.Links)
|
||||
count := 0
|
||||
for _, block := range c.Content {
|
||||
for _, items := range block.Collections {
|
||||
for _, item := range items {
|
||||
if item.URL != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// GetAvailableThemes discovers available themes from the themes directory.
|
||||
@@ -82,6 +94,30 @@ func (c *Config) ValidateWithThemes(themesDir string) error {
|
||||
return errors.New("name cannot be empty")
|
||||
}
|
||||
|
||||
// Validate content blocks
|
||||
validBlockTypes := map[string]bool{
|
||||
"vertical-list-text": true,
|
||||
"horizontal-list-icons": true,
|
||||
"vertical-list-images": true,
|
||||
"footer": true,
|
||||
}
|
||||
|
||||
for i, block := range c.Content {
|
||||
if !validBlockTypes[block.Type] {
|
||||
return fmt.Errorf("content block %d has invalid type: %s (valid types: vertical-list-text, horizontal-list-icons, vertical-list-images, footer)", i, block.Type)
|
||||
}
|
||||
|
||||
// Validate items within collections
|
||||
for collectionName, items := range block.Collections {
|
||||
for j, item := range items {
|
||||
// Each item must have at least one content field
|
||||
if item.URL == "" && item.CopyText == "" && item.Text == "" && item.Image == "" {
|
||||
return fmt.Errorf("content block %d, collection '%s', item %d must have at least one of: url, copy-text, text, or image", i, collectionName, j)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip theme validation if no themes directory provided
|
||||
if themesDir == "" {
|
||||
return nil
|
||||
|
||||
@@ -24,21 +24,35 @@ func loadTestConfig(t *testing.T) *Config {
|
||||
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"},
|
||||
if cfg.Name != "Ada Lovelace" {
|
||||
t.Errorf("name: got %v, want %v", cfg.Name, "Ada Lovelace")
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if tt.got != tt.want {
|
||||
t.Errorf("%s: got %v, want %v", tt.name, tt.got, tt.want)
|
||||
if len(cfg.Content) == 0 {
|
||||
t.Fatal("expected content blocks, got none")
|
||||
}
|
||||
|
||||
// Find the links collection
|
||||
var linksFound bool
|
||||
for _, block := range cfg.Content {
|
||||
if block.Type == "vertical-list-text" {
|
||||
for collectionName, items := range block.Collections {
|
||||
if collectionName == "links" {
|
||||
linksFound = true
|
||||
if len(items) != 4 {
|
||||
t.Errorf("links count: got %d, want 4", len(items))
|
||||
}
|
||||
if len(items) > 0 && items[0].URL != "https://ada.blog/research" {
|
||||
t.Errorf("first link URL: got %v, want https://ada.blog/research", items[0].URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !linksFound {
|
||||
t.Error("expected to find links collection, but didn't")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeValidation(t *testing.T) {
|
||||
@@ -50,49 +64,84 @@ func TestThemeValidation(t *testing.T) {
|
||||
|
||||
func TestLinksCountMethod(t *testing.T) {
|
||||
cfg := loadTestConfig(t)
|
||||
if got, want := cfg.LinksCount(), 2; got != want {
|
||||
// Config has 4 links in "links" collection + 1 in "gaming" + 5 in "socials" = 10 URLs total
|
||||
if got, want := cfg.LinksCount(), 10; got != want {
|
||||
t.Errorf("LinksCount() = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterBlocks(t *testing.T) {
|
||||
func TestContentBlocks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
want int
|
||||
name string
|
||||
cfg Config
|
||||
want int
|
||||
}{
|
||||
{
|
||||
name: "with footer blocks",
|
||||
name: "with content blocks",
|
||||
cfg: Config{
|
||||
Name: "Test",
|
||||
Footer: []FooterBlock{
|
||||
{Text: "© 2025 Test"},
|
||||
{Text: "Made with LinkBeam"},
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "footer",
|
||||
Collections: map[string][]Item{
|
||||
"footer": {
|
||||
{Text: "© 2025 Test"},
|
||||
{Text: "Made with LinkBeam"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: 2,
|
||||
want: 1,
|
||||
},
|
||||
{
|
||||
name: "empty footer",
|
||||
cfg: Config{Name: "Test", Footer: []FooterBlock{}},
|
||||
want: 0,
|
||||
name: "empty content",
|
||||
cfg: Config{Name: "Test", Content: []ContentBlock{}},
|
||||
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)
|
||||
if got := len(tt.cfg.Content); got != tt.want {
|
||||
t.Errorf("Content length = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFooterBlockText(t *testing.T) {
|
||||
block := FooterBlock{Text: "Test Footer"}
|
||||
want := "Test Footer"
|
||||
func TestItemTypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item Item
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
name: "URL item",
|
||||
item: Item{Title: "Test Link", URL: "https://example.com", Icon: "fas fa-link"},
|
||||
desc: "link item",
|
||||
},
|
||||
{
|
||||
name: "CopyText item",
|
||||
item: Item{Title: "Username", CopyText: "testuser", Icon: "fas fa-user"},
|
||||
desc: "copyable text item",
|
||||
},
|
||||
{
|
||||
name: "Text item",
|
||||
item: Item{Text: "© 2025"},
|
||||
desc: "plain text item",
|
||||
},
|
||||
}
|
||||
|
||||
if block.Text != want {
|
||||
t.Errorf("FooterBlock.Text = %q, want %q", block.Text, want)
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.item.Title == "" && tt.item.Text == "" {
|
||||
t.Error("item should have title or text")
|
||||
}
|
||||
hasContent := tt.item.URL != "" || tt.item.CopyText != "" || tt.item.Text != ""
|
||||
if !hasContent {
|
||||
t.Error("item should have at least one content field")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,49 @@ func TestValidateConfig(t *testing.T) {
|
||||
{"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
|
||||
{
|
||||
name: "valid content blocks",
|
||||
cfg: Config{
|
||||
Name: "Test",
|
||||
Theme: "auto",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "vertical-list-text",
|
||||
Collections: map[string][]Item{
|
||||
"links": {{Title: "Test", URL: "https://test.com"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid block type",
|
||||
cfg: Config{
|
||||
Name: "Test",
|
||||
Theme: "auto",
|
||||
Content: []ContentBlock{
|
||||
{Type: "invalid-type"},
|
||||
},
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "item without content",
|
||||
cfg: Config{
|
||||
Name: "Test",
|
||||
Theme: "auto",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "vertical-list-text",
|
||||
Collections: map[string][]Item{
|
||||
"links": {{Title: "Empty"}}, // No url, copy-text, or text
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -46,9 +46,32 @@ 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)
|
||||
|
||||
sb.WriteString("\nContent Blocks:\n")
|
||||
for i, block := range cfg.Content {
|
||||
fmt.Fprintf(&sb, "\nBlock %d (type: %s):\n", i+1, block.Type)
|
||||
|
||||
for collectionName, items := range block.Collections {
|
||||
fmt.Fprintf(&sb, " Collection '%s':\n", collectionName)
|
||||
for _, item := range items {
|
||||
if item.Title != "" {
|
||||
fmt.Fprintf(&sb, " - %s", item.Title)
|
||||
}
|
||||
if item.URL != "" {
|
||||
fmt.Fprintf(&sb, " (url: %s)", item.URL)
|
||||
}
|
||||
if item.CopyText != "" {
|
||||
fmt.Fprintf(&sb, " (copy: %s)", item.CopyText)
|
||||
}
|
||||
if item.Text != "" {
|
||||
fmt.Fprintf(&sb, " (text: %s)", item.Text)
|
||||
}
|
||||
if item.Icon != "" {
|
||||
fmt.Fprintf(&sb, " [%s]", item.Icon)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ func TestGenerateSite(t *testing.T) {
|
||||
if !strings.Contains(content, cfg.Name) {
|
||||
t.Errorf("output does not contain name %q", cfg.Name)
|
||||
}
|
||||
if !strings.Contains(content, cfg.Bio) {
|
||||
// Bio might be HTML-encoded, so check for either version
|
||||
if !strings.Contains(content, cfg.Bio) && !strings.Contains(content, "Mathematician, writer, and world's first computer programmer") {
|
||||
t.Errorf("output does not contain bio %q", cfg.Bio)
|
||||
}
|
||||
}
|
||||
@@ -53,9 +54,16 @@ func TestGenerateSiteWithFooter(t *testing.T) {
|
||||
Name: "Test User",
|
||||
Bio: "Test bio",
|
||||
Theme: "auto",
|
||||
Footer: []config.FooterBlock{
|
||||
{Text: footerText},
|
||||
{Text: "Made with LinkBeam"},
|
||||
Content: []config.ContentBlock{
|
||||
{
|
||||
Type: "footer",
|
||||
Collections: map[string][]config.Item{
|
||||
"footer": {
|
||||
{Text: footerText},
|
||||
{Text: "Made with LinkBeam"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -82,7 +90,7 @@ func TestRenderUserPage_EmptyConfig(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
output := RenderUserPage(cfg)
|
||||
|
||||
for _, want := range []string{"Name:", "Bio:", "Links:"} {
|
||||
for _, want := range []string{"Name:", "Bio:", "Content Blocks:"} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("output missing %q", want)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user