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

This commit is contained in:
2025-10-12 21:56:53 +04:00
commit 20f949c250
42 changed files with 4478 additions and 0 deletions

View 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)
})
}

View 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)
}
}
})
}
}