From 6e76b275ae0ac99b74b8fd1a1766a4dc9968bcfb Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Tue, 20 Oct 2026 08:58:22 +0200 Subject: [PATCH 1/2] feat: Allow serving blog posts as markdown --- MIGRATION.md | 12 + docs/Migrations/UpgradeAssistant.md | 19 +- docs/Setup/Configuration.md | 34 ++- .../ApplicationConfiguration.cs | 2 + .../Features/MarkdownConverter.cs | 14 + .../BlogPostMarkdownMiddleware.cs | 88 ++++++ .../ShowBlogPost/BlogPostMarkdownRenderer.cs | 50 ++++ .../ShowBlogPost/ShowBlogPostPage.razor | 15 +- src/LinkDotNet.Blog.Web/Program.cs | 1 + .../WebApplicationExtensions.cs | 11 + src/LinkDotNet.Blog.Web/appsettings.json | 5 +- .../BlogPostMarkdownTests.cs | 267 ++++++++++++++++++ .../ApplicationConfigurationBuilder.cs | 8 + .../Web/ApplicationConfigurationTests.cs | 18 +- .../Web/Features/MarkdownConverterTests.cs | 17 ++ .../BlogPostMarkdownMiddlewareTests.cs | 30 ++ .../BlogPostMarkdownRendererTests.cs | 114 ++++++++ .../Migration16To17Tests.cs | 63 +++++ .../MigrationManagerTests.cs | 45 ++- .../MigrationDiscovery.cs | 3 +- .../Migrations/Migration_16_To_17.cs | 40 +++ 21 files changed, 832 insertions(+), 24 deletions(-) create mode 100644 src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownMiddleware.cs create mode 100644 src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownRenderer.cs create mode 100644 tests/LinkDotNet.Blog.IntegrationTests/BlogPostMarkdownTests.cs create mode 100644 tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs create mode 100644 tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownRendererTests.cs create mode 100644 tests/LinkDotNet.Blog.UpgradeAssistant.Tests/Migration16To17Tests.cs create mode 100644 tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_16_To_17.cs diff --git a/MIGRATION.md b/MIGRATION.md index f0e87e06..7fb613f6 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -27,6 +27,18 @@ For detailed documentation, see [docs/Migrations/UpgradeAssistant.md](docs/Migra --- +## 16.0 to 17.0 + +### Markdown for LLMs +Blog posts can be served as markdown to LLMs and agents that request them with `Accept: text/markdown`. The `EnableMarkdownForLlms` setting was added on the root level of the `appsettings.json` file (handled by the Upgrade Assistant). The default is `false`, set it to `true` to enable it. See [Markdown for LLMs](docs/Setup/Configuration.md#markdown-for-llms) for details. + +```json +{ + ... + "EnableMarkdownForLlms": false +} +``` + ## 15.0 to 16.0 ### Blog post record index diff --git a/docs/Migrations/UpgradeAssistant.md b/docs/Migrations/UpgradeAssistant.md index 7bb24ea3..1a1abb84 100644 --- a/docs/Migrations/UpgradeAssistant.md +++ b/docs/Migrations/UpgradeAssistant.md @@ -131,7 +131,7 @@ The tool looks for a `ConfigVersion` field in your `appsettings.json`: ```json { - "ConfigVersion": "16.0", + "ConfigVersion": "17.0", ... } ``` @@ -142,7 +142,7 @@ If this field doesn't exist, the tool assumes you're running version 11.0 or ear The tool applies migrations sequentially: 1. Detects current version (e.g., 12.0) -2. Finds all migrations from current to latest (12.0→13.0→15.0→16.0) +2. Finds all migrations from current to latest (12.0→13.0→15.0→16.0→17.0) 3. Applies each migration in order 4. Updates the `ConfigVersion` field to the latest version @@ -239,6 +239,21 @@ Version 14.0 had no configuration changes, so this migration goes directly from **Manual Steps Required:** - None (setting is optional) +### Version 16.0 → 17.0 + +**Changes:** +- Adds `EnableMarkdownForLlms` setting (default: `false`) + +**After:** +```json +{ + "EnableMarkdownForLlms": false +} +``` + +**Manual Steps Required:** +- None (setting is optional). Set it to `true` to serve blog posts as markdown to LLMs, see [Markdown for LLMs](../Setup/Configuration.md#markdown-for-llms) + ## Command-Line Reference ``` diff --git a/docs/Setup/Configuration.md b/docs/Setup/Configuration.md index fa98f87d..75a03556 100644 --- a/docs/Setup/Configuration.md +++ b/docs/Setup/Configuration.md @@ -73,7 +73,8 @@ Browser spellchecking uses the `` value in "UseMultiAuthorMode": false, "EnableTagDiscoveryPanel": true, "EnableBrokenLinkChecker": true, - "ShowCodeBlockLanguage": true + "ShowCodeBlockLanguage": true, + "EnableMarkdownForLlms": false } ``` @@ -122,3 +123,34 @@ Browser spellchecking uses the `` value in | EnableTagDiscoveryPanel | boolean | The default value is `true`. Enables the Tag Discovery Panel, which helps users discover topics by browsing popular tags. | | EnableBrokenLinkChecker | boolean | The default value is `true`. Checks all external links of published blog posts once a day and lists broken ones on the "Broken links" admin page. | | ShowCodeBlockLanguage | boolean | The default value is `true`. Shows the language of a fenced code block (e.g. `csharp`) in the header of the code block. | +| EnableMarkdownForLlms | boolean | The default value is `false`. Serves published blog posts as markdown to clients (like LLMs) that ask for `text/markdown`. See [Markdown for LLMs](#markdown-for-llms). | + +### Markdown for LLMs + +If `EnableMarkdownForLlms` is set to `true`, a blog post page (`/blogPost/{id}` or `/blogPost/{id}/{slug}`) can be requested as markdown instead of HTML. LLMs and agents then don't have to parse the HTML page. Only blog post pages support this; every other page always returns HTML. + +```bash +curl -H "Accept: text/markdown" https://example.com/blogPost/38a0d0f4-e9ff-4947-a05c-527af68b6cd1 +``` + +The response (`Content-Type: text/markdown; charset=utf-8`) contains YAML front matter, the title and the raw markdown content with all short codes expanded: + +```markdown +--- +title: "My blog post" +description: "The short description" +updated: 2026-09-01 +tags: ["dotnet", "csharp"] +canonical: "https://example.com/blogPost/38a0d0f4-e9ff-4947-a05c-527af68b6cd1/my-blog-post" +--- + +# My blog post + +The content... +``` + +- `author` is added when `UseMultiAuthorMode` is enabled and the blog post has an author; `tags` is omitted when the blog post has none. +- Markdown is only served if `text/markdown` is weighted at least as high as HTML in the `Accept` header (`text/html`, `text/*` or `*/*`), so `text/markdown, text/html;q=0.9` returns markdown, while `text/html, text/markdown;q=0.9` and a regular browser request return HTML. +- Only published blog posts are served. Unpublished, scheduled and unknown blog posts return `404` with a markdown body. +- Blog post responses carry `Vary: Accept` so caches and CDNs keep the HTML and markdown variants apart. +- If the setting is `false` or missing, the `Accept` header is ignored and HTML is always returned. diff --git a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs index c8a7730d..019cdb5d 100644 --- a/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs +++ b/src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs @@ -33,4 +33,6 @@ public sealed record ApplicationConfiguration public bool EnableBrokenLinkChecker { get; init; } = true; public bool ShowCodeBlockLanguage { get; init; } = true; + + public bool EnableMarkdownForLlms { get; init; } } diff --git a/src/LinkDotNet.Blog.Web/Features/MarkdownConverter.cs b/src/LinkDotNet.Blog.Web/Features/MarkdownConverter.cs index 549ae0e7..a351fb2f 100644 --- a/src/LinkDotNet.Blog.Web/Features/MarkdownConverter.cs +++ b/src/LinkDotNet.Blog.Web/Features/MarkdownConverter.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using LinkDotNet.Blog.Domain; using Markdig; using Markdig.Extensions.AutoIdentifiers; using Markdig.Renderers.Html; @@ -58,6 +59,19 @@ public static MarkupString ToMarkupStringWithHeadingAnchors(string markdown, str return (MarkupString)document.ToHtml(pipeline); } + public static string ExpandShortCodes(string content, IEnumerable shortCodes) + { + ArgumentNullException.ThrowIfNull(shortCodes); + + var sb = new StringBuilder(content); + foreach (var shortCode in shortCodes) + { + sb.Replace($"[[{shortCode.Name}]]", shortCode.MarkdownContent); + } + + return sb.ToString(); + } + public static string? ToPlainString(string markdown) { return string.IsNullOrEmpty(markdown) diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownMiddleware.cs b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownMiddleware.cs new file mode 100644 index 00000000..ac4244f8 --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownMiddleware.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence; +using Microsoft.AspNetCore.Components.Endpoints; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; + +namespace LinkDotNet.Blog.Web.Features.ShowBlogPost; + +public sealed class BlogPostMarkdownMiddleware +{ + private const string MarkdownMediaType = "text/markdown"; + private static readonly string[] HtmlMediaTypes = ["text/html", "text/*", "*/*"]; + + private readonly RequestDelegate next; + + public BlogPostMarkdownMiddleware(RequestDelegate next) + { + this.next = next; + } + + public async Task InvokeAsync( + HttpContext context, + IOptions appConfiguration, + IRepository blogPostRepository, + IRepository shortCodeRepository) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(appConfiguration); + ArgumentNullException.ThrowIfNull(blogPostRepository); + ArgumentNullException.ThrowIfNull(shortCodeRepository); + + if (!HttpMethods.IsGet(context.Request.Method) || !IsBlogPostPage(context)) + { + await next(context); + return; + } + + context.Response.Headers.Append(HeaderNames.Vary, HeaderNames.Accept); + if (!PrefersMarkdown(context.Request.GetTypedHeaders().Accept)) + { + await next(context); + return; + } + + // A content type keeps UseStatusCodePagesWithReExecute from swapping the 404 for the HTML NotFound page + context.Response.ContentType = $"{MarkdownMediaType}; charset=utf-8"; + var blogPost = await blogPostRepository.GetByIdAsync(context.Request.RouteValues["blogPostId"]?.ToString() ?? string.Empty); + if (blogPost is not { IsPublished: true }) + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync("# Not found\n", context.RequestAborted); + return; + } + + var shortCodes = await shortCodeRepository.GetAllAsync(); + var markdown = BlogPostMarkdownRenderer.Render(blogPost, shortCodes, GetCanonicalUrl(context.Request, blogPost), appConfiguration.Value.UseMultiAuthorMode); + await context.Response.WriteAsync(markdown, context.RequestAborted); + } + + public static bool PrefersMarkdown(IList accept) + { + ArgumentNullException.ThrowIfNull(accept); + + var markdownQuality = GetBestQuality(accept, [MarkdownMediaType]); + return markdownQuality > 0 && markdownQuality >= GetBestQuality(accept, HtmlMediaTypes); + } + + private static double GetBestQuality(IList accept, string[] mediaTypes) + => accept + .Where(a => mediaTypes.Any(m => a.MediaType.Equals(m, StringComparison.OrdinalIgnoreCase))) + .Select(a => a.Quality ?? 1) + .DefaultIfEmpty(0) + .Max(); + + private static bool IsBlogPostPage(HttpContext context) + => context.GetEndpoint()?.Metadata.GetMetadata()?.Type == typeof(ShowBlogPostPage); + + private static string GetCanonicalUrl(HttpRequest request, BlogPost blogPost) + { + var baseUrl = $"{request.Scheme}://{request.Host}{request.PathBase}/blogPost/{blogPost.Id}"; + return string.IsNullOrEmpty(blogPost.Slug) ? baseUrl : $"{baseUrl}/{blogPost.Slug}"; + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownRenderer.cs b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownRenderer.cs new file mode 100644 index 00000000..6e19cc4f --- /dev/null +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownRenderer.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using LinkDotNet.Blog.Domain; + +namespace LinkDotNet.Blog.Web.Features.ShowBlogPost; + +public static class BlogPostMarkdownRenderer +{ + public static string Render(BlogPost blogPost, IEnumerable shortCodes, string canonicalUrl, bool includeAuthor) + { + ArgumentNullException.ThrowIfNull(blogPost); + + var sb = new StringBuilder(); + sb.Append("---\n"); + AppendScalar(sb, "title", blogPost.Title); + AppendScalar(sb, "description", blogPost.ShortDescription); + if (includeAuthor && blogPost.AuthorName is not null) + { + AppendScalar(sb, "author", blogPost.AuthorName); + } + + sb.Append(CultureInfo.InvariantCulture, $"updated: {blogPost.UpdatedDate:yyyy-MM-dd}\n"); + if (blogPost.Tags is { Count: > 0 }) + { + sb.Append("tags: [").AppendJoin(", ", blogPost.Tags.Select(ToYamlString)).Append("]\n"); + } + + AppendScalar(sb, "canonical", canonicalUrl); + sb.Append("---\n\n"); + sb.Append("# ").Append(blogPost.Title).Append("\n\n"); + sb.Append(MarkdownConverter.ExpandShortCodes(blogPost.Content, shortCodes)); + return sb.ToString(); + } + + private static void AppendScalar(StringBuilder sb, string key, string value) + => sb.Append(key).Append(": ").Append(ToYamlString(value)).Append('\n'); + + private static string ToYamlString(string value) + { + var escaped = value + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("\"", "\\\"", StringComparison.Ordinal) + .Replace("\r", string.Empty, StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal); + return $"\"{escaped}\""; + } +} diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor index b388e674..a9c5a8ac 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/ShowBlogPostPage.razor @@ -1,5 +1,4 @@ @page "/blogPost/{blogPostId}/{slug?}" -@using System.Text @using Markdig @using LinkDotNet.Blog.Domain @using LinkDotNet.Blog.Infrastructure.Persistence @@ -167,18 +166,8 @@ else if (BlogPost is not null) private MarkupString EnrichWithShortCodes(string content) { - if (shortCodes.Count == 0) - { - return MarkdownConverter.ToMarkupStringWithHeadingAnchors(content, NavigationManager.Uri, AppConfiguration.Value.ShowCodeBlockLanguage); - } - - var sb = new StringBuilder(content); - foreach (var shortCode in shortCodes) - { - sb.Replace($"[[{shortCode.Name}]]", shortCode.MarkdownContent); - } - - return MarkdownConverter.ToMarkupStringWithHeadingAnchors(sb.ToString(), NavigationManager.Uri, AppConfiguration.Value.ShowCodeBlockLanguage); + var expandedContent = MarkdownConverter.ExpandShortCodes(content, shortCodes); + return MarkdownConverter.ToMarkupStringWithHeadingAnchors(expandedContent, NavigationManager.Uri, AppConfiguration.Value.ShowCodeBlockLanguage); } private async Task UpdateLikes(bool hasLiked) diff --git a/src/LinkDotNet.Blog.Web/Program.cs b/src/LinkDotNet.Blog.Web/Program.cs index 94b78861..6bdf6852 100644 --- a/src/LinkDotNet.Blog.Web/Program.cs +++ b/src/LinkDotNet.Blog.Web/Program.cs @@ -103,6 +103,7 @@ private static void ConfigureApp(WebApplication app) app.UseAntiforgery(); app.UseRateLimiter(); + app.UseBlogPostMarkdown(); app.MapControllers(); app.MapRazorPages(); app.MapRazorComponents() diff --git a/src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs b/src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs index e9c915e2..a180cc9d 100644 --- a/src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs +++ b/src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs @@ -1,6 +1,9 @@ using System.Globalization; using System.Linq; +using LinkDotNet.Blog.Web.Features.ShowBlogPost; using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace LinkDotNet.Blog.Web; @@ -17,4 +20,12 @@ public static void UseUserCulture(this WebApplication app) .AddSupportedCultures(SupportedCultures) .AddSupportedUICultures(SupportedCultures)); } + + public static void UseBlogPostMarkdown(this WebApplication app) + { + if (app.Services.GetRequiredService>().Value.EnableMarkdownForLlms) + { + app.UseMiddleware(); + } + } } diff --git a/src/LinkDotNet.Blog.Web/appsettings.json b/src/LinkDotNet.Blog.Web/appsettings.json index 8212a30b..32f7fe5d 100644 --- a/src/LinkDotNet.Blog.Web/appsettings.json +++ b/src/LinkDotNet.Blog.Web/appsettings.json @@ -1,5 +1,5 @@ { - "ConfigVersion": "16.0", + "ConfigVersion": "17.0", "Logging": { "LogLevel": { "Default": "Information", @@ -52,5 +52,6 @@ "UseMultiAuthorMode": false, "EnableTagDiscoveryPanel": true, "EnableBrokenLinkChecker": true, - "ShowCodeBlockLanguage": true + "ShowCodeBlockLanguage": true, + "EnableMarkdownForLlms": false } diff --git a/tests/LinkDotNet.Blog.IntegrationTests/BlogPostMarkdownTests.cs b/tests/LinkDotNet.Blog.IntegrationTests/BlogPostMarkdownTests.cs new file mode 100644 index 00000000..9508202d --- /dev/null +++ b/tests/LinkDotNet.Blog.IntegrationTests/BlogPostMarkdownTests.cs @@ -0,0 +1,267 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.Infrastructure.Persistence; +using LinkDotNet.Blog.Infrastructure.Persistence.Sql; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using TestContext = Xunit.TestContext; + +namespace LinkDotNet.Blog.IntegrationTests; + +public sealed class BlogPostMarkdownTests : IClassFixture, IDisposable +{ + private const string MarkdownContentType = "text/markdown; charset=utf-8"; + private readonly MarkdownEnabledFactory factory; + private readonly HttpClient client; + + public BlogPostMarkdownTests(MarkdownEnabledFactory factory) + { + this.factory = factory; + client = factory.CreateClient(); + } + + [Theory] + [InlineData("/blogPost/{0}")] + [InlineData("/blogPost/{0}/my-blog-post")] + [InlineData("/blogPost/{0}/a-wrong-slug")] + [InlineData("/blogPost/{0}/")] + [InlineData("/BLOGPOST/{0}")] + public async Task ShouldServeMarkdownWithAndWithoutSlug(string urlTemplate) + { + var blogPost = await SeedAsync(factory, new BlogPostBuilder().WithTitle("My Blog Post").WithContent("Hello").Build()); + + using var response = await GetAsync(client, string.Format(urlTemplate, blogPost.Id), "text/markdown"); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType!.ToString().ShouldBe(MarkdownContentType); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldStartWith("---\ntitle: \"My Blog Post\"\n"); + body.ShouldEndWith("---\n\n# My Blog Post\n\nHello"); + } + + [Theory] + [InlineData("text/markdown")] + [InlineData("TEXT/MARKDOWN")] + [InlineData("text/markdown; charset=utf-8")] + [InlineData("text/markdown, text/html;q=0.9")] + [InlineData("text/html;q=0.5, text/markdown")] + [InlineData("text/markdown, */*")] + public async Task ShouldServeMarkdownWhenPreferred(string accept) + { + var blogPost = await SeedAsync(factory, new BlogPostBuilder().Build()); + + using var response = await GetAsync(client, $"/blogPost/{blogPost.Id}", accept); + + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.ShouldBe("text/markdown"); + } + + [Theory] + [InlineData("text/markdown;q=0")] + [InlineData("text/html, text/markdown;q=0.9")] + [InlineData("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")] + [InlineData("*/*")] + [InlineData("text/*")] + [InlineData(null)] + public async Task ShouldServeHtmlWhenMarkdownIsNotPreferred(string? accept) + { + var blogPost = await SeedAsync(factory, new BlogPostBuilder().WithTitle("Html Post").Build()); + + using var response = await GetAsync(client, $"/blogPost/{blogPost.Id}", accept); + + await ShouldBeHtmlBlogPostAsync(response, "Html Post"); + response.Headers.Vary.ShouldContain("Accept"); + } + + [Fact] + public async Task ShouldServeContentVerbatimWithVaryHeader() + { + const string content = "## Heading\n\n```csharp\nvar x = \"\";\n```\n\nÄÖÜ ß 🚀 & bold"; + var blogPost = await SeedAsync(factory, new BlogPostBuilder().WithContent(content).Build()); + + using var response = await GetAsync(client, $"/blogPost/{blogPost.Id}", "text/markdown"); + + response.Headers.Vary.ShouldContain("Accept"); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldEndWith(content); + } + + [Fact] + public async Task ShouldRenderFrontMatter() + { + var blogPost = await SeedAsync(factory, new BlogPostBuilder() + .WithTitle("Title: with \"quotes\"") + .WithShortDescription("A short description") + .WithTags("C#", "Tag: \"x\"") + .WithUpdatedDate(new DateTime(2026, 9, 1)) + .Build()); + + using var response = await GetAsync(client, $"/blogPost/{blogPost.Id}", "text/markdown"); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldStartWith($""" + --- + title: "Title: with \"quotes\"" + description: "A short description" + updated: 2026-09-01 + tags: ["C#", "Tag: \"x\""] + canonical: "http://localhost/blogPost/{blogPost.Id}/{blogPost.Slug}" + --- + + # Title: with "quotes" + """.ReplaceLineEndings("\n")); + } + + [Fact] + public async Task ShouldFormatDateIndependentOfRequestCulture() + { + var blogPost = await SeedAsync(factory, new BlogPostBuilder().WithUpdatedDate(new DateTime(2026, 9, 1)).Build()); + using var request = new HttpRequestMessage(HttpMethod.Get, $"/blogPost/{blogPost.Id}"); + request.Headers.Add("Accept", "text/markdown"); + request.Headers.Add("Accept-Language", "ar-SA"); + + using var response = await client.SendAsync(request, TestContext.Current.CancellationToken); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain("updated: 2026-09-01\n"); + } + + [Fact] + public async Task ShouldExpandShortCodes() + { + var shortCodeName = $"Code{Guid.NewGuid():N}"; + var blogPost = await SeedAsync( + factory, + new BlogPostBuilder().WithContent($"Before [[{shortCodeName}]] [[Unknown]] after").Build(), + ShortCode.Create(shortCodeName, "**expanded**")); + + using var response = await GetAsync(client, $"/blogPost/{blogPost.Id}", "text/markdown"); + + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldEndWith("Before **expanded** [[Unknown]] after"); + } + + [Fact] + public async Task ShouldReturnNotFoundForUnpublishedBlogPost() + { + var blogPost = await SeedAsync(factory, new BlogPostBuilder().WithContent("Secret draft").IsPublished(false).Build()); + + using var response = await GetAsync(client, $"/blogPost/{blogPost.Id}", "text/markdown"); + + await ShouldBeMarkdownNotFoundAsync(response); + } + + [Fact] + public async Task ShouldReturnNotFoundForScheduledBlogPost() + { + var blogPost = await SeedAsync(factory, new BlogPostBuilder() + .WithContent("Secret draft") + .IsPublished(false) + .WithScheduledPublishDate(DateTime.UtcNow.AddDays(1)) + .Build()); + + using var response = await GetAsync(client, $"/blogPost/{blogPost.Id}", "text/markdown"); + + await ShouldBeMarkdownNotFoundAsync(response); + } + + [Fact] + public async Task ShouldReturnNotFoundForUnknownBlogPost() + { + using var response = await GetAsync(client, $"/blogPost/{Guid.NewGuid()}", "text/markdown"); + + await ShouldBeMarkdownNotFoundAsync(response); + } + + [Theory] + [InlineData("/")] + [InlineData("/searchByTag/dotnet")] + [InlineData("/feed.rss")] + [InlineData("/sitemap.xml")] + public async Task ShouldNotServeMarkdownForOtherPages(string url) + { + using var response = await GetAsync(client, url, "text/markdown"); + + response.IsSuccessStatusCode.ShouldBeTrue(); + response.Content.Headers.ContentType!.MediaType.ShouldNotBe("text/markdown"); + response.Headers.Vary.ShouldNotContain("Accept"); + } + + [Theory] + [InlineData("false")] + [InlineData(null)] + public async Task ShouldServeHtmlWhenDisabled(string? enableMarkdownForLlms) + { + await using var disabledFactory = new WebApplicationFactory() + .WithWebHostBuilder(builder => Configure(builder, enableMarkdownForLlms)); + using var disabledClient = disabledFactory.CreateClient(); + var blogPost = await SeedAsync(disabledFactory, new BlogPostBuilder().WithTitle("Disabled Post").Build()); + + using var response = await GetAsync(disabledClient, $"/blogPost/{blogPost.Id}", "text/markdown"); + + await ShouldBeHtmlBlogPostAsync(response, "Disabled Post"); + response.Headers.Vary.ShouldNotContain("Accept"); + } + + public void Dispose() => client.Dispose(); + + private static void Configure(IWebHostBuilder builder, string? enableMarkdownForLlms) + { + builder.UseSetting("PersistenceProvider", PersistenceProvider.Sqlite.Key); + builder.UseSetting("ConnectionString", $"DataSource=file:{Guid.NewGuid():N}?mode=memory&cache=shared"); + if (enableMarkdownForLlms is not null) + { + builder.UseSetting("EnableMarkdownForLlms", enableMarkdownForLlms); + } + } + + private static async Task SeedAsync(WebApplicationFactory app, BlogPost blogPost, params ShortCode[] shortCodes) + { + using var scope = app.Services.CreateScope(); + var contextFactory = scope.ServiceProvider.GetRequiredService>(); + await using var context = await contextFactory.CreateDbContextAsync(TestContext.Current.CancellationToken); + await context.BlogPosts.AddAsync(blogPost, TestContext.Current.CancellationToken); + await context.ShortCodes.AddRangeAsync(shortCodes, TestContext.Current.CancellationToken); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + return blogPost; + } + + private static async Task GetAsync(HttpClient httpClient, string url, string? accept) + { + using var request = new HttpRequestMessage(HttpMethod.Get, url); + if (accept is not null) + { + request.Headers.TryAddWithoutValidation("Accept", accept); + } + + return await httpClient.SendAsync(request, TestContext.Current.CancellationToken); + } + + private static async Task ShouldBeHtmlBlogPostAsync(HttpResponseMessage response, string title) + { + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.ShouldBe("text/html"); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldContain($"{title}"); + } + + private static async Task ShouldBeMarkdownNotFoundAsync(HttpResponseMessage response) + { + response.StatusCode.ShouldBe(HttpStatusCode.NotFound); + response.Content.Headers.ContentType!.ToString().ShouldBe(MarkdownContentType); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + body.ShouldBe("# Not found\n"); + } + + public sealed class MarkdownEnabledFactory : WebApplicationFactory + { + protected override void ConfigureWebHost(IWebHostBuilder builder) => Configure(builder, "true"); + } +} diff --git a/tests/LinkDotNet.Blog.TestUtilities/ApplicationConfigurationBuilder.cs b/tests/LinkDotNet.Blog.TestUtilities/ApplicationConfigurationBuilder.cs index 7a000dda..65e84a5a 100644 --- a/tests/LinkDotNet.Blog.TestUtilities/ApplicationConfigurationBuilder.cs +++ b/tests/LinkDotNet.Blog.TestUtilities/ApplicationConfigurationBuilder.cs @@ -19,6 +19,7 @@ public class ApplicationConfigurationBuilder private bool useMultiAuthorMode; private bool enableBrokenLinkChecker = true; private bool showCodeBlockLanguage = true; + private bool enableMarkdownForLlms; public ApplicationConfigurationBuilder WithBlogName(string blogName) { @@ -110,6 +111,12 @@ public ApplicationConfigurationBuilder WithShowCodeBlockLanguage(bool showCodeBl return this; } + public ApplicationConfigurationBuilder WithEnableMarkdownForLlms(bool enableMarkdownForLlms) + { + this.enableMarkdownForLlms = enableMarkdownForLlms; + return this; + } + public ApplicationConfiguration Build() { return new ApplicationConfiguration @@ -129,6 +136,7 @@ public ApplicationConfiguration Build() UseMultiAuthorMode = useMultiAuthorMode, EnableBrokenLinkChecker = enableBrokenLinkChecker, ShowCodeBlockLanguage = showCodeBlockLanguage, + EnableMarkdownForLlms = enableMarkdownForLlms, }; } } diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/ApplicationConfigurationTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/ApplicationConfigurationTests.cs index 79d66989..e9a9ccb4 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/ApplicationConfigurationTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/ApplicationConfigurationTests.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web; using LinkDotNet.Blog.Web.Authentication.OpenIdConnect; using LinkDotNet.Blog.Web.Features.ShowBlogPost.Components; using Microsoft.Extensions.Configuration; @@ -48,7 +49,8 @@ public void ShouldMapFromAppConfiguration() { "Authentication:ClientSecret","qwe"}, { "Authentication:Domain","example.com"}, { "UseMultiAuthorMode","true"}, - { "ShowCodeBlockLanguage","false"} + { "ShowCodeBlockLanguage","false"}, + { "EnableMarkdownForLlms","true"} }; var configuration = new ConfigurationBuilder() @@ -70,6 +72,7 @@ public void ShouldMapFromAppConfiguration() appConfiguration.ShowBuildInformation.ShouldBeTrue(); appConfiguration.UseMultiAuthorMode.ShouldBeTrue(); appConfiguration.ShowCodeBlockLanguage.ShouldBeFalse(); + appConfiguration.EnableMarkdownForLlms.ShouldBeTrue(); var giscusConfiguration = new GiscusConfigurationBuilder().Build(); configuration.GetSection(GiscusConfiguration.GiscusConfigurationSection).Bind(giscusConfiguration); @@ -210,6 +213,19 @@ public void ShouldSetDefaultBlogPostPerPageIfNotSet() appConfiguration.BlogPostsPerPage.ShouldBe(10); } + [Fact] + public void ShouldDisableMarkdownForLlmsIfNotSet() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + var appConfiguration = new ApplicationConfiguration { BlogName = "Blog", ConnectionString = "cs", DatabaseName = "db" }; + configuration.Bind(appConfiguration); + + appConfiguration.EnableMarkdownForLlms.ShouldBeFalse(); + } + [Fact] public void ShouldSetLogoutUriIfNotGiven() { diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/MarkdownConverterTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/MarkdownConverterTests.cs index 542f23df..b40824ab 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/MarkdownConverterTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/MarkdownConverterTests.cs @@ -1,3 +1,4 @@ +using LinkDotNet.Blog.Domain; using LinkDotNet.Blog.Web.Features; namespace LinkDotNet.Blog.UnitTests.Web.Features; @@ -217,4 +218,20 @@ public void ShouldWrapTablesInResponsiveContainer() html.ShouldContain(""""""); html.ShouldContain("
\n"); } + + [Fact] + public void ShouldLeaveContentUntouchedWhenNoShortCodes() + { + MarkdownConverter.ExpandShortCodes("Some [[code]]", []).ShouldBe("Some [[code]]"); + } + + [Fact] + public void ShouldExpandAllShortCodesAndAllOccurrences() + { + var shortCodes = new[] { ShortCode.Create("ONE", "**1**"), ShortCode.Create("TWO", "*2*") }; + + var expanded = MarkdownConverter.ExpandShortCodes("[[ONE]] and [[TWO]] and [[ONE]] but not [[THREE]]", shortCodes); + + expanded.ShouldBe("**1** and *2* and **1** but not [[THREE]]"); + } } diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs new file mode 100644 index 00000000..ca950f0d --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs @@ -0,0 +1,30 @@ +using LinkDotNet.Blog.Web.Features.ShowBlogPost; +using Microsoft.Net.Http.Headers; + +namespace LinkDotNet.Blog.UnitTests.Web.Features.ShowBlogPost; + +public class BlogPostMarkdownMiddlewareTests +{ + [Theory] + [InlineData("text/markdown", true)] + [InlineData("TEXT/MARKDOWN", true)] + [InlineData("text/markdown; charset=utf-8", true)] + [InlineData("text/markdown, text/html;q=0.9", true)] + [InlineData("text/html;q=0.5, text/markdown", true)] + [InlineData("text/markdown, */*", true)] + [InlineData("text/markdown;q=0.8, text/*;q=0.8", true)] + [InlineData("text/markdown;q=0", false)] + [InlineData("text/html, text/markdown;q=0.9", false)] + [InlineData("text/plain, text/markdown;q=0.9, */*", false)] + [InlineData("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", false)] + [InlineData("*/*", false)] + [InlineData("text/*", false)] + [InlineData("text/plain", false)] + [InlineData("", false)] + public void ShouldPreferMarkdownOnlyWhenRankedAtLeastAsHighAsHtml(string accept, bool expected) + { + var mediaTypes = MediaTypeHeaderValue.ParseList([accept]); + + BlogPostMarkdownMiddleware.PrefersMarkdown(mediaTypes).ShouldBe(expected); + } +} diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownRendererTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownRendererTests.cs new file mode 100644 index 00000000..e3358457 --- /dev/null +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownRendererTests.cs @@ -0,0 +1,114 @@ +using System; +using System.Globalization; +using LinkDotNet.Blog.Domain; +using LinkDotNet.Blog.TestUtilities; +using LinkDotNet.Blog.Web.Features.ShowBlogPost; + +namespace LinkDotNet.Blog.UnitTests.Web.Features.ShowBlogPost; + +public class BlogPostMarkdownRendererTests +{ + private const string CanonicalUrl = "https://localhost/blogPost/1/title"; + + [Fact] + public void ShouldRenderFrontMatterTitleAndContent() + { + var blogPost = new BlogPostBuilder() + .WithTitle("My Title") + .WithShortDescription("Short") + .WithContent("Some **content**") + .WithTags("C#", ".NET") + .WithUpdatedDate(new DateTime(2026, 9, 1)) + .Build(); + + var markdown = BlogPostMarkdownRenderer.Render(blogPost, [], CanonicalUrl, includeAuthor: false); + + markdown.ShouldBe(""" + --- + title: "My Title" + description: "Short" + updated: 2026-09-01 + tags: ["C#", ".NET"] + canonical: "https://localhost/blogPost/1/title" + --- + + # My Title + + Some **content** + """.ReplaceLineEndings("\n")); + } + + [Fact] + public void ShouldOmitTagsWhenThereAreNone() + { + var blogPost = new BlogPostBuilder().Build(); + + var markdown = BlogPostMarkdownRenderer.Render(blogPost, [], CanonicalUrl, includeAuthor: false); + + markdown.ShouldNotContain("tags:"); + } + + [Theory] + [InlineData(true, "Jane", true)] + [InlineData(false, "Jane", false)] + [InlineData(true, null, false)] + public void ShouldOnlyRenderAuthorWhenEnabledAndPresent(bool includeAuthor, string? authorName, bool expected) + { + var builder = new BlogPostBuilder(); + if (authorName is not null) + { + builder.WithAuthorName(authorName); + } + + var markdown = BlogPostMarkdownRenderer.Render(builder.Build(), [], CanonicalUrl, includeAuthor); + + markdown.Contains("author: \"Jane\"\n", StringComparison.Ordinal).ShouldBe(expected); + markdown.Contains("author:", StringComparison.Ordinal).ShouldBe(expected); + } + + [Fact] + public void ShouldEscapeFrontMatterValues() + { + var blogPost = new BlogPostBuilder() + .WithTitle("Say \"Hi\": C:\\temp") + .WithShortDescription("Line1\r\nLine2") + .WithTags("a\"b") + .Build(); + + var markdown = BlogPostMarkdownRenderer.Render(blogPost, [], CanonicalUrl, includeAuthor: false); + + markdown.ShouldContain("title: \"Say \\\"Hi\\\": C:\\\\temp\"\n"); + markdown.ShouldContain("description: \"Line1\\nLine2\"\n"); + markdown.ShouldContain("tags: [\"a\\\"b\"]\n"); + markdown.ShouldContain("# Say \"Hi\": C:\\temp\n"); + } + + [Fact] + public void ShouldExpandShortCodesInContent() + { + var blogPost = new BlogPostBuilder().WithContent("Before [[CODE]] after").Build(); + + var markdown = BlogPostMarkdownRenderer.Render(blogPost, [ShortCode.Create("CODE", "**expanded**")], CanonicalUrl, includeAuthor: false); + + markdown.ShouldEndWith("Before **expanded** after"); + } + + [Fact] + public void ShouldFormatDateIndependentOfCurrentCulture() + { + var blogPost = new BlogPostBuilder().WithUpdatedDate(new DateTime(2026, 9, 1)).Build(); + var previousCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = new CultureInfo("ar-SA"); + + try + { + var markdown = BlogPostMarkdownRenderer.Render(blogPost, [], CanonicalUrl, includeAuthor: false); + + markdown.ShouldContain("updated: 2026-09-01\n"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } +} diff --git a/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/Migration16To17Tests.cs b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/Migration16To17Tests.cs new file mode 100644 index 00000000..b9ea3c6d --- /dev/null +++ b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/Migration16To17Tests.cs @@ -0,0 +1,63 @@ +using LinkDotNet.Blog.UpgradeAssistant.Migrations; +using System.Text.Json; + +namespace LinkDotNet.Blog.UpgradeAssistant.Tests; + +public class Migration16To17Tests +{ + [Fact] + public void Should_Add_EnableMarkdownForLlms_Setting_Disabled() + { + // Arrange + var migration = new Migration16To17(); + var json = """ + { + "BlogName": "Test Blog" + } + """; + var document = JsonDocument.Parse(json); + + // Act + var result = migration.Apply(document, ref json); + + // Assert + result.ShouldBeTrue(); + json.ShouldContain("\"EnableMarkdownForLlms\": false"); + json.ShouldContain("\"BlogName\": \"Test Blog\""); + document.Dispose(); + } + + [Fact] + public void Should_Not_Change_When_Setting_Already_Exists() + { + // Arrange + var migration = new Migration16To17(); + var json = """ + { + "BlogName": "Test Blog", + "EnableMarkdownForLlms": true + } + """; + var document = JsonDocument.Parse(json); + + // Act + var result = migration.Apply(document, ref json); + + // Assert + result.ShouldBeFalse(); + json.ShouldContain("\"EnableMarkdownForLlms\": true"); + document.Dispose(); + } + + [Fact] + public void Should_Have_Correct_Version_Info() + { + // Arrange + var migration = new Migration16To17(); + + // Act & Assert + migration.FromVersion.ShouldBe("16.0"); + migration.ToVersion.ShouldBe("17.0"); + migration.GetDescription().ShouldNotBeNullOrEmpty(); + } +} diff --git a/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs index 13821e27..e9588fbf 100644 --- a/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs +++ b/tests/LinkDotNet.Blog.UpgradeAssistant.Tests/MigrationManagerTests.cs @@ -27,7 +27,8 @@ public async Task Should_Migrate_Config_To_Latest_Version() new Migration11To12(), new Migration12To13(), new Migration13To15(), - new Migration15To16()]); + new Migration15To16(), + new Migration16To17()]); var backupDir = Path.Combine(testDirectory, "backups"); // Act @@ -36,10 +37,11 @@ public async Task Should_Migrate_Config_To_Latest_Version() // Assert result.ShouldBeTrue(); var content = await File.ReadAllTextAsync(testFile, TestContext.Current.CancellationToken); - content.ShouldContain("\"ConfigVersion\": \"16.0\""); + content.ShouldContain("\"ConfigVersion\": \"17.0\""); content.ShouldContain("\"EnableTagDiscoveryPanel\": true"); content.ShouldContain("\"EnableBrokenLinkChecker\": true"); content.ShouldContain("\"ShowCodeBlockLanguage\": true"); + content.ShouldContain("\"EnableMarkdownForLlms\": false"); // Verify backup was created var backupFiles = Directory.GetFiles(backupDir); @@ -62,7 +64,8 @@ public async Task Should_Migrate_Config_With_Version_Between_Migrations() var manager = new MigrationManager([ new Migration11To12(), new Migration13To15(), - new Migration15To16()]); + new Migration15To16(), + new Migration16To17()]); var backupDir = Path.Combine(testDirectory, "backups"); // Act @@ -71,11 +74,45 @@ public async Task Should_Migrate_Config_With_Version_Between_Migrations() // Assert result.ShouldBeTrue(); var content = await File.ReadAllTextAsync(testFile, TestContext.Current.CancellationToken); - content.ShouldContain("\"ConfigVersion\": \"16.0\""); + content.ShouldContain("\"ConfigVersion\": \"17.0\""); content.ShouldContain("\"ShowCodeBlockLanguage\": true"); + content.ShouldContain("\"EnableMarkdownForLlms\": false"); content.ShouldNotContain("ShowBuildInformation"); } + [Fact] + public async Task Should_Only_Apply_Latest_Migration_From_Version_16() + { + // Arrange + var testFile = Path.Combine(testDirectory, "appsettings.Staging.json"); + var json = """ + { + "ConfigVersion": "16.0", + "BlogName": "Test Blog", + "ShowCodeBlockLanguage": false + } + """; + await File.WriteAllTextAsync(testFile, json, TestContext.Current.CancellationToken); + var manager = new MigrationManager([ + new Migration11To12(), + new Migration12To13(), + new Migration13To15(), + new Migration15To16(), + new Migration16To17()]); + var backupDir = Path.Combine(testDirectory, "backups"); + + // Act + var result = await manager.MigrateFileAsync(testFile, false, backupDir); + + // Assert + result.ShouldBeTrue(); + var content = await File.ReadAllTextAsync(testFile, TestContext.Current.CancellationToken); + content.ShouldContain("\"ConfigVersion\": \"17.0\""); + content.ShouldContain("\"ShowCodeBlockLanguage\": false"); + content.ShouldContain("\"EnableMarkdownForLlms\": false"); + content.ShouldNotContain("EnableTagDiscoveryPanel"); + } + [Fact] public async Task Should_Not_Modify_Already_Migrated_File() { diff --git a/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationDiscovery.cs b/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationDiscovery.cs index 616735d9..8001032a 100644 --- a/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationDiscovery.cs +++ b/tools/LinkDotNet.Blog.UpgradeAssistant/MigrationDiscovery.cs @@ -11,7 +11,8 @@ public static IReadOnlyList DiscoverAll() new Migration11To12(), new Migration12To13(), new Migration13To15(), - new Migration15To16() + new Migration15To16(), + new Migration16To17() } .OrderBy(m => Version.TryParse(m.FromVersion, out var v) ? v : new Version(0, 0)) .ThenBy(m => Version.TryParse(m.ToVersion, out var v) ? v : new Version(0, 0)) diff --git a/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_16_To_17.cs b/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_16_To_17.cs new file mode 100644 index 00000000..42c54e49 --- /dev/null +++ b/tools/LinkDotNet.Blog.UpgradeAssistant/Migrations/Migration_16_To_17.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace LinkDotNet.Blog.UpgradeAssistant.Migrations; + +/// +/// Migration from version 16.0 to 17.0. +/// Adds EnableMarkdownForLlms setting. +/// +public sealed class Migration16To17 : IMigration +{ + public string FromVersion => "16.0"; + public string ToVersion => "17.0"; + + public bool Apply(JsonDocument document, ref string jsonContent) + { + var jsonNode = JsonNode.Parse(jsonContent); + if (jsonNode is not JsonObject rootObject) + { + return false; + } + + if (rootObject.ContainsKey("EnableMarkdownForLlms")) + { + return false; + } + + rootObject["EnableMarkdownForLlms"] = false; + ConsoleOutput.WriteInfo("Added 'EnableMarkdownForLlms' setting (default: false). When enabled, blog posts are served as markdown to clients sending 'Accept: text/markdown'."); + + var options = new JsonSerializerOptions { WriteIndented = true }; + jsonContent = jsonNode.ToJsonString(options); + return true; + } + + public string GetDescription() + { + return "Adds EnableMarkdownForLlms setting that controls whether blog posts are served as markdown to LLMs (Accept: text/markdown)."; + } +} From 59db5ed89a2844c0c86b063c23245279a11838f2 Mon Sep 17 00:00:00 2001 From: Steven Giesel Date: Tue, 20 Oct 2026 09:09:19 +0200 Subject: [PATCH 2/2] refactor: PR review --- .../ShowBlogPost/BlogPostMarkdownMiddleware.cs | 13 +++++++------ src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs | 3 +++ .../ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs | 3 +++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownMiddleware.cs b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownMiddleware.cs index ac4244f8..4ef645f5 100644 --- a/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownMiddleware.cs +++ b/src/LinkDotNet.Blog.Web/Features/ShowBlogPost/BlogPostMarkdownMiddleware.cs @@ -66,15 +66,16 @@ public static bool PrefersMarkdown(IList accept) { ArgumentNullException.ThrowIfNull(accept); - var markdownQuality = GetBestQuality(accept, [MarkdownMediaType]); - return markdownQuality > 0 && markdownQuality >= GetBestQuality(accept, HtmlMediaTypes); + var markdownQuality = GetQuality(accept, MarkdownMediaType) ?? 0; + // RFC 9110 12.5.1: the most specific matching range wins, so "text/html;q=0, */*" still rejects HTML + var htmlQuality = HtmlMediaTypes.Select(m => GetQuality(accept, m)).FirstOrDefault(q => q.HasValue) ?? 0; + return markdownQuality > 0 && markdownQuality >= htmlQuality; } - private static double GetBestQuality(IList accept, string[] mediaTypes) + private static double? GetQuality(IList accept, string mediaType) => accept - .Where(a => mediaTypes.Any(m => a.MediaType.Equals(m, StringComparison.OrdinalIgnoreCase))) - .Select(a => a.Quality ?? 1) - .DefaultIfEmpty(0) + .Where(a => a.MediaType.Equals(mediaType, StringComparison.OrdinalIgnoreCase)) + .Select(a => (double?)(a.Quality ?? 1)) .Max(); private static bool IsBlogPostPage(HttpContext context) diff --git a/src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs b/src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs index a180cc9d..8e881c33 100644 --- a/src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs +++ b/src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs @@ -1,3 +1,4 @@ +using System; using System.Globalization; using System.Linq; using LinkDotNet.Blog.Web.Features.ShowBlogPost; @@ -23,6 +24,8 @@ public static void UseUserCulture(this WebApplication app) public static void UseBlogPostMarkdown(this WebApplication app) { + ArgumentNullException.ThrowIfNull(app); + if (app.Services.GetRequiredService>().Value.EnableMarkdownForLlms) { app.UseMiddleware(); diff --git a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs index ca950f0d..a6c08a8f 100644 --- a/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs +++ b/tests/LinkDotNet.Blog.UnitTests/Web/Features/ShowBlogPost/BlogPostMarkdownMiddlewareTests.cs @@ -13,6 +13,9 @@ public class BlogPostMarkdownMiddlewareTests [InlineData("text/html;q=0.5, text/markdown", true)] [InlineData("text/markdown, */*", true)] [InlineData("text/markdown;q=0.8, text/*;q=0.8", true)] + [InlineData("text/markdown;q=0.8, text/html;q=0, */*;q=1", true)] + [InlineData("text/markdown;q=0.5, text/*;q=0, */*", true)] + [InlineData("text/markdown;q=0.5, text/*, */*;q=0", false)] [InlineData("text/markdown;q=0", false)] [InlineData("text/html, text/markdown;q=0.9", false)] [InlineData("text/plain, text/markdown;q=0.9, */*", false)]