feat: add support for multi-lingual code snippets

This commit is contained in:
Oliver Booth 2024-04-27 00:25:32 +01:00
parent ba09fa22df
commit 14cac1e38d
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
12 changed files with 418 additions and 2 deletions

View File

@ -0,0 +1,14 @@
namespace OliverBooth.Data.Web;
/// <inheritdoc />
internal sealed class CodeSnippet : ICodeSnippet
{
/// <inheritdoc />
public string Content { get; } = string.Empty;
/// <inheritdoc />
public int Id { get; }
/// <inheritdoc />
public string Language { get; } = string.Empty;
}

View File

@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace OliverBooth.Data.Web.Configuration;
/// <summary>
/// Represents the configuration for the <see cref="Book" /> entity.
/// </summary>
internal sealed class CodeSnippetConfiguration : IEntityTypeConfiguration<CodeSnippet>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<CodeSnippet> 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);
}
}

View File

@ -0,0 +1,25 @@
namespace OliverBooth.Data.Web;
/// <summary>
/// Represents a code snippet.
/// </summary>
public interface ICodeSnippet
{
/// <summary>
/// Gets the content for this snippet.
/// </summary>
/// <value>The content for this snippet</value>
string Content { get; }
/// <summary>
/// Gets the ID for this snippet.
/// </summary>
/// <value>The ID for this snippet</value>
int Id { get; }
/// <summary>
/// Gets the language for this snippet.
/// </summary>
/// <value>The language for this snippet</value>
string Language { get; }
}

View File

@ -25,6 +25,12 @@ internal sealed class WebContext : DbContext
/// <value>The collection of books.</value>
public DbSet<Book> Books { get; private set; } = null!;
/// <summary>
/// Gets the collection of code snippets in the database.
/// </summary>
/// <value>The collection of code snippets.</value>
public DbSet<CodeSnippet> CodeSnippets { get; private set; } = null!;
/// <summary>
/// Gets the collection of blacklist entries in the database.
/// </summary>
@ -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());

View File

@ -0,0 +1,139 @@
using System.Diagnostics;
using System.Text;
using Markdig;
using OliverBooth.Data.Web;
using OliverBooth.Services;
namespace OliverBooth.Markdown.Template;
/// <summary>
/// Represents a custom template renderer which renders the <c>{{Snippet}}</c> template.
/// </summary>
internal sealed class CodeSnippetTemplateRenderer : CustomTemplateRenderer
{
private readonly ICodeSnippetService _codeSnippetService;
private readonly Lazy<MarkdownPipeline> _markdownPipeline;
private readonly IProgrammingLanguageService _programmingLanguageService;
/// <summary>
/// Initializes a new instance of the <see cref="CodeSnippetTemplateRenderer" /> class.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
public CodeSnippetTemplateRenderer(IServiceProvider serviceProvider) : base(serviceProvider)
{
// lazily evaluate to avoid circular dependency problem causing tremendous stack overflow
_markdownPipeline = new Lazy<MarkdownPipeline>(serviceProvider.GetRequiredService<MarkdownPipeline>);
_codeSnippetService = serviceProvider.GetRequiredService<ICodeSnippetService>();
_programmingLanguageService = serviceProvider.GetRequiredService<IProgrammingLanguageService>();
}
/// <inheritdoc />
public override string Render(TemplateInline template)
{
Debug.Assert(template.Name == "Snippet");
Trace.Assert(template.Name == "Snippet");
IReadOnlyList<string> 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<ICodeSnippet>();
IReadOnlyList<string> 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($"""
<ul class="nav nav-tabs mb-3" id="snp-{identifier:N}" data-identifier="{identifier:N}" role="tablist"
style="margin-bottom: -0.5em !important;">
""");
for (var index = 0; index < languages.Count; index++)
{
var language = languages[index];
string classList = "";
if (index == 0)
{
classList = " active";
}
builder.AppendLine("""<li class="nav-item" role="presentation">""");
builder.AppendLine($"""
<a
data-tab-init
class="nav-link{classList}"
id="snp-{snippetId}-{identifier:N}-{language}-l"
href="#snp-{snippetId}-{identifier:N}-{language}"
role="tab"
data-tabs="snp-{snippetId}-{identifier:N}"
aria-controls="snp-{snippetId}-{identifier:N}-{language}"
aria-selected="true"
>{_programmingLanguageService.GetLanguageName(language)}</a
>
""");
builder.AppendLine("</li>");
}
builder.AppendLine("</ul>");
builder.AppendLine($"""<div class="tab-content" id="snp-{snippetId}-{identifier:N}">""");
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($"""
<div class="tab-pane fade{classList}" id="snp-{snippetId}-{identifier:N}-{snippet.Language}" data-identifier="{identifier:N}" role="tabpanel"
aria-labelledby="snp-{snippetId}-{identifier:N}-{snippet.Language}">
""");
builder.AppendLine(html);
builder.AppendLine("</div>");
}
builder.AppendLine("</div>");
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)}}}}}";
}
}

View File

