DEV Community

Cover image for Build your own LinkTree with Go and GitHub Pages
Lucas Neves Pereira
Lucas Neves Pereira

Posted on

Build your own LinkTree with Go and GitHub Pages

Hello there! Lately I've been working on my personal website, I wanted to rebuild it to a very simple single page served by GitHub Pages.

I used to build my website using a known tool named Hugo, a static site generator. I also have a LinkTree account that displays all of my links.

So I started wondering for my new website if I could build something similar, only with the core functionalities I want, a minimal version of a static site generator.

I would just need a config.yml file containing my page configuration (site name, meta, links...), parse it to a struct and then inject it to an HTML template.

Finally, I would have to build the template and copy it's output + assets to the root of my repository, ready to be served by GitHub Pages.

How to implement this

Let's start by creating a config.yml file at the root of our project with some fields, this will contain our site configuration.

# config.yml
name: "Lucas Neves Pereira"
picture: "picture.jpg" # paste an img at root of project or an image source url
bio: "Software Engineer from Portugal living in Paris, France"
meta:
  lang: "en"
  description: "Software Engineer from Portugal living in Paris, France"
  title: "Lucas Neves Pereira"
  author: "Lucas Neves Pereira"
  siteUrl: "https://lucasnevespereira.github.io"
links:
  - name: "Github"
    url: "https://github.com/lucasnevespereira"
  - name: "LinkedIn"
    url: "https://www.linkedin.com/in/lucasnevespereira/"
  - name: "Youtube"
    url: "https://www.youtube.com/c/lucaasnp"
  - name: "Twitter/X"
    url: "https://twitter.com/lucaasnp_"
  - name: "Dev.to"
    url: "https://dev.to/lucasnevespereira"
theme: "custom"
Enter fullscreen mode Exit fullscreen mode

Now we can init a go module and add a main.go⁣ file with a package main to the root of our project.

go mod init gopagelink
Enter fullscreen mode Exit fullscreen mode
//main.go
package main

import "fmt"

func main() {}
Enter fullscreen mode Exit fullscreen mode

Load config data

We need to first load the yaml configuration from our config.yml into a struct that we could inject later to a template.

For that, we can create a package configs, so let's create a configs/ directory and add a site.go file.

go get -u gopkg.in/yaml.v2
Enter fullscreen mode Exit fullscreen mode
// configs/site.go
package configs

import (
    "os"

    "gopkg.in/yaml.v2"
)

type SiteConfig struct {
    Name    string  `yaml:"name"`
    Bio     string  `yaml:"bio"`
    Picture string  `yaml:"picture"`
    Meta    Meta    `yaml:"meta"`
    Links   []Link  `yaml:"links"`
        Theme   string  `yaml:"theme"`
}

type Meta struct {
    Title       string `yaml:"title"`
    Description string `yaml:"description"`
    Lang        string `yaml:"lang"`
    Author      string `yaml:"author"`
    SiteUrl     string `yaml:"siteUrl"`
}

type Link struct {
    Name string `yaml:"name"`
    URL  string `yaml:"url"`
}

func LoadSiteConfig(path string) (*SiteConfig, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }

    var config SiteConfig
    err = yaml.Unmarshal(data, &config)
    if err != nil {
        return nil, err
    }

    return &config, nil
}
Enter fullscreen mode Exit fullscreen mode

Here, we've created a function LoadSiteConfig that will simply read a file from a path and parse it's yaml content to a Go struct SiteConfig. We can go back to our entry file and use this LoadSiteConfig.

//main.go
package main

import (
"log"
"gopagelink/configs"
)

func main {
 // Load configuration
 config, err := configs.LoadSiteConfig("config.yml")
 if err != nil {
   log.Fatalf("Error loading config: %v", err)
 }

}
Enter fullscreen mode Exit fullscreen mode

Set up our first theme

Let's create a themes directory where we will store the HTML, CSS, and JavaScript for our page. The idea is to select the appropriate files based on the theme specified in the config.yml. Each subdirectory within the themes directory will represent a different theme.

For example, if our theme is set to "custom," we will use the following directory: themes/custom/.

