Skip to content

robots.txt Generator — C# source

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

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

// robots-txt-generator — C# port: standards-compliant robots.txt generator + parser.
//
// Display port of the CosmoDev Robots.txt Generator tool — same contract as
// cli/robots-txt-generator/robots-txt-generator.go (the live Go twin) and
// src/lib/robotsTxt.ts (canonical TypeScript). Pure + deterministic; BCL only.
// A group carries a LIST of user-agents (the REP spec's stacked User-agent
// lines); parsing aggregates repeated blocks per agent, then re-merges agents
// whose accumulated rules are identical into one stacked group, like the TS lib.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace RobotsTxtGenerator;

/// <summary>One REP group — mirrors <c>RuleGroup</c> in the Go twin.</summary>
public sealed record RuleGroup(
    IReadOnlyList<string> UserAgents,          // ["*"] or ["Googlebot", "Bingbot"]
    IReadOnlyList<string> Disallow,            // "" renders as a bare "Disallow:"
    IReadOnlyList<string> Allow,
    double? CrawlDelay = null);                // null = unset; non-finite never emitted

public sealed record RobotsConfig(
    IReadOnlyList<RuleGroup> Groups,
    IReadOnlyList<string> Sitemaps);

public static class RobotsTxt
{
    // cleanAgents in the TS lib: trim + drop blanks. A non-empty list that
    // trims away entirely degrades to ["*"]; an empty list means "no group yet".
    private static List<string> CleanAgents(IReadOnlyList<string> list)
    {
        var trimmed = list.Select(u => u.Trim()).Where(u => u.Length > 0).ToList();
        return trimmed.Count == 0 && list.Count > 0 ? new List<string> { "*" } : trimmed;
    }

    // JS-style number rendering for Crawl-delay: 10.0 -> "10", 1.5 -> "1.5".
    private static string NumStr(double f) => f == Math.Floor(f) ? ((long)f).ToString() : f.ToString();

    public static string GenerateRobots(RobotsConfig config)
    {
        var lines = new List<string>();
        foreach (var g in config.Groups)
        {
            var uas = CleanAgents(g.UserAgents);
            if (uas.Count == 0) continue;
            uas.ForEach(ua => lines.Add($"User-agent: {ua}"));
            foreach (var a in g.Allow)
            {
                var p = a.Trim();
                if (p.Length > 0) lines.Add($"Allow: {p}");
            }
            if (g.Disallow.Count == 0)
            {
                lines.Add("Disallow:"); // no entries -> the allow-all marker
            }
            else
            {
                foreach (var d in g.Disallow) lines.Add($"Disallow: {d}"); // "" kept verbatim
            }
            if (g.CrawlDelay is double cd && double.IsFinite(cd))
                lines.Add($"Crawl-delay: {NumStr(cd)}");
            lines.Add(""); // blank line separates groups
        }
        foreach (var s in config.Sitemaps)
        {
            var url = s.Trim();
            if (url.Length > 0) lines.Add($"Sitemap: {url}");
        }
        return Regex.Replace(string.Join('\n', lines), @"\n{3,}", "\n\n").TrimEnd() + "\n";
    }

    private sealed class Acc
    {
        public readonly string Ua;
        public readonly List<string> Disallow = new(), Allow = new();
        public double? CrawlDelay;
        public Acc(string ua) { Ua = ua; }
    }

    // ParseRobots in the Go twin: strip '#'-comments, split on the FIRST ':'
    // (values may contain colons), unknown directives ignored. TryParse yields
    // null for garbage where JS Number() yields NaN — both are never emitted.
    public static RobotsConfig ParseRobots(string? text)
    {
        var perUA = new List<Acc>(); // insertion-ordered
        var sitemaps = new List<string>();
        var stack = new List<string>();
        var current = new List<Acc>();

        void Flush() // a rule line applies to every agent of the pending stack
        {
            if (stack.Count == 0) return;
            current = stack.Select(ua =>
            {
                var hit = perUA.Find(a => a.Ua == ua);
                if (hit is null) { hit = new Acc(ua); perUA.Add(hit); }
                return hit;
            }).ToList();
            stack.Clear();
        }

        foreach (var raw in (text ?? "").Split('\n'))
        {
            var line = Regex.Replace(raw, @"#.*$", "").Trim();
            if (line.Length == 0) continue;
            var colon = line.IndexOf(':');
            if (colon < 0) continue;
            var field = line[..colon].Trim().ToLowerInvariant();
            var value = line[(colon + 1)..].Trim();

            switch (field)
            {
                case "user-agent":
                    var ua = value.Length > 0 ? value : "*";
                    if (!stack.Contains(ua)) stack.Add(ua);
                    break;
                case "disallow":
                    Flush(); current.ForEach(a => a.Disallow.Add(value)); break;
                case "allow":
                    Flush(); current.ForEach(a => a.Allow.Add(value)); break;
                case "crawl-delay":
                    Flush(); // Number() semantics: garbage -> NaN (never emitted)
                    current.ForEach(a => a.CrawlDelay = double.TryParse(value, out var v) ? v : double.NaN);
                    break;
                case "sitemap":
                    Flush(); sitemaps.Add(value); break;
            }
        }

        // Merge agents with identical rule sets into one stacked group.
        var groups = new List<RuleGroup>();
        foreach (var acc in perUA)
        {
            var i = groups.FindIndex(g => g.Disallow.SequenceEqual(acc.Disallow)
                                       && g.Allow.SequenceEqual(acc.Allow) && g.CrawlDelay == acc.CrawlDelay);
            if (i >= 0) groups[i] = groups[i] with { UserAgents = groups[i].UserAgents.Append(acc.Ua).ToList() };
            else groups.Add(new RuleGroup(new[] { acc.Ua }, acc.Disallow, acc.Allow, acc.CrawlDelay));
        }
        return new RobotsConfig(groups, sitemaps);
    }
}

public static class Program
{
    public static void Main()
    {
        // Sample config -> generated body -> parse round-trip check (tool page behavior).
        var config = new RobotsConfig(
            new RuleGroup[]
            {
                new(new[] { "*" }, new[] { "/private/", "/admin/" }, new[] { "/admin/public/" }),
                new(new[] { "GPTBot", "CCBot" }, new[] { "/" }, Array.Empty<string>(), 10.0),
            },
            new[] { "https://example.com/sitemap.xml" });
        var body = RobotsTxt.GenerateRobots(config);
        Console.Write(body);
        Console.WriteLine($"round-trip stable: {RobotsTxt.GenerateRobots(RobotsTxt.ParseRobots(body)) == body}");
    }
}

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 →