@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
namespace OliverBooth.Markdown.Template;
/// <summary>
/// Represents a custom renderer which overrides the default behaviour of the template engine.
/// </summary>
internal abstract class CustomTemplateRenderer
{
/// <summary>
/// Initializes a new instance of the <see cref="CustomTemplateRenderer" /> class.
/// </summary>
/// <param name="serviceProvider">The service provider.</param>
protected CustomTemplateRenderer(IServiceProvider serviceProvider)
{
DbContextFactory = serviceProvider.GetRequiredService<IDbContextFactory<WebContext>>();
}
/// <summary>
/// Gets the <see cref="WebContext" /> factory that was injected into this instance.
/// </summary>
/// <value>An <see cref="IDbContextFactory{TContext}" /> for <see cref="WebContext" />.</value>
protected IDbContextFactory<WebContext> DbContextFactory { get; }
/// <summary>
/// Renders the specified template.
/// </summary>
/// <param name="template">The template to render.</param>
/// <returns>The rendered result of the template.</returns>
public abstract string Render(TemplateInline template);
}

View File

@ -33,10 +33,12 @@ builder.Services.AddSingleton(provider => new MarkdownPipelineBuilder()
builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddDbContextFactory<WebContext>();
builder.Services.AddHttpClient();
builder.Services.AddSingleton<ICodeSnippetService, CodeSnippetService>();
builder.Services.AddSingleton<IContactService, ContactService>();
builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
builder.Services.AddSingleton<IBlogUserService, BlogUserService>();
builder.Services.AddSingleton<IProgrammingLanguageService, ProgrammingLanguageService>();
builder.Services.AddSingleton<IProjectService, ProjectService>();
builder.Services.AddSingleton<IMastodonService, MastodonService>();
builder.Services.AddSingleton<ITutorialService, TutorialService>();

View File

@ -0,0 +1,48 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
/// <inheritdoc />
internal sealed class CodeSnippetService : ICodeSnippetService
{
private readonly IDbContextFactory<WebContext> _dbContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="CodeSnippetService" /> class.
/// </summary>
/// <param name="dbContextFactory">The <see cref="WebContext" /> factory.</param>
public CodeSnippetService(IDbContextFactory<WebContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public IReadOnlyList<string> GetLanguagesForSnippet(int id)
{
var languages = new HashSet<string>();
using WebContext context = _dbContextFactory.CreateDbContext();
foreach (CodeSnippet snippet in context.CodeSnippets.Where(s => s.Id == id))
{
languages.Add(snippet.Language);
}
return languages.ToArray();
}
/// <inheritdoc />
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<CodeSnippet> snippets = context.CodeSnippets.Where(s => s.Id == id);
snippet = snippets.FirstOrDefault(s => s.Language == language);
return snippet is not null;
}
}

View File

@ -0,0 +1,32 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service which can fetch multi-language code snippets.
/// </summary>
public interface ICodeSnippetService
{
/// <summary>
/// Returns all the languages which apply to the specified snippet.
/// </summary>
/// <param name="id">The ID of the snippet whose languages should be returned.</param>
/// <returns>
/// A read-only view of the languages that apply to the snippet. This list may be empty if the snippet ID is invalid.
/// </returns>
IReadOnlyList<string> GetLanguagesForSnippet(int id);
/// <summary>
/// Attempts to find a code snippet by the specified ID, in the specified language.
/// </summary>
/// <param name="id">The ID of the snippet to search for.</param>
/// <param name="language">The language to search for.</param>
/// <param name="snippet">
/// When this method returns, contains the code snippet matching the specified criteria, if such a snippet was found;
/// otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the snippet was found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="language" /> is <see langword="null" />.</exception>
bool TryGetCodeSnippetForLanguage(int id, string language, [NotNullWhen(true)] out ICodeSnippet? snippet);
}

View File

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore;
using OliverBooth.Data.Web;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service which can perform programming language lookup.
/// </summary>
public interface IProgrammingLanguageService
{
/// <summary>
/// Returns the human-readable name of a language.
/// </summary>
/// <param name="alias">The alias of the language.</param>
/// <returns>The human-readable name, or <paramref name="alias" /> if the name could not be found.</returns>
string GetLanguageName(string alias);
}
/// <inheritdoc />
internal sealed class ProgrammingLanguageService : IProgrammingLanguageService
{
private readonly IDbContextFactory<WebContext> _dbContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="ProgrammingLanguageService" /> class.
/// </summary>
/// <param name="dbContextFactory">The <see cref="WebContext" /> factory.</param>
public ProgrammingLanguageService(IDbContextFactory<WebContext> dbContextFactory)
{
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc />
public string GetLanguageName(string alias)
{
using WebContext context = _dbContextFactory.CreateDbContext();
ProgrammingLanguage? language = context.ProgrammingLanguages.FirstOrDefault(l => l.Key == alias);
return language?.Name ?? alias;
}
}

View File

@ -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;
/// </summary>
internal sealed class TemplateService : ITemplateService
{
private readonly Dictionary<string, CustomTemplateRenderer> _customTemplateRendererOverrides = new();
private static readonly Random Random = new();
private readonly ILogger<TemplateService> _logger;
private readonly IDbContextFactory<WebContext> _webContextFactory;
private readonly SmartFormatter _formatter;
/// <summary>
/// Initializes a new instance of the <see cref="TemplateService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="serviceProvider">The <see cref="IServiceProvider" />.</param>
/// <param name="webContextFactory">The <see cref="WebContext" /> factory.</param>
public TemplateService(IServiceProvider serviceProvider,
public TemplateService(ILogger<TemplateService> logger,
IServiceProvider serviceProvider,
IDbContextFactory<WebContext> 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;
}
/// <inheritdoc />
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)

View File

@ -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.