I'll start by writing an index.html with the structure I want for my page and let's already put the fields we want from our SiteConfig struct that we will populate later.

<!-- themes/custom/index.html -->

<html lang="{{.Config.Meta.Lang}}">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initialscale=1.0" />
    <meta name="description" content="{{.Config.Meta.Description}}"/>
    <title>{{.Config.Meta.Title}}</title>
    <meta name="author" content="{{.Config.Meta.Author}}" />
    <link rel="canonical" href="{{.Config.Meta.SiteUrl}}" />
    <link rel="icon" type="image/x-icon" href="/assets/icons/favicon.ico" />
    <link rel="shortcut icon" href="/assetsicons/favicon.ico" />
    <link
      rel="stylesheet"
      href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
    />
    <link rel="stylesheet" href="/assets/css/styles.css" />

    <meta property="og:title" content="{{.Config.Meta.Title}}" />
    <meta property="og:site_name" content="{{.Config.Meta.Title}}" />
    <meta property="og:description" content="{{.Config.Meta.Description}}" />
    <meta property="og:locale" content="{{.Config.Meta.Lang}}" />

    <meta name="twitter:title" content="{{.Config.Meta.Title}}" />
    <meta name="twitter:description" content="{{.Config.Meta.Description}}" />
    <meta name="language" content="{{.Config.Meta.Lang}}" />
  </head>
  <body>
    <header>
      <img src="{{.Config.Picture}}" alt="Picture" class="avatar" />
      <h1>{{.Config.Name}}</h1>
      <small class="bio">{{.Config.Bio}}</small>
    </header>
    <main>
      <section class="links">
        {{range .Config.Links}}
        <a
          class="link-item"
          href="{{.URL}}"
          target="_blank"
          rel="noopener noreferrer"
          ><p>{{.Name}}</p>
        </a>
        {{end}}
      </section>
    </main>
    <footer>
      <small>© <span class="year"></span> {{.Config.Meta.Author}} </small>
    </footer>
    <script src="/assets/js/script.js"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Next, I'll set up an assets folder within the theme directory that will contain js, css and icons files.

// themes/custom/assets/js/script.js
console.log("scripts loaded");

const yearDate = new Date().getFullYear().toString();
document.querySelector(".year").innerText = yearDate;

Enter fullscreen mode Exit fullscreen mode
/* themes/custom/assets/css/styles.css */
/* CSS Reset */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

/* Variables */
:root {
  --max-width: 600px;
  --font-family: 'Inter', sans-serif;
  --padding: 1rem;
  --header-margin-bottom: 1rem;
  --line-height: 2;
  --font-size: 16px;

  --primary-color-light: #ffffff;
  --background-color-light: #f0f0f0;
  --text-color-light: #333;
  --link-color-light: #1a73e8;
  --bio-color-light: #666;

  --primary-color-dark: #1e1e1e;
  --background-color-dark: #121212;
  --text-color-dark: #e0e0e0;
  --link-color-dark: #8ab4f8;
  --bio-color-dark: #aaa;
}

/* Light Theme */
@media (prefers-color-scheme: light) {
  :root {
    --primary-color: var(--primary-color-light);
    --background-color: var(--background-color-light);
    --text-color: var(--text-color-light);
    --link-color: var(--link-color-light);
    --bio-color: var(--bio-color-light);
  }
}

/* Dark Theme */
@media (prefers-color-scheme: dark) {
  :root {
    --primary-color: var(--primary-color-dark);
    --background-color: var(--background-color-dark);
    --text-color: var(--text-color-dark);
    --link-color: var(--link-color-dark);
    --bio-color: var(--bio-color-dark);
  }
}

/* Global Styles */
html {
  font-family: var(--font-family);
  font-size: var(--font-size);
  line-height: var(--line-height);
}

body {
  max-width: var(--max-width);
  min-height: 100dvh;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  background-color: var(--background-color);
  color: var(--text-color);
  padding: var(--padding);
}

/* Header Styles */
header {
  padding: var(--padding) 0;
  margin-bottom: var(--header-margin-bottom);
  width: 100%;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: center;
  text-align: center;
}

