Skip to content

robots.txt Generator — Go source

Build a standards-compliant robots.txt with per-user-agent allow/disallow rules, crawl-delay, and sitemap entries.

This is the Go implementation — the same logic the interactive tool runs, in a shareable, citable form.

// Package robotstxt is the Go twin of CosmoDev's src/lib/robotsTxt.ts (dual
// source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// robots-txt-generator_test.go share vectors with src/lib/robotsTxt.test.ts so
// the two implementations are held to the same contract.
//
// GenerateRobots builds standards-compliant User-agent / Allow / Disallow /
// Crawl-delay groups plus Sitemap entries; ParseRobots reads them back. Both
// mirror the TS lib's algorithm and public surface faithfully.
package robotstxt

import (
	"math"
	"regexp"
	"slices"
	"strconv"
	"strings"
)

// RuleGroup mirrors src/lib/robotsTxt.ts RuleGroup. UserAgents carries the
// REP spec's stacked User-agent lines (one group, several agents). CrawlDelay
// is a *float64 so the zero value (nil) matches the TS default
// (crawlDelay?: number | null); a non-nil non-finite value (NaN/±Inf) is
// treated like TS's Number.isFinite guard and omitted on output.
type RuleGroup struct {
	UserAgents []string // ['*'] or ['Googlebot', 'Bingbot'] - one REP group
	Disallow   []string // paths ('' means "Disallow:" → allow all); kept verbatim, NOT trimmed
	Allow      []string // whitespace-only entries dropped on output
	CrawlDelay *float64 // nil → omitted; non-finite → omitted on output
}

// RobotsConfig mirrors src/lib/robotsTxt.ts RobotsConfig.
type RobotsConfig struct {
	Groups   []RuleGroup
	Sitemaps []string
}

// multiBlank collapses runs of 3+ newlines into a single blank line, mirroring
// the TS `replace(/\n{3,}/g, '\n\n')` final normalization step.
var multiBlank = regexp.MustCompile(`\n{3,}`)

// finiteCrawlDelay reports cd's value iff it is non-nil and finite, mirroring
// the TS guard `crawlDelay !== undefined && crawlDelay !== null && Number.isFinite(crawlDelay)`.
func finiteCrawlDelay(cd *float64) (float64, bool) {
	if cd == nil {
		return 0, false
	}
	v := *cd
	if math.IsNaN(v) || math.IsInf(v, 0) {
		return 0, false
	}
	return v, true
}

// cleanAgents is the Go twin of cleanAgents() in src/lib/robotsTxt.ts: trim
// the agent list and drop empties; a non-empty list that trims away entirely
// degrades to ['*'] (a group without a User-agent line is not valid
// robots.txt); an empty list means "no group yet" and skips the group.
func cleanAgents(list []string) []string {
	trimmed := make([]string, 0, len(list))
	for _, u := range list {
		if t := strings.TrimSpace(u); t != "" {
			trimmed = append(trimmed, t)
		}
	}
	if len(trimmed) == 0 && len(list) > 0 {
		return []string{"*"}
	}
	return trimmed
}

// GenerateRobots is the Go twin of generateRobots() in src/lib/robotsTxt.ts.
// It renders a RobotsConfig into robots.txt text. Pure + deterministic.
func GenerateRobots(config RobotsConfig) string {
	var out []string
	for _, g := range config.Groups {
		uas := cleanAgents(g.UserAgents)
		if len(uas) == 0 {
			continue // skip a group whose agent list is empty
		}
		for _, ua := range uas {
			out = append(out, "User-agent: "+ua)
		}

		for _, a := range g.Allow {
			p := strings.TrimSpace(a)
			if p != "" {
				out = append(out, "Allow: "+p)
			}
		}

		if len(g.Disallow) == 0 {
			out = append(out, "Disallow:")
		} else {
			// Disallow paths are kept verbatim (NOT trimmed) — '' → "Disallow: ".
			for _, d := range g.Disallow {
				out = append(out, "Disallow: "+d)
			}
		}

		if v, ok := finiteCrawlDelay(g.CrawlDelay); ok {
			// FormatFloat with prec -1 mirrors JS `${number}`: 10 → "10", 7.5 → "7.5".
			out = append(out, "Crawl-delay: "+strconv.FormatFloat(v, 'f', -1, 64))
		}

		out = append(out, "") // blank line separating groups / trailing
	}

	for _, s := range config.Sitemaps {
		url := strings.TrimSpace(s)
		if url != "" {
			out = append(out, "Sitemap: "+url)
		}
	}

	joined := strings.Join(out, "\n")
	joined = multiBlank.ReplaceAllString(joined, "\n\n")
	// trimEnd() + '\n' — strip all trailing whitespace, then restore a single newline.
	return strings.TrimRight(joined, "\n\r\t\v\f ") + "\n"
}

