blog/.dagger/main.go

274 lines
6.5 KiB
Go

// A Dagger module for building and deploying a Zola static website
//
// This module provides modular functions to:
// - Build the Zola website
// - Create a Docker image serving the site with nginx
// - Deploy to S3 (future)
package main
import (
"context"
"dagger/blog/internal/dagger"
"fmt"
"strings"
)
const (
// Zola version to use for building
ZolaImage = "ghcr.io/getzola/zola:v0.21.0"
// Nginx version to use for serving
NginxImage = "nginx:1.27-alpine3.20-slim"
// Build output directory
BuildOutputDir = "public"
// Lychee version for link checking
LycheeImage = "lycheeverse/lychee:0.22-alpine"
// htmltest version for HTML validation
HtmltestImage = "wjdp/htmltest:v0.17.0"
)
type Blog struct{}
// Build builds the Zola website and returns the public directory
//
// Args:
// - source: The source directory containing the Zola site
//
// Returns:
// - A Directory containing the built website
func (m *Blog) Build(
ctx context.Context,
// The source directory containing the Zola site
// +defaultPath="/"
// +ignore=[".git", ".dagger", "public"]
source *dagger.Directory,
) *dagger.Directory {
return dag.Container().
From(ZolaImage).
WithMountedDirectory("/site", source).
WithWorkdir("/site").
WithExec([]string{"zola", "build", "--output-dir", BuildOutputDir, "--minify"}).
Directory(BuildOutputDir)
}
// prepareNginx prepares an nginx container with configuration
func (m *Blog) prepareNginx() *dagger.Container {
nginxConfig := `server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
location / {
try_files $uri $uri/ =404;
}
# Cache static assets
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}`
return dag.Container().
From(NginxImage).
WithNewFile("/etc/nginx/conf.d/default.conf", nginxConfig).
WithExposedPort(80)
}
// BuildContainer creates a Docker container image with nginx serving the built website
//
// Args:
// - source: The source directory containing the Zola site
//
// Returns:
// - A Container ready to serve the website
func (m *Blog) BuildContainer(
ctx context.Context,
// The source directory containing the Zola site
// +defaultPath="/"
// +ignore=[".git", ".dagger", "public"]
source *dagger.Directory,
) *dagger.Container {
builtSite := m.Build(ctx, source)
nginxContainer := m.prepareNginx()
return nginxContainer.WithDirectory("/usr/share/nginx/html", builtSite)
}
// getDefaultTag returns a tag based on the latest git commit date
func (m *Blog) getDefaultTag(
ctx context.Context,
source *dagger.Directory,
) (string, error) {
// Get the commit date in YYYYMMDDHHMMSS format
output, err := dag.Container().
From("alpine/git:latest").
WithMountedDirectory("/repo", source).
WithWorkdir("/repo").
WithExec([]string{"git", "log", "-1", "--format=%cd", "--date=format:%Y%m%d%H%M%S"}).
Stdout(ctx)
if err != nil {
return "", err
}
return strings.TrimSpace(output), nil
}
// Publish publishes the container to a registry
//
// Args:
// - source: The source directory containing the Zola site
// - registry: The registry address (e.g., "ghcr.io/username/blog")
// - tag: The tag to use (defaults to git commit date in YYYYMMDDHHMMSS format)
//
// Returns:
// - The published image reference
func (m *Blog) Publish(
ctx context.Context,
// The source directory containing the Zola site
// +defaultPath="/"
// +ignore=[".git", ".dagger", "public"]
source *dagger.Directory,
// The registry address
registry string,
// +optional
tag string,
) (string, error) {
if tag == "" {
defaultTag, err := m.getDefaultTag(ctx, source)
if err != nil {
return "", err
}
tag = defaultTag
}
container := m.BuildContainer(ctx, source)
address := registry + ":" + tag
return container.Publish(ctx, address)
}
// checkLinks validates all links in the built site
func (m *Blog) checkLinks(
ctx context.Context,
builtSite *dagger.Directory,
) error {
_, err := dag.Container().
From(LycheeImage).
WithMountedDirectory("/site", builtSite).
WithWorkdir("/site").
WithExec([]string{
"lychee",
"--base-url", "https://enoent.fr",
"--remap", "https://enoent\\.fr/ file:///site/",
"--offline",
"--no-progress",
"/site",
}).
Sync(ctx)
return err
}
// checkHTML validates HTML structure
func (m *Blog) checkHTML(
ctx context.Context,
builtSite *dagger.Directory,
) error {
// htmltest configuration
// Enable comprehensive HTML validation
htmltestConfig := `DirectoryPath: /site
CheckDoctype: true
CheckAnchors: true
CheckLinks: true
CheckImages: true
CheckScripts: true
CheckFavicon: false
CheckMetaRefresh: true
CheckMetaDescription: true
EnforceHTTPS: false
IgnoreInternalEmptyHash: true
IgnoreDirectoryMissingTrailingSlash: true
CheckExternal: false
IgnoreURLs:
- "^https://enoent\\.fr/"`
_, err := dag.Container().
From(HtmltestImage).
WithMountedDirectory("/site", builtSite).
WithWorkdir("/test").
WithNewFile(".htmltest.yml", htmltestConfig).
WithExec([]string{"htmltest"}).
Sync(ctx)
return err
}
// Check runs all quality checks on the built site
//
// Args:
// - source: The source directory containing the Zola site
//
// Returns:
// - An error if any check fails
func (m *Blog) Check(
ctx context.Context,
// The source directory containing the Zola site
// +defaultPath="/"
// +ignore=[".git", ".dagger", "public"]
source *dagger.Directory,
) error {
// Build the site first
builtSite := m.Build(ctx, source)
type checkResult struct {
name string
err error
}
checks := []struct {
name string
fn func(context.Context, *dagger.Directory) error
}{
{"Link Check", m.checkLinks},
{"HTML Validation", m.checkHTML},
}
results := make(chan checkResult, len(checks))
for _, check := range checks {
check := check
go func() {
results <- checkResult{
name: check.name,
err: check.fn(ctx, builtSite),
}
}()
}
// Collect results
var failures []string
for i := 0; i < len(checks); i++ {
result := <-results
if result.err != nil {
failures = append(failures, fmt.Sprintf("%s failed: %v", result.name, result.err))
}
}
// Return combined error if any checks failed
if len(failures) > 0 {
return fmt.Errorf("checks failed:\n%s", strings.Join(failures, "\n"))
}
return nil
}