tailwind API

tailwind

package

API reference for the tailwind package.

S
struct

plugin

cmd/rfw/plugins/tailwind/tailwind.go:20-22
type plugin struct

Methods

Name
Method

Returns

string
func (*plugin) Name() string
{ return "tailwind" }
Priority
Method

Returns

int
func (*plugin) Priority() int
{ return 0 }
Build
Method

Parameters

Returns

error
func (*plugin) Build(raw json.RawMessage) error
{
	logging.Log.Info("starting build", logging.F("plugin", "tailwind"))
	cfg := struct {
		Input  string   `json:"input"`
		Output string   `json:"output"`
		Minify bool     `json:"minify"`
		Args   []string `json:"args"`
	}{
		Input:  "index.css",
		Output: "tailwind.css",
		Minify: true,
	}
	if len(raw) > 0 {
		if err := json.Unmarshal(raw, &cfg); err != nil {
			return fmt.Errorf("decode tailwind plugin config: %w", err)
		}
	}
	input, err := validateProjectFile(cfg.Input, true)
	if err != nil {
		return fmt.Errorf("invalid tailwind input: %w", err)
	}
	output, err := validateProjectFile(cfg.Output, false)
	if err != nil {
		return fmt.Errorf("invalid tailwind output: %w", err)
	}
	if input == output {
		return fmt.Errorf("tailwind input and output must differ")
	}
	useConfig, err := validateExtraArgs(cfg.Args)
	if err != nil {
		return err
	}
	p.output = output

	root, err := os.OpenRoot(".")
	if err != nil {
		return fmt.Errorf("open project root: %w", err)
	}
	defer func() {
		if closeErr := root.Close(); closeErr != nil {
			logging.Log.Error("close project root", logging.F("plugin", "tailwind"), logging.F("error", closeErr.Error()))
		}
	}()
	inputCSS, err := root.ReadFile(input)
	if err != nil {
		return fmt.Errorf("read tailwind input: %w", err)
	}

	cmd := tailwindCommand(cfg.Minify, useConfig)
	cmd.Stdin = bytes.NewReader(inputCSS)
	var generatedCSS bytes.Buffer
	var diagnostics bytes.Buffer
	cmd.Stdout = &generatedCSS
	cmd.Stderr = &diagnostics
	logging.Log.Info("running command", logging.F("plugin", "tailwind"), logging.F("args", strings.Join(cmd.Args, " ")))
	if err := cmd.Run(); err != nil {
		if errors.Is(err, exec.ErrNotFound) {
			logging.Log.Warn("tailwindcss not found, please install it manually", logging.F("plugin", "tailwind"))
		}
		return fmt.Errorf("tailwind build failed: %s: %w", strings.TrimSpace(diagnostics.String()), err)
	}
	if err := writeProjectFile(root, output, generatedCSS.Bytes()); err != nil {
		return fmt.Errorf("write tailwind output: %w", err)
	}
	logging.Log.Info("build complete", logging.F("plugin", "tailwind"))
	return nil
}
ShouldRebuild
Method

Parameters

path string

Returns

bool
func (*plugin) ShouldRebuild(path string) bool
{
	if strings.HasSuffix(path, ".css") && !strings.HasSuffix(path, p.output) {
		logging.Log.Info("rebuild triggered", logging.F("plugin", "tailwind"), logging.F("path", path))
		return true
	}
	if strings.HasSuffix(path, ".rtml") || strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".go") {
		logging.Log.Info("rebuild triggered", logging.F("plugin", "tailwind"), logging.F("path", path))
		return true
	}
	return false
}

Fields

Name Type Description
output string
F
function

init

cmd/rfw/plugins/tailwind/tailwind.go:24-26
func init()

{
	plugins.Register(&plugin{})
}
F
function

tailwindCommand

Parameters

minify
bool
useConfig
bool

Returns

cmd/rfw/plugins/tailwind/tailwind.go:100-111
func tailwindCommand(minify, useConfig bool) *exec.Cmd

