Local text (Domain/Content/ContentRenderer): Markdig with raw HTML off and
autolinks, or plain text for API clients; then @user and @user@host are
resolved (locally or through WebFinger) into Mastodon's h-card markup and
#tags into hashtag links, skipping code, links and e-mail addresses. A
post stores the result, its mentions and its tags, and is delivered to
the mentioned actors and the parent's author as well as to followers.
Renderer:
- Mastodon's @context (toot, schema PropertyValue, discoverable,
indexable, blurhash, focalPoint, featured, alsoKnownAs, movedTo) and
FEP-2c59's webfinger;
- an actor's url is its HTML page, its fields are PropertyValue
attachments, published is cut to the day and indexable is false;
- a note's content is the stored rendering; a title becomes name and a
bold first line, a content warning without its own text is summarised
by the title or "Content warning"; Mention and Hashtag tags, contentMap,
updated, and to/cc by visibility.
Routes take the agreed names: /groupies, /stalking, /scribbles/{id},
/grunts/{id} (a Create resolves), /whispers/{id} for a DM's context. The
outbox is a collection with a first page, paged by max_id. A browser
asking for an actor or a note is redirected to /@user, and WebFinger's
profile-page points there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
151 lines
5.7 KiB
C#
151 lines
5.7 KiB
C#
using AngleSharp.Dom;
|
|
using AngleSharp.Html.Parser;
|
|
|
|
using Markdig;
|
|
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Federation.Objects;
|
|
using PrivaPub.Models.Federation;
|
|
|
|
using System.Net;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace PrivaPub.Domain.Content
|
|
{
|
|
public sealed record ResolvedMention(string ActorUri, string UserName, string Domain, string ProfileUrl, bool IsLocal, string AccountId, string Inbox)
|
|
{
|
|
public string Handle => $"{UserName}@{Domain}";
|
|
}
|
|
|
|
public sealed record RenderedContent(string Html, IReadOnlyList<ResolvedMention> Mentions, IReadOnlyList<string> Tags);
|
|
|
|
public interface IContentRenderer
|
|
{
|
|
Task<RenderedContent> Markdown(string text, CancellationToken token);
|
|
Task<RenderedContent> PlainText(string text, CancellationToken token);
|
|
string TagUrl(string tag);
|
|
}
|
|
|
|
public partial class ContentRenderer : IContentRenderer
|
|
{
|
|
const int MaxMentions = 20;
|
|
static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder().DisableHtml().UseAutoLinks().UseEmphasisExtras().Build();
|
|
static readonly HtmlParser Parser = new();
|
|
|
|
readonly ILocalActorService _localActors;
|
|
readonly IRemoteActorService _remoteActors;
|
|
|
|
public ContentRenderer(ILocalActorService localActors, IRemoteActorService remoteActors)
|
|
{
|
|
_localActors = localActors;
|
|
_remoteActors = remoteActors;
|
|
}
|
|
|
|
public string TagUrl(string tag) => $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}";
|
|
|
|
public Task<RenderedContent> Markdown(string text, CancellationToken token) =>
|
|
Decorate(string.IsNullOrWhiteSpace(text) ? string.Empty : Markdig.Markdown.ToHtml(text, Pipeline), token);
|
|
|
|
public Task<RenderedContent> PlainText(string text, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return Decorate(string.Empty, token);
|
|
var paragraphs = Regex.Split(text.Replace("\r\n", "\n").Trim(), "\n{2,}")
|
|
.Select(p => "<p>" + Linkify(WebUtility.HtmlEncode(p)).Replace("\n", "<br>") + "</p>");
|
|
return Decorate(string.Concat(paragraphs), token);
|
|
}
|
|
|
|
async Task<RenderedContent> Decorate(string html, CancellationToken token)
|
|
{
|
|
var document = Parser.ParseDocument($"<body>{html}</body>");
|
|
var textNodes = document.Body.Descendants<IText>()
|
|
.Where(node => !node.Ancestors<IElement>().Any(e => e.LocalName is "a" or "code" or "pre"))
|
|
.ToList();
|
|
|
|
var handles = textNodes.SelectMany(node => MentionPattern().Matches(node.Data).Select(m => m.Value))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(MaxMentions)
|
|
.ToList();
|
|
var mentions = new Dictionary<string, ResolvedMention>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var handle in handles)
|
|
{
|
|
var resolved = await Resolve(handle, token);
|
|
if (resolved != default)
|
|
mentions[handle] = resolved;
|
|
}
|
|
|
|
var tags = new List<string>();
|
|
foreach (var node in textNodes)
|
|
{
|
|
var replaced = Replace(node.Data, mentions, tags);
|
|
if (replaced == default)
|
|
continue;
|
|
var fragment = Parser.ParseFragment(replaced, node.ParentElement);
|
|
foreach (var child in fragment.ToList())
|
|
node.Parent.InsertBefore(child, node);
|
|
node.Parent.RemoveChild(node);
|
|
}
|
|
|
|
return new RenderedContent(
|
|
ContentSanitizer.Html(document.Body.InnerHtml),
|
|
mentions.Values.DistinctBy(m => m.ActorUri).ToList(),
|
|
tags.Distinct().ToList());
|
|
}
|
|
|
|
string Replace(string text, IReadOnlyDictionary<string, ResolvedMention> mentions, List<string> tags)
|
|
{
|
|
var changed = false;
|
|
var encoded = WebUtility.HtmlEncode(text);
|
|
encoded = MentionPattern().Replace(encoded, match =>
|
|
{
|
|
if (!mentions.TryGetValue(WebUtility.HtmlDecode(match.Value), out var mention))
|
|
return match.Value;
|
|
changed = true;
|
|
return $"<span class=\"h-card\" translate=\"no\"><a href=\"{WebUtility.HtmlEncode(mention.ProfileUrl)}\" class=\"u-url mention\">@<span>{WebUtility.HtmlEncode(mention.UserName)}</span></a></span>";
|
|
});
|
|
encoded = HashtagPattern().Replace(encoded, match =>
|
|
{
|
|
var tag = match.Groups["tag"].Value;
|
|
if (tag.All(char.IsDigit))
|
|
return match.Value;
|
|
changed = true;
|
|
tags.Add(tag.ToLowerInvariant());
|
|
return $"{match.Groups["lead"].Value}<a href=\"{WebUtility.HtmlEncode(TagUrl(tag.ToLowerInvariant()))}\" class=\"mention hashtag\" rel=\"tag\">#<span>{tag}</span></a>";
|
|
});
|
|
return changed ? encoded : default;
|
|
}
|
|
|
|
async Task<ResolvedMention> Resolve(string handle, CancellationToken token)
|
|
{
|
|
var parts = handle.TrimStart('@').Split('@');
|
|
var localDomain = new Uri(_localActors.BaseAddress).Authority;
|
|
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var local = await _localActors.FindByUserName(parts[0], token);
|
|
return local is { IsFederated: true, Kind: not LocalActorKind.Application }
|
|
? new ResolvedMention(local.Uri, local.UserName, local.Domain, local.Uri, true, local.Id, local.Inbox)
|
|
: default;
|
|
}
|
|
|
|
var actorUri = await _remoteActors.ResolveHandle($"{parts[0]}@{parts[1]}", token);
|
|
var actor = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
|
|
return actor == default
|
|
? default
|
|
: new ResolvedMention(actor.ActorURI, actor.UserName ?? parts[0], parts[1].ToLowerInvariant(), actor.Url ?? actor.ActorURI, false, actor.ID,
|
|
actor.InboxURL);
|
|
}
|
|
|
|
static string Linkify(string encoded) =>
|
|
UrlPattern().Replace(encoded, match => $"<a href=\"{match.Value}\">{match.Value}</a>");
|
|
|
|
[GeneratedRegex(@"(?<![\w@/.])@[a-zA-Z0-9_]+(?:@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+(?::\d+)?)?(?![\w@])")]
|
|
private static partial Regex MentionPattern();
|
|
|
|
[GeneratedRegex(@"(?<lead>^|[^\w&#/])#(?<tag>[\p{L}\p{N}_]{1,100})(?![\p{L}\p{N}_])")]
|
|
private static partial Regex HashtagPattern();
|
|
|
|
[GeneratedRegex(@"https?://[^\s<]+[^\s<.,;:!?)\]'""]")]
|
|
private static partial Regex UrlPattern();
|
|
}
|
|
}
|