using System; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Text; namespace Htmx.SourceGenerator { [Generator] public class HtmxGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { var globalOptions = context.AnalyzerConfigOptionsProvider.Select((opt, _) => (RootNamespace: GetOption(opt.GlobalOptions, "build_property.RootNamespace", "HtmxTemplates"), ProjectDir: GetOption(opt.GlobalOptions, "build_property.MSBuildProjectDirectory", ""))); context.RegisterSourceOutput(globalOptions, (spc, opt) => { var infrastructureSource = $@"// using System; using System.Buffers; using System.Runtime.CompilerServices; namespace {opt.RootNamespace}; public readonly struct HtmxRenderContext {{ public readonly IBufferWriter Writer; public readonly int Depth; private const int MaxDepth = 512; public HtmxRenderContext(IBufferWriter writer, int depth = 0) {{ if (depth > MaxDepth) throw new InvalidOperationException(""Template recursion limit exceeded.""); Writer = writer; Depth = depth; }} [MethodImpl(MethodImplOptions.AggressiveInlining)] public HtmxRenderContext Next() => new(Writer, Depth + 1); }} public interface IHtmxComponent {{ void Render(HtmxRenderContext context); }} public static class HtmxGeneratedExtensions {{ public static byte[] ToUtf8Bytes(this string str) => System.Text.Encoding.UTF8.GetBytes(str); public static void WriteHtmx(this IBufferWriter writer, IHtmxComponent component) {{ component.Render(new HtmxRenderContext(writer)); }} [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void WriteUtf8(this IBufferWriter writer, ReadOnlySpan data) {{ var buffer = writer.GetSpan(data.Length); data.CopyTo(buffer); writer.Advance(data.Length); }} public static void WriteHtmxBody(this HttpContext context, IHtmxComponent component) {{ context.Response.ContentType = ""text/html; charset=utf-8""; var writerContext = new HtmxRenderContext(context.Response.BodyWriter); component.Render(writerContext); }} }}"; spc.AddSource("HtmxInfrastructure.g.cs", SourceText.From(infrastructureSource, Encoding.UTF8)); }); var htmxFiles = context.AdditionalTextsProvider .Where(file => file.Path.EndsWith(".htmx")) .Combine(globalOptions); var templateData = htmxFiles.Select((combined, ct) => { var (file, opt) = combined; var content = file.GetText(ct)?.ToString() ?? string.Empty; string relativePath = Path.GetDirectoryName(file.Path) ?? ""; string subNamespace = ""; if (!string.IsNullOrEmpty(opt.ProjectDir) && relativePath.StartsWith(opt.ProjectDir)) { subNamespace = relativePath.Substring(opt.ProjectDir.Length) .Trim(Path.DirectorySeparatorChar) .Replace(Path.DirectorySeparatorChar, '.'); } string fullNamespace = string.IsNullOrEmpty(subNamespace) ? opt.RootNamespace : $"{opt.RootNamespace}.{subNamespace}"; return new TemplateInfo( Path.GetFileNameWithoutExtension(file.Path), fullNamespace, opt.RootNamespace, ParseTemplate(content) ); }); context.RegisterSourceOutput(templateData, (spc, data) => { spc.AddSource($"{data.ClassName}.g.cs", GenerateSource(data)); }); } private static string GetOption(AnalyzerConfigOptions options, string key, string fallback) => options.TryGetValue(key, out var value) ? value : fallback; private static string GenerateSource(TemplateInfo data) { var sb = new StringBuilder(); sb.AppendLine("// "); sb.AppendLine("using System;"); sb.AppendLine("using System.Buffers;"); sb.AppendLine("using System.Runtime.CompilerServices;"); if (data.Namespace != data.RootNamespace) { sb.AppendLine($"using {data.RootNamespace};"); } sb.AppendLine($"namespace {data.Namespace};"); sb.AppendLine(); sb.AppendLine($"public abstract partial class {data.ClassName}Base : IHtmxComponent"); sb.AppendLine("{"); var variables = data.Segments.Where(s => !s.IsStatic).Select(s => s.Value).Distinct(); foreach (var v in variables) { // Passing context allows user to write direct spans OR nested components sb.AppendLine($" protected abstract void Render{v}(HtmxRenderContext context);"); } int staticCount = 0; foreach (var s in data.Segments.Where(s => s.IsStatic)) { var bytes = Encoding.UTF8.GetBytes(s.Value); var byteString = string.Join(", ", bytes.Select(b => $"0x{b:X2}")); sb.AppendLine($" private static ReadOnlySpan _part{staticCount++} => new byte[] {{ {byteString} }};"); } sb.AppendLine(); sb.AppendLine(" public void Render(HtmxRenderContext context)"); sb.AppendLine(" {"); int sIdx = 0; foreach (var segment in data.Segments) { if (segment.IsStatic) sb.AppendLine($" context.Writer.WriteUtf8(_part{sIdx++});"); else // context.Next() enforces the depth limit before calling the user implementation sb.AppendLine($" Render{segment.Value}(context.Next());"); } sb.AppendLine(" }"); sb.AppendLine("}"); return sb.ToString(); } private static ImmutableArray ParseTemplate(string content) { var builder = ImmutableArray.CreateBuilder(); var variableRegex = new Regex(@"\$\$([a-zA-Z0-9_]+)\$\$", RegexOptions.Compiled); var matches = variableRegex.Matches(content); int lastPos = 0; foreach (Match match in matches) { if (match.Index > lastPos) builder.Add(new(content.Substring(lastPos, match.Index - lastPos), true)); builder.Add(new(match.Groups[1].Value, false)); lastPos = match.Index + match.Length; } if (lastPos < content.Length) builder.Add(new(content.Substring(lastPos), true)); return builder.ToImmutable(); } private record TemplateSegment(string Value, bool IsStatic); private record TemplateInfo(string ClassName, string Namespace, string RootNamespace, ImmutableArray Segments); } } namespace System.Runtime.CompilerServices { // Required for records/init properties in netstandard2.0 internal static class IsExternalInit { } }