{
	switch {
	case minify && useConfig:
		return exec.Command("tailwindcss", "-i", "-", "--minify", "--config", "tailwind.config.js")
	case minify:
		return exec.Command("tailwindcss", "-i", "-", "--minify")
	case useConfig:
		return exec.Command("tailwindcss", "-i", "-", "--config", "tailwind.config.js")
	default:
		return exec.Command("tailwindcss", "-i", "-")
	}
}
F
function

writeProjectFile

Parameters

root
filePath
string
content
[]byte

Returns

error
cmd/rfw/plugins/tailwind/tailwind.go:113-130
func writeProjectFile(root *os.Root, filePath string, content []byte) error

{
	directory := filepath.Dir(filePath)
	if directory != "." {
		if err := root.MkdirAll(directory, 0o755); err != nil {
			return err
		}
	}
	if err := root.Remove(filePath); err != nil && !errors.Is(err, os.ErrNotExist) {
		return err
	}
	file, err := root.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
	if err != nil {
		return err
	}
	_, writeErr := file.Write(content)
	closeErr := file.Close()
	return errors.Join(writeErr, closeErr)
}
F
function

validateProjectFile

Parameters

filePath
string
mustExist
bool

Returns

string
error
cmd/rfw/plugins/tailwind/tailwind.go:132-174
func validateProjectFile(filePath string, mustExist bool) (string, error)

{
	if filePath == "" || filepath.IsAbs(filePath) || strings.HasPrefix(filePath, "-") {
		return "", fmt.Errorf("path %q must be a project-relative file", filePath)
	}
	cleaned := filepath.Clean(filePath)
	if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("path %q escapes the project", filePath)
	}

	projectRoot, err := filepath.Abs(".")
	if err != nil {
		return "", err
	}
	resolvedRoot, err := filepath.EvalSymlinks(projectRoot)
	if err != nil {
		return "", err
	}
	candidate := filepath.Join(resolvedRoot, cleaned)
	checkPath := candidate
	if !mustExist {
		for {
			if _, statErr := os.Lstat(checkPath); statErr == nil {
				break
			} else if !os.IsNotExist(statErr) {
				return "", statErr
			}
			checkPath = filepath.Dir(candidate)
			if checkPath == resolvedRoot {
				break
			}
			candidate = checkPath
		}
	}
	resolved, err := filepath.EvalSymlinks(checkPath)
	if err != nil {
		return "", err
	}
	relative, err := filepath.Rel(resolvedRoot, resolved)
	if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
		return "", fmt.Errorf("path %q resolves outside the project", filePath)
	}
	return cleaned, nil
}
F
function

validateExtraArgs

Parameters

args
[]string

Returns

bool
error
cmd/rfw/plugins/tailwind/tailwind.go:176-191
func validateExtraArgs(args []string) (bool, error)

{
	if len(args) == 0 {
		return false, nil
	}
	if len(args) != 2 || args[0] != "--config" && args[0] != "-c" {
		return false, fmt.Errorf("tailwind args only support the project-root tailwind.config.js")
	}
	configPath, err := validateProjectFile(args[1], true)
	if err != nil {
		return false, fmt.Errorf("invalid tailwind config: %w", err)
	}
	if configPath != "tailwind.config.js" {
		return false, fmt.Errorf("tailwind config must be tailwind.config.js in the project root")
	}
	return true, nil
}
F
function

TestShouldRebuild

TestShouldRebuild ensures the plugin’s rebuild triggers are detected
correctly based on file paths and extensions.

Parameters

cmd/rfw/plugins/tailwind/tailwind_test.go:13-31
func TestShouldRebuild(t *testing.T)

{
	p := &plugin{output: "tailwind.css"}

	if !p.ShouldRebuild("style.css") {
		t.Fatalf("expected css change to trigger rebuild")
	}
	if p.ShouldRebuild("tailwind.css") {
		t.Fatalf("output file should not trigger rebuild")
	}
	if !p.ShouldRebuild("index.html") || !p.ShouldRebuild("tmpl.rtml") {
		t.Fatalf("html/rtml should trigger rebuild")
	}
	if !p.ShouldRebuild("main.go") {
		t.Fatalf("go files should trigger rebuild")
	}
	if p.ShouldRebuild("image.png") {
		t.Fatalf("unrelated files should not trigger rebuild")
	}
}
F
function

