From d2ddfa4be4e9ca74ecc923deb17ac7a5636174bb Mon Sep 17 00:00:00 2001 From: Saphire Date: Wed, 16 Feb 2022 19:38:08 +0700 Subject: [PATCH] Use completely standalone Razor template and compiler --- DataModel.cs | 3 + HtmlSafeTemplate.cs | 42 -------- Program.cs | 1 - RazorStandalone.cs | 214 ++++++++++++++++++++++++++++++++++++++++ Services/Generator.cs | 41 ++++---- Services/PostsSource.cs | 23 ++++- page_template.cshtml | 60 +++++++---- quest_reader.csproj | 5 +- 8 files changed, 299 insertions(+), 90 deletions(-) delete mode 100644 HtmlSafeTemplate.cs create mode 100644 RazorStandalone.cs diff --git a/DataModel.cs b/DataModel.cs index 57ddc42..5025f0d 100644 --- a/DataModel.cs +++ b/DataModel.cs @@ -17,6 +17,7 @@ public record ThreadPost [JsonIgnore] public bool IsChapterAnnounce { get; set; } = false; public ChapterMetadata? Chapter { get; set; } + public List? RepliesTo { get; set; } } public record Metadata @@ -25,6 +26,7 @@ public record Metadata public string Author { get; set; } public Uri AuthorPage { get; set; } public string? AuthorTwitter { get; set; } + public string? Description { get; set; } public Uri AssetsBaseUrl { get; set; } public string SocialPreview { get; set; } public List Threads { get; set; } @@ -62,6 +64,7 @@ public class TemplateModel public Metadata Metadata { get; set; } public DateTime Now { get; set; } public List Posts { get; set; } + public List AllPosts { get; set; } public string BaseUrl { get; set; } public string ToolVersion { get; set; } } \ No newline at end of file diff --git a/HtmlSafeTemplate.cs b/HtmlSafeTemplate.cs deleted file mode 100644 index a7a9e62..0000000 --- a/HtmlSafeTemplate.cs +++ /dev/null @@ -1,42 +0,0 @@ -using RazorEngineCore; - -namespace QuestReader -{ - public class HtmlSafeTemplate : RazorEngineTemplateBase - { - public new TModel Model { get; set; } - - class RawContent - { - public object Value { get; set; } - - public RawContent(object value) - { - Value = value; - } - } - - public static object Raw(object value) - { - return new RawContent(value); - } - - public override Task WriteAsync(object? obj = null) - { - var value = obj is not null and RawContent rawContent - ? rawContent.Value - : System.Web.HttpUtility.HtmlEncode(obj); - - return base.WriteAsync(value); - } - - public override Task WriteAttributeValueAsync(string prefix, int prefixOffset, object? value, int valueOffset, int valueLength, bool isLiteral) - { - value = value is RawContent rawContent - ? rawContent.Value - : System.Web.HttpUtility.HtmlAttributeEncode(value?.ToString()); - - return base.WriteAttributeValueAsync(prefix, prefixOffset, value, valueOffset, valueLength, isLiteral); - } - } -} \ No newline at end of file diff --git a/Program.cs b/Program.cs index f30a658..28f30d7 100644 --- a/Program.cs +++ b/Program.cs @@ -1,4 +1,3 @@ - using QuestReader.Services; using System.CommandLine; diff --git a/RazorStandalone.cs b/RazorStandalone.cs new file mode 100644 index 0000000..7e2094a --- /dev/null +++ b/RazorStandalone.cs @@ -0,0 +1,214 @@ +using System.Reflection; +using System.Text; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; + +namespace QuestReader; + +public class RazorStandalone +{ + RazorProjectEngine Engine { get; set; } + public RazorStandalone(string @namespace) + { + var fs = RazorProjectFileSystem.Create("."); + + Engine = RazorProjectEngine.Create(RazorConfiguration.Default, fs, (builder) => + { + builder.SetNamespace(@namespace); + builder.SetBaseType(typeof(TTemplate).FullName); + builder.AddTargetExtension(new TemplateTargetExtension() + { + TemplateTypeName = "RazorStandaloneHelperResult", + }); + }); + } + + public TTemplate? Compile(string filename) + { + var doc = RazorSourceDocument.Create(File.ReadAllText("page_template.cshtml"), Path.GetFileName(filename)); + + var codeDocument = Engine.Process(doc, null, new List(), new List()); + var cs = codeDocument.GetCSharpDocument(); + var tree = CSharpSyntaxTree.ParseText(cs.GeneratedCode, new CSharpParseOptions(LanguageVersion.Latest)); + + var dllName = Path.GetFileNameWithoutExtension(filename) + ".dll"; + var assemblies = new[] + { + Assembly.Load(new AssemblyName("netstandard")), + typeof(object).Assembly, + typeof(Uri).Assembly, + Assembly.Load(new AssemblyName("System.Runtime")), + Assembly.Load(new AssemblyName("System.Collections")), + Assembly.Load(new AssemblyName("System.Linq")), + Assembly.Load(new AssemblyName("System.Linq.Expressions")), + Assembly.Load(new AssemblyName("Microsoft.CSharp")), + Assembly.GetExecutingAssembly(), + }; + + var compilation = CSharpCompilation.Create(dllName, new[] { tree }, + assemblies.Select(assembly => MetadataReference.CreateFromFile(assembly.Location)).ToArray(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var memoryStream = new MemoryStream(); + + var result = compilation.Emit(memoryStream, options: new EmitOptions(debugInformationFormat: DebugInformationFormat.PortablePdb)); + if (!result.Success) + { + Console.WriteLine(string.Join(Environment.NewLine, result.Diagnostics)); + throw new Exception("Welp"); + } + + var asm = Assembly.Load(memoryStream.ToArray()); + var templateInstance = (TTemplate?) Activator.CreateInstance(asm.GetType("QuestReader.Template")); + if (templateInstance is null) + throw new Exception("Template is null"); + + return templateInstance; + } +} + +public interface IHelper +{ + public void WriteTo(TextWriter writer); +} + +public class RazorStandaloneHelperResult : IHelper +{ + public Action WriteAction { get; } + + public RazorStandaloneHelperResult(Action action) + { + WriteAction = action; + } + + public void WriteTo(TextWriter writer) + { + WriteAction(writer); + } + + public override string ToString() + { + using var buffer = new MemoryStream(); + using var writer = new StreamWriter(buffer, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), 4096, leaveOpen: true); + WriteTo(writer); + return Encoding.UTF8.GetString(buffer.ToArray()); + } +} + +class RawContent : IHelper +{ + public object Value { get; set; } + + public RawContent(object value) + { + Value = value; + } + + public void WriteTo(TextWriter writer) + { + writer.Write(Value); + } + + public override string ToString() => Value?.ToString() ?? ""; +} + +public abstract class StandaloneTemplate +{ + public TModel Model { get; set; } + + protected StreamWriter Output { get; set; } + + public async Task WriteLiteralAsync(string literal) + { + await Output.WriteAsync(literal); + } + + string? Suffix {get;set;} + + public async Task BeginWriteAttributeAsync( + string name, + string prefix, int prefixOffset, + string suffix, int suffixOffset, + int attributeValuesCount + ) + { + Suffix = suffix; + await WriteLiteralAsync(prefix); + } + public async Task WriteAttributeValueAsync(string prefix, int prefixOffset, object? value, int valueOffset, int valueLength, bool isLiteral) + { + await WriteLiteralAsync(prefix); + await WriteAsync(value); + } + + public async Task EndWriteAttributeAsync() { + await WriteLiteralAsync(Suffix!); + Suffix = null; + } + + public async Task WriteAsync(object? obj) + { + if (obj is not null and IHelper helper) + helper.WriteTo(Output); + else + await Output.WriteAsync(System.Web.HttpUtility.HtmlEncode(obj)); + Output.Flush(); + } + + public async Task ExecuteAsync(Stream stream) + { + // We technically don't need this intermediate buffer if this method accepts a memory stream. + //var buffer = new MemoryStream(); + Output = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true), 4096, leaveOpen: true); + await ExecuteAsync(); + await Output.FlushAsync(); + await Output.DisposeAsync(); + //buffer.Seek(0, SeekOrigin.Begin); + //await buffer.CopyToAsync(stream); + } + + public void PushWriter(TextWriter writer) + { + + } + + public StreamWriter PopWriter() + { + return Output; + } + + public virtual Task ExecuteAsync() + { + throw new NotImplementedException(); + } + + public void Write(object? obj) + { + WriteAsync(obj).Wait(); + } + public void WriteLiteral(string literal) + { + WriteLiteralAsync(literal).Wait(); + } + public void BeginWriteAttribute(string name, string prefix, int prefixOffset, string suffix, int suffixOffset, int attributeValuesCount) + { + BeginWriteAttributeAsync(name, prefix, prefixOffset, suffix, suffixOffset, attributeValuesCount).Wait(); + } + public void WriteAttributeValue(string prefix, int prefixOffset, object value, int valueOffset, int valueLength, bool isLiteral) + { + WriteAttributeValueAsync(prefix, prefixOffset, value, valueOffset, valueLength, isLiteral).Wait(); + } + public void EndWriteAttribute() + { + EndWriteAttributeAsync().Wait(); + } + + public static object Raw(object value) + { + return new RawContent(value); + } +} \ No newline at end of file diff --git a/Services/Generator.cs b/Services/Generator.cs index de0e2af..a115396 100644 --- a/Services/Generator.cs +++ b/Services/Generator.cs @@ -1,11 +1,10 @@ namespace QuestReader.Services; using System.Reflection; -using RazorEngineCore; public class Generator { - public IRazorEngineCompiledTemplate> RazorTemplate { get; set; } + public StandaloneTemplate RazorTemplate { get; set; } public string QuestName { get; set; } @@ -27,38 +26,34 @@ public class Generator p.Chapter = PostsSource.Metadata.Chapters.Single(c => (c.Announce ?? c.Start) == p.Id); }); - var razorEngine = new RazorEngine(); + var razorEngine = new RazorStandalone>("QuestReader"); var templateFile = "page_template.cshtml"; var baseUrl = ""; - RazorTemplate = razorEngine.Compile>( - File.ReadAllText("page_template.cshtml"), - option => { - option.AddAssemblyReferenceByName("System.Collections"); - option.AddAssemblyReferenceByName("System.Private.Uri"); - } - ); + RazorTemplate = razorEngine.Compile( + "page_template.cshtml" + ) ?? throw new Exception("No template"); Console.WriteLine($"Using \"{templateFile}\" with base URL {baseUrl}"); } public string Run() { - string result = RazorTemplate.Run(instance => + RazorTemplate.Model = new TemplateModel { - instance.Model = new TemplateModel - { - Metadata = PostsSource.Metadata, - Posts = PostsSource.Accepted, - Now = @DateTime.UtcNow, - //BaseUrl = "assets" - BaseUrl = $"/static/{QuestName}", - ToolVersion = Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion ?? "unknown" - }; - }); + Metadata = PostsSource.Metadata, + Posts = PostsSource.Accepted, + AllPosts = PostsSource.Posts, + Now = @DateTime.UtcNow, + BaseUrl = $"/static/{QuestName}", + ToolVersion = Assembly.GetEntryAssembly()?.GetCustomAttribute()?.InformationalVersion ?? "unknown" + }; + + var outputStream = new MemoryStream(); + RazorTemplate.ExecuteAsync(outputStream).Wait(); - Console.WriteLine($"Template output {result.Length} bytes"); var outputPath = Path.Join(QuestPath, "output.html"); - File.WriteAllText(outputPath, result); + Console.WriteLine($"Template output {outputStream.Length} bytes"); + File.WriteAllBytes(outputPath, outputStream.ToArray()); Console.WriteLine($"Wrote output to {outputPath}"); return outputPath; } diff --git a/Services/PostsSource.cs b/Services/PostsSource.cs index e6e9727..5e164c8 100644 --- a/Services/PostsSource.cs +++ b/Services/PostsSource.cs @@ -2,6 +2,7 @@ namespace QuestReader.Services; using System.Text.Json; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; public class PostsSource { @@ -16,7 +17,8 @@ public class PostsSource var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = true }; using var fileStream = File.OpenRead(Path.Combine(basePath, "metadata.json")); @@ -39,5 +41,24 @@ public class PostsSource ?? throw new InvalidDataException("Empty deserialisation result for quest metadata"); Accepted = Posts.Where(p => ids.Contains(p.Id)).ToList(); Console.Out.WriteLine($"Loaded a list of {Accepted.Count} posts, referencing {Accepted.Where(a => a.File is not null).Count()} files"); + + var rx = new Regex(@"data-post-ref=""(\d+)""", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + foreach (var post in Posts) + { + var matches = rx.Matches(post.RawHtml); + if (!matches.Any()) + continue; + + post.RepliesTo = new List(); + foreach (Match match in matches) + { + var replyId = int.Parse(match.Groups[1].Value); + var found = Posts.FirstOrDefault(p => p.Id == replyId); + if (found is null) + continue; + post.RepliesTo.Add(replyId); + } + } } } \ No newline at end of file diff --git a/page_template.cshtml b/page_template.cshtml index 13dcd52..fc0575e 100644 --- a/page_template.cshtml +++ b/page_template.cshtml @@ -1,6 +1,7 @@ - @namespace QuestReader -@inherits HtmlSafeTemplate +@using System +@using System.Linq +@inherits StandaloneTemplate @@ -11,8 +12,9 @@ @{ var title = $"\"{Model.Metadata.Name}\" by {Model.Metadata.Author}"; - var description = $"Quest single-page archive. Generated {Model.Now} (UTC), {Model.Posts.Count} posts, {Model.Metadata.Chapters.Count} chapters"; + var autoDescription = $"Quest single-page archive. Generated {Model.Now} (UTC), {Model.Posts.Count} posts, {Model.Metadata.Chapters.Count} chapters"; // A hack, tbh, should be something better instead.. + var description = Model.Metadata.Description ?? autoDescription; var preview = $"https://media.lunar.exchange{Model.BaseUrl}/{Model.Metadata.SocialPreview}"; } @title @@ -44,10 +46,33 @@

@Model.Metadata.Name by @Model.Metadata.Author

-

@description

-

Posts from @Model.Posts[0].Date - @Model.Posts[Model.Posts.Count -1].Date

+ @if (Model.Metadata.Description is not null) + { +

@Model.Metadata.Description

+ } +

@autoDescription

+

Posts from @Model.Posts.First().Date - @Model.Posts.Last().Date

+ @{ + Func<(ThreadPost, bool), object> makePost = + @
+ @if (item.Item1.Title is not null) { +

@item.Item1.Title

+ } +

#@item.Item1.Id @item.Item1.Author

+
+ @if (item.Item1.File is not null) { +
+ @item.Item1.Filename +
+ } + @if (item.Item1.RawHtml.Trim().Length > 0) { +
@Raw(item.Item1.RawHtml)
+ } +
+
; + } @foreach (var item in Model.Posts) { @if (item.IsChapterAnnounce) { @@ -55,26 +80,19 @@ # @item.Chapter.Name - @item.Chapter.Subtitle } -
- @if (item.Title is not null) { -

@item.Title

+ if (item.RepliesTo is not null && item.RepliesTo.Count > 0) + { + @foreach (var replyId in item.RepliesTo) + { + @makePost((Model.AllPosts.First(p => p.Id == replyId), false)) } -

#@item.Id @item.Author

-
- @if (item.File is not null) { -
- @item.Filename -
- } - @if (item.RawHtml.Trim().Length > 0) { -
@Raw(item.RawHtml)
- } -
-
+ } + + @makePost((item, true)); }
-

Ⓒ @Model.Metadata.Author @Model.Posts[0].Date.Year - @Model.Posts[Model.Posts.Count -1].Date.Year, page generated with quest-reader v.@Model.ToolVersion by Saphire

+

Ⓒ @Model.Metadata.Author @Model.Posts.First().Date.Year - @Model.Posts.Last().Date.Year, page generated with quest-reader v.@Model.ToolVersion by Saphire

\ No newline at end of file diff --git a/quest_reader.csproj b/quest_reader.csproj index 98c6ee8..959601b 100644 --- a/quest_reader.csproj +++ b/quest_reader.csproj @@ -11,9 +11,10 @@ $(NoWarn);1591;8618 - - + + +