diff --git a/.gitignore b/.gitignore index 459710e..e215e95 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,10 @@ config.yaml # Pre-commit .pre-commit-cache/ + +static/ +!static/favicon.png +!static/logo.png +!static/ada-256x256.png + +!.gitignore diff --git a/assets/Screenshot.png b/assets/Screenshot.png index e6e9cf8..f5ac180 100644 Binary files a/assets/Screenshot.png and b/assets/Screenshot.png differ diff --git a/config.example.yaml b/config.example.yaml index 9570820..1fd4222 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,35 +2,86 @@ # Example configuration for LinkBeam --- -name: "Your Name" -bio: "A short bio about yourself" -avatar: "static/logo.png" # Can be a local path or URL (http://... or https://...) -theme: "auto" -links: - - title: "My Website" - url: "https://yourwebsite.com" - icon: "fas fa-globe" - - title: "Blog" - url: "https://yourblog.com" - icon: "fas fa-blog" - - title: "GitHub" - url: "https://github.com/yourusername" - icon: "fab fa-github" - - title: "Portfolio" - url: "https://portfolio.com" - icon: "fas fa-briefcase" +name: "Ada Lovelace" +bio: "Mathematician, writer, and world's first computer programmer" +avatar: "static/ada-256x256.png" # Can be a local path or URL (http://... or https://...) +theme: "auto" # Options: auto, nord, gruvbox, catppuccin-mocha, catppuccin-latte, catppuccin-frappe, catppuccin-macchiato -socials: - - platform: "Twitter" - url: "https://twitter.com/yourusername" - icon: "fab fa-twitter" - - platform: "LinkedIn" - url: "https://linkedin.com/in/yourusername" - icon: "fab fa-linkedin" - - platform: "Instagram" - url: "https://instagram.com/yourusername" - icon: "fab fa-instagram" +# Content blocks - flexible structure with named collections +# Each block can contain any named collection of items +# Item types: url (clickable link), copy-text (click to copy), text (plain display) -footer: - - text: "© 2025 Your Name" - - text: "Made with LinkBeam" +content: + # Vertical list with text labels - good for primary links + - type: vertical-list-text + links: + - title: "My Research Papers" + url: "https://ada.blog/research" + icon: "fas fa-file-alt" + - title: "Analytical Engine Notes" + url: "https://ada.blog/notes" + icon: "fas fa-scroll" + - title: "GitHub Projects" + url: "https://github.com/ada" + icon: "fab fa-github" + - title: "Speaking Events" + url: "https://ada.blog/events" + icon: "fas fa-calendar" + + # Another vertical list - gaming profiles with copy-text + - type: vertical-list-text + gaming: + - title: "Steam" + url: "https://steamcommunity.com/id/ada_lovelace" + icon: "fab fa-steam" + - title: "Discord" + copy-text: "AdaLovelace#1842" + icon: "fab fa-discord" + - title: "Epic Games" + copy-text: "ada_lovelace" + icon: "fas fa-gamepad" + + # Image gallery - displays images with optional captions + - type: vertical-list-images + gallery: + - image: "static/ada-256x256.png" + alt-text: "Portrait of Ada Lovelace" + text: "The world's first computer programmer" + - image: "https://picsum.photos/600/400" + alt-text: "Sample external image" + text: "Images can be local files or external URLs" + + # Horizontal icon list - perfect for social media + - type: horizontal-list-icons + socials: + - title: "Twitter" + url: "https://twitter.com/ada_lovelace" + icon: "fab fa-twitter" + - title: "LinkedIn" + url: "https://linkedin.com/in/ada-lovelace" + icon: "fab fa-linkedin-in" + - title: "Mastodon" + url: "https://mastodon.social/@ada" + icon: "fab fa-mastodon" + - title: "YouTube" + url: "https://youtube.com/@adalovelace" + icon: "fab fa-youtube" + - title: "Email" + url: "mailto:ada@lovelace.dev" + icon: "fas fa-envelope" + - title: "Matrix" + copy-text: "@ada:matrix.org" + icon: "fas fa-comments" + + # Footer - plain text items + - type: footer + footer: + - text: "© 1843-2025 Ada Lovelace" + - text: "Made with LinkBeam" + +# Notes: +# - You can have multiple blocks of the same type +# - Collection names (links, gaming, socials, footer) can be anything you want +# - Each item must have at least one of: url, copy-text, or text +# - Icons use Font Awesome class names (https://fontawesome.com/icons) +# - Section titles are automatically generated from collection names diff --git a/internal/config/config.go b/internal/config/config.go index c72734c..ee5381e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bc3be0b..5135352 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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") + } + }) } } diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go index 56919fd..497cfaf 100644 --- a/internal/config/validate_test.go +++ b/internal/config/validate_test.go @@ -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 { diff --git a/internal/generator/generator.go b/internal/generator/generator.go index d0fc486..9d0c55d 100644 --- a/internal/generator/generator.go +++ b/internal/generator/generator.go @@ -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() } diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go index 7d05e9b..ce5272e 100644 --- a/internal/generator/generator_test.go +++ b/internal/generator/generator_test.go @@ -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) } diff --git a/static/ada-256x256.png b/static/ada-256x256.png new file mode 100644 index 0000000..71482c6 Binary files /dev/null and b/static/ada-256x256.png differ diff --git a/templates/base.html b/templates/base.html index e436521..9627c5e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -7,44 +7,19 @@ {{ .Name }} - LinkBeam - {{- if .FontAwesomeCDN }} - - {{- else }} - {{- end }} @@ -92,34 +99,156 @@

{{ .Name }}

{{ .Bio }}

-
- -
- {{- if .Socials }} -
- {{- range .Socials }} - - {{- if .Icon }}{{- end }} - {{- end }} -
- {{- end }} - {{- if .Footer }} - {{- end }} + +
Copied to clipboard!