TestValidateProjectFile

Parameters

cmd/rfw/plugins/tailwind/tailwind_test.go:33-70
func TestValidateProjectFile(t *testing.T)

{
	root := t.TempDir()
	oldWorkingDirectory, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}
	if err := os.Chdir(root); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		if err := os.Chdir(oldWorkingDirectory); err != nil {
			t.Errorf("restore working directory: %v", err)
		}
	})
	if err := os.WriteFile("input.css", []byte("body {}"), 0o600); err != nil {
		t.Fatal(err)
	}

	if _, err := validateProjectFile("input.css", true); err != nil {
		t.Fatalf("expected project file to be valid: %v", err)
	}
	if _, err := validateProjectFile("dist/css/app.css", false); err != nil {
		t.Fatalf("expected nested output to be valid: %v", err)
	}
	for _, filePath := range []string{"../input.css", "/tmp/input.css", "-o"} {
		if _, err := validateProjectFile(filePath, false); err == nil {
			t.Errorf("expected %q to be rejected", filePath)
		}
	}

	outside := t.TempDir()
	if err := os.Symlink(outside, filepath.Join(root, "outside")); err != nil {
		t.Fatal(err)
	}
	if _, err := validateProjectFile("outside/output.css", false); err == nil {
		t.Fatal("expected symlink escape to be rejected")
	}
}
F
function

TestValidateExtraArgs

Parameters

cmd/rfw/plugins/tailwind/tailwind_test.go:72-102
func TestValidateExtraArgs(t *testing.T)

{
	root := t.TempDir()
	oldWorkingDirectory, err := os.Getwd()
	if err != nil {
		t.Fatal(err)
	}
	if err := os.Chdir(root); err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() {
		if err := os.Chdir(oldWorkingDirectory); err != nil {
			t.Errorf("restore working directory: %v", err)
		}
	})
	if err := os.WriteFile("tailwind.config.js", nil, 0o600); err != nil {
		t.Fatal(err)
	}

	useConfig, err := validateExtraArgs([]string{"--config", "tailwind.config.js"})
	if err != nil {
		t.Fatalf("expected config argument to be valid: %v", err)
	}
	if !useConfig {
		t.Fatal("expected config to be enabled")
	}
	for _, args := range [][]string{{"--watch"}, {"--config"}, {"--config", "../config.js"}, {"--config", "config/tailwind.config.js"}} {
		if _, err := validateExtraArgs(args); err == nil {
			t.Errorf("expected %v to be rejected", args)
		}
	}
}
F
function

TestTailwindCommandUsesFixedArguments

Parameters

cmd/rfw/plugins/tailwind/tailwind_test.go:104-134
func TestTailwindCommandUsesFixedArguments(t *testing.T)

{
	tests := []struct {
		name      string
		minify    bool
		useConfig bool
		want      []string
	}{
		{name: "default", want: []string{"tailwindcss", "-i", "-"}},
		{name: "minified", minify: true, want: []string{"tailwindcss", "-i", "-", "--minify"}},
		{name: "configured", useConfig: true, want: []string{"tailwindcss", "-i", "-", "--config", "tailwind.config.js"}},
		{
			name:      "configured and minified",
			minify:    true,
			useConfig: true,
			want:      []string{"tailwindcss", "-i", "-", "--minify", "--config", "tailwind.config.js"},
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			command := tailwindCommand(test.minify, test.useConfig)
			if len(command.Args) != len(test.want) {
				t.Fatalf("args %v, want %v", command.Args, test.want)
			}
			for index := range test.want {
				if command.Args[index] != test.want[index] {
					t.Fatalf("args %v, want %v", command.Args, test.want)
				}
			}
		})
	}
}