a73x

internal/site/site.go

Ref:   Size: 7.2 KiB   History

package site

import (
	"fmt"
	"html/template"
	"os"
	"path/filepath"
	"strings"
)

// pages is the published doc set: how-to material for people running or using
// a fleet. The rest of docs/ (architecture, ethos, decisions, the shape
// diagram, superpowers/) is for people working on eitri and never publishes.
// Build hard-fails if any listed doc is missing.
var pages = []string{
	"quickstart",
	"joining",
	"connecting",
	"networking",
	"volumes",
	"self-hosting",
	"byo-idp",
	"mcp",
	"upgrade",
	"cert-rotation",
	"credential-revocation",
	"faq",
	"releases",
}

// Config locates the generator's inputs and output.
type Config struct {
	DocsDir string // repo docs/ — markdown sources for the pages list
	SiteDir string // site/ — index.md, docs.md, template.html, style.css
	DistDir string // optional dist/<version> with release artifacts; "" renders a docs-only preview
	OutDir  string // webroot to emit
	BaseURL string // where the site is served from; "" leaves preview links relative
}

// abs makes a site path absolute against BaseURL. Link-preview scrapers do
// not resolve relative og:image or og:url against the page they fetched, so
// a published site must name itself.
func (cfg Config) abs(path string) string {
	if cfg.BaseURL == "" {
		return path
	}
	return strings.TrimSuffix(cfg.BaseURL, "/") + path
}

type pageData struct {
	Title       string
	Section     string // "home" | "docs" | "dl" — which nav entry is active
	Content     template.HTML
	Description string // the page's own summary, for search and link previews
	URL         string // canonical location
	Image       string // the link-preview card drawn for this page
}

