diff --git a/Wave/Components/ArticleComponent.razor b/Wave/Components/ArticleComponent.razor index 6d1d034..fbe218a 100644 --- a/Wave/Components/ArticleComponent.razor +++ b/Wave/Components/ArticleComponent.razor @@ -2,10 +2,11 @@ @using Humanizer @using Wave.Utilities +@inject NavigationManager Navigation @inject IStringLocalizer Localizer -
+

@Article.Title

@@ -46,6 +47,39 @@
+@if (Article.Headings.Count > 0) { +
+

@Localizer["TableOfContents"]

+ ")) + } + + while (headingLevel > level) { + level++; + @(new MarkupString("
    • ")) + } + +
    • + @heading.Label +
    • + } + + while (level > 1) { + level--; + @(new MarkupString("
      • ")) + } + } +
      +
  • +} +
    @Content diff --git a/Wave/Components/Pages/ArticleView.razor b/Wave/Components/Pages/ArticleView.razor index 864e77b..19da952 100644 --- a/Wave/Components/Pages/ArticleView.razor +++ b/Wave/Components/Pages/ArticleView.razor @@ -62,7 +62,7 @@ } - +
    @if (article.AllowedToEdit(HttpContext.User)) { diff --git a/Wave/Components/Pages/Partials/ArticleEditorPartial.razor b/Wave/Components/Pages/Partials/ArticleEditorPartial.razor index 6cf60d5..a877ba7 100644 --- a/Wave/Components/Pages/Partials/ArticleEditorPartial.razor +++ b/Wave/Components/Pages/Partials/ArticleEditorPartial.razor @@ -194,7 +194,6 @@ } Article.LastModified = DateTimeOffset.UtcNow; - Article.UpdateBody(); await using var context = await ContextFactory.CreateDbContextAsync(); @@ -213,6 +212,8 @@ .Where(ac => ac.Article.Id == Article.Id).LoadAsync(); context.Update(Article); + context.RemoveRange(Article.Headings); + Article.UpdateBody(); var existingImages = await context.Set
    () .IgnoreQueryFilters().Where(a => a.Id == Article.Id) diff --git a/Wave/Data/ApplicationDbContext.cs b/Wave/Data/ApplicationDbContext.cs index d4c66a4..c53d831 100644 --- a/Wave/Data/ApplicationDbContext.cs +++ b/Wave/Data/ApplicationDbContext.cs @@ -43,6 +43,7 @@ public class ApplicationDbContext(DbContextOptions options .IsRequired().OnDelete(DeleteBehavior.Cascade); article.HasOne(a => a.Reviewer).WithMany() .IsRequired(false).OnDelete(DeleteBehavior.SetNull); + article.OwnsMany(a => a.Headings); article.Property(a => a.CreationDate) .IsRequired().HasDefaultValueSql("now()") diff --git a/Wave/Data/Article.cs b/Wave/Data/Article.cs index 2bace2a..8d46644 100644 --- a/Wave/Data/Article.cs +++ b/Wave/Data/Article.cs @@ -10,10 +10,20 @@ public enum ArticleStatus { Published = 2 } +public class ArticleHeading { + [Key] + public int Id { get; set; } + public required int Order { get; set; } + [MaxLength(128)] + public required string Label { get; set; } + [MaxLength(256)] + public required string Anchor { get; set; } +} + // TODO:: Add tags for MVP ? // TODO:: Archive System (Notice / Redirect to new content?) (Deprecation date?) -public class Article : ISoftDelete { +public partial class Article : ISoftDelete { [Key] public Guid Id { get; set; } public bool IsDeleted { get; set; } @@ -46,6 +56,7 @@ public class Article : ISoftDelete { public IList Categories { get; } = []; public IList Images { get; } = []; + public IList Headings { get; } = []; public void UpdateSlug(string? potentialNewSlug = null) { if (!string.IsNullOrWhiteSpace(potentialNewSlug) && Uri.IsWellFormedUriString(potentialNewSlug, UriKind.Relative)) { @@ -74,11 +85,27 @@ public class Article : ISoftDelete { } Slug = slug[..Math.Min(slug.Length, 64 - escapeTrimOvershoot)]; - if (Slug.EndsWith("%")) Slug = Slug[..^1]; } public void UpdateBody() { BodyHtml = MarkdownUtilities.Parse(Body).Trim(); BodyPlain = HtmlUtilities.GetPlainText(BodyHtml).Trim(); + + Headings.Clear(); + var headings = HeadingsRegex().Matches(BodyHtml); + foreach(Match match in headings) { + string label = match.Groups["Label"].Value; + string anchor = match.Groups["Anchor"].Value; + + var h = new ArticleHeading { + Order = match.Index * 10 + int.Parse(match.Groups["Level"].Value), + Label = label[..Math.Min(128, label.Length)], + Anchor = anchor[..Math.Min(256, anchor.Length)] + }; + Headings.Add(h); + } } + + [GeneratedRegex("[1-6]).*id=\"(?.+)\".*>(?