Conversation
Add a RavenDB GenAI task that automatically generates SEO metadata for each blog post (meta description, keywords) without modifying post content. - Post model: add SeoMetaDescription, SeoKeywords, SeoLastAnalyzedAt - View models: expose SEO fields for public and admin views - AutoMapper: map SEO fields, TagsResolver null-safe fix - SeoHelper: effective meta desc (AI with fallback) + JSON-LD schema - Public view: AI meta desc, keywords tag, BlogPosting structured data - Admin views: SEO analysis panel + read-only fields in edit form - Documentation: GenAI task config for RavenDB Client API and Studio
- SocialPublishCommand model with @refresh metadata for RavenDB subscription-based scheduling - SocialTagParser for @social/* tag routing (target/account, disable, all-targets expansion) - RavenDB subscription worker processes commands when PublishAt arrives - Twitter/X strategy: OAuth 1.0a, POST /2/tweets with Title + SeoMetaDescription + URL - BlogConfig: credential fields for Twitter (RavenDB + personal), Discord, GitHub - Post validation: new posts require at least one @social tag or @social/disable - Date changes update @refresh on pending commands - @-prefixed tags hidden from all public display (views + tag cloud index)
There was a problem hiding this comment.
Pull request overview
Adds social-publishing infrastructure driven by @social/* tags and introduces AI-generated SEO metadata (meta description/keywords + JSON-LD) surfaced in both public and admin views.
Changes:
- Hide “system” tags (those starting with
@) from tag lists and tag displays across the site and in the tags-count index. - Add SEO fields (
SeoMetaDescription,SeoKeywords,SeoLastAnalyzedAt) to models/view-models and render meta keywords + JSON-LD on post detail pages. - Introduce command-based social publishing (RavenDB subscription worker + Twitter publishing strategy) and admin-side creation of per-post publishing commands from tags.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| RaccoonBlog.Web/Views/Section/TagsList.cshtml | Filters out tags starting with @ from the tags list UI. |
| RaccoonBlog.Web/Views/Posts/List.cshtml | Filters out @* tags when rendering per-post tag lists. |
| RaccoonBlog.Web/Views/PostDetails/Details.cshtml | Switches meta description logic to SeoHelper, adds meta keywords and JSON-LD output, hides @* tags in tag rendering. |
| RaccoonBlog.Web/ViewModels/PostViewModel.cs | Adds SEO fields to the public post view model. |
| RaccoonBlog.Web/ViewModels/AdminPostDetailsViewModel.cs | Adds SEO fields to the admin post details view model. |
| RaccoonBlog.Web/Services/SubmitToTwitterStrategy.cs | New strategy for publishing a post to Twitter/X via API. |
| RaccoonBlog.Web/Services/SocialTagParser.cs | New parser for @social/* tags to derive publishing targets/accounts. |
| RaccoonBlog.Web/Services/SocialPublishingWorker.cs | New RavenDB subscription worker to process SocialPublishCommand documents. |
| RaccoonBlog.Web/Models/SocialPublishCommand.cs | New command document model to represent pending/completed/failed social publishing actions. |
| RaccoonBlog.Web/Models/Post.cs | Adds SEO fields to the persisted Post document and to PostInput. |
| RaccoonBlog.Web/Models/BlogConfig.cs | Adds config fields for Twitter credentials plus Discord/GitHub settings. |
| RaccoonBlog.Web/Infrastructure/Indexes/Tags_Count.cs | Excludes @* tags from tag counting. |
| RaccoonBlog.Web/Infrastructure/AutoMapper/Profiles/Resolvers/TagsResolver.cs | Makes ResolveTags null/empty-safe. |
| RaccoonBlog.Web/Infrastructure/AutoMapper/Profiles/PostViewModelMapperProfile.cs | Maps SeoKeywords into the public post details VM. |
| RaccoonBlog.Web/Infrastructure/AutoMapper/Profiles/PostsAdminViewModelMapperProfile.cs | Maps SEO keywords to/from admin edit input (string form) and to admin details VM. |
| RaccoonBlog.Web/Helpers/TwitterOAuthHelper.cs | New helper to generate OAuth 1.0a Authorization headers. |
| RaccoonBlog.Web/Helpers/SeoHelper.cs | New helper for effective meta description and JSON-LD generation. |
| RaccoonBlog.Web/Global.asax.cs | Starts the new social publishing subscription worker at application startup. |
| RaccoonBlog.Web/Documentation/SeoGenAiTask.md | Adds documentation for the RavenDB GenAI SEO task and how fields are used. |
| RaccoonBlog.Web/Areas/Admin/Views/Posts/Edit.cshtml | Displays read-only AI SEO fields during post editing. |
| RaccoonBlog.Web/Areas/Admin/Views/Posts/Details.cshtml | Displays AI SEO fields on admin details page; shows spam-check pending label/class. |
| RaccoonBlog.Web/Areas/Admin/Controllers/PostsController.cs | Enforces @social/* tagging on new posts, maintains SocialPublishCommand docs, updates @refresh on reschedule. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+64
to
+88
| if (string.IsNullOrEmpty(Model.SeoMetaDescription) == false || Model.SeoLastAnalyzedAt.HasValue) | ||
| { | ||
| <hr /> | ||
| <div class="row"> | ||
| <div class="col-lg-12"> | ||
| <h4>SEO <small>(generated by AI, read-only)</small></h4> | ||
| </div> | ||
| </div> | ||
| <div class="row"> | ||
| <div class="col-lg-6"> | ||
| <div class="form-group"> | ||
| <label class="control-label">Meta Description</label> | ||
| <textarea class="form-control" rows="3" readonly="readonly">@Model.SeoMetaDescription</textarea> | ||
| </div> | ||
| </div> | ||
| <div class="col-lg-6"> | ||
| <div class="form-group"> | ||
| <label class="control-label">Keywords</label> | ||
| <input type="text" class="form-control" value="@Model.SeoKeywords" readonly="readonly" /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| if (Model.SeoLastAnalyzedAt.HasValue) | ||
| { | ||
| <div class="row"> |
Comment on lines
+74
to
+83
| <div class="form-group"> | ||
| <label class="control-label">Meta Description</label> | ||
| <textarea class="form-control" rows="3" readonly="readonly">@Model.SeoMetaDescription</textarea> | ||
| </div> | ||
| </div> | ||
| <div class="col-lg-6"> | ||
| <div class="form-group"> | ||
| <label class="control-label">Keywords</label> | ||
| <input type="text" class="form-control" value="@Model.SeoKeywords" readonly="readonly" /> | ||
| </div> |
| .ForMember(x => x.LastEditedByUserId, o => o.Ignore()) | ||
| .ForMember(x => x.LastEditedAt, o => o.Ignore()) | ||
| .ForMember(x => x.Tags, o => o.MapFrom(m => TagsResolver.ResolveTagsInput(m.Tags))) | ||
| .ForMember(x => x.SeoKeywords, o => o.MapFrom(m => TagsResolver.ResolveTagsInput(m.SeoKeywords))) |
Comment on lines
+55
to
+86
| using (var session = store.OpenSession()) | ||
| { | ||
| foreach (var item in batch.Items) | ||
| { | ||
| var cmd = item.Result; | ||
|
|
||
| if (cmd.PublishAt > DateTimeOffset.Now) | ||
| { | ||
| _log.Debug("Skipping command {0} — PublishAt {1} is still in the future (waiting for @refresh)", cmd.Id, cmd.PublishAt); | ||
| continue; | ||
| } | ||
|
|
||
| _log.Info("Processing social publish command {0}: target={1}, account={2}, post={3}", cmd.Id, cmd.Target, cmd.Account, cmd.PostId); | ||
|
|
||
| try | ||
| { | ||
| ExecuteCommand(cmd, session); | ||
| cmd.Status = CommandStatus.Completed; | ||
| _log.Info("Successfully completed command {0}", cmd.Id); | ||
| } | ||
| catch (Exception ex) | ||
| { | ||
| _log.Error(ex, "Failed to execute social publish command {0}", cmd.Id); | ||
| cmd.Status = CommandStatus.Failed; | ||
| cmd.ErrorMessage = ex.Message; | ||
| } | ||
|
|
||
| cmd.CompletedAt = DateTimeOffset.Now; | ||
| } | ||
|
|
||
| session.SaveChanges(); | ||
| } |
Comment on lines
+15
to
+62
| sb.AppendLine("<script type=\"application/ld+json\">"); | ||
| sb.AppendLine("{"); | ||
| sb.AppendLine(" \"@context\": \"https://schema.org\","); | ||
| sb.AppendLine(" \"@type\": \"BlogPosting\","); | ||
| sb.AppendLine(" \"headline\": " + HttpUtility.JavaScriptStringEncode(post.Title) + ","); | ||
| sb.AppendLine(" \"url\": " + HttpUtility.JavaScriptStringEncode(canonicalUrl) + ","); | ||
|
|
||
| if (post.PublishedAt != default) | ||
| sb.AppendLine(" \"datePublished\": \"" + post.PublishedAt.ToString("yyyy-MM-ddTHH:mm:ssK") + "\","); | ||
|
|
||
| if (post.CreatedAt != default) | ||
| sb.AppendLine(" \"dateCreated\": \"" + post.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssK") + "\","); | ||
|
|
||
| var description = post.SeoMetaDescription ?? ""; | ||
| if (string.IsNullOrEmpty(description) == false) | ||
| sb.AppendLine(" \"description\": " + HttpUtility.JavaScriptStringEncode(description) + ","); | ||
|
|
||
| if (post.Author != null) | ||
| { | ||
| sb.AppendLine(" \"author\": {"); | ||
| sb.AppendLine(" \"@type\": \"Person\","); | ||
| sb.AppendLine(" \"name\": " + HttpUtility.JavaScriptStringEncode(post.Author.FullName ?? "Oren Eini") + ""); | ||
| sb.AppendLine(" },"); | ||
| } | ||
|
|
||
| if (post.Tags != null && post.Tags.Any()) | ||
| { | ||
| sb.Append(" \"keywords\": \""); | ||
| sb.Append(string.Join(", ", post.Tags.Select(t => t.Name))); | ||
| sb.AppendLine("\","); | ||
| } | ||
|
|
||
| if (post.SeoKeywords != null && post.SeoKeywords.Any()) | ||
| { | ||
| sb.Append(" \"keywords\": \""); | ||
| sb.Append(string.Join(", ", post.SeoKeywords)); | ||
| sb.AppendLine("\""); | ||
| } | ||
| else if (post.Tags == null || post.Tags.Any() == false) | ||
| { | ||
| sb.Length -= 2; | ||
| sb.AppendLine(); | ||
| } | ||
| else | ||
| { | ||
| sb.Length -= 2; | ||
| sb.AppendLine(); | ||
| } |
| { | ||
| if (tags == null || tags.Count == 0) | ||
| return false; | ||
| return tags.Any(t => t.StartsWith(SocialPrefix, StringComparison.OrdinalIgnoreCase)); |
Comment on lines
+32
to
+75
| public static List<(string Target, string Account)> Parse(ICollection<string> tags, BlogConfig config) | ||
| { | ||
| var result = new List<(string Target, string Account)>(); | ||
|
|
||
| if (tags == null || tags.Count == 0) | ||
| return result; | ||
|
|
||
| if (IsDisabled(tags)) | ||
| return result; | ||
|
|
||
| var socialTags = tags | ||
| .Where(t => t.StartsWith(SocialPrefix, StringComparison.OrdinalIgnoreCase)) | ||
| .ToList(); | ||
|
|
||
| foreach (var tag in socialTags) | ||
| { | ||
| var parts = tag.Split('/'); | ||
|
|
||
| // @social (bare prefix → all configured targets) | ||
| if (parts.Length == 1 && parts[0].Equals(SocialPrefix, StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| result.AddRange(GetAllConfiguredTargets(config)); | ||
| continue; | ||
| } | ||
|
|
||
| // @social/disable — already handled | ||
| if (parts.Length >= 2 && parts[1].Equals("disable", StringComparison.OrdinalIgnoreCase)) | ||
| continue; | ||
|
|
||
| // @social/target or @social/target/account | ||
| if (parts.Length >= 2 && ValidTargets.Contains(parts[1])) | ||
| { | ||
| var target = parts[1].ToLowerInvariant(); | ||
| var account = parts.Length >= 3 ? parts[2].ToLowerInvariant() : null; | ||
| result.Add((target, account)); | ||
| } | ||
| } | ||
|
|
||
| // Deduplicate — same target+account shouldn't generate multiple commands | ||
| return result | ||
| .GroupBy(x => (x.Target, x.Account)) | ||
| .Select(g => g.First()) | ||
| .ToList(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.