commit 20f949c2508460e428dfe4f2f31c146c88c447bc Author: Vladimir nett00n Budylnikov Date: Sun Oct 12 21:56:53 2025 +0400 Init commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8e27901 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# Build artifacts +/bin/ +/build/ +/dist/ +linkbeam +*.exe +*.out +*.test + +# Git +.git/ +.gitignore +.gitattributes + +# CI/CD +.gitea/ +.github/ +.gitlab/ +.circleci/ + +# Editor & IDE +.vscode/ +.idea/ +*.swp +*.swo +*.bak +.DS_Store + +# Testing +coverage.out +coverage.html +*.cover +*.cov + +# Go module cache +/vendor/ + +# Environment +.env +.env.local +.venv/ + +# User config (example tracked separately) +config.yaml + +# Documentation (keep lightweight, exclude these) + +.editorconfig +.gitattributes + +# Pre-commit +.pre-commit-config.yaml +.pre-commit-cache/ diff --git a/.gitea/README.md b/.gitea/README.md new file mode 100644 index 0000000..5f040ff --- /dev/null +++ b/.gitea/README.md @@ -0,0 +1,109 @@ +# Gitea CI/CD Configuration + +This directory contains Gitea Actions workflows for automated testing, building, and deployment. + +## Workflows + +### `ci.yml` - Main CI/CD Pipeline + +Runs on every push and pull request: + +1. **Lint Stage** + - golangci-lint with all enabled linters + - go fmt verification + - go vet static analysis + +2. **Test Stage** + - Run all tests with `-race` flag + - Generate coverage reports + - Upload coverage artifacts + +3. **Build Stage** + - Matrix build for multiple platforms: + - Linux: amd64, 386, arm64, armv7 + - Windows: amd64, 386, arm64 + - macOS: amd64, arm64 + - Build with version and commit info in ldflags + - Upload build artifacts + +4. **Docker Stage** + - Multi-architecture Docker build + - Cache optimization with GitHub Actions cache + - Build for: linux/amd64, linux/arm64, linux/arm/v7 + +### `docker-publish.yml` - Docker Registry Publishing + +Triggered on version tags (`v*`): +- Builds multi-arch Docker images +- Pushes to container registry +- Tags with version numbers and `latest` +- Uses registry caching for faster builds + +### `release.yml` - Release Creation + +Triggered on semver tags (`v*.*.*`): +- Creates GitHub/Gitea release +- Uploads platform-specific binaries (tar.gz for Unix, zip for Windows) +- Generates SHA256 checksums +- Includes release notes + +## Configuration Required + +### Secrets + +Set these in your Gitea repository settings: + +- `GITEA_TOKEN` - Gitea API token with package write permissions +- `GITHUB_TOKEN` - Automatically provided by Gitea Actions + +### Registry Configuration + +Update the registry URL in `docker-publish.yml`: +```yaml +env: + REGISTRY: gitea.example.com # Change to your Gitea instance + IMAGE_NAME: ${{ github.repository }} +``` + +## Local Testing + +### Test builds locally: + +```bash +# Test Go build +make build + +# Test multi-platform build +GOOS=linux GOARCH=arm64 go build -o linkbeam-linux-arm64 cmd/linkbeam/main.go + +# Test Docker build (requires Docker) +docker build -t linkbeam:test . + +# Test Docker with compose +docker-compose up +``` + +## Triggering Releases + +To create a new release: + +```bash +# Tag with semantic version +git tag v1.0.0 +git push origin v1.0.0 + +# This will trigger: +# 1. Full CI pipeline +# 2. Docker image build and push +# 3. Release creation with binaries +``` + +## Build Artifacts + +Artifacts are uploaded for each workflow run: +- Coverage reports (test stage) +- Platform binaries (build stage) +- Docker images (docker stage) +- Release assets (release stage) + +Artifacts are available for 90 days after the workflow run. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..abf3ccd --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,218 @@ +name: CI/CD Pipeline + +on: + push: + branches: + - main + - develop + tags: + - 'v*' + pull_request: + branches: + - main + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + args: --timeout=5m + + - name: Run go fmt check + run: | + if [ -n "$(gofmt -l .)" ]; then + echo "Code is not formatted. Run 'go fmt ./...'" + gofmt -l . + exit 1 + fi + + - name: Run go vet + run: go vet ./... + + test: + name: Test + runs-on: ubuntu-latest + needs: lint + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run tests + run: go test -v -race -coverprofile=coverage.out ./... + + - name: Generate coverage report + run: go tool cover -html=coverage.out -o coverage.html + + - name: Upload coverage + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: | + coverage.out + coverage.html + + build: + name: Build + runs-on: ubuntu-latest + needs: test + strategy: + matrix: + include: + # Linux builds + - goos: linux + goarch: amd64 + output: linkbeam-linux-amd64 + - goos: linux + goarch: 386 + output: linkbeam-linux-386 + - goos: linux + goarch: arm64 + output: linkbeam-linux-arm64 + - goos: linux + goarch: arm + goarm: 7 + output: linkbeam-linux-armv7 + + # Windows builds + - goos: windows + goarch: amd64 + output: linkbeam-windows-amd64.exe + - goos: windows + goarch: 386 + output: linkbeam-windows-386.exe + - goos: windows + goarch: arm64 + output: linkbeam-windows-arm64.exe + + # macOS builds + - goos: darwin + goarch: amd64 + output: linkbeam-darwin-amd64 + - goos: darwin + goarch: arm64 + output: linkbeam-darwin-arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + run: | + go build -o dist/${{ matrix.output }} \ + -ldflags="-s -w -X main.Version=${{ github.ref_name }} -X main.Commit=${{ github.sha }}" \ + ./cmd/linkbeam + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.output }} + path: dist/${{ matrix.output }} + + docker: + name: Build Docker Image + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + gitea.example.com/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha + + - name: Build Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Save Docker image + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + run: | + docker save -o linkbeam-docker-image.tar ${{ steps.meta.outputs.tags }} + + - name: Upload Docker image artifact + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: actions/upload-artifact@v4 + with: + name: docker-image + path: linkbeam-docker-image.tar + + release: + name: Create Release + runs-on: ubuntu-latest + needs: [build, docker] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: release-artifacts + + - name: Create checksums + run: | + cd release-artifacts + find . -type f -name "linkbeam-*" -exec sha256sum {} \; > checksums.txt + + - name: Create release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref_name }} + release_name: Release ${{ github.ref_name }} + draft: false + prerelease: false + + - name: Upload release assets + uses: softprops/action-gh-release@v1 + with: + files: | + release-artifacts/**/* + release-artifacts/checksums.txt diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml new file mode 100644 index 0000000..d9af1b4 --- /dev/null +++ b/.gitea/workflows/docker-publish.yml @@ -0,0 +1,61 @@ +name: Docker Publish + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +env: + REGISTRY: gitea.example.com + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + name: Build and Push Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITEA_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max + build-args: | + VERSION=${{ github.ref_name }} + COMMIT=${{ github.sha }} diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..305b480 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,135 @@ +name: Release + +on: + push: + tags: + - 'v*.*.*' + +jobs: + create-release: + name: Create Release + runs-on: ubuntu-latest + outputs: + upload_url: ${{ steps.create_release.outputs.upload_url }} + steps: + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref_name }} + release_name: Release ${{ github.ref_name }} + body: | + ## What's Changed + + See [CHANGELOG.md](./CHANGELOG.md) for details. + + ## Installation + + Download the appropriate binary for your platform below. + + ### Docker + ```bash + docker pull gitea.example.com/${{ github.repository }}:${{ github.ref_name }} + ``` + + ### Verification + Verify your download with the provided SHA256 checksums. + draft: false + prerelease: false + + build-and-upload: + name: Build and Upload Assets + needs: create-release + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: 386 + - goos: linux + goarch: arm64 + - goos: linux + goarch: arm + goarm: 7 + - goos: windows + goarch: amd64 + - goos: windows + goarch: 386 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + GOARM: ${{ matrix.goarm }} + run: | + BINARY_NAME="linkbeam-${{ matrix.goos }}-${{ matrix.goarch }}" + if [ "${{ matrix.goarm }}" != "" ]; then + BINARY_NAME="${BINARY_NAME}v${{ matrix.goarm }}" + fi + if [ "${{ matrix.goos }}" = "windows" ]; then + BINARY_NAME="${BINARY_NAME}.exe" + fi + + go build -o "${BINARY_NAME}" \ + -ldflags="-s -w -X main.Version=${{ github.ref_name }} -X main.Commit=${{ github.sha }}" \ + ./cmd/linkbeam + + # Create tarball (except for Windows) + if [ "${{ matrix.goos }}" != "windows" ]; then + tar -czf "${BINARY_NAME}.tar.gz" "${BINARY_NAME}" + sha256sum "${BINARY_NAME}.tar.gz" > "${BINARY_NAME}.tar.gz.sha256" + rm "${BINARY_NAME}" + else + zip "${BINARY_NAME}.zip" "${BINARY_NAME}" + sha256sum "${BINARY_NAME}.zip" > "${BINARY_NAME}.zip.sha256" + rm "${BINARY_NAME}" + fi + + - name: Upload Release Asset (tar.gz) + if: matrix.goos != 'windows' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ./linkbeam-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }}.tar.gz + asset_name: linkbeam-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }}.tar.gz + asset_content_type: application/gzip + + - name: Upload Release Asset (zip) + if: matrix.goos == 'windows' + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ./linkbeam-${{ matrix.goos }}-${{ matrix.goarch }}.exe.zip + asset_name: linkbeam-${{ matrix.goos }}-${{ matrix.goarch }}.zip + asset_content_type: application/zip + + - name: Upload Checksum + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ needs.create-release.outputs.upload_url }} + asset_path: ./linkbeam-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }}${{ matrix.goos == 'windows' && '.exe.zip.sha256' || '.tar.gz.sha256' }} + asset_name: linkbeam-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.goarm && format('v{0}', matrix.goarm) || '' }}${{ matrix.goos == 'windows' && '.zip.sha256' || '.tar.gz.sha256' }} + asset_content_type: text/plain diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..459710e --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Build artifacts +/dist/ +linkbeam +*.exe +*.out +*.test + +# Test coverage +coverage.out +coverage.html +*.cover +*.cov + +# Editor & IDE +.vscode/ +.idea/ +*.swp +*.swo +*.bak +.DS_Store + +# Testing +coverage.out +coverage.html +*.cover +*.cov + +# Go module cache +/vendor/ + +# Environment +.env +.env.local +.venv/ + +# User config (example file is tracked) +config.yaml + +# Pre-commit +.pre-commit-cache/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..326ee76 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,28 @@ +# golangci-lint configuration +# See https://golangci-lint.run/usage/configuration/ + +version: 2 + +linters-settings: + govet: + enable-all: true + +linters: + # Use default linters: errcheck, govet, ineffassign, staticcheck, unused + enable: + - errcheck # Check for unchecked errors (enabled by default) + - govet # Examines Go source code (enabled by default) + - ineffassign # Detect ineffectual assignments (enabled by default) + - staticcheck # Go static analysis (enabled by default) + - unused # Check for unused code (enabled by default) + - gocritic # Additional diagnostics for bugs, performance, and style + +run: + timeout: 5m + tests: true + skip-dirs: + - vendor + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..458f0dc --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,63 @@ +# Pre-commit hooks for Go project +# See https://pre-commit.com for more information + +repos: + # Go-specific hooks + - repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.1 + hooks: + # Format code + - id: go-fmt + args: [-w] + + # Organize imports + - id: go-imports + args: [-w] + + # Examine Go source code and report suspicious constructs + - id: go-vet + + # Run tests + - id: go-test-mod + - id: go-test-pkg + - id: go-test-repo-mod + - id: go-test-repo-pkg + + # Tidy go.mod and go.sum + - id: go-mod-tidy + + # Run golangci-lint (comprehensive linter) + - id: golangci-lint + args: [--fast] + + # General hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + # Prevent giant files from being committed + - id: check-added-large-files + args: ['--maxkb=1000'] + + # Check for files that would conflict in case-insensitive filesystems + - id: check-case-conflict + + # Check for merge conflicts + - id: check-merge-conflict + + # Check YAML syntax + - id: check-yaml + + # Ensure files end with newline + - id: end-of-file-fixer + exclude: '\.html$' + + # Trim trailing whitespace + - id: trailing-whitespace + args: [--markdown-linebreak-ext=md] + + # Detect private keys + - id: detect-private-key + + # Don't commit directly to main + - id: no-commit-to-branch + args: ['--branch', 'main', '--branch', 'master'] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1e7d29a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,303 @@ +# Contributing to LinkBeam + +Thank you for your interest in contributing to LinkBeam! This document provides guidelines and instructions for development. + +## Development Setup + +### Prerequisites + +- Go 1.21 or later +- Make (optional, for using Makefile commands) +- golangci-lint (for code quality checks) +- pre-commit (optional, for automated checks) + +### Getting Started + +1. Fork and clone the repository: + +```bash +git clone https://github.com/5mdt/linkbeam.git +cd linkbeam +``` + +2. Install dependencies: + +```bash +go mod download +``` + +3. Build the project: + +```bash +make build +# or: go build -o dist/linkbeam cmd/linkbeam/main.go +``` + +## Pre-commit Hooks + +This project uses pre-commit hooks to ensure code quality. Setting them up is highly recommended. + +### Installation + +```bash +# Install pre-commit (if not already installed) +pip install pre-commit +# or: brew install pre-commit (on macOS) + +# Install golangci-lint (if not already installed) +# See: https://golangci-lint.run/usage/install/ +go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +# Install the git hook scripts +pre-commit install +# or: make install-hooks +``` + +### Running Hooks + +```bash +# Run against all files +pre-commit run --all-files +# or: make pre-commit + +# Hooks will automatically run on git commit +git commit -m "your message" +``` + +The pre-commit hooks check: +- Go code formatting (`go fmt`) +- Import organization (`goimports`) +- Code quality (`go vet`, `golangci-lint`) +- Tests (`go test`) +- Module tidiness (`go mod tidy`) +- YAML syntax +- Trailing whitespace +- End of file fixes +- And more + +**Note**: The pre-commit framework will automatically download required Go tools on first run if they're not already installed. + +## Development Commands + +### Building + +```bash +# Build binary (outputs to dist/linkbeam) +make build +# or: go build -o dist/linkbeam cmd/linkbeam/main.go +``` + +### Running + +```bash +# Run with example config +make run-example +# or: go run cmd/linkbeam/main.go --config internal/config/testdata/config.yaml + +# Run with custom config (long flags) +./linkbeam --config config.yaml --template templates/base.html --output dist/index.html + +# Run with custom config (short flags) +./linkbeam -c config.yaml -t templates/base.html -o dist/index.html +``` + +### Testing + +```bash +# Run all tests +make test +# or: go test -v ./... + +# Run tests for a specific package +go test ./internal/config +go test ./internal/generator + +# Run a single test function +go test -v -run TestLoad ./internal/config + +# Run tests with coverage +go test -cover ./... +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +### Code Quality + +```bash +# Format code +go fmt ./... +# or: make fmt + +# Run go vet +go vet ./... + +# Run golangci-lint +golangci-lint run +# or: make vet + +# Run all quality checks +make pre-commit +``` + +### Cleaning + +```bash +# Clean build artifacts +make clean +``` + +## Project Structure + +``` +linkbeam/ +├── cmd/linkbeam/ # Main application entry point +│ ├── main.go +│ └── main_test.go +├── internal/ +│ ├── config/ # Configuration loading and validation +│ │ ├── config.go +│ │ ├── config_test.go +│ │ └── testdata/ +│ └── generator/ # Site generation and rendering +│ ├── generator.go +│ └── generator_test.go +├── templates/ # HTML templates +│ └── base.html +├── themes/ # CSS theme files +│ ├── light.css +│ └── dark.css +├── dist/ # Build output: binary and generated site (gitignored) +├── .gitea/ # CI/CD workflows +│ └── workflows/ +├── Makefile # Build automation +├── go.mod # Go module definition +└── go.sum # Go dependencies +``` + +## Architecture + +### Two-Layer Structure + +1. **Config Layer** (`internal/config/`): + - Loads and validates YAML configuration files + - Defines data structures: `Config`, `Link`, `Social` + - Validates theme (must be "light" or "dark") and required fields + - Config validation happens automatically during `config.Load()` + +2. **Generator Layer** (`internal/generator/`): + - **HTML Generation**: Orchestrates template rendering using Go's `html/template` + - `GenerateSite(cfg, templatePath, outPath)` - Creates HTML from templates + - **Asset Management**: Copies CSS themes and static files to output directory + - `CopyAssets(distDir, themeDirs...)` - Copies theme files to dist/themes/ + - **Text Rendering**: Plain-text representation for debugging + - `RenderUserPage(cfg)` - Returns text-based preview of the page + +### Testing Strategy + +- Tests use table-driven patterns and dependency injection +- `cmd/linkbeam/main_test.go`: Mocks the config loader and captures stdout +- Config tests validate YAML parsing and business rules +- Generator tests verify file creation and template execution +- All tests use the testdata in `internal/config/testdata/config.yaml` + +## Making Changes + +### Adding New Features + +1. Create a new branch for your feature: +```bash +git checkout -b feature/your-feature-name +``` + +2. Write tests first (TDD approach recommended) +3. Implement the feature +4. Ensure all tests pass: `go test ./...` +5. Run pre-commit hooks: `pre-commit run --all-files` +6. Commit your changes with a descriptive message + +### Code Style + +- Follow standard Go conventions and idioms +- Use `gofmt` for formatting (handled by pre-commit) +- Write clear, descriptive variable and function names +- Add comments for exported functions and types +- Keep functions focused and concise + +### Testing Guidelines + +- Write tests for all new functionality +- Aim for high test coverage +- Use table-driven tests for multiple test cases +- Use testdata files for fixtures +- Test both success and error cases + +### Commit Messages + +- Use clear, descriptive commit messages +- Start with a verb in present tense (e.g., "Add", "Fix", "Update") +- Reference issue numbers when applicable +- Keep the first line under 72 characters + +Examples: +``` +Add support for custom CSS themes +Fix avatar path resolution in config loader +Update template to include meta tags +``` + +## CI/CD Pipeline + +The project uses Gitea Actions for continuous integration and deployment. + +### Workflows + +1. **CI Pipeline** (`.gitea/workflows/ci.yml`): + - Runs on push to main/develop and pull requests + - **Lint**: golangci-lint, go fmt, go vet + - **Test**: Run tests with race detection and coverage + - **Build**: Multi-platform builds (Linux, Windows, macOS) for multiple architectures + - **Docker**: Build multi-arch Docker images + +2. **Docker Publish** (`.gitea/workflows/docker-publish.yml`): + - Triggered on version tags (v*) + - Builds and pushes Docker images to container registry + - Multi-architecture: linux/amd64, linux/arm64, linux/arm/v7 + +3. **Release** (`.gitea/workflows/release.yml`): + - Triggered on semver tags (v*.*.*) + - Creates releases with binaries for all platforms + - Generates SHA256 checksums for verification + +### Local Testing with Docker + +```bash +# Test Docker build +docker build -t linkbeam:test . + +# Run Docker container +docker run -v $(pwd)/config.yaml:/app/config/config.yaml \ + -v $(pwd)/dist:/app/dist \ + linkbeam:test + +# Test with docker-compose +docker-compose up +``` + +## Submitting Changes + +1. Ensure your code passes all tests and checks +2. Push your branch to your fork +3. Open a pull request with a clear description of your changes +4. Wait for CI checks to pass +5. Respond to any review feedback + +## Questions or Issues? + +If you have questions or run into issues: +- Check existing issues on the repository +- Open a new issue with a clear description +- Join our community discussions (if available) + +## License + +By contributing to LinkBeam, you agree that your contributions will be licensed under the GNU General Public License v3.0. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..838cf1d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +# Install build dependencies +RUN apk add --no-cache git make + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build \ + -a -installsuffix cgo \ + -ldflags="-s -w -X main.Version=${VERSION:-dev} -X main.Commit=${COMMIT:-unknown}" \ + -o linkbeam \ + ./cmd/linkbeam + +# Final stage +FROM alpine:latest + +# Install ca-certificates for HTTPS +RUN apk --no-cache add ca-certificates + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/linkbeam /app/linkbeam + +# Copy templates and themes +COPY templates /app/templates +COPY themes /app/themes + +# Create config directory +RUN mkdir -p /app/config + +# Expose port (if needed for future HTTP server) +EXPOSE 8080 + +# Set default config path +ENV CONFIG_PATH=/app/config/config.yaml + +# Run as non-root user +RUN addgroup -g 1000 linkbeam && \ + adduser -D -u 1000 -G linkbeam linkbeam && \ + chown -R linkbeam:linkbeam /app + +USER linkbeam + +ENTRYPOINT ["/app/linkbeam"] +CMD ["--config", "/app/config/config.yaml", "--output", "/app/dist/index.html"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..94a9ed0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ee09154 --- /dev/null +++ b/Makefile @@ -0,0 +1,64 @@ +.PHONY: build test run clean help + +# Build the linkbeam binary +build: + @mkdir -p dist + go build -o dist/linkbeam cmd/linkbeam/main.go + @echo "Binary built at: dist/linkbeam" + +# Run all tests +test: + go test -v ./... + +# Run with default config +run: + go run cmd/linkbeam/main.go + +# Run with custom config +run-example: + go run cmd/linkbeam/main.go --config internal/config/testdata/config.yaml + +# Clean build artifacts +clean: + rm -rf dist/ + rm -f linkbeam + +# Run tests with coverage +test-coverage: + go test -v -cover ./... + +# Format code +fmt: + go fmt ./... + +# Run go vet +vet: + go vet ./... + +# Run linter +lint: + golangci-lint run + +# Install pre-commit hooks +install-hooks: + pre-commit install + +# Run pre-commit checks on all files +pre-commit: + pre-commit run --all-files + +# Show help +help: + @echo "LinkBeam Makefile Commands:" + @echo " make build - Build the linkbeam binary" + @echo " make test - Run all tests" + @echo " make run - Run with default config.yaml" + @echo " make run-example - Run with example config" + @echo " make clean - Remove build artifacts" + @echo " make test-coverage - Run tests with coverage" + @echo " make fmt - Format code" + @echo " make vet - Run go vet" + @echo " make lint - Run golangci-lint" + @echo " make install-hooks - Install pre-commit hooks" + @echo " make pre-commit - Run pre-commit on all files" + @echo " make help - Show this help message" diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ae5970 --- /dev/null +++ b/README.md @@ -0,0 +1,162 @@ +# LinkBeam + +![logo](./static/logo.png) + +A lightweight static site generator for creating beautiful personal link-in-bio pages. +Built with Go, LinkBeam transforms a simple YAML configuration into a themed HTML page. + +## Features +![Screenshot](./assets/Screenshot.png) +- Simple YAML-based configuration - Define your links and profile in a single YAML file +- Multiple built-in themes - Auto, Nord, Gruvbox, and Catppuccin variants (Latte, Frappé, Macchiato, Mocha) +- Theme switcher - Users can toggle between system/light/dark modes +- Font Awesome icons - Full support for Font Awesome icons with customizable CDN +- Social media links - Dedicated section for social profiles with icon-only display +- Responsive design - Works beautifully on mobile, tablet, and desktop +- Fast static site generation - Built with Go for blazing-fast builds +- Clean, minimal aesthetics - Focus on your content, not clutter +- [Catppuccin](https://catppuccin.com/), [gruvbox](https://github.com/morhetz/gruvbox) and [nord](https://www.nordtheme.com/) color palletes available by default + +## Installation + +### From Source + +```bash +# Clone the repository +git clone https://github.com/5mdt/linkbeam.git +cd linkbeam + +# Build the binary +make build +``` + +Binary will be output to `dist/linkbeam`. + +### From Docker + +```bash +# Build the image locally (Docker images not published yet) +docker build -t linkbeam . + +# Run with your config +docker run -v $(pwd)/config.yaml:/app/config/config.yaml \ + -v $(pwd)/dist:/app/dist \ + linkbeam + +# Or using docker-compose +docker-compose up +``` + +## Quick Start + +1. Create a `config.yaml` file (see `config.example.yaml` for reference): + +```yaml +name: "Your Name" +bio: "A short bio about yourself" +avatar: "static/logo.png" # Can be a local path or URL +theme: "auto" # Options: auto, nord, gruvbox, catppuccin-mocha, catppuccin-latte, etc. + +# Optional: Customize Font Awesome CDN (defaults to Font Awesome 6.5.1 if not specified) +# font_awesome_cdn: "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" + +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" + +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" + +footer: + - text: "© 2025 Your Name" + - text: "Made with LinkBeam" +``` + +2. Generate your site: + +```bash +./linkbeam +``` + +3. Open `dist/index.html` in your browser + +## Usage + +```bash +./linkbeam [options] + +Options: + -c, --config string path to config YAML file (default "config.yaml") + -t, --template string path to HTML template (default "templates/base.html") + -o, --output string path to output HTML file (default "dist/index.html") + -v, --version print version information +``` + +### Examples + +```bash +# Generate with default config.yaml +./linkbeam + +# Check version +./linkbeam -v + +# Generate with custom config +./linkbeam -c myconfig.yaml -o public/index.html +``` + +## Configuration + +### Required Fields + +- `name`: Your display name (cannot be empty) + +### Optional Fields + +- `bio`: A short description about yourself +- `avatar`: Path to your avatar/profile image (local path or URL starting with `http://` or `https://`) +- `theme`: Theme name (default: `auto`) + - Available themes: `auto`, `nord`, `gruvbox`, `catppuccin-mocha`, `catppuccin-latte`, `catppuccin-frappe`, `catppuccin-macchiato` + - The `auto` theme follows system preferences and allows users to toggle between system/light/dark modes +- `font_awesome_cdn`: Custom Font Awesome CDN URL (defaults to Font Awesome 6.5.1 if not specified) +- `links`: Array of link objects + - `title`: Link text (required) + - `url`: Link destination (required) + - `icon`: Font Awesome icon class (optional, e.g., `fas fa-globe`, `fab fa-github`) +- `socials`: Array of social media links (displayed as icon-only buttons) + - `platform`: Platform name (used for accessibility) + - `url`: Social media profile URL + - `icon`: Font Awesome icon class (e.g., `fab fa-twitter`, `fab fa-linkedin`) +- `footer`: Array of footer text blocks + - `text`: Footer text content + +### Theme Details + +All themes support light/dark mode switching: +- **auto**: Minimalist monochrome theme with system preference detection +- **nord**: Arctic-inspired color palette (Nord theme) +- **gruvbox**: Warm retro groove colors (Gruvbox theme) +- **catppuccin-***: Soothing pastel themes (Catppuccin Latte, Frappé, Macchiato, Mocha) + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, testing, and contribution guidelines. + +## License + +This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details. diff --git a/assets/Screenshot.png b/assets/Screenshot.png new file mode 100644 index 0000000..e6e9cf8 Binary files /dev/null and b/assets/Screenshot.png differ diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..9570820 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,36 @@ +# config.example.yaml +# 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" + +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" + +footer: + - text: "© 2025 Your Name" + - text: "Made with LinkBeam" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b703ffc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,26 @@ +--- + +services: + app: + image: docker.io/5mdt/linkbeam:latest + build: . + volumes: + # Mount your config file + - ./config.yaml:/app/config/config.yaml:ro + # Mount output directory + - ./dist:/app/dist + # Optional: mount custom templates + - ./templates:/app/templates:ro + # Optional: mount custom themes + - ./themes:/app/themes:ro + environment: + - CONFIG_PATH=${CONFIG_PATH:-/app/config/config.yaml} + restart: "no" + + nginx: + image: nginx:alpine + ports: + - ${PUBLIC_PORT:-"80:80"} + volumes: + - ./dist:/usr/share/nginx/html:ro + restart: unless-stopped diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b2cc072 --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module linkbeam + +go 1.21 + +require gopkg.in/yaml.v3 v3.0.1 + +require github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bea0ee8 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c72734c --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..bc3be0b --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/config/load.go b/internal/config/load.go new file mode 100644 index 0000000..679f63a --- /dev/null +++ b/internal/config/load.go @@ -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() +} diff --git a/internal/config/load_test.go b/internal/config/load_test.go new file mode 100644 index 0000000..7549e80 --- /dev/null +++ b/internal/config/load_test.go @@ -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") + } +} diff --git a/internal/config/testdata/config-catppuccin-frappe.yaml b/internal/config/testdata/config-catppuccin-frappe.yaml new file mode 100644 index 0000000..5d481a5 --- /dev/null +++ b/internal/config/testdata/config-catppuccin-frappe.yaml @@ -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" diff --git a/internal/config/testdata/config-catppuccin-latte.yaml b/internal/config/testdata/config-catppuccin-latte.yaml new file mode 100644 index 0000000..a5ac063 --- /dev/null +++ b/internal/config/testdata/config-catppuccin-latte.yaml @@ -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" diff --git a/internal/config/testdata/config-catppuccin-macchiato.yaml b/internal/config/testdata/config-catppuccin-macchiato.yaml new file mode 100644 index 0000000..df2b8da --- /dev/null +++ b/internal/config/testdata/config-catppuccin-macchiato.yaml @@ -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" diff --git a/internal/config/testdata/config-catppuccin-mocha.yaml b/internal/config/testdata/config-catppuccin-mocha.yaml new file mode 100644 index 0000000..6c0e544 --- /dev/null +++ b/internal/config/testdata/config-catppuccin-mocha.yaml @@ -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" diff --git a/internal/config/testdata/config-gruvbox.yaml b/internal/config/testdata/config-gruvbox.yaml new file mode 100644 index 0000000..daf9fac --- /dev/null +++ b/internal/config/testdata/config-gruvbox.yaml @@ -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" diff --git a/internal/config/testdata/config-invalid-theme.yaml b/internal/config/testdata/config-invalid-theme.yaml new file mode 100644 index 0000000..ddd17a2 --- /dev/null +++ b/internal/config/testdata/config-invalid-theme.yaml @@ -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" diff --git a/internal/config/testdata/config-nord.yaml b/internal/config/testdata/config-nord.yaml new file mode 100644 index 0000000..4794c29 --- /dev/null +++ b/internal/config/testdata/config-nord.yaml @@ -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" diff --git a/internal/config/validate_test.go b/internal/config/validate_test.go new file mode 100644 index 0000000..56919fd --- /dev/null +++ b/internal/config/validate_test.go @@ -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)) + } +} diff --git a/internal/generator/generator.go b/internal/generator/generator.go new file mode 100644 index 0000000..d0fc486 --- /dev/null +++ b/internal/generator/generator.go @@ -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) + }) +} diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go new file mode 100644 index 0000000..7d05e9b --- /dev/null +++ b/internal/generator/generator_test.go @@ -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) + } + } + }) + } +} diff --git a/static/favicon.png b/static/favicon.png new file mode 100644 index 0000000..e52baec Binary files /dev/null and b/static/favicon.png differ diff --git a/static/logo.png b/static/logo.png new file mode 100644 index 0000000..7fb7e99 Binary files /dev/null and b/static/logo.png differ diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..e436521 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,189 @@ + + + + + + + {{ .Name }} - LinkBeam + + + {{- if .FontAwesomeCDN }} + + {{- else }} + + {{- end }} + + + + +
+ {{- if .Avatar }} + {{ .Name }} + {{- end }} +