// crawlDelayEq reports whether two CrawlDelay values match the TS bucket key
// `crawlDelay ?? null` — nil equals nil, and non-nil compares by value.
func crawlDelayEq(a, b *float64) bool {
	if a == nil || b == nil {
		return a == nil && b == nil
	}
	return *a == *b
}

// ParseRobots is the Go twin of parseRobots() in src/lib/robotsTxt.ts. It reads
// robots.txt text into a RobotsConfig, tolerating comments, blank lines, and
// empty input without error. Pure + deterministic.
//
// Rules attach PER AGENT (Google's merge semantics): a rule line applies to
// every agent of the immediately preceding consecutive User-agent stack. At
// the end, agents whose accumulated rule sets are identical are emitted as one
// stacked-agent group, so the common (A, B share everything) case stays
// compact while partially-overlapping blocks regroup losslessly.
func ParseRobots(text string) RobotsConfig {
	config := RobotsConfig{}

	type acc struct {
		disallow   []string
		allow      []string
		crawlDelay *float64
	}
	perUA := map[string]*acc{}
	var order []string
	var stack []string
	var current []string

	flush := func() {
		if len(stack) == 0 {
			return
		}
		for _, ua := range stack {
			if _, ok := perUA[ua]; !ok {
				perUA[ua] = &acc{}
				order = append(order, ua)
			}
		}
		current = stack
		stack = nil
	}

	for _, rawLine := range strings.Split(text, "\n") {
		// Strip comments: rawLine.replace(/#.*$/, '').
		if h := strings.IndexByte(rawLine, '#'); h >= 0 {
			rawLine = rawLine[:h]
		}
		line := strings.TrimSpace(rawLine)
		if line == "" {
			continue
		}
		// First-colon split (line.slice(0, colon) / line.slice(colon+1)).
		fieldRaw, value, found := strings.Cut(line, ":")
		if !found {
			continue
		}
		field := strings.ToLower(strings.TrimSpace(fieldRaw))
		value = strings.TrimSpace(value)

		switch field {
		case "user-agent":
			ua := value
			if ua == "" {
				ua = "*" // value || '*'
			}
			if !slices.Contains(stack, ua) {
				stack = append(stack, ua)
			}
		case "disallow":
			flush()
			for _, ua := range current {
				a := perUA[ua]
				a.disallow = append(a.disallow, value)
			}
		case "allow":
			flush()
			for _, ua := range current {
				a := perUA[ua]
				a.allow = append(a.allow, value)
			}
		case "crawl-delay":
			flush()
			cd, err := strconv.ParseFloat(value, 64)
			if err != nil {
				cd = math.NaN() // mirror Number(value) → NaN for garbage input
			}
			for _, ua := range current {
				c := cd // fresh copy per agent — each acc owns its pointer
				perUA[ua].crawlDelay = &c
			}
		case "sitemap":
			flush() // a Sitemap line is groupless; the last stack keeps receiving rules
			config.Sitemaps = append(config.Sitemaps, value)
		}
	}
	flush()

	// Regroup: agents whose accumulated rule sets are identical share one
	// stacked-agent group, buckets created in first-appearance order (mirrors
	// the TS Map<key, RuleGroup> bucket pass).
	var groups []RuleGroup
	for _, ua := range order {
		a := perUA[ua]
		merged := false
		for i := range groups {
			g := &groups[i]
			if crawlDelayEq(g.CrawlDelay, a.crawlDelay) &&
				slices.Equal(g.Disallow, a.disallow) &&
				slices.Equal(g.Allow, a.allow) {
				g.UserAgents = append(g.UserAgents, ua)
				merged = true
				break
			}
		}
		if !merged {
			groups = append(groups, RuleGroup{
				UserAgents: []string{ua},
				Disallow:   a.disallow,
				Allow:      a.allow,
				CrawlDelay: a.crawlDelay,
			})
		}
	}

	config.Groups = groups
	return config
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →