From 7778a94cf5c0f429895b476ed09fdcb844fd1d6d Mon Sep 17 00:00:00 2001 From: Enciphered Date: Sun, 3 May 2026 23:35:42 +0500 Subject: [PATCH] Init --- .gitignore | 2 + Htmx.ApiDemo/AppJsonSerializerContext.cs | 9 + Htmx.ApiDemo/Htmx.ApiDemo.csproj | 33 +++ Htmx.ApiDemo/Htmx.ApiDemo.http | 11 + Htmx.ApiDemo/Htmx.ApiDemo.sln | 24 ++ Htmx.ApiDemo/Program.cs | 28 +++ Htmx.ApiDemo/Properties/launchSettings.json | 15 ++ Htmx.ApiDemo/Templates/Greeting.htmx | 4 + Htmx.ApiDemo/Templates/Greeting.htmx.cs | 37 ++++ Htmx.ApiDemo/appsettings.Development.json | 8 + Htmx.ApiDemo/appsettings.json | 9 + .../Htmx.SourceGenerator.csproj | 21 ++ Htmx.SourceGenerator/HtmxGenerator.cs | 205 ++++++++++++++++++ Htmx.slnx | 4 + 14 files changed, 410 insertions(+) create mode 100644 .gitignore create mode 100644 Htmx.ApiDemo/AppJsonSerializerContext.cs create mode 100644 Htmx.ApiDemo/Htmx.ApiDemo.csproj create mode 100644 Htmx.ApiDemo/Htmx.ApiDemo.http create mode 100644 Htmx.ApiDemo/Htmx.ApiDemo.sln create mode 100644 Htmx.ApiDemo/Program.cs create mode 100644 Htmx.ApiDemo/Properties/launchSettings.json create mode 100644 Htmx.ApiDemo/Templates/Greeting.htmx create mode 100644 Htmx.ApiDemo/Templates/Greeting.htmx.cs create mode 100644 Htmx.ApiDemo/appsettings.Development.json create mode 100644 Htmx.ApiDemo/appsettings.json create mode 100644 Htmx.SourceGenerator/Htmx.SourceGenerator.csproj create mode 100644 Htmx.SourceGenerator/HtmxGenerator.cs create mode 100644 Htmx.slnx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d4a6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +bin +obj \ No newline at end of file diff --git a/Htmx.ApiDemo/AppJsonSerializerContext.cs b/Htmx.ApiDemo/AppJsonSerializerContext.cs new file mode 100644 index 0000000..bdb87bb --- /dev/null +++ b/Htmx.ApiDemo/AppJsonSerializerContext.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace Htmx.ApiDemo; + +[JsonSerializable(typeof(string))] +internal partial class AppJsonSerializerContext : JsonSerializerContext +{ + +} \ No newline at end of file diff --git a/Htmx.ApiDemo/Htmx.ApiDemo.csproj b/Htmx.ApiDemo/Htmx.ApiDemo.csproj new file mode 100644 index 0000000..fda4646 --- /dev/null +++ b/Htmx.ApiDemo/Htmx.ApiDemo.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + true + true + true + obj/Generated + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Htmx.ApiDemo/Htmx.ApiDemo.http b/Htmx.ApiDemo/Htmx.ApiDemo.http new file mode 100644 index 0000000..f64fdf5 --- /dev/null +++ b/Htmx.ApiDemo/Htmx.ApiDemo.http @@ -0,0 +1,11 @@ +@Htmx.ApiDemo_HostAddress = http://localhost:5120 + +GET {{Htmx.ApiDemo_HostAddress}}/todos/ +Accept: application/json + +### + +GET {{Htmx.ApiDemo_HostAddress}}/todos/1 +Accept: application/json + +### diff --git a/Htmx.ApiDemo/Htmx.ApiDemo.sln b/Htmx.ApiDemo/Htmx.ApiDemo.sln new file mode 100644 index 0000000..9f398a0 --- /dev/null +++ b/Htmx.ApiDemo/Htmx.ApiDemo.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Htmx.ApiDemo", "Htmx.ApiDemo.csproj", "{38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Debug|Any CPU.Build.0 = Debug|Any CPU + {38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Release|Any CPU.ActiveCfg = Release|Any CPU + {38A5EDEF-D21B-8D4E-D1F2-DDFE0335BD22}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {B66FEAA2-59A2-4489-9AEB-ED875EEE5D3E} + EndGlobalSection +EndGlobal diff --git a/Htmx.ApiDemo/Program.cs b/Htmx.ApiDemo/Program.cs new file mode 100644 index 0000000..a8ea6f5 --- /dev/null +++ b/Htmx.ApiDemo/Program.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using Htmx.ApiDemo.Templates; +using Microsoft.AspNetCore.Http.HttpResults; +using Immediate.Apis; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; +using Htmx.ApiDemo; + +var builder = WebApplication.CreateSlimBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default); +}); +builder.Services.AddHttpContextAccessor(); +builder.Services + .AddHtmxApiDemoBehaviors() + .AddHtmxApiDemoHandlers(); +builder.Services.AddOpenApi(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) + app.MapOpenApi(); + +app.MapHtmxApiDemoEndpoints(); + +app.Run(); \ No newline at end of file diff --git a/Htmx.ApiDemo/Properties/launchSettings.json b/Htmx.ApiDemo/Properties/launchSettings.json new file mode 100644 index 0000000..e74f83a --- /dev/null +++ b/Htmx.ApiDemo/Properties/launchSettings.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "todos", + "applicationUrl": "http://localhost:5120", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx b/Htmx.ApiDemo/Templates/Greeting.htmx new file mode 100644 index 0000000..bf76153 --- /dev/null +++ b/Htmx.ApiDemo/Templates/Greeting.htmx @@ -0,0 +1,4 @@ +
+

