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 36eceb5..1fd4222 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -2,36 +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"
-# font_awesome_cdn: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" # Optional: custom Font Awesome CDN URL (default: Font Awesome 6.5.1)
-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/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 }}
-
-
- {{- range .Links }}
- -
-
- {{- if .Icon }} {{- end }}{{ .Title }}
-
-
+
+ {{- range .Content }}
+ {{- if eq .Type "vertical-list-text" }}
+ {{- range $collectionName, $items := .Collections }}
+ {{- if $collectionName }}
+ {{ $collectionName }}
+ {{- end }}
+
+
+ {{- range $items }}
+ -
+ {{- if .URL }}
+
+ {{- if .Icon }} {{- end }}{{ .Title }}
+
+ {{- else if .CopyText }}
+
+ {{- else if .Text }}
+
+ {{- if .Icon }} {{- end }}{{ .Text }}
+
+ {{- end }}
+
+ {{- end }}
+
+
+ {{- end }}
+ {{- else if eq .Type "horizontal-list-icons" }}
+ {{- range $collectionName, $items := .Collections }}
+ {{- if $collectionName }}
+ {{ $collectionName }}
+ {{- end }}
+
+ {{- range $items }}
+ {{- if .URL }}
+
+ {{- if .Icon }}{{- end }}
+
+ {{- else if .CopyText }}
+
+ {{- end }}
+ {{- end }}
+
+ {{- end }}
+ {{- else if eq .Type "vertical-list-images" }}
+ {{- range $collectionName, $items := .Collections }}
+ {{- if $collectionName }}
+ {{ $collectionName }}
+ {{- end }}
+
+
+ {{- range $items }}
+ -
+
+ {{- if .Image }}
+

+ {{- end }}
+ {{- if .Text }}
+
{{ .Text }}
+ {{- end }}
+
+
+ {{- end }}
+
+
+ {{- end }}
+ {{- else if eq .Type "footer" }}
+ {{- range $collectionName, $items := .Collections }}
+
{{- end }}
-
-
- {{- if .Socials }}
-
- {{- end }}
- {{- if .Footer }}
-
{{- end }}
+
+ Copied to clipboard!