Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions docs/Migrations/UpgradeAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ The tool looks for a `ConfigVersion` field in your `appsettings.json`:

```json
{
"ConfigVersion": "16.0",
"ConfigVersion": "17.0",
...
}
```
Expand All @@ -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

Expand Down Expand Up @@ -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

```
Expand Down
34 changes: 33 additions & 1 deletion docs/Setup/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ Browser spellchecking uses the `<html lang="en">` value in
"UseMultiAuthorMode": false,
"EnableTagDiscoveryPanel": true,
"EnableBrokenLinkChecker": true,
"ShowCodeBlockLanguage": true
"ShowCodeBlockLanguage": true,
"EnableMarkdownForLlms": false
}
```

Expand Down Expand Up @@ -122,3 +123,34 @@ Browser spellchecking uses the `<html lang="en">` 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.
2 changes: 2 additions & 0 deletions src/LinkDotNet.Blog.Web/ApplicationConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
14 changes: 14 additions & 0 deletions src/LinkDotNet.Blog.Web/Features/MarkdownConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,19 @@ public static MarkupString ToMarkupStringWithHeadingAnchors(string markdown, str
return (MarkupString)document.ToHtml(pipeline);
}

public static string ExpandShortCodes(string content, IEnumerable<ShortCode> 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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<ApplicationConfiguration> appConfiguration,
IRepository<BlogPost> blogPostRepository,
IRepository<ShortCode> 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<MediaTypeHeaderValue> accept)
{
ArgumentNullException.ThrowIfNull(accept);

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? GetQuality(IList<MediaTypeHeaderValue> accept, string mediaType)
=> accept
.Where(a => a.MediaType.Equals(mediaType, StringComparison.OrdinalIgnoreCase))
.Select(a => (double?)(a.Quality ?? 1))
.Max();

private static bool IsBlogPostPage(HttpContext context)
=> context.GetEndpoint()?.Metadata.GetMetadata<ComponentTypeMetadata>()?.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}";
}
}
Original file line number Diff line number Diff line change
@@ -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<ShortCode> 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}\"";
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@page "/blogPost/{blogPostId}/{slug?}"
@using System.Text
@using Markdig
@using LinkDotNet.Blog.Domain
@using LinkDotNet.Blog.Infrastructure.Persistence
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/LinkDotNet.Blog.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ private static void ConfigureApp(WebApplication app)
app.UseAntiforgery();

app.UseRateLimiter();
app.UseBlogPostMarkdown();
app.MapControllers();
app.MapRazorPages();
app.MapRazorComponents<App>()
Expand Down
14 changes: 14 additions & 0 deletions src/LinkDotNet.Blog.Web/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
using System;
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;

Expand All @@ -17,4 +21,14 @@ public static void UseUserCulture(this WebApplication app)
.AddSupportedCultures(SupportedCultures)
.AddSupportedUICultures(SupportedCultures));
}

public static void UseBlogPostMarkdown(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);

if (app.Services.GetRequiredService<IOptions<ApplicationConfiguration>>().Value.EnableMarkdownForLlms)
{
app.UseMiddleware<BlogPostMarkdownMiddleware>();
}
}
}
5 changes: 3 additions & 2 deletions src/LinkDotNet.Blog.Web/appsettings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"ConfigVersion": "16.0",
"ConfigVersion": "17.0",
"Logging": {
"LogLevel": {
"Default": "Information",
Expand Down Expand Up @@ -52,5 +52,6 @@
"UseMultiAuthorMode": false,
"EnableTagDiscoveryPanel": true,
"EnableBrokenLinkChecker": true,
"ShowCodeBlockLanguage": true
"ShowCodeBlockLanguage": true,
"EnableMarkdownForLlms": false
}
Loading
Loading