.avatar {
  width: 100px;
  height: 100px;
  border-radius: 50%; 
  object-fit: cover;
  border: 2px solid var(--primary-color);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
  display: flex;
  justify-content: center;
}

h1 {
  font-size: 24px;
  margin-bottom: 0.5rem;
}

.bio {
  font-size: 14px;
  color: var(--bio-color);
  margin-bottom: 1rem;
}

/* Main Content Styles */
main {
  width: 100%;
  flex: 1;
}

.links {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  text-align: center;
  overflow-y: scroll;
  max-height: 400px;
}

.link-item {
  display: block;
  padding: 16px 20px;
  text-decoration: none;
  color: var(--link-color);
  background: var(--primary-color);
  border-radius: 12px;
  border: 1px solid var(--link-color);
  border-radius: 14px;
  transition: box-shadow 0.25s cubic-bezier(0.08, 0.59, 0.29, 0.99), border-color 0.25s cubic-bezier(0.08, 0.59, 0.29, 0.99), transform 0.25s cubic-bezier(0.08, 0.59, 0.29, 0.99), background-color 0.25s cubic-bezier(0.08, 0.59, 0.29, 0.99);
}

.link-item:hover,
.link-item:focus {
  background-color: var(--link-color);
  color: var(--primary-color);
}

.link-item p {
  line-height: 1.5;
  font-weight: 500;
}

/* Footer Styles */
footer {
  width: 100%;
  text-align: center;
  padding: 1rem 0;
  font-size: 14px;
  gap: 1rem;
  display: flex;
  justify-content: center;
  align-items: center;
}

/* SrollBar */
/* width */
::-webkit-scrollbar {
  width: 5px;
}

/* Track */
::-webkit-scrollbar-track {
  background: transparent;
}

/* Handle */
::-webkit-scrollbar-thumb {
  background: transparent;
}

/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
  background: transparent;
}
Enter fullscreen mode Exit fullscreen mode

And the icons needed, like favicon.ico for example into themes/custom/assets/icons/.

With that, our first theme is created! Feel free to customize or create different themes (sub-directories) for your page.

Populate theme template

Back to our main.go file we are going to create a function generateHTML.

package main

import (
"log"
"gopagelink/configs"
)

func main {
 // Load configuration
 config, err := configs.LoadSiteConfig("config.yaml")
 if err != nil {
   log.Fatalf("Error loading config: %v", err)
 }

// Generate HTML
err = generateHTML(config)
if err != nil {
  log.Fatalf("Error generating HTML: %v", err)
}


fmt.Println("Site generated successfully!")

}

func generateHTML(config *configs.SiteConfig) error {   
  themeFile := fmt.Sprintf("themes/%s/index.html", 
  config.Theme)

  // Load HTML template
  tmpl, err := template.ParseFiles(themeFile)
  if err != nil {
    return err
  }

  // Open output file
  outputFile, err := os.Create("index.html")
  if err != nil {
    return err
  }
  defer outputFile.Close()

  // Define data to pass to the template
  data := struct {
    Config  *configs.SiteConfig
  }{
    Config:  config,
  }

  // Execute template with data
  return tmpl.Execute(outputFile, data)
}

Enter fullscreen mode Exit fullscreen mode

