Go 140 lines
// Package docs renders eeco's tracked-tree documentation scaffolds.
//
// It backs `eeco docs new <target>` — a one-shot, deterministic write
// of a tracked-tree doc (today: VISION.md) at the repository root,
// invoked explicitly by the operator. Reuses the precedent set by
// `eeco init` for the workspace `.gitignore` line: eeco may write into
// the tracked tree on explicit invocation, the operator stages and
// commits (Constraint 6).
//
// The render is template-driven and project-shape-aware: placeholders
// fill in the project basename, the eeco version, and which of the
// usual companion docs already exist so the scaffold's "See also"
// reflects the project rather than a generic stub.
package docs
import (
"bytes"
"embed"
"errors"
"fmt"
"os"
"path/filepath"
"text/template"
)
//go:embed templates/*.tmpl
var templatesFS embed.FS
// Target enumerates the tracked-tree docs the scaffolder can produce.
// Each target maps to one filename at the repository root and one
// embedded template file under templates/.
type Target string
const (
// TargetVision scaffolds VISION.md.
TargetVision Target = "vision"
// TargetReadme scaffolds README.md.
TargetReadme Target = "readme"
)
// AllTargets returns every supported target in the order presented in
// usage messages.
func AllTargets() []Target {
return []Target{TargetVision, TargetReadme}
}
// Filename returns the tracked-tree filename this target scaffolds to.
// An empty string means the target is not recognised.
func (t Target) Filename() string {
switch t {
case TargetVision:
return "VISION.md"
case TargetReadme:
return "README.md"
}
return ""
}
// Params carries the substitutions a template can reference. Fields
// are kept small and deterministic; nothing here depends on wall-clock
// time so a re-render of the same project yields byte-identical bytes.
type Params struct {
// Project is the repository basename (e.g. "eeco").
Project string
// Version is the eeco version string the binary was built with.
Version string
// HasReadme reports whether README.md exists at the repository root.
HasReadme bool
// HasUsage reports whether docs/USAGE.md exists.
HasUsage bool
// HasArch reports whether docs/ARCHITECTURE.md exists.
HasArch bool
}
// Scaffold renders target's template into repoRoot/<target-filename>
// and returns the repo-relative path written. If the file already
// exists and overwrite is false, Scaffold refuses and returns an error
// whose message names the file and the override flag.
func Scaffold(target Target, repoRoot string, overwrite bool, p Params) (string, error) {
name := target.Filename()
if name == "" {
return "", fmt.Errorf("unknown target %q", string(target))
}
// Filenames are hardcoded per target; this is belt-and-braces against
// a future target accidentally introducing an escape.
if filepath.IsAbs(name) || filepath.Clean(name) != name {
return "", fmt.Errorf("target filename %q is not a safe relative path", name)
}
full := filepath.Join(repoRoot, name)
if !overwrite {
if _, err := os.Stat(full); err == nil {
return "", fmt.Errorf("%s already exists at the repo root; pass --overwrite to replace", name)
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
}
tmplPath := "templates/" + string(target) + ".md.tmpl"
raw, err := templatesFS.ReadFile(tmplPath)
if err != nil {
return "", fmt.Errorf("read template: %w", err)
}
tmpl, err := template.New(string(target)).Parse(string(raw))
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, p); err != nil {
return "", fmt.Errorf("render template: %w", err)
}
if err := os.WriteFile(full, buf.Bytes(), 0o644); err != nil {
return "", err
}
return name, nil
}
// Render returns the rendered template bytes for target without
// touching the filesystem. Useful for tests and any future caller that
// wants to inspect the scaffold without writing it.
func Render(target Target, p Params) (string, error) {
name := target.Filename()
if name == "" {
return "", fmt.Errorf("unknown target %q", string(target))
}
tmplPath := "templates/" + string(target) + ".md.tmpl"
raw, err := templatesFS.ReadFile(tmplPath)
if err != nil {
return "", fmt.Errorf("read template: %w", err)
}
tmpl, err := template.New(string(target)).Parse(string(raw))
if err != nil {
return "", fmt.Errorf("parse template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, p); err != nil {
return "", fmt.Errorf("render template: %w", err)
}
return buf.String(), nil
}