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"
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
//main.go
package main
import "fmt"
func main() {}
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
// 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
}
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)
}
}
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>
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;
/* 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;
}
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)
}
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
}
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
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
}
// 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
}
...
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
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.
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.
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
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)
cool, i liked it, i did the same thing, only in python, check out dev.to/king_triton/build-your-own-... ?
@king_triton thanks for reading! Looks good with Python implementation 🔥
thanks for the feedback bro 👍