As you can guess reading the comments we are loading the template of our theme, creating an output index.html at the root of project (that's what will be served by GitHub Pages). Next, we define a data struct with our config, and lastly we execute our template with our data injected!

A last step we need to do is to copy our theme assets to root directory to be served with our html. For that let's create a function named copyAssets.

//main.go
package main

import (
    "bytes"
    "fmt"
    "html/template"
    "io"
    "log"
    "gopagelink/configs"
    "os"
    "path/filepath"
)

func main {

 // Load configuration
 config, err := configs.LoadSiteConfig("config.yaml")
 if err != nil {
   log.Fatalf("Error loading config: %v", err)
 }

// Generate HTML
err = generateHTML(config, htmlContent)
if err != nil {
  log.Fatalf("Error generating HTML: %v", err)
}

// Copy assets
err = copyAssets(config.Theme)
if err != nil {
  log.Fatalf("Error copying and minifying assets: %v", err)
}


fmt.Println("Site generated successfully!")
}

func generateHTML(config *configs.SiteConfig) error {   
  templateFile := fmt.Sprintf("internal/templates/%s/index.html", 
  config.Theme)

  // Load HTML template
  tmpl, err := template.ParseFiles(templateFile)
  if err != nil {
    return err
  }

  // Open output file
  outputFile, err := os.Create("index.html")
  if err != nil {
    return err
  }
  defer outputFile.Close()

  // Define data to pass to the template
  data := struct {
    Config  *configs.SiteConfig
  }{
    Config:  config,
  }

  // Execute template with data
  return tmpl.Execute(outputFile, data)
}

func copyAssets(theme string) error {
  // Create assets directories if they don't exist
  if err := os.MkdirAll("assets/css", os.ModePerm); err != nil{
    return fmt.Errorf("failed to create assets/css directory: 
     %w",err)
  }
  if err := os.MkdirAll("assets/js", os.ModePerm); err != nil {
    return fmt.Errorf("failed to create assets/js directory: %w", 
    err)
  }
  if err := os.MkdirAll("assets/icons", os.ModePerm); err != nil {
    return fmt.Errorf("failed to create assets/icons directory: %w", 
    err)
  }

 // Get theme assets files
 cssFiles, err := 
   filepath.Glob(fmt.Sprintf("themes/%s/assets/css/*.css", 
   theme))
   if err != nil {
     return fmt.Errorf("failed to list CSS files: %w", err)
   }
 if err := copyFiles(cssFiles, "assets/css"); err != nil {
    return fmt.Errorf("failed to copy css files: %w", err)
 }

 jsFiles, err := 
  filepath.Glob(fmt.Sprintf("themes/%s/assets/js/*.js", 
  theme))
 if err != nil {
    return fmt.Errorf("failed to list JS files: %w", err)
 }
 if err := copyFiles(jsFiles, "assets/js"); err != nil {
    return fmt.Errorf("failed to copy js files: %w", err)
 }

 // Copy favicons
 iconsFiles, err := 
  filepath.Glob(fmt.Sprintf("themes/%s/assets/icons/*", 
  theme))
  if err != nil {
    return fmt.Errorf("failed to list icon files: %w", err)
  }
  if err := copyFiles(iconsFiles, "assets/icons"); err != nil {
    return fmt.Errorf("failed to copy icon files: %w", err)
  }

 return nil

}

func copyFiles(files []string, outputDir string) error {
    for _, file := range files {
        data, err := os.ReadFile(file)
        if err != nil {
            return fmt.Errorf("failed to read %s: %w", file, err)
        }

        outputPath := filepath.Join(outputDir, filepath.Base(file))
        outFile, err := os.Create(outputPath)
        if err != nil {
            return fmt.Errorf("failed to create %s: %w", outputPath, err)
        }
        defer outFile.Close()

        if _, err := io.Copy(outFile, bytes.NewReader(data)); err != nil {
            return fmt.Errorf("failed to copy data to %s: %w", outputPath, err)
        }
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Quick Note: Besides copying the assets, it would be a good practice to minify our js and css files, if you are interested in implementing that you can use libraries like tdewolff/minify

Minify Assets

Here’s how we could handle both minification and copying of js and css files by creating a minifyAndCopyFiles function.

go get github.com/tdewolff/minify
Enter fullscreen mode Exit fullscreen mode
func minifyAndCopyFiles(files []string, destDir string, fileType string) error {
    m := minify.New()
    switch fileType {
    case "text/javascript":
        m.AddFunc("text/javascript", js.Minify)
    case "text/css":
        m.AddFunc("text/css", css.Minify)
    default:
        return fmt.Errorf("unsupported file type: %s", fileType)
    }

    for _, file := range files {
        content, err := os.ReadFile(file)
        if err != nil {
            return fmt.Errorf("failed to read file %s: %w", file, err)
        }

        minifiedContent, err := m.Bytes(fileType, content)
        if err != nil {
            return fmt.Errorf("failed to minify file %s: %w", file, err)
        }

        destPath := filepath.Join(destDir, filepath.Base(file))
        if err := os.WriteFile(destPath, minifiedContent, os.ModePerm); err != nil {
            return fmt.Errorf("failed to write minified file to %s: %w", destPath, err)
        }
    }

    return nil
}
Enter fullscreen mode Exit fullscreen mode
// main.go
package main
....
func copyAssets(theme string) error {
    // Create assets directories if they don't exist
    if err := os.MkdirAll("assets/css", os.ModePerm); err != nil {
        return fmt.Errorf("failed to create assets/css directory: %w", err)
    }
    if err := os.MkdirAll("assets/js", os.ModePerm); err != nil {
        return fmt.Errorf("failed to create assets/js directory: %w", err)
    }
    if err := os.MkdirAll("assets/icons", os.ModePerm); err != nil {
        return fmt.Errorf("failed to create assets/icons directory: %w", err)
    }

    // Get theme assets files
    cssFiles, err := filepath.Glob(fmt.Sprintf("themes/%s/assets/css/*.css", theme))
    if err != nil {
        return fmt.Errorf("failed to list CSS files: %w", err)
    }
    if err := minifyAndCopyFiles(cssFiles, "assets/css", "text/css"); err != nil {
        return fmt.Errorf("failed to copy css files: %w", err)
    }

    jsFiles, err := filepath.Glob(fmt.Sprintf("themes/%s/assets/js/*.js", theme))
    if err != nil {
        return fmt.Errorf("failed to list JS files: %w", err)
    }
    if err := minifyAndCopyFiles(jsFiles, "assets/js", "text/javascript"); err != nil {
        return fmt.Errorf("failed to copy js files: %w", err)
    }

    // Copy favicons
    iconsFiles, err := filepath.Glob(fmt.Sprintf("themes/%s/assets/icons/*", theme))
    if err != nil {
        return fmt.Errorf("failed to list icon files: %w", err)
    }
    if err := copyFiles(iconsFiles, "assets/icons"); err != nil {
        return fmt.Errorf("failed to copy icon files: %w", err)
    }

    return nil
}
...
Enter fullscreen mode Exit fullscreen mode

This updated copyAssets function now includes minification for js and css files.

Building site

Now that we are ready to build and test this, we can create a simple Makefile

.PHONY: clean site

clean:
    @echo "Cleaning site..."
    rm -rf index.html
    rm -rf assets

site: clean
    @echo "Building site..."
    go run main.go

Enter fullscreen mode Exit fullscreen mode

Let's build our site by running make site

This should have generated our assets and index.html at the root of our project.

If we open our html in a default browser we could see the result of it.

Result of project

Deploying with GitHub Pages

Now that our site is built, let's deploy it using GitHub Pages.

Follow the steps below:

Push code to a GitHub repository:

  • Ensure that all your changes, including the generated index.html and assets, are committed to your repository.
  • Push the changes to your GitHub repository using the following commands: bash git add --all git commit -m "Deploy my site" git push origin main Set Up GitHub Pages:
  • Navigate to your repository on GitHub.
  • Go to the Settings tab.
  • Scroll down to the Pages section on the left-hand side.
  • In the Source section, select the branch main.
  • Choose the root folder (/) as the source directory for your site.
  • Click Save.

Github Pages Settings

Wait for Deployment:

  • After saving, GitHub Pages will automatically start building your site.
  • You should see a notification on the Pages section that your site is being deployed.
  • Once the deployment is successful, you will be given a URL where your site is hosted.

Access Your Site:

  • Visit the URL provided by GitHub Pages to see your live site.
  • The URL usually follows the pattern https://<username>.github.io/<repository-name>/.

Quick Note: If you want GithubPages to serve to https://yourusername.github.io you must name your repository yourusername.github.io

Deploy complete

Conclusion

There it is, this is how I've built my self a static site.

You can find the source code for this article is here.

Hope this can be useful for some of you. 😀

See you soon 👋🏼

Top comments (3)

Collapse
 
king_triton profile image
King Triton

cool, i liked it, i did the same thing, only in python, check out dev.to/king_triton/build-your-own-... ?

Collapse
 
lucasnevespereira profile image
Lucas Neves Pereira

@king_triton thanks for reading! Looks good with Python implementation 🔥

Collapse
 
king_triton profile image
King Triton

thanks for the feedback bro 👍