// Build renders the whole site into cfg.OutDir.
func Build(cfg Config) error {
	tmplSrc, err := os.ReadFile(filepath.Join(cfg.SiteDir, "template.html"))
	if err != nil {
		return err
	}
	tmpl, err := template.New("page").Parse(string(tmplSrc))
	if err != nil {
		return err
	}

	targets := linkTargets()

	page := func(outPath string, d pageData) error {
		if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
			return err
		}
		f, err := os.Create(outPath)
		if err != nil {
			return err
		}
		if err := tmpl.Execute(f, d); err != nil {
			f.Close()
			return err
		}
		return f.Close()
	}
	// renderFile returns a page's HTML and the summary drawn from its own
	// opening paragraph — one source of truth for what the page is about.
	renderFile := func(src string) (template.HTML, string, error) {
		b, err := os.ReadFile(src)
		if err != nil {
			return "", "", err
		}
		h, secs, err := render(b, targets)
		if err != nil {
			return "", "", fmt.Errorf("%s: %w", src, err)
		}
		return template.HTML(withTOC(h, secs)), summary(b), nil
	}

	// preview draws the page's link-preview card and returns the canonical
	// URL and image to advertise. label names the page on the card; empty
	// leaves the landing page unlabelled.
	preview := func(name, sitePath, label string) (url, image string, err error) {
		raw, err := renderCard(label)
		if err != nil {
			return "", "", fmt.Errorf("card %q: %w", name, err)
		}
		dir := filepath.Join(cfg.OutDir, "og")
		if err := os.MkdirAll(dir, 0o755); err != nil {
			return "", "", err
		}
		if err := os.WriteFile(filepath.Join(dir, name+".png"), raw, 0o644); err != nil {
			return "", "", err
		}
		return cfg.abs(sitePath), cfg.abs("/og/" + name + ".png"), nil
	}

	// Landing.
	content, desc, err := renderFile(filepath.Join(cfg.SiteDir, "index.md"))
	if err != nil {
		return err
	}
	url, image, err := preview("home", "/", "")
	if err != nil {
		return err
	}
	if err := page(filepath.Join(cfg.OutDir, "index.html"), pageData{
		Title: "eitri", Section: "home", Content: content,
		Description: desc, URL: url, Image: image,
	}); err != nil {
		return err
	}

	// Docs index (site-owned: the published set is a curated subset of docs/,
	// so the repo's own docs/README.md would dangle links here) + one page per
	// published doc + the roadmap.
	if content, desc, err = renderFile(filepath.Join(cfg.SiteDir, "docs.md")); err != nil {
		return err
	}
	if url, image, err = preview("docs", "/docs/", "docs"); err != nil {
		return err
	}
	if err := page(filepath.Join(cfg.OutDir, "docs", "index.html"), pageData{
		Title: "eitri – docs", Section: "docs", Content: content,
		Description: desc, URL: url, Image: image,
	}); err != nil {
		return err
	}
	for _, slug := range pages {
		if content, desc, err = renderFile(filepath.Join(cfg.DocsDir, slug+".md")); err != nil {
			return err
		}
		label := strings.ReplaceAll(slug, "-", " ")
		if url, image, err = preview(slug, "/docs/"+slug+"/", label); err != nil {
			return err
		}
		if err := page(filepath.Join(cfg.OutDir, "docs", slug, "index.html"), pageData{
			Title: "eitri – " + slug, Section: "docs", Content: content,
			Description: desc, URL: url, Image: image,
		}); err != nil {
			return err
		}
	}
	if content, desc, err = renderFile(filepath.Join(cfg.DocsDir, "..", "ROADMAP.md")); err != nil {
		return err
	}
	if url, image, err = preview("roadmap", "/docs/roadmap/", "roadmap"); err != nil {
		return err
	}
	if err := page(filepath.Join(cfg.OutDir, "docs", "roadmap", "index.html"), pageData{
		Title: "eitri – roadmap", Section: "docs", Content: content,
		Description: desc, URL: url, Image: image,
	}); err != nil {
		return err
	}

	// Downloads page.
	dlMD, err := downloadsMarkdown(cfg.DistDir)
	if err != nil {
		return err
	}
	h, _, err := render([]byte(dlMD), targets)
	if err != nil {
		return err
	}
	if url, image, err = preview("dl", "/dl/", "downloads"); err != nil {
		return err
	}
	if err := page(filepath.Join(cfg.OutDir, "dl", "index.html"), pageData{
		Title: "eitri – downloads", Section: "dl", Content: template.HTML(h),
		Description: summary([]byte(dlMD)), URL: url, Image: image,
	}); err != nil {
		return err
	}

	// The API contract publishes verbatim at the site root when the docs
	// tree carries it (site-check enforces presence in the real tree;
	// synthetic docs trees may omit it).
	spec, err := os.ReadFile(filepath.Join(cfg.DocsDir, "openapi.json"))
	switch {
	case err == nil:
		if err := os.WriteFile(filepath.Join(cfg.OutDir, "openapi.json"), spec, 0o644); err != nil {
			return err
		}
	case !os.IsNotExist(err):
		return err
	}

	// The one stylesheet.
	css, err := os.ReadFile(filepath.Join(cfg.SiteDir, "style.css"))
	if err != nil {
		return err
	}
	if err := os.WriteFile(filepath.Join(cfg.OutDir, "style.css"), css, 0o644); err != nil {
		return err
	}

	// The tab icon, drawn from the font rather than kept as a source file so
	// it cannot drift from the mark on the page.
	iconFont, err := parseBDF(gohu11)
	if err != nil {
		return err
	}
	iconMark, err := markBitmap(iconFont)
	if err != nil {
		return err
	}
	icon, err := faviconSVG(iconMark, faviconBox)
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(cfg.OutDir, "favicon.svg"), []byte(icon), 0o644)
}

// linkTargets maps markdown link destinations (as written in the sources) to
// published site paths — the vocabulary the link rewriter validates against.
// A doc outside the published set is deliberately absent: linking it from a
// published page is a build failure, not a dangling link.
func linkTargets() map[string]string {
	t := map[string]string{"../ROADMAP.md": "/docs/roadmap/"}
	for _, s := range pages {
		t[s+".md"] = "/docs/" + s + "/"
	}
	return t
}