Skip to content

Social#132

Draft
ayende wants to merge 2 commits into
masterfrom
social
Draft

Social#132
ayende wants to merge 2 commits into
masterfrom
social

Conversation

@ayende

@ayende ayende commented May 6, 2026

Copy link
Copy Markdown
Owner

No description provided.

ayende added 2 commits May 5, 2026 21:00
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)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants