// 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 }