{{ .Name }}

+

{{ .Bio }}

+
+
+ +
+ {{- if .Socials }} +
+ {{- range .Socials }} + + {{- if .Icon }}{{- end }} + + {{- end }} +
+ {{- end }} + {{- if .Footer }} +
+ {{- range .Footer }} +

{{ .Text }}

+ {{- end }} +
+ {{- end }} + + + diff --git a/themes/auto.css b/themes/auto.css new file mode 100644 index 0000000..e4e548f --- /dev/null +++ b/themes/auto.css @@ -0,0 +1,124 @@ +/* auto.css - Super-simple monochrome theme for LinkBeam */ + +/* Default: Light mode */ +:root { + --bg-color: #ffffff; + --text-color: #000000; + --text-secondary: #666666; + --link-bg: #f5f5f5; + --link-hover: #e0e0e0; + --border-color: #cccccc; +} + +/* System dark mode preference */ +@media (prefers-color-scheme: dark) { + :root { + --bg-color: #000000; + --text-color: #ffffff; + --text-secondary: #999999; + --link-bg: #1a1a1a; + --link-hover: #2a2a2a; + --border-color: #333333; + } +} + +/* Manual overrides */ +html[data-theme="light"] { + --bg-color: #ffffff; + --text-color: #000000; + --text-secondary: #666666; + --link-bg: #f5f5f5; + --link-hover: #e0e0e0; + --border-color: #cccccc; +} + +html[data-theme="dark"] { + --bg-color: #000000; + --text-color: #ffffff; + --text-secondary: #999999; + --link-bg: #1a1a1a; + --link-hover: #2a2a2a; + --border-color: #333333; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: sans-serif; + background: var(--bg-color); + color: var(--text-color); + line-height: 1.5; +} + +header { + text-align: center; + padding: 2rem 1rem; + max-width: 600px; + margin: 0 auto; +} + +header img { + border: 1px solid var(--border-color); +} + +header h1 { + margin: 1rem 0 0.5rem; + font-size: 1.5rem; + color: var(--text-color); +} + +header p { + margin: 0.5rem 0; + color: var(--text-secondary); +} + +main { + max-width: 600px; + margin: 0 auto; + padding: 1rem; +} + +main ul { + list-style: none; + padding: 0; + margin: 0; +} + +main li { + margin-bottom: 0.5rem; +} + +main a { + display: block; + padding: 1rem; + background-color: var(--link-bg); + border: 1px solid var(--border-color); + text-decoration: none; + color: var(--text-color); + text-align: center; +} + +main a:hover { + background-color: var(--link-hover); +} + +main a i { + margin-right: 0.5rem; +} + +footer { + text-align: center; + padding: 2rem 1rem; + max-width: 600px; + margin: 0 auto; +} + +footer p { + margin: 0.5rem 0; + color: var(--text-secondary); + font-size: 0.9rem; +} diff --git a/themes/catppuccin-frappe.css b/themes/catppuccin-frappe.css new file mode 100644 index 0000000..bd81097 --- /dev/null +++ b/themes/catppuccin-frappe.css @@ -0,0 +1,138 @@ +/* catppuccin-frappe.css - Catppuccin Frappé theme for LinkBeam (with light/dark toggle) */ +/* Based on: https://github.com/catppuccin/catppuccin */ + +/* Default: Latte (light) */ +:root { + --bg-color: #eff1f5; + --text-color: #4c4f69; + --text-secondary: #6c6f85; + --link-bg: #e6e9ef; + --link-hover: #dce0e8; + --border-color: #ccd0da; + --accent-color: #7287fd; +} + +/* System dark mode: Switch to Frappé */ +@media (prefers-color-scheme: dark) { + :root { + --bg-color: #303446; + --text-color: #c6d0f5; + --text-secondary: #a5adce; + --link-bg: #414559; + --link-hover: #51576d; + --border-color: #626880; + --accent-color: #babbf1; + } +} + +/* Manual overrides */ +html[data-theme="light"] { + --bg-color: #eff1f5; + --text-color: #4c4f69; + --text-secondary: #6c6f85; + --link-bg: #e6e9ef; + --link-hover: #dce0e8; + --border-color: #ccd0da; + --accent-color: #7287fd; +} + +html[data-theme="dark"] { + --bg-color: #303446; + --text-color: #c6d0f5; + --text-secondary: #a5adce; + --link-bg: #414559; + --link-hover: #51576d; + --border-color: #626880; + --accent-color: #babbf1; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-color); + color: var(--text-color); + line-height: 1.6; + min-height: 100vh; +} + +header { + text-align: center; + padding: 3rem 1rem 2rem; + max-width: 600px; + margin: 0 auto; +} + +header img { + border-radius: 50%; + border: 3px solid var(--border-color); + margin-bottom: 1.5rem; +} + +header h1 { + margin: 0.5rem 0; + font-size: 2rem; + font-weight: 700; + color: var(--accent-color); +} + +header p { + margin: 0.75rem 0; + color: var(--text-secondary); +} + +main { + max-width: 600px; + margin: 0 auto; + padding: 1rem 1rem 3rem; +} + +main ul { + list-style: none; + padding: 0; + margin: 0; +} + +main li { + margin-bottom: 1rem; +} + +main a { + display: block; + padding: 1rem 1.5rem; + background-color: var(--link-bg); + border: 2px solid var(--border-color); + border-radius: 8px; + text-decoration: none; + color: var(--text-color); + font-weight: 600; + text-align: center; + transition: all 0.2s ease; +} + +main a:hover { + background-color: var(--link-hover); + border-color: var(--accent-color); + transform: translateY(-2px); +} + +main a i { + margin-right: 0.5rem; +} + +footer { + text-align: center; + padding: 2rem 1rem 3rem; + max-width: 600px; + margin: 0 auto; +} + +footer p { + margin: 0.5rem 0; + color: var(--text-secondary); + font-size: 0.9rem; +} diff --git a/themes/catppuccin-latte.css b/themes/catppuccin-latte.css new file mode 100644 index 0000000..9d12dc5 --- /dev/null +++ b/themes/catppuccin-latte.css @@ -0,0 +1,138 @@ +/* catppuccin-latte.css - Catppuccin Latte theme for LinkBeam (with light/dark toggle) */ +/* Based on: https://github.com/catppuccin/catppuccin */ + +/* Default: Latte (light) */ +:root { + --bg-color: #eff1f5; + --text-color: #4c4f69; + --text-secondary: #6c6f85; + --link-bg: #e6e9ef; + --link-hover: #dce0e8; + --border-color: #ccd0da; + --accent-color: #7287fd; +} + +/* System dark mode: Switch to Mocha */ +@media (prefers-color-scheme: dark) { + :root { + --bg-color: #1e1e2e; + --text-color: #cdd6f4; + --text-secondary: #bac2de; + --link-bg: #313244; + --link-hover: #45475a; + --border-color: #585b70; + --accent-color: #b4befe; + } +} + +/* Manual overrides */ +html[data-theme="light"] { + --bg-color: #eff1f5; + --text-color: #4c4f69; + --text-secondary: #6c6f85; + --link-bg: #e6e9ef; + --link-hover: #dce0e8; + --border-color: #ccd0da; + --accent-color: #7287fd; +} + +html[data-theme="dark"] { + --bg-color: #1e1e2e; + --text-color: #cdd6f4; + --text-secondary: #bac2de; + --link-bg: #313244; + --link-hover: #45475a; + --border-color: #585b70; + --accent-color: #b4befe; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-color); + color: var(--text-color); + line-height: 1.6; + min-height: 100vh; +} + +header { + text-align: center; + padding: 3rem 1rem 2rem; + max-width: 600px; + margin: 0 auto; +} + +header img { + border-radius: 50%; + border: 3px solid var(--border-color); + margin-bottom: 1.5rem; +} + +header h1 { + margin: 0.5rem 0; + font-size: 2rem; + font-weight: 700; + color: var(--accent-color); +} + +header p { + margin: 0.75rem 0; + color: var(--text-secondary); +} + +main { + max-width: 600px; + margin: 0 auto; + padding: 1rem 1rem 3rem; +} + +main ul { + list-style: none; + padding: 0; + margin: 0; +} + +main li { + margin-bottom: 1rem; +} + +main a { + display: block; + padding: 1rem 1.5rem; + background-color: var(--link-bg); + border: 2px solid var(--border-color); + border-radius: 8px; + text-decoration: none; + color: var(--text-color); + font-weight: 600; + text-align: center; + transition: all 0.2s ease; +} + +main a:hover { + background-color: var(--link-hover); + border-color: var(--accent-color); + transform: translateY(-2px); +} + +main a i { + margin-right: 0.5rem; +} + +footer { + text-align: center; + padding: 2rem 1rem 3rem; + max-width: 600px; + margin: 0 auto; +} + +footer p { + margin: 0.5rem 0; + color: var(--text-secondary); + font-size: 0.9rem; +} diff --git a/themes/catppuccin-macchiato.css b/themes/catppuccin-macchiato.css new file mode 100644 index 0000000..5bbf2fb --- /dev/null +++ b/themes/catppuccin-macchiato.css @@ -0,0 +1,138 @@ +/* catppuccin-macchiato.css - Catppuccin Macchiato theme for LinkBeam (with light/dark toggle) */ +/* Based on: https://github.com/catppuccin/catppuccin */ + +/* Default: Latte (light) */ +:root { + --bg-color: #eff1f5; + --text-color: #4c4f69; + --text-secondary: #6c6f85; + --link-bg: #e6e9ef; + --link-hover: #dce0e8; + --border-color: #ccd0da; + --accent-color: #7287fd; +} + +/* System dark mode: Switch to Macchiato */ +@media (prefers-color-scheme: dark) { + :root { + --bg-color: #24273a; + --text-color: #cad3f5; + --text-secondary: #a5adcb; + --link-bg: #363a4f; + --link-hover: #494d64; + --border-color: #5b6078; + --accent-color: #b7bdf8; + } +} + +/* Manual overrides */ +html[data-theme="light"] { + --bg-color: #eff1f5; + --text-color: #4c4f69; + --text-secondary: #6c6f85; + --link-bg: #e6e9ef; + --link-hover: #dce0e8; + --border-color: #ccd0da; + --accent-color: #7287fd; +} + +html[data-theme="dark"] { + --bg-color: #24273a; + --text-color: #cad3f5; + --text-secondary: #a5adcb; + --link-bg: #363a4f; + --link-hover: #494d64; + --border-color: #5b6078; + --accent-color: #b7bdf8; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-color); + color: var(--text-color); + line-height: 1.6; + min-height: 100vh; +} + +header { + text-align: center; + padding: 3rem 1rem 2rem; + max-width: 600px; + margin: 0 auto; +} + +header img { + border-radius: 50%; + border: 3px solid var(--border-color); + margin-bottom: 1.5rem; +} + +header h1 { + margin: 0.5rem 0; + font-size: 2rem; + font-weight: 700; + color: var(--accent-color); +} + +header p { + margin: 0.75rem 0; + color: var(--text-secondary); +} + +main { + max-width: 600px; + margin: 0 auto; + padding: 1rem 1rem 3rem; +} + +main ul { + list-style: none; + padding: 0; + margin: 0; +} + +main li { + margin-bottom: 1rem; +} + +main a { + display: block; + padding: 1rem 1.5rem; + background-color: var(--link-bg); + border: 2px solid var(--border-color); + border-radius: 8px; + text-decoration: none; + color: var(--text-color); + font-weight: 600; + text-align: center; + transition: all 0.2s ease; +} + +main a:hover { + background-color: var(--link-hover); + border-color: var(--accent-color); + transform: translateY(-2px); +} + +main a i { + margin-right: 0.5rem; +} + +footer { + text-align: center; + padding: 2rem 1rem 3rem; + max-width: 600px; + margin: 0 auto; +} + +footer p { + margin: 0.5rem 0; + color: var(--text-secondary); + font-size: 0.9rem; +} diff --git a/themes/catppuccin-mocha.css b/themes/catppuccin-mocha.css new file mode 100644 index 0000000..62e71c8 --- /dev/null +++ b/themes/catppuccin-mocha.css @@ -0,0 +1,138 @@ +/* catppuccin-mocha.css - Catppuccin Mocha theme for LinkBeam (with light/dark toggle) */ +/* Based on: https://github.com/catppuccin/catppuccin */ + +/* Default: Latte (light) */ +:root { + --bg-color: #eff1f5; + --text-color: #4c4f69; + --text-secondary: #6c6f85; + --link-bg: #e6e9ef; + --link-hover: #dce0e8; + --border-color: #ccd0da; + --accent-color: #7287fd; +} + +/* System dark mode: Switch to Mocha */ +@media (prefers-color-scheme: dark) { + :root { + --bg-color: #1e1e2e; + --text-color: #cdd6f4; + --text-secondary: #bac2de; + --link-bg: #313244; + --link-hover: #45475a; + --border-color: #585b70; + --accent-color: #b4befe; + } +} + +/* Manual overrides */ +html[data-theme="light"] { + --bg-color: #eff1f5; + --text-color: #4c4f69; + --text-secondary: #6c6f85; + --link-bg: #e6e9ef; + --link-hover: #dce0e8; + --border-color: #ccd0da; + --accent-color: #7287fd; +} + +html[data-theme="dark"] { + --bg-color: #1e1e2e; + --text-color: #cdd6f4; + --text-secondary: #bac2de; + --link-bg: #313244; + --link-hover: #45475a; + --border-color: #585b70; + --accent-color: #b4befe; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg-color); + color: var(--text-color); + line-height: 1.6; + min-height: 100vh; +} + +header { + text-align: center; + padding: 3rem 1rem 2rem; + max-width: 600px; + margin: 0 auto; +} + +header img { + border-radius: 50%; + border: 3px solid var(--border-color); + margin-bottom: 1.5rem; +} + +header h1 { + margin: 0.5rem 0; + font-size: 2rem; + font-weight: 700; + color: var(--accent-color); +} + +header p { + margin: 0.75rem 0; + color: var(--text-secondary); +} + +main { + max-width: 600px; + margin: 0 auto; + padding: 1rem 1rem 3rem; +} + +main ul { + list-style: none; + padding: 0; + margin: 0; +} + +main li { + margin-bottom: 1rem; +} + +main a { + display: block; + padding: 1rem 1.5rem; + background-color: var(--link-bg); + border: 2px solid var(--border-color); + border-radius: 8px; + text-decoration: none; + color: var(--text-color); + font-weight: 600; + text-align: center; + transition: all 0.2s ease; +} + +main a:hover { + background-color: var(--link-hover); + border-color: var(--accent-color); + transform: translateY(-2px); +} + +main a i { + margin-right: 0.5rem; +} + +footer { + text-align: center; + padding: 2rem 1rem 3rem; + max-width: 600px; + margin: 0 auto; +} + +footer p { + margin: 0.5rem 0; + color: var(--text-secondary); + font-size: 0.9rem; +} diff --git a/themes/gruvbox.css b/themes/gruvbox.css new file mode 100644 index 0000000..00be801 --- /dev/null +++ b/themes/gruvbox.css @@ -0,0 +1,247 @@ +/* gruvbox.css - Gruvbox theme for LinkBeam (Automatic light/dark) */ +/* Based on the Gruvbox color scheme: https://github.com/morhetz/gruvbox */ + +/* Default: Light mode */ +:root { + /* Gruvbox Light background colors */ + --bg-color: #fbf1c7; + --text-color: #3c3836; + --text-secondary: #504945; + --link-bg: #f9f5d7; + --link-hover: #f2e5bc; + --border-color: #d5c4a1; + + /* Gruvbox accent colors (green/aqua tones) */ + --accent-color: #689d6a; + --accent-light: #98971a; + + /* Shadows with warm tones for Gruvbox Light */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); + + /* Gradients using Gruvbox light shades */ + --bg-gradient-start: #fbf1c7; + --bg-gradient-end: #f9f5d7; + + /* Glow effects using Gruvbox green/aqua colors */ + --glow-color: rgba(104, 157, 106, 0.3); + --glow-hover: rgba(104, 157, 106, 0.2); + --shimmer: rgba(104, 157, 106, 0.1); +} + +/* System dark mode preference */ +@media (prefers-color-scheme: dark) { + :root { + /* Gruvbox Dark background colors */ + --bg-color: #282828; + --text-color: #ebdbb2; + --text-secondary: #d5c4a1; + --link-bg: #3c3836; + --link-hover: #504945; + --border-color: #665c54; + + /* Gruvbox accent colors (aqua/green tones) */ + --accent-color: #8ec07c; + --accent-light: #b8bb26; + + /* Shadows with warm tones for Gruvbox Dark */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + + /* Gradients using Gruvbox dark shades */ + --bg-gradient-start: #282828; + --bg-gradient-end: #3c3836; + + /* Glow effects using Gruvbox green/aqua colors */ + --glow-color: rgba(142, 192, 124, 0.4); + --glow-hover: rgba(142, 192, 124, 0.3); + --shimmer: rgba(142, 192, 124, 0.15); + } +} + +/* Manual overrides (highest specificity) */ +html[data-theme="light"] { + /* Gruvbox Light background colors */ + --bg-color: #fbf1c7; + --text-color: #3c3836; + --text-secondary: #504945; + --link-bg: #f9f5d7; + --link-hover: #f2e5bc; + --border-color: #d5c4a1; + + /* Gruvbox accent colors (green/aqua tones) */ + --accent-color: #689d6a; + --accent-light: #98971a; + + /* Shadows with warm tones for Gruvbox Light */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); + + /* Gradients using Gruvbox light shades */ + --bg-gradient-start: #fbf1c7; + --bg-gradient-end: #f9f5d7; + + /* Glow effects using Gruvbox green/aqua colors */ + --glow-color: rgba(104, 157, 106, 0.3); + --glow-hover: rgba(104, 157, 106, 0.2); + --shimmer: rgba(104, 157, 106, 0.1); +} + +html[data-theme="dark"] { + /* Gruvbox Dark background colors */ + --bg-color: #282828; + --text-color: #ebdbb2; + --text-secondary: #d5c4a1; + --link-bg: #3c3836; + --link-hover: #504945; + --border-color: #665c54; + + /* Gruvbox accent colors (aqua/green tones) */ + --accent-color: #8ec07c; + --accent-light: #b8bb26; + + /* Shadows with warm tones for Gruvbox Dark */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + + /* Gradients using Gruvbox dark shades */ + --bg-gradient-start: #282828; + --bg-gradient-end: #3c3836; + + /* Glow effects using Gruvbox green/aqua colors */ + --glow-color: rgba(142, 192, 124, 0.4); + --glow-hover: rgba(142, 192, 124, 0.3); + --shimmer: rgba(142, 192, 124, 0.15); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); + background-attachment: fixed; + color: var(--text-color); + line-height: 1.6; + min-height: 100vh; +} + +header { + text-align: center; + padding: 3rem 1rem 2rem; + max-width: 600px; + margin: 0 auto; +} + +header img { + border-radius: 50%; + border: 4px solid var(--link-bg); + box-shadow: var(--shadow-lg), 0 0 0 1px var(--border-color); + margin-bottom: 1.5rem; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +header img:hover { + transform: scale(1.05); + box-shadow: var(--shadow-lg), 0 0 20px var(--glow-color); +} + +header h1 { + margin: 0.5rem 0; + font-size: 2rem; + font-weight: 700; + background: linear-gradient(135deg, var(--accent-color), var(--accent-light)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +header p { + margin: 0.75rem 0; + color: var(--text-secondary); + font-size: 1rem; + line-height: 1.5; +} + +main { + max-width: 600px; + margin: 0 auto; + padding: 1rem 1rem 3rem; +} + +main ul { + list-style: none; + padding: 0; + margin: 0; +} + +main li { + margin-bottom: 1rem; +} + +main a { + display: block; + padding: 1.25rem 1.5rem; + background-color: var(--link-bg); + border: 2px solid var(--border-color); + border-radius: 12px; + text-decoration: none; + color: var(--text-color); + font-weight: 600; + text-align: center; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-sm); + position: relative; + overflow: hidden; +} + +main a::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, var(--shimmer), transparent); + transition: left 0.5s ease; +} + +main a:hover::before { + left: 100%; +} + +main a:hover { + background-color: var(--link-hover); + border-color: var(--accent-color); + transform: translateY(-4px) scale(1.02); + box-shadow: var(--shadow-md), 0 0 20px var(--glow-hover); +} + +main a:active { + transform: translateY(-2px) scale(1.01); + box-shadow: var(--shadow-sm); +} + +main a i { + margin-right: 0.5rem; +} + +footer { + text-align: center; + padding: 2rem 1rem 3rem; + max-width: 600px; + margin: 0 auto; +} + +footer p { + margin: 0; + color: var(--text-secondary); + font-size: 0.9rem; +} diff --git a/themes/nord.css b/themes/nord.css new file mode 100644 index 0000000..42249fc --- /dev/null +++ b/themes/nord.css @@ -0,0 +1,247 @@ +/* nord.css - Nord theme for LinkBeam (Automatic light/dark) */ +/* Based on the Nord color palette: https://www.nordtheme.com/ */ + +/* Default: Light mode */ +:root { + /* Nord Snow Storm (light whites/grays) */ + --bg-color: #eceff4; + --text-color: #2e3440; + --text-secondary: #3b4252; + --link-bg: #e5e9f0; + --link-hover: #d8dee9; + --border-color: #d8dee9; + + /* Nord Frost (blue accent colors) */ + --accent-color: #5e81ac; + --accent-light: #81a1c1; + + /* Shadows with lighter tones for Nord Light */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); + + /* Gradients using Nord Snow Storm shades */ + --bg-gradient-start: #eceff4; + --bg-gradient-end: #e5e9f0; + + /* Glow effects using Nord Frost colors */ + --glow-color: rgba(94, 129, 172, 0.3); + --glow-hover: rgba(94, 129, 172, 0.2); + --shimmer: rgba(94, 129, 172, 0.1); +} + +/* System dark mode preference */ +@media (prefers-color-scheme: dark) { + :root { + /* Nord Polar Night (dark blues/grays) */ + --bg-color: #2e3440; + --text-color: #eceff4; + --text-secondary: #d8dee9; + --link-bg: #3b4252; + --link-hover: #434c5e; + --border-color: #4c566a; + + /* Nord Frost (blue accent colors) */ + --accent-color: #88c0d0; + --accent-light: #8fbcbb; + + /* Shadows with darker tones for Nord */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + + /* Gradients using Nord Polar Night shades */ + --bg-gradient-start: #2e3440; + --bg-gradient-end: #3b4252; + + /* Glow effects using Nord Frost colors */ + --glow-color: rgba(136, 192, 208, 0.4); + --glow-hover: rgba(136, 192, 208, 0.3); + --shimmer: rgba(136, 192, 208, 0.15); + } +} + +/* Manual overrides (highest specificity) */ +html[data-theme="light"] { + /* Nord Snow Storm (light whites/grays) */ + --bg-color: #eceff4; + --text-color: #2e3440; + --text-secondary: #3b4252; + --link-bg: #e5e9f0; + --link-hover: #d8dee9; + --border-color: #d8dee9; + + /* Nord Frost (blue accent colors) */ + --accent-color: #5e81ac; + --accent-light: #81a1c1; + + /* Shadows with lighter tones for Nord Light */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12); + + /* Gradients using Nord Snow Storm shades */ + --bg-gradient-start: #eceff4; + --bg-gradient-end: #e5e9f0; + + /* Glow effects using Nord Frost colors */ + --glow-color: rgba(94, 129, 172, 0.3); + --glow-hover: rgba(94, 129, 172, 0.2); + --shimmer: rgba(94, 129, 172, 0.1); +} + +html[data-theme="dark"] { + /* Nord Polar Night (dark blues/grays) */ + --bg-color: #2e3440; + --text-color: #eceff4; + --text-secondary: #d8dee9; + --link-bg: #3b4252; + --link-hover: #434c5e; + --border-color: #4c566a; + + /* Nord Frost (blue accent colors) */ + --accent-color: #88c0d0; + --accent-light: #8fbcbb; + + /* Shadows with darker tones for Nord */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.5); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.6); + + /* Gradients using Nord Polar Night shades */ + --bg-gradient-start: #2e3440; + --bg-gradient-end: #3b4252; + + /* Glow effects using Nord Frost colors */ + --glow-color: rgba(136, 192, 208, 0.4); + --glow-hover: rgba(136, 192, 208, 0.3); + --shimmer: rgba(136, 192, 208, 0.15); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%); + background-attachment: fixed; + color: var(--text-color); + line-height: 1.6; + min-height: 100vh; +} + +header { + text-align: center; + padding: 3rem 1rem 2rem; + max-width: 600px; + margin: 0 auto; +} + +header img { + border-radius: 50%; + border: 4px solid var(--link-bg); + box-shadow: var(--shadow-lg), 0 0 0 1px var(--border-color); + margin-bottom: 1.5rem; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +header img:hover { + transform: scale(1.05); + box-shadow: var(--shadow-lg), 0 0 20px var(--glow-color); +} + +header h1 { + margin: 0.5rem 0; + font-size: 2rem; + font-weight: 700; + background: linear-gradient(135deg, var(--accent-color), var(--accent-light)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +header p { + margin: 0.75rem 0; + color: var(--text-secondary); + font-size: 1rem; + line-height: 1.5; +} + +main { + max-width: 600px; + margin: 0 auto; + padding: 1rem 1rem 3rem; +} + +main ul { + list-style: none; + padding: 0; + margin: 0; +} + +main li { + margin-bottom: 1rem; +} + +main a { + display: block; + padding: 1.25rem 1.5rem; + background-color: var(--link-bg); + border: 2px solid var(--border-color); + border-radius: 12px; + text-decoration: none; + color: var(--text-color); + font-weight: 600; + text-align: center; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-sm); + position: relative; + overflow: hidden; +} + +main a::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, var(--shimmer), transparent); + transition: left 0.5s ease; +} + +main a:hover::before { + left: 100%; +} + +main a:hover { + background-color: var(--link-hover); + border-color: var(--accent-color); + transform: translateY(-4px) scale(1.02); + box-shadow: var(--shadow-md), 0 0 20px var(--glow-hover); +} + +main a:active { + transform: translateY(-2px) scale(1.01); + box-shadow: var(--shadow-sm); +} + +main a i { + margin-right: 0.5rem; +} + +footer { + text-align: center; + padding: 2rem 1rem 3rem; + max-width: 600px; + margin: 0 auto; +} + +footer p { + margin: 0; + color: var(--text-secondary); + font-size: 0.9rem; +}