feat(host): article markdown rendering with redaction, infobox, TOC, mermaid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,9 @@ public sealed record Attachment(string MediaType, byte[] Bytes, string? Caption
|
||||
["image/gif"] = "gif",
|
||||
};
|
||||
|
||||
/// <summary>The file extensions of all supported media types.</summary>
|
||||
public static IReadOnlyCollection<string> FileExtensions { get; } = Extensions.Values;
|
||||
|
||||
/// <summary>The file extension for a supported media type, or null.</summary>
|
||||
public static string? ExtensionFor(string mediaType) => Extensions.TryGetValue(mediaType, out var e) ? e : null;
|
||||
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Markdig;
|
||||
using Markdig.Extensions.CustomContainers;
|
||||
using Markdig.Helpers;
|
||||
using Markdig.Renderers;
|
||||
using Markdig.Renderers.Html;
|
||||
using Markdig.Syntax;
|
||||
using Markdig.Syntax.Inlines;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Wiki.Core.Model;
|
||||
using Wiki.Core.Parsing;
|
||||
|
||||
namespace Wiki.Host.Rendering;
|
||||
|
||||
public sealed record TocEntry(int Level, string Id, string Text);
|
||||
|
||||
public sealed record RenderedArticle(string Html, string? InfoboxHtml, IReadOnlyList<TocEntry> Toc);
|
||||
|
||||
/// <summary>
|
||||
/// Renders an encyclopedia article body. For an anonymous reader the body goes through
|
||||
/// <see cref="ArticleBody.RedactPrivate"/> first, and the parsed document may then contain only
|
||||
/// <c>infobox</c> and <c>private-locked</c> containers; any other container renders the whole body locked.
|
||||
/// Raw HTML is disabled. <c>[[slug]]</c> links show the target's title, and links to slugs the reader
|
||||
/// may not see are dropped with their text. Images are served by the media endpoint by index into
|
||||
/// <see cref="ImagePaths"/>; every other image and every link into <c>raw/</c> is dropped.
|
||||
/// </summary>
|
||||
public static partial class ArticleMarkdown
|
||||
{
|
||||
private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder()
|
||||
.UseCustomContainers()
|
||||
.UsePipeTables()
|
||||
.UseGridTables()
|
||||
.UseAutoIdentifiers()
|
||||
.UseEmphasisExtras()
|
||||
.UseDiagrams()
|
||||
.DisableHtml()
|
||||
.Build();
|
||||
|
||||
private const string Infobox = "infobox";
|
||||
private const string Locked = "private-locked";
|
||||
private const string RawPrefix = "raw/";
|
||||
|
||||
private static readonly object WrappedKey = new();
|
||||
|
||||
private static readonly Regex RawImage = new(
|
||||
$@"^raw/[A-Za-z0-9._-]+\.({string.Join('|', Attachment.FileExtensions.Select(Regex.Escape))})\z",
|
||||
RegexOptions.CultureInvariant);
|
||||
|
||||
[GeneratedRegex(@"\[\[([^\]\[|#]+)(?:#[^\]\[|]*)?(?:\|[^\]\[]*)?\]\]")]
|
||||
private static partial Regex LinkPattern();
|
||||
|
||||
[GeneratedRegex(@"^(:{3,})[ \t]*(.*?)[ \t]*$")]
|
||||
private static partial Regex ContainerLine();
|
||||
|
||||
[GeneratedRegex(@"^ {0,3}(`{3,}|~{3,})(.*)$")]
|
||||
private static partial Regex FenceLine();
|
||||
|
||||
private static readonly string[] Separators = [", ", " · "];
|
||||
|
||||
/// <param name="signedIn">false: the body is redacted first.</param>
|
||||
/// <param name="visibleTitles">slug → title in <paramref name="lang"/> of every article this reader may see.</param>
|
||||
public static RenderedArticle Render(string body, string lang, string slug, bool signedIn,
|
||||
IReadOnlyDictionary<string, string> visibleTitles, ILogger? logger = null) =>
|
||||
RenderVisible(signedIn ? body : ArticleBody.RedactPrivate(body), lang, slug, signedIn, visibleTitles, logger);
|
||||
|
||||
/// <summary>Renders a body that is already what this reader may see.</summary>
|
||||
internal static RenderedArticle RenderVisible(string visibleBody, string lang, string slug, bool signedIn,
|
||||
IReadOnlyDictionary<string, string> visibleTitles, ILogger? logger = null)
|
||||
{
|
||||
var doc = Parse(visibleBody);
|
||||
if (!signedIn)
|
||||
{
|
||||
if (doc.Descendants<CustomContainer>().Any(c => c.Info is not (Infobox or Locked)))
|
||||
{
|
||||
logger?.LogWarning("Article {Slug} ({Lang}) has an unexpected container after redaction; rendering it locked", slug, lang);
|
||||
doc = Parse($"{ArticleBody.PrivateLocked}\n{ArticleBody.Close}");
|
||||
}
|
||||
foreach (var c in doc.Descendants<CustomContainer>().Where(c => c.Info is not (Infobox or Locked)).ToList())
|
||||
c.Parent?.Remove(c);
|
||||
}
|
||||
|
||||
RewriteImagesAndRawLinks(doc, lang, slug);
|
||||
ResolveWikiLinks(doc, lang, visibleTitles);
|
||||
RemoveEmptyListItems(doc);
|
||||
var infobox = ExtractInfobox(doc, signedIn);
|
||||
if (infobox is not null) RemoveEmptyRows(infobox);
|
||||
|
||||
var toc = doc.Descendants<HeadingBlock>()
|
||||
.Where(h => h.Level is 2 or 3 && h.GetAttributes().Id is not null)
|
||||
.Select(h => new TocEntry(h.Level, h.GetAttributes().Id!, PlainText(h.Inline).Trim()))
|
||||
.ToList();
|
||||
|
||||
return new RenderedArticle(
|
||||
ToHtml(doc, lang, slug, signedIn),
|
||||
infobox is null ? null : ToHtml(infobox, lang, slug, signedIn),
|
||||
toc);
|
||||
}
|
||||
|
||||
/// <summary>The valid <c>raw/</c> image paths of a body, in order; the media index <c>n</c> of <see cref="Render"/> points into this list.</summary>
|
||||
public static IReadOnlyList<string> ImagePaths(string visibleBody) =>
|
||||
Parse(visibleBody).Descendants<LinkInline>()
|
||||
.Where(l => l.IsImage && l.Url is not null && RawImage.IsMatch(l.Url))
|
||||
.Select(l => l.Url!)
|
||||
.ToList();
|
||||
|
||||
private static MarkdownDocument Parse(string body) => Markdown.Parse(NestContainers(body), Pipeline);
|
||||
|
||||
/// <summary>
|
||||
/// Gives every column-0 container that holds another one more colons than its content, so that Markdig
|
||||
/// closes the inner container first: <c>::: private</c> around <c>::: infobox</c> becomes <c>:::: private</c>.
|
||||
/// </summary>
|
||||
private static string NestContainers(string body)
|
||||
{
|
||||
var lines = body.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
|
||||
var open = new Stack<int>();
|
||||
var height = new int[lines.Length];
|
||||
var pairs = new List<(int Open, int Close)>();
|
||||
string? fence = null;
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var f = FenceLine().Match(lines[i]);
|
||||
if (fence is not null)
|
||||
{
|
||||
var marker = f.Groups[1].Value;
|
||||
if (f.Success && marker[0] == fence[0] && marker.Length >= fence.Length && f.Groups[2].Value.Trim().Length == 0) fence = null;
|
||||
continue;
|
||||
}
|
||||
if (f.Success)
|
||||
{
|
||||
fence = f.Groups[1].Value;
|
||||
continue;
|
||||
}
|
||||
var c = ContainerLine().Match(lines[i]);
|
||||
if (!c.Success) continue;
|
||||
if (c.Groups[2].Length > 0)
|
||||
{
|
||||
open.Push(i);
|
||||
}
|
||||
else if (open.Count > 0)
|
||||
{
|
||||
var o = open.Pop();
|
||||
pairs.Add((o, i));
|
||||
if (open.Count > 0) height[open.Peek()] = Math.Max(height[open.Peek()], height[o] + 1);
|
||||
}
|
||||
}
|
||||
foreach (var (o, close) in pairs.Where(p => height[p.Open] > 0))
|
||||
{
|
||||
var colons = new string(':', 3 + height[o]);
|
||||
lines[o] = $"{colons} {ContainerLine().Match(lines[o]).Groups[2].Value}";
|
||||
lines[close] = colons;
|
||||
}
|
||||
return string.Join('\n', lines);
|
||||
}
|
||||
|
||||
private static void RewriteImagesAndRawLinks(MarkdownDocument doc, string lang, string slug)
|
||||
{
|
||||
var index = 0;
|
||||
foreach (var link in doc.Descendants<LinkInline>().ToList())
|
||||
{
|
||||
if (link.IsImage)
|
||||
{
|
||||
if (link.Url is not null && RawImage.IsMatch(link.Url))
|
||||
link.Url = $"/house/media/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}/{index++}";
|
||||
else
|
||||
link.Remove();
|
||||
}
|
||||
else if (link.Url?.Trim().TrimStart('.', '/').StartsWith(RawPrefix, StringComparison.OrdinalIgnoreCase) == true)
|
||||
{
|
||||
Unwrap(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Unwrap(ContainerInline container)
|
||||
{
|
||||
var child = container.FirstChild;
|
||||
while (child is not null)
|
||||
{
|
||||
var next = child.NextSibling;
|
||||
child.Remove();
|
||||
container.InsertBefore(child);
|
||||
child = next;
|
||||
}
|
||||
container.Remove();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces each <c>[[slug]]</c> in literal text by a link titled from <paramref name="visibleTitles"/>, or drops it,
|
||||
/// together with one adjacent list separator, when the slug is not there. A heading that held a link gets its id
|
||||
/// recomputed from the resolved text, so no dropped slug survives in an id.
|
||||
/// </summary>
|
||||
private static void ResolveWikiLinks(MarkdownDocument doc, string lang, IReadOnlyDictionary<string, string> visibleTitles)
|
||||
{
|
||||
var ids = doc.Descendants<HeadingBlock>().Select(h => h.GetAttributes().Id).OfType<string>().ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var leaf in doc.Descendants<LeafBlock>().Where(b => b.Inline is not null).ToList())
|
||||
{
|
||||
var root = leaf.Inline!;
|
||||
var containers = new List<ContainerInline> { root };
|
||||
containers.AddRange(root.Descendants<ContainerInline>());
|
||||
var changed = false;
|
||||
foreach (var container in containers)
|
||||
changed |= ResolveIn(container, lang, visibleTitles);
|
||||
|
||||
if (changed && leaf is HeadingBlock heading)
|
||||
{
|
||||
var attributes = heading.GetAttributes();
|
||||
if (attributes.Id is not null) ids.Remove(attributes.Id);
|
||||
var baseId = LinkHelper.Urilize(PlainText(root).Trim(), true);
|
||||
if (baseId.Length == 0) baseId = "section";
|
||||
var id = baseId;
|
||||
for (var n = 1; ids.Contains(id); n++) id = $"{baseId}-{n}";
|
||||
ids.Add(id);
|
||||
attributes.Id = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ResolveIn(ContainerInline container, string lang, IReadOnlyDictionary<string, string> visibleTitles)
|
||||
{
|
||||
var insideLink = false;
|
||||
for (Inline? p = container; p is not null; p = p.Parent)
|
||||
insideLink |= p is LinkInline;
|
||||
|
||||
var changed = false;
|
||||
var child = container.FirstChild;
|
||||
while (child is not null)
|
||||
{
|
||||
if (child is not LiteralInline)
|
||||
{
|
||||
child = child.NextSibling;
|
||||
continue;
|
||||
}
|
||||
var run = new List<LiteralInline>();
|
||||
while (child is LiteralInline literal)
|
||||
{
|
||||
run.Add(literal);
|
||||
child = child.NextSibling;
|
||||
}
|
||||
var text = string.Concat(run.Select(l => l.Content.ToString()));
|
||||
if (!LinkPattern().IsMatch(text)) continue;
|
||||
|
||||
var parts = new List<Inline>();
|
||||
var pending = new StringBuilder();
|
||||
var skipSeparator = false;
|
||||
var pos = 0;
|
||||
foreach (Match m in LinkPattern().Matches(text))
|
||||
{
|
||||
AppendText(text[pos..m.Index]);
|
||||
pos = m.Index + m.Length;
|
||||
var slug = m.Groups[1].Value.Trim();
|
||||
if (visibleTitles.TryGetValue(slug, out var title))
|
||||
{
|
||||
FlushText();
|
||||
if (insideLink)
|
||||
{
|
||||
parts.Add(new LiteralInline(title));
|
||||
}
|
||||
else
|
||||
{
|
||||
var link = new LinkInline($"/house/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}", "");
|
||||
link.AppendChild(new LiteralInline(title));
|
||||
parts.Add(link);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var before = pending.ToString();
|
||||
var separator = Separators.FirstOrDefault(s => before.EndsWith(s, StringComparison.Ordinal));
|
||||
if (separator is not null) pending.Length -= separator.Length;
|
||||
else skipSeparator = true;
|
||||
}
|
||||
}
|
||||
AppendText(text[pos..]);
|
||||
FlushText();
|
||||
|
||||
foreach (var part in parts) run[0].InsertBefore(part);
|
||||
foreach (var literal in run) literal.Remove();
|
||||
changed = true;
|
||||
|
||||
void AppendText(string segment)
|
||||
{
|
||||
if (skipSeparator)
|
||||
{
|
||||
var separator = Separators.FirstOrDefault(s => segment.StartsWith(s, StringComparison.Ordinal));
|
||||
if (separator is not null) segment = segment[separator.Length..];
|
||||
if (segment.Length > 0 || separator is not null) skipSeparator = false;
|
||||
}
|
||||
pending.Append(segment);
|
||||
}
|
||||
|
||||
void FlushText()
|
||||
{
|
||||
if (pending.Length > 0) parts.Add(new LiteralInline(pending.ToString()));
|
||||
pending.Clear();
|
||||
skipSeparator = false;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static void RemoveEmptyListItems(MarkdownDocument doc)
|
||||
{
|
||||
foreach (var item in doc.Descendants<ListItemBlock>().ToList().Where(i => !HasContent(i)))
|
||||
{
|
||||
var list = item.Parent;
|
||||
list?.Remove(item);
|
||||
if (list is ListBlock { Count: 0 }) list.Parent?.Remove(list);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes rows whose cells after the first are all empty.</summary>
|
||||
private static void RemoveEmptyRows(CustomContainer infobox)
|
||||
{
|
||||
foreach (var row in infobox.Descendants<Markdig.Extensions.Tables.TableRow>().ToList())
|
||||
{
|
||||
if (row.Count >= 2 && row.Skip(1).All(cell => !HasContent(cell)))
|
||||
row.Parent?.Remove(row);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasContent(MarkdownObject block) =>
|
||||
block.Descendants().Any(o => o switch
|
||||
{
|
||||
LiteralInline l => !l.Content.IsEmptyOrWhitespace(),
|
||||
CodeInline or AutolinkInline or HtmlEntityInline or CodeBlock or ThematicBreakBlock => true,
|
||||
LinkInline { IsImage: true } => true,
|
||||
_ => false,
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Takes the first top-level infobox out of the document, or for a signed-in reader one that is the content of a
|
||||
/// top-level private block; that one is marked private and the then empty private block goes too.
|
||||
/// </summary>
|
||||
private static CustomContainer? ExtractInfobox(MarkdownDocument doc, bool signedIn)
|
||||
{
|
||||
foreach (var block in doc.ToList())
|
||||
{
|
||||
if (block is not CustomContainer container) continue;
|
||||
if (container.Info == Infobox)
|
||||
{
|
||||
doc.Remove(container);
|
||||
return container;
|
||||
}
|
||||
if (signedIn && container.Info != Locked
|
||||
&& container.OfType<CustomContainer>().FirstOrDefault(c => c.Info == Infobox) is { } inner)
|
||||
{
|
||||
container.Remove(inner);
|
||||
if (!HasContent(container)) doc.Remove(container);
|
||||
inner.SetData(WrappedKey, true);
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string PlainText(ContainerInline? container)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
Append(container);
|
||||
return sb.ToString();
|
||||
|
||||
void Append(Inline? inline)
|
||||
{
|
||||
switch (inline)
|
||||
{
|
||||
case LiteralInline l: sb.Append(l.Content.ToString()); break;
|
||||
case CodeInline c: sb.Append(c.Content); break;
|
||||
case HtmlEntityInline e: sb.Append(e.Transcoded.ToString()); break;
|
||||
case LineBreakInline: sb.Append(' '); break;
|
||||
case ContainerInline c:
|
||||
for (var child = c.FirstChild; child is not null; child = child.NextSibling) Append(child);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToHtml(MarkdownObject obj, string lang, string slug, bool signedIn)
|
||||
{
|
||||
using var writer = new StringWriter();
|
||||
var renderer = new HtmlRenderer(writer);
|
||||
Pipeline.Setup(renderer);
|
||||
renderer.ObjectRenderers.Replace<HtmlCustomContainerRenderer>(new ContainerRenderer(lang, slug, signedIn));
|
||||
renderer.Render(obj);
|
||||
writer.Flush();
|
||||
return writer.ToString();
|
||||
}
|
||||
|
||||
private sealed class ContainerRenderer(string lang, string slug, bool signedIn) : HtmlObjectRenderer<CustomContainer>
|
||||
{
|
||||
private bool English => lang == "en";
|
||||
|
||||
protected override void Write(HtmlRenderer renderer, CustomContainer obj)
|
||||
{
|
||||
renderer.EnsureLine();
|
||||
switch (obj.Info)
|
||||
{
|
||||
case Infobox when obj.GetData(WrappedKey) is true:
|
||||
renderer.WriteLine("<div class=\"infobox private\">");
|
||||
renderer.WriteLine(Label());
|
||||
renderer.WriteChildren(obj);
|
||||
renderer.WriteLine("</div>");
|
||||
break;
|
||||
case Infobox:
|
||||
renderer.WriteLine("<div class=\"infobox\">");
|
||||
renderer.WriteChildren(obj);
|
||||
renderer.WriteLine("</div>");
|
||||
break;
|
||||
case Locked:
|
||||
var returnUrl = Uri.EscapeDataString($"/house/{Uri.EscapeDataString(lang)}/{Uri.EscapeDataString(slug)}");
|
||||
var text = English ? "Private section — log in to read" : "Privater Abschnitt — anmelden zum Lesen";
|
||||
renderer.WriteLine(
|
||||
$"<div class=\"private-locked\"><span aria-hidden=\"true\">🔒</span> <a href=\"/house/login?returnUrl={returnUrl}\">{WebUtility.HtmlEncode(text)}</a></div>");
|
||||
break;
|
||||
case var _ when signedIn:
|
||||
renderer.WriteLine("<div class=\"private\">");
|
||||
renderer.WriteLine(Label());
|
||||
renderer.WriteChildren(obj);
|
||||
renderer.WriteLine("</div>");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string Label() =>
|
||||
$"<div class=\"private-label\"><span aria-hidden=\"true\">🔒</span> {(English ? "Private section" : "Privater Abschnitt")}</div>";
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@
|
||||
<PackageReference Include="MudBlazor" Version="9.10.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Wiki.Host.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/* House encyclopedia article, Wikipedia look. */
|
||||
.house-article {
|
||||
font-family: Georgia, "Linux Libertine", serif;
|
||||
color: #202122;
|
||||
background: #fff;
|
||||
line-height: 1.6;
|
||||
max-width: 60rem;
|
||||
padding: 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.house-article h1 {
|
||||
font-weight: normal;
|
||||
font-size: 1.9em;
|
||||
border-bottom: 1px solid #a2a9b1;
|
||||
margin: 0 0 .25rem;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.house-article h2 {
|
||||
font-weight: normal;
|
||||
font-size: 1.4em;
|
||||
border-bottom: 1px solid #a2a9b1;
|
||||
margin: 1.4rem 0 .5rem;
|
||||
}
|
||||
|
||||
.house-article h3 {
|
||||
font-family: sans-serif;
|
||||
font-size: 1.05em;
|
||||
margin: .9rem 0 .25rem;
|
||||
}
|
||||
|
||||
.house-article a { color: #3366cc; text-decoration: none; }
|
||||
.house-article a:hover { text-decoration: underline; }
|
||||
|
||||
/* Infobox floats right; diagrams, figures, tables and sections start below it, never beneath it. */
|
||||
.house-article .infobox {
|
||||
float: right;
|
||||
width: 270px;
|
||||
margin: 0 0 .9rem 1.25rem;
|
||||
border: 1px solid #a2a9b1;
|
||||
background: #f8f9fa;
|
||||
font-family: sans-serif;
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
.house-article .mermaid,
|
||||
.house-article figure,
|
||||
.house-article table,
|
||||
.house-article h2 {
|
||||
clear: right;
|
||||
}
|
||||
|
||||
.house-article .infobox table { clear: none; width: 100%; border-collapse: collapse; margin: 0; }
|
||||
.house-article .infobox th,
|
||||
.house-article .infobox td { text-align: left; vertical-align: top; padding: 4px 6px; border: 0; }
|
||||
.house-article .infobox thead th { background: #cee0f2; text-align: center; }
|
||||
.house-article .infobox tr + tr { border-top: 1px solid #eaecf0; }
|
||||
.house-article .infobox img { max-width: 100%; height: auto; }
|
||||
|
||||
.house-article .toc {
|
||||
display: inline-block;
|
||||
border: 1px solid #a2a9b1;
|
||||
background: #f8f9fa;
|
||||
padding: .5rem .9rem;
|
||||
font-family: sans-serif;
|
||||
font-size: .85em;
|
||||
margin: .4rem 0 .25rem;
|
||||
}
|
||||
.house-article .toc ol { margin: .25rem 0 0; padding-left: 1.1rem; }
|
||||
|
||||
.house-article table { border-collapse: collapse; font-family: sans-serif; font-size: .9em; margin: .5rem 0; }
|
||||
.house-article th,
|
||||
.house-article td { border: 1px solid #a2a9b1; padding: 5px 10px; }
|
||||
.house-article th { background: #eaecf0; }
|
||||
|
||||
.house-article pre.mermaid {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #c8ccd1;
|
||||
padding: .4rem;
|
||||
margin: .6rem 0;
|
||||
text-align: center;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.house-article figure,
|
||||
.house-article img { max-width: 100%; }
|
||||
|
||||
.house-article .private {
|
||||
border-left: 4px solid #b32424;
|
||||
background: #fef6f6;
|
||||
padding: .5rem .75rem;
|
||||
margin: .75rem 0;
|
||||
}
|
||||
|
||||
.house-article .private-label,
|
||||
.house-article .private-locked {
|
||||
font-family: sans-serif;
|
||||
font-size: .85em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.house-article .private-locked {
|
||||
display: block;
|
||||
border-left: 4px solid #b32424;
|
||||
background: #fee7e6;
|
||||
padding: .5rem .75rem;
|
||||
margin: .75rem 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.house-article .infobox.private { border-left: 4px solid #b32424; }
|
||||
.house-article .infobox .private-label { padding: 4px 6px; background: #fee7e6; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.house-article { padding: 1rem; }
|
||||
.house-article .infobox { float: none; width: auto; margin: 0 0 1rem; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 - 2022 Knut Sveidqvist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+2314
File diff suppressed because one or more lines are too long
@@ -0,0 +1,258 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Wiki.Host.Rendering;
|
||||
|
||||
namespace Wiki.Host.Tests;
|
||||
|
||||
public class ArticleMarkdownTests
|
||||
{
|
||||
private static readonly Dictionary<string, string> Titles = new()
|
||||
{
|
||||
["heating"] = "Heizung",
|
||||
["blinds"] = "Rollläden",
|
||||
};
|
||||
|
||||
private static RenderedArticle Anonymous(string body) => ArticleMarkdown.Render(body, "de", "switches", signedIn: false, Titles);
|
||||
|
||||
private static RenderedArticle SignedIn(string body) => ArticleMarkdown.Render(body, "de", "switches", signedIn: true, Titles);
|
||||
|
||||
[Fact]
|
||||
public void Render_Anonymous_ContainsNoPrivateText()
|
||||
{
|
||||
var article = Anonymous("Öffentlich.\n\n::: private\nGEHEIM\n:::\n\nDanach.");
|
||||
|
||||
Assert.DoesNotContain("GEHEIM", article.Html);
|
||||
Assert.Contains("Öffentlich.", article.Html);
|
||||
Assert.Contains("Danach.", article.Html);
|
||||
Assert.Contains("<div class=\"private-locked\">", article.Html);
|
||||
Assert.Contains("Privater Abschnitt — anmelden zum Lesen", article.Html);
|
||||
Assert.Contains("href=\"/house/login?returnUrl=%2Fhouse%2Fde%2Fswitches\"", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_Anonymous_LockedBlockIsLocalized()
|
||||
{
|
||||
var article = ArticleMarkdown.Render("::: private\nSECRET\n:::", "en", "switches", signedIn: false, Titles);
|
||||
|
||||
Assert.DoesNotContain("SECRET", article.Html);
|
||||
Assert.Contains("Private section — log in to read", article.Html);
|
||||
Assert.Contains("href=\"/house/login?returnUrl=%2Fhouse%2Fen%2Fswitches\"", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_SignedIn_ShowsPrivateBlock()
|
||||
{
|
||||
var article = SignedIn("Öffentlich.\n\n::: private\nGEHEIM\n:::\n\nDanach.");
|
||||
|
||||
Assert.Contains("<div class=\"private\">", article.Html);
|
||||
Assert.Contains("GEHEIM", article.Html);
|
||||
Assert.Contains("Privater Abschnitt", article.Html);
|
||||
Assert.DoesNotContain("private-locked", article.Html);
|
||||
Assert.Contains("Danach.", article.Html);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("- ::: private\n GEHEIM\n :::")]
|
||||
[InlineData("> ::: private\n> GEHEIM\n> :::")]
|
||||
public void Render_Anonymous_ContainerInListOrQuote_LocksWholeBody(string body)
|
||||
{
|
||||
var article = Anonymous(body);
|
||||
|
||||
Assert.DoesNotContain("GEHEIM", article.Html);
|
||||
Assert.Contains("<div class=\"private-locked\">", article.Html);
|
||||
Assert.DoesNotContain("<li>", article.Html);
|
||||
Assert.DoesNotContain("<blockquote>", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderVisible_Anonymous_UnexpectedContainer_LocksWholeBody()
|
||||
{
|
||||
var logger = new ListLogger();
|
||||
|
||||
var article = ArticleMarkdown.RenderVisible(
|
||||
"## Teil\n\nÖffentlich.\n\n::: warning\nGEHEIM\n:::", "de", "switches", signedIn: false, Titles, logger);
|
||||
|
||||
Assert.DoesNotContain("GEHEIM", article.Html);
|
||||
Assert.DoesNotContain("Öffentlich", article.Html);
|
||||
Assert.Contains("<div class=\"private-locked\">", article.Html);
|
||||
Assert.Empty(article.Toc);
|
||||
Assert.Null(article.InfoboxHtml);
|
||||
Assert.Contains(logger.Entries, e => e.Level == LogLevel.Warning && e.Message.Contains("switches", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderVisible_Anonymous_PrivateContainer_LocksWholeBody()
|
||||
{
|
||||
var article = ArticleMarkdown.RenderVisible("Öffentlich.\n\n::: private\nGEHEIM\n:::", "de", "switches", signedIn: false, Titles);
|
||||
|
||||
Assert.DoesNotContain("GEHEIM", article.Html);
|
||||
Assert.Contains("<div class=\"private-locked\">", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_InfoboxExtracted()
|
||||
{
|
||||
var article = SignedIn("Lead.\n\n::: infobox\n| Eigenschaft | Wert |\n|---|---|\n| Typ | Taster |\n:::\n\n## Prinzip\n\nText.");
|
||||
|
||||
Assert.NotNull(article.InfoboxHtml);
|
||||
Assert.StartsWith("<div class=\"infobox\">", article.InfoboxHtml);
|
||||
Assert.Contains("Taster", article.InfoboxHtml);
|
||||
Assert.DoesNotContain("Taster", article.Html);
|
||||
Assert.DoesNotContain("infobox", article.Html);
|
||||
Assert.Contains("Lead.", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_SignedIn_PrivateWrappedInfobox()
|
||||
{
|
||||
var body = "Lead.\n\n::: private\n::: infobox\n| Eigenschaft | Wert |\n|---|---|\n| Host | GEHEIM |\n:::\n:::\n\n## Prinzip\n\nDanach.";
|
||||
|
||||
var article = SignedIn(body);
|
||||
|
||||
Assert.NotNull(article.InfoboxHtml);
|
||||
Assert.StartsWith("<div class=\"infobox private\">", article.InfoboxHtml);
|
||||
Assert.Contains("GEHEIM", article.InfoboxHtml);
|
||||
Assert.Contains("Privater Abschnitt", article.InfoboxHtml);
|
||||
Assert.DoesNotContain("GEHEIM", article.Html);
|
||||
Assert.DoesNotContain("class=\"private\"", article.Html);
|
||||
Assert.Contains("<p>Danach.</p>", article.Html);
|
||||
Assert.Contains("<h2 id=\"prinzip\">", article.Html);
|
||||
|
||||
var anonymous = Anonymous(body);
|
||||
Assert.Null(anonymous.InfoboxHtml);
|
||||
Assert.DoesNotContain("GEHEIM", anonymous.Html);
|
||||
Assert.Contains("<p>Danach.</p>", anonymous.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_TocMatchesHeadingIds()
|
||||
{
|
||||
var article = Anonymous("Lead.\n\n## Wie es funktioniert\n\nA.\n\n### Im Bad\n\nB.\n\n#### Tiefer\n\nC.\n\n## Wie es funktioniert\n\nD.");
|
||||
|
||||
Assert.Equal([2, 3, 2], article.Toc.Select(t => t.Level));
|
||||
Assert.Equal("Wie es funktioniert", article.Toc[0].Text);
|
||||
Assert.Equal("Im Bad", article.Toc[1].Text);
|
||||
Assert.NotEqual(article.Toc[0].Id, article.Toc[2].Id);
|
||||
foreach (var entry in article.Toc)
|
||||
Assert.Contains($"id=\"{entry.Id}\"", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_LinkUsesTitle()
|
||||
{
|
||||
var article = Anonymous("Siehe [[heating]].");
|
||||
|
||||
Assert.Contains("<a href=\"/house/de/heating\">Heizung</a>", article.Html);
|
||||
Assert.DoesNotContain("[[", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_Anonymous_LinkToInvisibleSlug_DroppedWithText()
|
||||
{
|
||||
var article = Anonymous("Siehe [[heating]] und [[network-internals|Netzinterna]].\n\n## Über [[network-internals]]\n\nText.");
|
||||
|
||||
Assert.DoesNotContain("network-internals", article.Html);
|
||||
Assert.DoesNotContain("Netzinterna", article.Html);
|
||||
Assert.DoesNotContain("network", article.Html);
|
||||
Assert.DoesNotContain(article.Toc, t => t.Id.Contains("network", StringComparison.Ordinal) || t.Text.Contains("network", StringComparison.Ordinal));
|
||||
Assert.Contains("<a href=\"/house/de/heating\">Heizung</a>", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_ListItemWithOnlyInvisibleLink_Removed()
|
||||
{
|
||||
var article = Anonymous("Weiter:\n\n- [[heating]]\n- [[network-internals]]\n\nEnde.\n\n- [[network-internals]]\n");
|
||||
|
||||
Assert.Single(System.Text.RegularExpressions.Regex.Matches(article.Html, "<li>"));
|
||||
Assert.Single(System.Text.RegularExpressions.Regex.Matches(article.Html, "<ul>"));
|
||||
Assert.Contains("Heizung", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_InfoboxRowWithOnlyInvisibleLink_Removed()
|
||||
{
|
||||
var article = Anonymous(
|
||||
"::: infobox\n| Eigenschaft | Wert |\n|---|---|\n| Typ | Taster |\n| Verwandt | [[network-internals]] |\n| Siehe | [[heating]], [[network-internals]] |\n| Auch | [[network-internals]] · [[blinds]] |\n:::\n\nLead.");
|
||||
|
||||
var infobox = Assert.IsType<string>(article.InfoboxHtml);
|
||||
Assert.DoesNotContain("Verwandt", infobox);
|
||||
Assert.DoesNotContain("network-internals", infobox);
|
||||
Assert.Contains("<td><a href=\"/house/de/heating\">Heizung</a></td>", infobox);
|
||||
Assert.Contains("<td><a href=\"/house/de/blinds\">Rollläden</a></td>", infobox);
|
||||
Assert.Contains("Taster", infobox);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_RawHtmlEscaped()
|
||||
{
|
||||
var article = SignedIn("Text <script>alert(1)</script>\n\n<div onclick=\"x\">block</div>\n\n[[heating|<script>]]");
|
||||
|
||||
Assert.DoesNotContain("<script>", article.Html);
|
||||
Assert.DoesNotContain("<div onclick", article.Html);
|
||||
Assert.Contains("<script>", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_MathIsNotParsed()
|
||||
{
|
||||
var article = SignedIn("$$\nx\n$$");
|
||||
|
||||
Assert.DoesNotContain("math", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_MermaidFenceBecomesDiagramElement()
|
||||
{
|
||||
var article = Anonymous("```mermaid\ngraph TD\nA[[Taster]] --> B<b>\n```");
|
||||
|
||||
Assert.Contains("<pre class=\"mermaid\">graph TD\nA[[Taster]] --> B<b>\n</pre>", article.Html);
|
||||
Assert.DoesNotContain("<b>", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_RawImageRewrittenToMediaEndpoint()
|
||||
{
|
||||
var body = "::: private\n\n:::\n\n\n\n ";
|
||||
|
||||
var anonymous = Anonymous(body);
|
||||
Assert.Contains("<img src=\"/house/media/de/switches/0\" alt=\"Taster\" />", anonymous.Html);
|
||||
Assert.DoesNotContain("raw/", anonymous.Html);
|
||||
Assert.DoesNotContain("example.com", anonymous.Html);
|
||||
Assert.DoesNotContain("Keller", anonymous.Html);
|
||||
Assert.Equal(["raw/taster-1.png"], ArticleMarkdown.ImagePaths(Wiki.Core.Parsing.ArticleBody.RedactPrivate(body)));
|
||||
|
||||
var signedIn = SignedIn(body);
|
||||
Assert.Contains("<img src=\"/house/media/de/switches/0\" alt=\"Keller\" />", signedIn.Html);
|
||||
Assert.Contains("<img src=\"/house/media/de/switches/1\" alt=\"Taster\" />", signedIn.Html);
|
||||
Assert.Equal(["raw/keller.jpg", "raw/taster-1.png"], ArticleMarkdown.ImagePaths(body));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Render_RawFileLink_DroppedKeepsText()
|
||||
{
|
||||
var article = SignedIn("Ein [Foto](raw/x.jpg) und [Notiz](raw/n.md) und [extern](https://example.com/).");
|
||||
|
||||
Assert.DoesNotContain("raw/", article.Html);
|
||||
Assert.Contains("Ein Foto und Notiz und", article.Html);
|
||||
Assert.Contains("<a href=\"https://example.com/\">extern</a>", article.Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ImagePaths_RejectsTraversalAndNonImages()
|
||||
{
|
||||
var body = "     \n\n```\n\n```";
|
||||
|
||||
Assert.Equal(["raw/zaehler-1.jpg"], ArticleMarkdown.ImagePaths(body));
|
||||
}
|
||||
|
||||
private sealed class ListLogger : ILogger
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter) =>
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user