Init
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
bin
|
||||||
|
obj
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace Htmx.ApiDemo;
|
||||||
|
|
||||||
|
[JsonSerializable(typeof(string))]
|
||||||
|
internal partial class AppJsonSerializerContext : JsonSerializerContext
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<PublishAot>true</PublishAot>
|
||||||
|
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
|
||||||
|
<CompilerGeneratedFilesOutputPath>obj/Generated</CompilerGeneratedFilesOutputPath>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<CompilerVisibleProperty Include="RootNamespace" />
|
||||||
|
<CompilerVisibleProperty Include="MSBuildProjectDirectory" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<AdditionalFiles Include="**/*.htmx" />
|
||||||
|
<None Remove="**/*.htmx" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Immediate.Apis" Version="4.2.0" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.7" />
|
||||||
|
<ProjectReference Include="..\Htmx.SourceGenerator\Htmx.SourceGenerator.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\Htmx.SourceGenerator\Htmx.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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
|
||||||
|
|
||||||
|
###
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="greeting">
|
||||||
|
<h1>Hello, $$User$$!</h1>
|
||||||
|
<p>Welcome to high-performance htmx rendering.</p>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
|
||||||
|
<IsRoslynComponent>true</IsRoslynComponent>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0">
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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 = $@"// <auto-generated/>
|
||||||
|
using System;
|
||||||
|
using System.Buffers;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
namespace {opt.RootNamespace};
|
||||||
|
|
||||||
|
public readonly struct HtmxRenderContext
|
||||||
|
{{
|
||||||
|
public readonly IBufferWriter<byte> Writer;
|
||||||
|
public readonly int Depth;
|
||||||
|
private const int MaxDepth = 512;
|
||||||
|
|
||||||
|
public HtmxRenderContext(IBufferWriter<byte> 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<byte> writer, IHtmxComponent component)
|
||||||
|
{{
|
||||||
|
component.Render(new HtmxRenderContext(writer));
|
||||||
|
}}
|
||||||
|
|
||||||
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
|
public static void WriteUtf8(this IBufferWriter<byte> writer, ReadOnlySpan<byte> 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("// <auto-generated/>");
|
||||||
|
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<byte> _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<TemplateSegment> ParseTemplate(string content)
|
||||||
|
{
|
||||||
|
var builder = ImmutableArray.CreateBuilder<TemplateSegment>();
|
||||||
|
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<TemplateSegment> Segments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace System.Runtime.CompilerServices
|
||||||
|
{
|
||||||
|
// Required for records/init properties in netstandard2.0
|
||||||
|
internal static class IsExternalInit { }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user