diff --git a/OliverBooth/Data/Web/CodeSnippet.cs b/OliverBooth/Data/Web/CodeSnippet.cs new file mode 100644 index 0000000..19d29c1 --- /dev/null +++ b/OliverBooth/Data/Web/CodeSnippet.cs @@ -0,0 +1,14 @@ +namespace OliverBooth.Data.Web; + +/// +internal sealed class CodeSnippet : ICodeSnippet +{ + /// + public string Content { get; } = string.Empty; + + /// + public int Id { get; } + + /// + public string Language { get; } = string.Empty; +} diff --git a/OliverBooth/Data/Web/Configuration/CodeSnippetConfiguration.cs b/OliverBooth/Data/Web/Configuration/CodeSnippetConfiguration.cs new file mode 100644 index 0000000..709e96e --- /dev/null +++ b/OliverBooth/Data/Web/Configuration/CodeSnippetConfiguration.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace OliverBooth.Data.Web.Configuration; + +/// +/// Represents the configuration for the entity. +/// +internal sealed class CodeSnippetConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CodeSnippet"); + builder.HasKey(e => new { e.Id, e.Language }); + + builder.Property(e => e.Id); + builder.Property(e => e.Language); + builder.Property(e => e.Content); + } +} diff --git a/OliverBooth/Data/Web/ICodeSnippet.cs b/OliverBooth/Data/Web/ICodeSnippet.cs new file mode 100644 index 0000000..ba1d324 --- /dev/null +++ b/OliverBooth/Data/Web/ICodeSnippet.cs @@ -0,0 +1,25 @@ +namespace OliverBooth.Data.Web; + +/// +/// Represents a code snippet. +/// +public interface ICodeSnippet +{ + /// + /// Gets the content for this snippet. + /// + /// The content for this snippet + string Content { get; } + + /// + /// Gets the ID for this snippet. + /// + /// The ID for this snippet + int Id { get; } + + /// + /// Gets the language for this snippet. + /// + /// The language for this snippet + string Language { get; } +} diff --git a/OliverBooth/Data/Web/WebContext.cs b/OliverBooth/Data/Web/WebContext.cs index 8d43e43..61c2290 100644 --- a/OliverBooth/Data/Web/WebContext.cs +++ b/OliverBooth/Data/Web/WebContext.cs @@ -25,6 +25,12 @@ internal sealed class WebContext : DbContext /// The collection of books. public DbSet Books { get; private set; } = null!; + /// + /// Gets the collection of code snippets in the database. + /// + /// The collection of code snippets. + public DbSet CodeSnippets { get; private set; } = null!; + /// /// Gets the collection of blacklist entries in the database. /// @@ -80,6 +86,7 @@ internal sealed class WebContext : DbContext { modelBuilder.ApplyConfiguration(new BlacklistEntryConfiguration()); modelBuilder.ApplyConfiguration(new BookConfiguration()); + modelBuilder.ApplyConfiguration(new CodeSnippetConfiguration()); modelBuilder.ApplyConfiguration(new ProgrammingLanguageConfiguration()); modelBuilder.ApplyConfiguration(new ProjectConfiguration()); modelBuilder.ApplyConfiguration(new TemplateConfiguration()); diff --git a/OliverBooth/Markdown/Template/CodeSnippetTemplateRenderer.cs b/OliverBooth/Markdown/Template/CodeSnippetTemplateRenderer.cs new file mode 100644 index 0000000..ad3d45b --- /dev/null +++ b/OliverBooth/Markdown/Template/CodeSnippetTemplateRenderer.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; +using System.Text; +using Markdig; +using OliverBooth.Data.Web; +using OliverBooth.Services; + +namespace OliverBooth.Markdown.Template; + +/// +/// Represents a custom template renderer which renders the {{Snippet}} template. +/// +internal sealed class CodeSnippetTemplateRenderer : CustomTemplateRenderer +{ + private readonly ICodeSnippetService _codeSnippetService; + private readonly Lazy _markdownPipeline; + private readonly IProgrammingLanguageService _programmingLanguageService; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + public CodeSnippetTemplateRenderer(IServiceProvider serviceProvider) : base(serviceProvider) + { + // lazily evaluate to avoid circular dependency problem causing tremendous stack overflow + _markdownPipeline = new Lazy(serviceProvider.GetRequiredService); + _codeSnippetService = serviceProvider.GetRequiredService(); + _programmingLanguageService = serviceProvider.GetRequiredService(); + } + + /// + public override string Render(TemplateInline template) + { + Debug.Assert(template.Name == "Snippet"); + Trace.Assert(template.Name == "Snippet"); + + IReadOnlyList argumentList = template.ArgumentList; + + if (argumentList.Count < 1) + { + return DefaultRender(template); + } + + if (!int.TryParse(argumentList[0], out int snippetId)) + { + return DefaultRender(template); + } + + var identifier = Guid.NewGuid(); + var snippets = new List(); + + IReadOnlyList languages = argumentList.Count > 1 + ? argumentList[1].Split(';') + : _codeSnippetService.GetLanguagesForSnippet(snippetId); + + foreach (string language in languages) + { + if (_codeSnippetService.TryGetCodeSnippetForLanguage(snippetId, language, out ICodeSnippet? snippet)) + { + snippets.Add(snippet); + } + } + + if (snippets.Count == 1) + { + ICodeSnippet snippet = snippets[0]; + return RenderHtml(snippet); + } + + var builder = new StringBuilder(); + builder.AppendLine($""" + "); + + builder.AppendLine($"""
"""); + + for (var index = 0; index < snippets.Count; index++) + { + string classList = ""; + if (index == 0) + { + classList = " show active"; + } + + var snippet = snippets[index]; + string html = RenderHtml(snippet); + builder.AppendLine($""" +
+ """); + builder.AppendLine(html); + builder.AppendLine("
"); + } + + builder.AppendLine("
"); + + return builder.ToString(); + } + + private string RenderHtml(ICodeSnippet snippet) + { + return Markdig.Markdown.ToHtml($"```{snippet.Language}\n{snippet.Content}\n```", _markdownPipeline.Value); + } + + private static string DefaultRender(TemplateInline template) + { + return template.ArgumentList.Count == 0 + ? $"{{{{{template.Name}}}}}" + : $"{{{{{template.Name}|{string.Join('|', template.ArgumentList)}}}}}"; + } +} diff --git a/OliverBooth/Markdown/Template/CustomTemplateRenderer.cs b/OliverBooth/Markdown/Template/CustomTemplateRenderer.cs new file mode 100644 index 0000000..1d3f122 --- /dev/null +++ b/OliverBooth/Markdown/Template/CustomTemplateRenderer.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using OliverBooth.Data.Web; + +namespace OliverBooth.Markdown.Template; + +/// +/// Represents a custom renderer which overrides the default behaviour of the template engine. +/// +internal abstract class CustomTemplateRenderer +{ + /// + /// Initializes a new instance of the class. + /// + /// The service provider. + protected CustomTemplateRenderer(IServiceProvider serviceProvider) + { + DbContextFactory = serviceProvider.GetRequiredService>(); + } + + /// + /// Gets the factory that was injected into this instance. + /// + /// An for . + protected IDbContextFactory DbContextFactory { get; } + + /// + /// Renders the specified template. + /// + /// The template to render. + /// The rendered result of the template. + public abstract string Render(TemplateInline template); +} diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs index dec79fe..b09d159 100644 --- a/OliverBooth/Program.cs +++ b/OliverBooth/Program.cs @@ -33,10 +33,12 @@ builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder() builder.Services.AddDbContextFactory(); builder.Services.AddDbContextFactory(); builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/OliverBooth/Services/CodeSnippetService.cs b/OliverBooth/Services/CodeSnippetService.cs new file mode 100644 index 0000000..643f6ea --- /dev/null +++ b/OliverBooth/Services/CodeSnippetService.cs @@ -0,0 +1,48 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore; +using OliverBooth.Data.Web; + +namespace OliverBooth.Services; + +/// +internal sealed class CodeSnippetService : ICodeSnippetService +{ + private readonly IDbContextFactory _dbContextFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The factory. + public CodeSnippetService(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + /// + public IReadOnlyList GetLanguagesForSnippet(int id) + { + var languages = new HashSet(); + using WebContext context = _dbContextFactory.CreateDbContext(); + + foreach (CodeSnippet snippet in context.CodeSnippets.Where(s => s.Id == id)) + { + languages.Add(snippet.Language); + } + + return languages.ToArray(); + } + + /// + public bool TryGetCodeSnippetForLanguage(int id, string language, [NotNullWhen(true)] out ICodeSnippet? snippet) + { + if (language is null) + { + throw new ArgumentNullException(nameof(language)); + } + + using WebContext context = _dbContextFactory.CreateDbContext(); + IQueryable snippets = context.CodeSnippets.Where(s => s.Id == id); + snippet = snippets.FirstOrDefault(s => s.Language == language); + return snippet is not null; + } +} diff --git a/OliverBooth/Services/ICodeSnippetService.cs b/OliverBooth/Services/ICodeSnippetService.cs new file mode 100644 index 0000000..b81cb88 --- /dev/null +++ b/OliverBooth/Services/ICodeSnippetService.cs @@ -0,0 +1,32 @@ +using System.Diagnostics.CodeAnalysis; +using OliverBooth.Data.Web; + +namespace OliverBooth.Services; + +/// +/// Represents a service which can fetch multi-language code snippets. +/// +public interface ICodeSnippetService +{ + /// + /// Returns all the languages which apply to the specified snippet. + /// + /// The ID of the snippet whose languages should be returned. + /// + /// A read-only view of the languages that apply to the snippet. This list may be empty if the snippet ID is invalid. + /// + IReadOnlyList GetLanguagesForSnippet(int id); + + /// + /// Attempts to find a code snippet by the specified ID, in the specified language. + /// + /// The ID of the snippet to search for. + /// The language to search for. + /// + /// When this method returns, contains the code snippet matching the specified criteria, if such a snippet was found; + /// otherwise, . + /// + /// if the snippet was found; otherwise, . + /// is . + bool TryGetCodeSnippetForLanguage(int id, string language, [NotNullWhen(true)] out ICodeSnippet? snippet); +} diff --git a/OliverBooth/Services/ProgrammingLanguageService.cs b/OliverBooth/Services/ProgrammingLanguageService.cs new file mode 100644 index 0000000..3f82bbc --- /dev/null +++ b/OliverBooth/Services/ProgrammingLanguageService.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore; +using OliverBooth.Data.Web; + +namespace OliverBooth.Services; + +/// +/// Represents a service which can perform programming language lookup. +/// +public interface IProgrammingLanguageService +{ + /// + /// Returns the human-readable name of a language. + /// + /// The alias of the language. + /// The human-readable name, or if the name could not be found. + string GetLanguageName(string alias); +} + +/// +internal sealed class ProgrammingLanguageService : IProgrammingLanguageService +{ + private readonly IDbContextFactory _dbContextFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The factory. + public ProgrammingLanguageService(IDbContextFactory dbContextFactory) + { + _dbContextFactory = dbContextFactory; + } + + /// + public string GetLanguageName(string alias) + { + using WebContext context = _dbContextFactory.CreateDbContext(); + ProgrammingLanguage? language = context.ProgrammingLanguages.FirstOrDefault(l => l.Key == alias); + return language?.Name ?? alias; + } +} diff --git a/OliverBooth/Services/TemplateService.cs b/OliverBooth/Services/TemplateService.cs index 58334af..623a1fd 100644 --- a/OliverBooth/Services/TemplateService.cs +++ b/OliverBooth/Services/TemplateService.cs @@ -1,5 +1,7 @@ using System.Buffers.Binary; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using Markdig; using Microsoft.EntityFrameworkCore; using OliverBooth.Data.Web; using OliverBooth.Formatting; @@ -14,31 +16,51 @@ namespace OliverBooth.Services; /// internal sealed class TemplateService : ITemplateService { + private readonly Dictionary _customTemplateRendererOverrides = new(); private static readonly Random Random = new(); + private readonly ILogger _logger; private readonly IDbContextFactory _webContextFactory; private readonly SmartFormatter _formatter; /// /// Initializes a new instance of the class. /// + /// The logger. /// The . /// The factory. - public TemplateService(IServiceProvider serviceProvider, + public TemplateService(ILogger logger, + IServiceProvider serviceProvider, IDbContextFactory webContextFactory) { + _logger = logger; + _formatter = Smart.CreateDefaultSmartFormat(); _formatter.AddExtensions(new DefaultSource()); _formatter.AddExtensions(new ReflectionSource()); _formatter.AddExtensions(new DateFormatter()); _formatter.AddExtensions(new MarkdownFormatter(serviceProvider)); + _logger.LogDebug("Registering template override Snippet to CodeSnippetTemplateRenderer"); + AddRendererOverride("Snippet", new CodeSnippetTemplateRenderer(serviceProvider)); + _webContextFactory = webContextFactory; } /// public string RenderGlobalTemplate(TemplateInline templateInline) { - if (templateInline is null) throw new ArgumentNullException(nameof(templateInline)); + if (templateInline is null) + { + _logger.LogWarning("Attempting to render null inline template!"); + throw new ArgumentNullException(nameof(templateInline)); + } + + _logger.LogDebug("Inline name is {Name}", templateInline.Name); + if (_customTemplateRendererOverrides.TryGetValue(templateInline.Name, out CustomTemplateRenderer? renderer)) + { + _logger.LogDebug("This matches renderer {Name}", renderer.GetType().Name); + return renderer.Render(templateInline); + } return TryGetTemplate(templateInline.Name, templateInline.Variant, out ITemplate? template) ? RenderTemplate(templateInline, template) @@ -89,6 +111,12 @@ internal sealed class TemplateService : ITemplateService return template is not null; } + private void AddRendererOverride(string templateName, CustomTemplateRenderer renderer) + { + _logger.LogDebug("Registering template override {Name} to {Renderer}", templateName, renderer.GetType().Name); + _customTemplateRendererOverrides[templateName] = renderer; + } + private static string GetDefaultRender(TemplateInline templateInline) { return string.IsNullOrWhiteSpace(templateInline.ArgumentString) diff --git a/src/ts/UI.ts b/src/ts/UI.ts index c04e37a..1f55919 100644 --- a/src/ts/UI.ts +++ b/src/ts/UI.ts @@ -77,6 +77,7 @@ class UI { UI.addHighlighting(element); UI.addBootstrapTooltips(element); UI.renderSpoilers(element); + UI.renderTabs(element); UI.renderTeX(element); UI.renderTimestamps(element); UI.updateProjectCards(element); @@ -146,6 +147,33 @@ class UI { }); } + /** + * Renders tabs in the document. + * @param element The element to search for tabs in. + */ + public static renderTabs(element?: Element) { + element = element || document.body; + element.querySelectorAll("[role=\"tablist\"]").forEach(function (tabList: HTMLElement) { + const identifier = tabList.dataset.identifier; + const tabLinks = tabList.querySelectorAll(".nav-link"); + const tabPanes = element.querySelectorAll(`.tab-pane[data-identifier="${identifier}"]`); + + tabLinks.forEach(function (tabLink: Element) { + tabLink.addEventListener("click", () => { + const controls = document.getElementById(tabLink.getAttribute("aria-controls")); + + // switch "active" tab link + tabLinks.forEach(e => e.classList.remove("active")); + tabLink.classList.add("active"); + + // switch active tab itself + tabPanes.forEach(e => e.classList.remove("show", "active")); + controls.classList.add("show", "active"); + }); + }); + }); + } + /** * Renders all TeX in the document. * @param element The element to search for TeX in.