Hello, $$User$$!

+

Welcome to high-performance htmx rendering.

+
\ No newline at end of file diff --git a/Htmx.ApiDemo/Templates/Greeting.htmx.cs b/Htmx.ApiDemo/Templates/Greeting.htmx.cs new file mode 100644 index 0000000..62b79ce --- /dev/null +++ b/Htmx.ApiDemo/Templates/Greeting.htmx.cs @@ -0,0 +1,37 @@ +using Htmx.ApiDemo; +using Immediate.Apis; +using Immediate.Apis.Shared; +using Immediate.Handlers.Shared; + +namespace Htmx.ApiDemo.Templates; + +public class Greeting : GreetingBase +{ + private byte[] _userData = []; + public required string Username { init => _userData = value.ToUtf8Bytes(); } + protected override void RenderUser(HtmxRenderContext context) => context.Writer.WriteUtf8(_userData); +} + +[Handler] +[MapGet("/greet/{username}")] +public static partial class GetGreetingHandler +{ + public record Query(string Username); + + private static ValueTask HandleAsync( + Query query, + IHttpContextAccessor httpContextAccessor, + CancellationToken token) + { + var context = httpContextAccessor.HttpContext; + if(context is null) + throw new InvalidOperationException("HttpContext is not available."); + + var template = new Greeting { Username = query.Username }; + + context.Response.ContentType = "text/html; charset=utf-8"; + context.Response.BodyWriter.WriteHtmx(template); + + return ValueTask.CompletedTask; + } +} \ No newline at end of file diff --git a/Htmx.ApiDemo/appsettings.Development.json b/Htmx.ApiDemo/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/Htmx.ApiDemo/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Htmx.ApiDemo/appsettings.json b/Htmx.ApiDemo/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/Htmx.ApiDemo/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Htmx.SourceGenerator/Htmx.SourceGenerator.csproj b/Htmx.SourceGenerator/Htmx.SourceGenerator.csproj new file mode 100644 index 0000000..6b43140 --- /dev/null +++ b/Htmx.SourceGenerator/Htmx.SourceGenerator.csproj @@ -0,0 +1,21 @@ + + + + netstandard2.0 + latest + + + + true + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/Htmx.SourceGenerator/HtmxGenerator.cs b/Htmx.SourceGenerator/HtmxGenerator.cs new file mode 100644 index 0000000..5d9a781 --- /dev/null +++ b/Htmx.SourceGenerator/HtmxGenerator.cs @@ -0,0 +1,205 @@ +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 { } +} \ No newline at end of file diff --git a/Htmx.slnx b/Htmx.slnx new file mode 100644 index 0000000..0b8728d --- /dev/null +++ b/Htmx.slnx @@ -0,0 +1,4 @@ + + + +