Code/TailBox/Application/TailBoxGenerationPipeline.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Sandbox.TailBox;
internal static class TailBoxGenerationPipeline
{
public static TailBoxGenerationResult Run( IEnumerable<TailBoxSourceText> sources, TailBoxConfig config, string projectRoot, string outputPath )
{
var input = TailBoxPipelineInput.Create( sources, config, projectRoot, outputPath );
var inventory = CollectCandidates( input );
var compilation = CompileCandidates( inventory, TailBoxThemeFactory.FromConfig( input.Config ) );
var scss = TailBoxScssRenderer.Render( compilation.Rules, input.Config, input.Sources.Count );
return BuildResult( input, inventory, compilation, scss );
}
private static TailBoxCandidateInventory CollectCandidates( TailBoxPipelineInput input )
{
var occurrences = TailBoxClassExtractor.ExtractClassOccurrencesFromSources( input.Sources );
var discoveredClasses = new SortedSet<string>( occurrences.Select( occurrence => occurrence.ClassName ), StringComparer.Ordinal );
var sourceByClass = new Dictionary<string, string>( StringComparer.Ordinal );
foreach ( var occurrence in occurrences )
{
if ( !sourceByClass.ContainsKey( occurrence.ClassName ) )
sourceByClass[occurrence.ClassName] = occurrence.SourcePath;
}
foreach ( var safelisted in input.Config.Safelist ?? Enumerable.Empty<string>() )
{
foreach ( var item in SplitClassList( safelisted ) )
{
discoveredClasses.Add( item );
}
}
return new TailBoxCandidateInventory( discoveredClasses, sourceByClass );
}
private static TailBoxCompilationOutput CompileCandidates( TailBoxCandidateInventory inventory, TailBoxTheme theme )
{
var generatedClasses = new List<string>();
var skippedClasses = new List<string>();
var skippedItems = new List<TailBoxSkippedClass>();
var warnings = new List<string>();
var rules = new List<TailBoxUtilityRule>();
foreach ( var className in inventory.Classes )
{
if ( TailBoxUtilityCompiler.TryCompileDetailed( className, theme, out var rule, out var skipped ) )
{
generatedClasses.Add( className );
rules.Add( rule );
continue;
}
if ( skipped is null )
continue;
var withSource = WithSource( skipped, inventory.SourceByClass.TryGetValue( className, out var sourcePath ) ? sourcePath : null );
skippedItems.Add( withSource );
skippedClasses.Add( className );
if ( !string.IsNullOrWhiteSpace( withSource.Detail ) )
warnings.Add( $"{className}: {withSource.Detail}" );
}
AddGapPairRules( rules );
AddBorderSidePairRules( rules, theme );
return new TailBoxCompilationOutput( rules, generatedClasses, skippedClasses, skippedItems, warnings );
}
private static void AddGapPairRules( List<TailBoxUtilityRule> rules )
{
var xRules = rules
.Where( rule => rule.ClassName.StartsWith( "gap-x-", StringComparison.Ordinal ) )
.Select( rule => new { Rule = rule, Value = GetDeclarationValue( rule, "column-gap" ) } )
.Where( item => !string.IsNullOrWhiteSpace( item.Value ) )
.ToArray();
var yRules = rules
.Where( rule => rule.ClassName.StartsWith( "gap-y-", StringComparison.Ordinal ) )
.Select( rule => new { Rule = rule, Value = GetDeclarationValue( rule, "row-gap" ) } )
.Where( item => !string.IsNullOrWhiteSpace( item.Value ) )
.ToArray();
foreach ( var x in xRules )
{
foreach ( var y in yRules )
{
var rule = new TailBoxUtilityRule
{
ClassName = $"{x.Rule.ClassName}+{y.Rule.ClassName}",
Selector = $"{x.Rule.Selector}{y.Rule.Selector}"
};
rule.Declarations.Add( new TailBoxDeclaration( "gap", $"{y.Value} {x.Value}" ) );
rule.Declarations.Add( new TailBoxDeclaration( "row-gap", y.Value ) );
rule.Declarations.Add( new TailBoxDeclaration( "column-gap", x.Value ) );
rules.Add( rule );
}
}
}
private static string GetDeclarationValue( TailBoxUtilityRule rule, string property )
{
return rule.Declarations.FirstOrDefault( declaration => declaration.Property == property ).Value;
}
private static void AddBorderSidePairRules( List<TailBoxUtilityRule> rules, TailBoxTheme theme )
{
foreach ( var side in new[] { "t", "r", "b", "l" } )
{
var property = side switch
{
"t" => "border-top",
"r" => "border-right",
"b" => "border-bottom",
"l" => "border-left",
_ => ""
};
var prefix = "border-" + side;
var sideRules = rules
.Where( rule => rule.ClassName == prefix || rule.ClassName.StartsWith( prefix + "-", StringComparison.Ordinal ) )
.Select( rule => new
{
Rule = rule,
Value = GetDeclarationValue( rule, property ),
Kind = GetBorderSideRuleKind( rule.ClassName, prefix, theme )
} )
.Where( item => !string.IsNullOrWhiteSpace( item.Value ) )
.ToArray();
var widthRules = sideRules.Where( item => item.Kind == BorderSideRuleKind.Width ).ToArray();
var colorRules = sideRules.Where( item => item.Kind == BorderSideRuleKind.Color ).ToArray();
foreach ( var width in widthRules )
{
foreach ( var color in colorRules )
{
var borderWidth = GetBorderShorthandWidth( width.Value );
var borderColor = GetBorderShorthandColor( color.Value );
if ( string.IsNullOrWhiteSpace( borderWidth ) || string.IsNullOrWhiteSpace( borderColor ) )
continue;
var rule = new TailBoxUtilityRule
{
ClassName = $"{width.Rule.ClassName}+{color.Rule.ClassName}",
Selector = $"{width.Rule.Selector}{color.Rule.Selector}"
};
rule.Declarations.Add( new TailBoxDeclaration( property, $"{borderWidth} solid {borderColor}" ) );
rules.Add( rule );
}
}
}
}
private enum BorderSideRuleKind
{
Unknown,
Width,
Color
}
private static BorderSideRuleKind GetBorderSideRuleKind( string className, string prefix, TailBoxTheme theme )
{
if ( className == prefix )
return BorderSideRuleKind.Width;
var key = className[(prefix.Length + 1)..];
if ( IsArbitraryValue( key ) )
{
var arbitrary = TailBoxText.DecodeArbitraryValue( key[1..^1] );
return LooksLikeColorValue( arbitrary ) ? BorderSideRuleKind.Color : BorderSideRuleKind.Width;
}
if ( theme.BorderWidths.ContainsKey( key ) || double.TryParse( key, out _ ) )
return BorderSideRuleKind.Width;
return BorderSideRuleKind.Color;
}
private static bool IsArbitraryValue( string key )
{
return key.StartsWith( "[", StringComparison.Ordinal ) && key.EndsWith( "]", StringComparison.Ordinal );
}
private static bool LooksLikeColorValue( string value )
{
var lower = value.Trim().ToLowerInvariant();
return lower.StartsWith( "#", StringComparison.Ordinal )
|| lower.StartsWith( "rgb(", StringComparison.Ordinal )
|| lower.StartsWith( "rgba(", StringComparison.Ordinal )
|| lower.StartsWith( "hsl(", StringComparison.Ordinal )
|| lower.StartsWith( "hsla(", StringComparison.Ordinal );
}
private static string GetBorderShorthandWidth( string value )
{
var marker = " solid ";
var index = value.IndexOf( marker, StringComparison.Ordinal );
return index < 0 ? "" : value[..index];
}
private static string GetBorderShorthandColor( string value )
{
var marker = " solid ";
var index = value.IndexOf( marker, StringComparison.Ordinal );
return index < 0 ? "" : value[(index + marker.Length)..];
}
private static TailBoxGenerationResult BuildResult(
TailBoxPipelineInput input,
TailBoxCandidateInventory inventory,
TailBoxCompilationOutput compilation,
string scss )
{
return new TailBoxGenerationResult
{
ProjectRoot = input.ProjectRoot,
OutputPath = input.OutputPath,
GeneratedScss = scss,
ScannedFileCount = input.Sources.Count,
DiscoveredClassCount = inventory.Classes.Count,
GeneratedClasses = compilation.GeneratedClasses,
SkippedClasses = compilation.SkippedClasses,
Skipped = compilation.Skipped,
Warnings = compilation.Warnings,
WroteFile = false
};
}
private static IEnumerable<string> SplitClassList( string value )
{
if ( string.IsNullOrWhiteSpace( value ) )
yield break;
foreach ( var token in value.Split( new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries ) )
{
var cleaned = token.Trim().Trim( ',', ';', '"', '\'' );
if ( !string.IsNullOrWhiteSpace( cleaned ) )
yield return cleaned;
}
}
private static TailBoxSkippedClass WithSource( TailBoxCompileDiagnostic skipped, string sourcePath )
{
return new TailBoxSkippedClass
{
ClassName = skipped.ClassName,
Reason = skipped.Reason,
Detail = skipped.Detail,
SourcePath = sourcePath
};
}
}