This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Établissement d'un attacheteur de marquage à distance pour Markdig
Friday, 07 November 2025
Introduction
L’un des défis auxquels j’ai dû faire face en créant ce blog était de savoir comment inclure efficacement le contenu externe du marquage sans la copier et la coller manuellement partout.
Dans ce post, I'll walk you through how I built Mostlylucid.Markdig.FetchExtension - une solution complète pour récupérer et cacher le contenu de marquage à distance avec support pour plusieurs backends d'entreposageM SK1 sondage automatique , et un outil parallèle-whileMSC4revalider le modèle de cacheMST5
NOTE: Il s'agit encore d'un pré-relevé, mais j'ai voulu le faire parvenir là-bas.
Cet article est généré par l'AI - en utilisant le code claude qui m'a également aidé à construire la fonction.
Mise à jour: Ajouté
disable="true"Paramètre pour que nous puissions maintenant démontrer les tags correctement sans qu’ils ne soient traités!
Mise à jour (Nov 7, 2025): Table des matières ajoutée (TOCM SK2 fonction de génération! Usage
[TOC]dans votre balisage pour générer automatiquement une table de contenu à cliquer sur des titres de document.
Voir la source ici. sur GitHub pour ce site.
Pourquoi construire ce modèle?
Avant de se plonger dans les détails techniques, permettez-moi d’expliquer le problème
- Rédigations de paquets: Lorsque je rédige des articles sur un paquet NuGet, j’ai ' publié, Je veux inclure son README directement à partir de GitHub
- Documentation API: Docs API externes qui changent fréquemment doivent rester en sync
- Contenu partagé: Documentation qui vit dans un même répertoire mais doit apparaître à plusieurs endroits
- Rendement: Je ne veux pas ' récupérer ce contenu à chaque chargement de page.
L’approche naïve consisterait à utiliser un client HTTP pour récupérer le démarrage chaque fois que vous en avez besoin.
- Chaque demande touche le serveur éloigné
- La latence du réseau affecte les temps de chargement des pages
- Aucun soutien offline
- Aucun traitement des défaillances transitoires
J’avais besoin d’une chose plus intelligente: enlever une fois, cacher avec intelligence , actualiser automatiquementM SK3 et traiter les défaillances gracieusementMSC4
Aperçu de l'architecture
L’extension suit une approche de prétraitement plutôt que d’être partie du pipeline de partage Markdig. Cela est crucial parce qu’il signifie que le contenu téléchargé s’écoule tout au long de votre pipeline Markdig.
graph TD
A[Markdown with fetch tags] --> B[MarkdownFetchPreprocessor]
B --> C{Check Cache}
C -->|Fresh| D[Return Cached Content]
C -->|Stale/Missing| E[Fetch from Remote URL]
E -->|Success| F[Update Cache]
E -->|Failure| G{Has Cached?}
G -->|Yes| H[Return Stale Cache]
G -->|No| I[Return Error Comment]
F --> J[Replace fetch with Content]
D --> J
H --> J
I --> J
J --> K[Processed Markdown]
K --> L[Your Markdig Pipeline]
L --> M[Final HTML]
L'incertitude clé est prétraitement. Avant que votre démarrage ne touche le gazoduc de Markdig
- Examiner pour
<fetch>Les étiquettes - Résoudre le contenu (du cache ou à distance)
- Remplacer les balises avec le marquage effectif
- Ensuite, laissez Markdig traiter tout ensemble
Cela assure la cohérence - tous les démarrages reçoivent le même traitement indépendamment de leur source
La syntaxe de base
Utilisation de l'extension est simple. Dans votre mise à jour
# My Documentation
<!-- Failed to fetch content from https://raw.githubusercontent.com/user/repo/main/README.md: HTTP 404: Not Found -->
Que'c'est ça! La prolongation sera :
- Relever le README de GitHub
- Le cacher pendant 24 heures
- Retour du contenu cache sur les demandes subséquentes
- Auto-refresh lorsque le cache expire
Architecture des fournisseurs de stockage
L’un des principes de conception que j’ai suivi était flexibilité. Différentes applications ont des besoins différents . Une petite application de démonstration n’a pas besoin de PostgreSQLM SK3 mais un déploiement de production multi-servers- a besoin de . J’ai donc construit une architecture d’entreposage pluggable.
graph LR
A[IMarkdownFetchService Interface] --> B[InMemoryMarkdownFetchService]
A --> C[FileBasedMarkdownFetchService]
A --> D[PostgresMarkdownFetchService]
A --> E[SqliteMarkdownFetchService]
A --> F[SqlServerMarkdownFetchService]
A --> G[YourCustomService]
B --> H[ConcurrentDictionary]
C --> I[File System + SemaphoreSlim]
D --> J[PostgreSQL Database]
E --> K[SQLite Database]
F --> L[SQL Server Database]
G --> M[Your Storage Backend]
L'interface de base
Tout est mis en œuvre IMarkdownFetchService:
public interface IMarkdownFetchService
{
Task<MarkdownFetchResult> FetchMarkdownAsync(
string url,
int pollFrequencyHours,
int blogPostId = 0);
Task<bool> RemoveCachedMarkdownAsync(
string url,
int blogPostId = 0);
}
Simple et propre. Chaque mise en œuvre gère l'entreposage de sa propre façon , mais l'interface demeure cohérente
En-Système de stockage des souvenirs: parfait pour les démonstrations
La mise en œuvre la plus simple ConcurrentDictionary:
public class InMemoryMarkdownFetchService : IMarkdownFetchService
{
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<InMemoryMarkdownFetchService> _logger;
public async Task<MarkdownFetchResult> FetchMarkdownAsync(
string url,
int pollFrequencyHours,
int blogPostId)
{
var cacheKey = GetCacheKey(url, blogPostId);
// Check cache
if (_cache.TryGetValue(cacheKey, out var cached))
{
var age = DateTimeOffset.UtcNow - cached.FetchedAt;
if (age.TotalHours < pollFrequencyHours)
{
_logger.LogDebug("Returning cached content for {Url}", url);
return new MarkdownFetchResult
{
Success = true,
Content = cached.Content
};
}
}
// Fetch fresh content
var fetchResult = await FetchFromUrlAsync(url);
if (fetchResult.Success)
{
_cache[cacheKey] = new CacheEntry
{
Content = fetchResult.Content,
FetchedAt = DateTimeOffset.UtcNow
};
}
else if (cached != null)
{
// Fetch failed, return stale cache
_logger.LogWarning("Fetch failed, returning stale cache for {Url}", url);
return new MarkdownFetchResult
{
Success = true,
Content = cached.Content
};
}
return fetchResult;
}
private static string GetCacheKey(string url, int blogPostId)
=> $"{url}_{blogPostId}";
}
Comme vous pouvez le voir, cela fait les choses suivantes:
- Crée une clé de cache à partir du URL et de l'ID d'affichage du blog
- Vérifie si nous avons caché le contenu et s’il' est frais
- Si la cache est fraîche,, elle le retourne immédiatement
- Si stale, essaie de récupérer du contenu frais
- Sur succès, mise à jour du cache
- En cas de défaillance avec un contenu caché, retourne le cache inexistant M SK1stale-while -revalidateMSC4
- Sur défaut sans cache, retourne l'erreur
Ce modèle - idle-while-revalidate - est crucial pour la fiabilité. Même si GitHub est en panneM SK2 votre site continue de travailler avec des contenus cached .
Fichier-Système de stockage basé: Persévérance simple
Pour les déploiements de serveurs uniques-,fichierM SK2l'entreposage basé sur MSK fonctionne bien
public class FileBasedMarkdownFetchService : IMarkdownFetchService
{
private readonly string _cacheDirectory;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<FileBasedMarkdownFetchService> _logger;
private readonly SemaphoreSlim _fileLock = new(1, 1);
public async Task<MarkdownFetchResult> FetchMarkdownAsync(
string url,
int pollFrequencyHours,
int blogPostId)
{
var cacheKey = ComputeCacheKey(url, blogPostId);
var cacheFile = GetCacheFilePath(cacheKey);
await _fileLock.WaitAsync();
try
{
// Check if file exists and is fresh
if (File.Exists(cacheFile))
{
var fileInfo = new FileInfo(cacheFile);
var age = DateTimeOffset.UtcNow - fileInfo.LastWriteTimeUtc;
if (age.TotalHours < pollFrequencyHours)
{
var cached = await File.ReadAllTextAsync(cacheFile);
return new MarkdownFetchResult
{
Success = true,
Content = cached
};
}
}
// Fetch fresh
var fetchResult = await FetchFromUrlAsync(url);
if (fetchResult.Success)
{
await File.WriteAllTextAsync(cacheFile, fetchResult.Content);
}
else if (File.Exists(cacheFile))
{
// Return stale on fetch failure
var stale = await File.ReadAllTextAsync(cacheFile);
return new MarkdownFetchResult
{
Success = true,
Content = stale
};
}
return fetchResult;
}
finally
{
_fileLock.Release();
}
}
private string GetCacheFilePath(string cacheKey)
=> Path.Combine(_cacheDirectory, $"{cacheKey}.md");
private static string ComputeCacheKey(string url, int blogPostId)
{
var combined = $"{url}_{blogPostId}";
using var sha256 = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(combined);
var hash = sha256.ComputeHash(bytes);
return Convert.ToHexString(hash);
}
}
Points clés:
- Utilisations
SemaphoreSlimpour le thread-accès sécurisé aux fichiers - Hashe l’URL + blog post ID pour créer des noms de fichier sûrs
- Utilise le temps de modification des fichiers pour déterminer la fraîcheur
- Persistent à travers la réouverture de l'application
- Same stale-while-revalidate pattern
Entreposage de la base de données: Production-Ready
Pour les déploiements de production, particulièrement multi-configuration des serveurs , vous voulez une cache partagéeM SK3 Celle-ciMSC4 où se trouvent les fournisseurs de bases de données
public class PostgresMarkdownFetchService : IMarkdownFetchService
{
private readonly MarkdownCacheDbContext _dbContext;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<PostgresMarkdownFetchService> _logger;
public async Task<MarkdownFetchResult> FetchMarkdownAsync(
string url,
int pollFrequencyHours,
int blogPostId)
{
var cacheKey = GetCacheKey(url, blogPostId);
// Query cache
var cached = await _dbContext.MarkdownCache
.FirstOrDefaultAsync(c => c.CacheKey == cacheKey);
if (cached != null)
{
var age = DateTimeOffset.UtcNow - cached.LastFetchedAt;
if (age.TotalHours < pollFrequencyHours)
{
return new MarkdownFetchResult
{
Success = true,
Content = cached.Content
};
}
}
// Fetch fresh
var fetchResult = await FetchFromUrlAsync(url);
if (fetchResult.Success)
{
if (cached == null)
{
cached = new MarkdownCacheEntry
{
CacheKey = cacheKey,
Url = url,
BlogPostId = blogPostId
};
_dbContext.MarkdownCache.Add(cached);
}
cached.Content = fetchResult.Content;
cached.LastFetchedAt = DateTimeOffset.UtcNow;
await _dbContext.SaveChangesAsync();
}
else if (cached != null)
{
// Return stale
return new MarkdownFetchResult
{
Success = true,
Content = cached.Content
};
}
return fetchResult;
}
}
Le schéma de base de données est simple:
CREATE TABLE markdown_cache (
id SERIAL PRIMARY KEY,
cache_key VARCHAR(128) NOT NULL UNIQUE,
url VARCHAR(2048) NOT NULL,
blog_post_id INTEGER NOT NULL,
content TEXT NOT NULL,
last_fetched_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT ix_markdown_cache_cache_key UNIQUE (cache_key)
);
CREATE INDEX ix_markdown_cache_url_blog_post_id
ON markdown_cache(url, blog_post_id);
Dans un déploiement de serveurs multiples-, , cela vous donne une cohérence du cache dans toutes les instances.
graph TB
subgraph "Load Balancer"
LB[Load Balancer]
end
subgraph "Application Servers"
A1[App Server 1<br/>FetchExtension]
A2[App Server 2<br/>FetchExtension]
A3[App Server 3<br/>FetchExtension]
end
subgraph "Shared Cache"
PG[(PostgreSQL<br/>markdown_cache table)]
end
subgraph "External Content"
R1[Remote URL 1]
R2[Remote URL 2]
R3[Remote URL 3]
end
LB --> A1
LB --> A2
LB --> A3
A1 <-->|Read/Write Cache| PG
A2 <-->|Read/Write Cache| PG
A3 <-->|Read/Write Cache| PG
A1 -.->|Fetch if cache miss| R1
A2 -.->|Fetch if cache miss| R2
A3 -.->|Fetch if cache miss| R3
Tous les serveurs partagent la même cache. Lorsque le serveur 1 reçoit un READMEM SK2 Les serveurs 2 et 3 bénéficient immédiatement de ce contenu cachéMSC5
Établissement de l'extension
Le début est simple. Premièrement, installer le paquet de baseM SK2
dotnet add package mostlylucid.Markdig.FetchExtension
Choisissez ensuite votre fournisseur de stockage:
# For in-memory (demos/testing)
# Already included in base package
# For file-based storage
# Already included in base package
# For PostgreSQL
dotnet add package mostlylucid.Markdig.FetchExtension.Postgres
# For SQLite
dotnet add package mostlylucid.Markdig.FetchExtension.Sqlite
# For SQL Server
dotnet add package mostlylucid.Markdig.FetchExtension.SqlServer
Configuration dans ASP.NET Core
Dans votre Program.cs:
using Mostlylucid.Markdig.FetchExtension;
var builder = WebApplication.CreateBuilder(args);
// Option 1: In-Memory (simplest)
builder.Services.AddInMemoryMarkdownFetch();
// Option 2: File-Based (persists across restarts)
builder.Services.AddFileBasedMarkdownFetch("./markdown-cache");
// Option 3: PostgreSQL (multi-server)
builder.Services.AddPostgresMarkdownFetch(
builder.Configuration.GetConnectionString("MarkdownCache"));
// Option 4: SQLite (single server with DB)
builder.Services.AddSqliteMarkdownFetch("Data Source=markdown-cache.db");
// Option 5: SQL Server (enterprise)
builder.Services.AddSqlServerMarkdownFetch(
builder.Configuration.GetConnectionString("MarkdownCache"));
var app = builder.Build();
// If using database storage, ensure schema exists
if (app.Environment.IsDevelopment())
{
app.Services.EnsureMarkdownCacheDatabase();
}
// Configure the extension with your service provider
FetchMarkdownExtension.ConfigureServiceProvider(app.Services);
app.Run();
Intégration avec votre Rendement de marquage
La clé est la étape de prétraitement.
public class MarkdownRenderingService
{
private readonly IServiceProvider _serviceProvider;
private readonly MarkdownFetchPreprocessor _preprocessor;
private readonly MarkdownPipeline _pipeline;
public MarkdownRenderingService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
_preprocessor = new MarkdownFetchPreprocessor(serviceProvider);
_pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.UseSyntaxHighlighting()
.UseToc() // Add TOC support for [TOC] markers
.UseYourCustomExtensions()
.Build();
}
public string RenderMarkdown(string markdown)
{
// Step 1: Preprocess to handle fetch tags
var processed = _preprocessor.Preprocess(markdown);
// Step 2: Run through your normal Markdig pipeline
return Markdown.ToHtml(processed, _pipeline);
}
}
Le débit est:
- Votre démarrage contient
<fetch>Les étiquettes - Le préprocesseur les résout à un marquage effectif
- Le marquage combiné passe par Markdig
- Toutes les extensions sont personnalisées, styling, etcM SK2
Caractéristiques avancées
Table des matières Création
Le paquet comprend maintenant un Détermination de l'emplacement Table des matières (TOCM SK1 extension! Bien qu’elle soit emballée à côté de l’extension d’acheminement , elleMSC5 est complètement indépendante et peut être utilisée par elle-mêmeMNK6 Vous pouvez automatiquement générer une table de matières à cliquer depuis votre documentMRK7 ses titresMMK8
Usage de base:
Ajouter simplement [TOC] où que ce soit dans votre zone de référence:
# My Document
[TOC]
# Introduction
Content here...
# Getting Started
More content...
## Installation
Details...
Ceci génère une liste neste de toutes les positions avec des liens d'ancrage:
<nav class="ml_toc" aria-label="Table of Contents">
<ul>
<li><a href="#introduction">Introduction</a></li>
<li><a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#installation">Installation</a></li>
</ul>
</li>
</ul>
</nav>
Classes de CSS personnalisés:
Vous pouvez spécifier une classe CSS personnalisée pour le style:
[TOC cssclass="my-custom-toc"]
Cela rend avec votre classe personnalisée:
<nav class="my-custom-toc" aria-label="Table of Contents">
<!-- TOC content -->
</nav>
Comment il fonctionne:
Auto-Detection: Le CTO détecte automatiquement le niveau minimal des titres dans votre document et l’ajuste en conséquenceM SK1 Si votre document commence par H2,, le CTO traite H 2 comme étant le niveau supérieur
Production de l'ID: Les titres reçoivent automatiquement des ID pour le couplage d'ancrageM SK1
- "Getting StartedM SK1 →
id="getting-started" - "API Référence
id="api-reference"
- "Getting StartedM SK1 →
Structure nichée: Le rédacteur construit un emplacement approprié
ul/listructure qui reflète la hiérarchie de votre document.
Possibilité du soutien TOC:
Lors de la configuration de votre pipeline Markdig, ajouter l'extension TOC:
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.UseToc() // Add TOC support - position in pipeline doesn't matter!
.Use<YourOtherExtensions>()
.Build();
Position du pipeline: Contrairement à certaines extensions Markdig,, l’extension TOC doesn't care where you add it dans le pipeline. L'extension automatique:
- Insérer son analyseur au début de la liste des analyseurs (position 0)
- Rende après que l'analyse est terminée, en recueillant les titres de tout le document
Vous pouvez donc ajouter .UseToc() où que ce soit - au début, au milieu , ou à la fin de votre configuration de pipelineM SK3
Important: L’extension TOC est complètement indépendante de l’extendue de téléchargement.
- Utiliser le TOC sans aller chercher
- Utiliser fetch sans TOC
- Utiliser les deux ensemble
Note: Le marqueur TOC fonctionne à la fois dans vos fichiers principaux et dans le contenu téléchargé. Si vous téléchargez un README de GitHub qui contient [TOC], il produira automatiquement une table des contenus de ce document ' ses titres M SK2 supposant que vous .UseToc() à votre pipeline).
Transformation des liens
Lors de l'acheminement du marquage à distance (en particulier depuis GitHub), rupture des liens relatifsM SK2 L'extension peut les réécrire automatiquement :
<!-- Failed to fetch content from https://raw.githubusercontent.com/user/repo/main/docs/README.md: HTTP 404: Not Found -->
Cela transforme:
./CONTRIBUTING.md→https://github.com/user/repo/blob/main/docs/CONTRIBUTING.md../images/logo.png→https://github.com/user/repo/blob/main/images/logo.png- Préserve les URLs absolus et les ancrages
La mise en œuvre utilise le Markdig AST pour réécrire les liens:
public class MarkdownLinkRewriter
{
public static string RewriteLinks(string markdown, string sourceUrl)
{
var document = Markdown.Parse(markdown);
var baseUri = GetBaseUri(sourceUrl);
foreach (var link in document.Descendants<LinkInline>())
{
if (IsRelativeLink(link.Url))
{
link.Url = ResolveRelativeLink(baseUri, link.Url);
}
}
using var writer = new StringWriter();
var renderer = new NormalizeRenderer(writer);
renderer.Render(document);
return writer.ToString();
}
private static bool IsRelativeLink(string url)
{
if (string.IsNullOrEmpty(url)) return false;
if (url.StartsWith("http://") || url.StartsWith("https://")) return false;
if (url.StartsWith("#")) return false; // Anchor
if (url.StartsWith("mailto:")) return false;
return true;
}
}
Résumé des métadonnées
Vous pouvez montrer aux lecteurs quand le contenu a été téléchargé pour la dernière fois:
<!-- Failed to fetch content from https://api.example.com/status.md: HTTP request failed: Name or service not known (api.example.com:443) -->
Ce résultat se traduit par un footer:
Contenu téléchargé de https://api .example.comM SK3statusMSC4md sur 06 Jan 2025 (2 il y a quelques heures)
Ou personnaliser le modèle:
<!-- Failed to fetch content from https://example.com/docs.md: HTTP 404: Not Found -->
Output:~~~~
Mise à jour la plus récente: M SK1 Janvier 2025 | | 14:30 \ | État de l'opération: cached || Suivant actualiserMSC7 en |
Placeurs disponibles:
{retrieved:format}- Date de la dernière saisie{age}- Homme-temps lisible depuis le téléchargement{url}- URL source{nextrefresh:format}- Quand le contenu sera actualisé{pollfrequency}- Durée du cache en heures{status}- État du cache fresh/cachedM SK3staleMSC4
Disabler le traitement de la documentation
Lorsque vous rédigez la documentation sur l'extension de téléchargement (comme dans cet article !),, vous avez besoin d'une façon de montrer les balises sans qu'elles ne soient traitées. Utilisez le disable="true" attribute:
<!-- This will be processed and fetch content -->
<!-- Failed to fetch content from https://example.com/README.md: HTTP 404: Not Found -->
<!-- This will NOT be processed - useful for documentation -->
<!-- Failed to fetch content from https://example.com/README.md: HTTP 404: Not Found -->
L'étiquette désactivée demeure dans le marquage en tant que-isM SK1 parfaite pour:
- Édition de la documentation concernant l'extension elle-même
- Créer des exemples dans les tutoriels
- Afficher la syntaxe des balises sans déclencher les fetches
Cela fonctionne pour les deux <fetch> et <fetch-summary> tags:
<!-- No fetch data available for https://example.com/api/status.md -->
Système d'événements de surveillance
L'extension publie des événements pour toutes les opérations de fetch:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddPostgresMarkdownFetch(connectionString);
var sp = services.BuildServiceProvider();
var eventPublisher = sp.GetRequiredService<IMarkdownFetchEventPublisher>();
// Subscribe to events
eventPublisher.FetchBeginning += (sender, args) =>
{
Console.WriteLine($"Fetching {args.Url}...");
};
eventPublisher.FetchCompleted += (sender, args) =>
{
var source = args.WasCached ? "cache" : "remote";
Console.WriteLine($"Fetched {args.Url} from {source} in {args.Duration.TotalMilliseconds}ms");
};
eventPublisher.FetchFailed += (sender, args) =>
{
Console.WriteLine($"Failed to fetch {args.Url}: {args.ErrorMessage}");
};
}
}
Cela facilite l’intégration avec Application Insights, Prometheus, ou votre infrastructure d’enregistrementM SK2
sequenceDiagram
participant MD as Markdown Processor
participant EP as Event Publisher
participant FS as Fetch Service
participant ST as Storage Backend
participant L as Your Listeners
MD->>EP: FetchBeginning
EP->>L: Notify FetchBeginning
EP->>FS: FetchMarkdownAsync(url)
FS->>ST: Check Cache
alt Cache Fresh
ST-->>FS: Cached Content
FS->>EP: FetchCompleted (cached=true)
else Cache Stale/Missing
FS->>FS: HTTP GET
alt Success
FS->>ST: Update Cache
ST-->>FS: OK
FS->>EP: FetchCompleted (cached=false)
else Failure
FS->>EP: FetchFailed
EP->>L: Notify FetchFailed
end
end
EP->>L: Notify FetchCompleted
EP->>L: Notify ContentUpdated
FS-->>MD: MarkdownFetchResult
Note over L: Listeners can be: Logging, Metrics, Telemetry, Webhooks
Stratégie de stockage en détail
Le comportement de cache suit un modèle d’état de la machine:
stateDiagram-v2
[*] --> CheckCache: Fetch Request
CheckCache --> Fresh: Cache exists & age < pollFrequency
CheckCache --> Stale: Cache exists & age >= pollFrequency
CheckCache --> Missing: No cache entry
Fresh --> ReturnCached: Return cached content
ReturnCached --> [*]
Stale --> FetchRemote: Attempt HTTP GET
Missing --> FetchRemote: Attempt HTTP GET
FetchRemote --> UpdateCache: Success
FetchRemote --> HasStale: Failure
UpdateCache --> ReturnFresh: Return new content
ReturnFresh --> [*]
HasStale --> ReturnStale: Return stale cache
HasStale --> ReturnError: No cache available
ReturnStale --> [*]
ReturnError --> [*]
note right of Fresh
pollFrequency = 0
means always stale
end note
note right of HasStale
Stale-while-revalidate
pattern ensures uptime
end note
Les principaux points de vue ci-dessus:
- Cache fraîche - Retour immédiat , aucun coup de réseau
- Cache permanente - Essayez de récupérer des données fraîches, mais retournez à l'ancienneté si le téléchargement échoue
- Cache manquante - Il faut récupérer ou retourner une erreur
- Nulle fréquence de sondage - Faire toujours en sorte que les données soient fraîches.
Ce modèle est appelé idle-while-revalidate et il est excellent pour la fiabilité. Même si votre source se dégradeM SK2 votre site continue de servir du contenu cached.
Remplacement et gestion des caches
Parfois, vous devez invalider manuellement le cache:
public class MarkdownController : Controller
{
private readonly IMarkdownFetchService _fetchService;
public async Task<IActionResult> InvalidateCache(string url)
{
var removed = await _fetchService.RemoveCachedMarkdownAsync(url);
if (removed)
{
return Ok(new { message = "Cache invalidated" });
}
return NotFound(new { message = "No cache entry found" });
}
}
Ou via webhooks lorsque le contenu change:
// GitHub webhook notifies of README update
app.MapPost("/webhooks/github", async (
GitHubWebhookPayload payload,
IMarkdownFetchService fetchService) =>
{
if (payload.Repository?.FullName == "user/repo" &&
payload.Commits?.Any(c => c.Modified?.Contains("README.md") == true) == true)
{
var url = "https://raw.githubusercontent.com/user/repo/main/README.md";
await fetchService.RemoveCachedMarkdownAsync(url);
return Results.Ok(new { message = "Cache invalidated" });
}
return Results.Ok(new { message = "No action needed" });
});
Évaluation de l'extension
L’extension inclut des essais complets. Voici ' la façon dont je les structure:
public class MarkdownFetchServiceTests
{
[Fact]
public async Task FetchMarkdownAsync_CachesContent()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.AddInMemoryMarkdownFetch();
var sp = services.BuildServiceProvider();
var fetchService = sp.GetRequiredService<IMarkdownFetchService>();
var url = "https://raw.githubusercontent.com/user/repo/main/README.md";
// Act - First fetch (from network)
var result1 = await fetchService.FetchMarkdownAsync(url, 24, 0);
// Act - Second fetch (from cache)
var result2 = await fetchService.FetchMarkdownAsync(url, 24, 0);
// Assert
Assert.True(result1.Success);
Assert.True(result2.Success);
Assert.Equal(result1.Content, result2.Content);
}
[Fact]
public async Task FetchMarkdownAsync_ReturnsStaleOnFailure()
{
// Arrange
var services = new ServiceCollection();
services.AddLogging();
services.AddInMemoryMarkdownFetch();
var sp = services.BuildServiceProvider();
var fetchService = sp.GetRequiredService<IMarkdownFetchService>();
var url = "https://httpstat.us/200?sleep=100";
// Act - First fetch succeeds
var result1 = await fetchService.FetchMarkdownAsync(url, 0, 0);
// Change URL to fail
var badUrl = "https://httpstat.us/500";
// Act - Second fetch fails, should return stale
var result2 = await fetchService.FetchMarkdownAsync(badUrl, 0, 0);
// Assert
Assert.True(result1.Success);
// Even though fetch failed, we return success with stale content
Assert.True(result2.Success);
}
}
Considérations relatives au rendement
L'extension est conçue pour la performance:
- Dictionnaire concurrent - Utilisations de la mise en œuvre de mémoire dans l'-
ConcurrentDictionarypour thread-accès sécuritaire - SémaphoreSlim - Dossier- basé sur le blocage async pour prévenir les conditions de la course
- Index de base de données - Tous les fournisseurs de bases de données ont des index appropriés sur les clés du cache
- Répartition des clients HTTP - Utilisations
IHttpClientFactorypour une réutilisation efficace des connexions - Async All The Way - Aucun appel de blocage, tout est async
Nombres de performance typiques sur mon serveur domestique:
- Cache hit: M SK1 ≥1ms
- Perte de cache avec le réseau: M SK1ms ( dépend du serveur à distance)
- Recherche de base de données: M SK1ms (PostgreSQL avec des indices appropriés)
- Examen du système de fichiers: 1-3ms
Déploiement avec les actions de GitHub
Je publie tous les quatre paquets à NuGet en utilisant GitHub Actions avec l’authentification OIDC:
name: Publish Markdig.FetchExtension
on:
push:
tags:
- 'fetchextension-v*.*.*'
permissions:
id-token: write
contents: read
jobs:
build-and-publish:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Extract version from tag
id: get_version
run: |
TAG=${GITHUB_REF#refs/tags/fetchextension-v}
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
- name: Build
run: dotnet build Mostlylucid.Markdig.FetchExtension/Mostlylucid.Markdig.FetchExtension.csproj --configuration Release -p:Version=${{ steps.get_version.outputs.VERSION }}
- name: Pack
run: dotnet pack Mostlylucid.Markdig.FetchExtension/Mostlylucid.Markdig.FetchExtension.csproj --configuration Release --no-build -p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --output ./artifacts
- name: Login to NuGet (OIDC)
id: nuget_login
uses: NuGet/login@v1
with:
user: 'mostlylucid'
- name: Publish to NuGet
run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ steps.nuget_login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate
L’approche OIDC est plus sécuritaire que l’entreposage de clés API - GitHub génère automatiquement des tokens vivants courts-
Utilisation mondiale
Voici comment je l’utilise dans mes articles de blog:
# My NuGet Package Documentation
Here's the official README from GitHub:
# Umami.Net
**Version 1.1.0** · [Upgrade guide](https://github.com/scottgal/mostlylucidweb/blob/umamiv1.1.0/Umami.Net/MIGRATION.md) · [Release notes](https://github.com/scottgal/mostlylucidweb/blob/umamiv1.1.0/Umami.Net/RELEASE.md)
A .NET 8, 9 and 10 client for [Umami Analytics](https://umami.is), reviewed against
[Umami 3.4.0](https://github.com/umami-software/umami/releases/tag/v3.4.0).
The library sends page views, events, visitor identity and Web Vitals, and reads
website analytics. It supports self-hosted username/password or API-key
credentials, and the current Umami Cloud Bearer API-key protocol.
## Install and register
```sh
dotnet add package Umami.Net --version 1.1.0
builder.Services.SetupUmamiClient(builder.Configuration); // Collection and background sender
builder.Services.SetupUmamiData(builder.Configuration); // Authenticated analytics API
Les deux extensions utilisent le Analytics section de configuration. Inscrire soit
tous les deux. L'inscription ne modifie pas votre application ' son niveau d'enregistrement ou ses fournisseurs M SK2
{
"Analytics": {
"UmamiPath": "https://analytics.example.com/",
"WebsiteId": "12345678-1234-1234-1234-123456789abc",
"ApiVersion": "V3"
}
}
Établir les secrets par l'entremise des secrets d'utilisateur ou des variables environnementales plutôt que de s'engager
them. Pour le self-hosted Umami M SK2 set Analytics__ApiKey à une clé API d'un compte
created under Settings → API keys. Le client envoie Authorization: Bearer ...
et n’appelle pas les points de fin auth, qui sont bloqués pour ces clés.
Autrement, approvisionnement Analytics__Username et Analytics__Password. Le client
logs in, vérifie les tokens existants avec POST /api/auth/verify, et reprend une
401 une fois qu'on a forcé un nouveau login. Défaut d'autorisation de site puis retour
401 sans répétition indéfinieM SK1 Un token partiel de 2FA n'est pas accepté comme un
connexion terminée; utiliser une clé API pour accéder sans surveillance à un compte 2FAM SK2 habilitant.
Pour Umami Cloud, configure UmamiPath En ce qui a trait à la protection des droits de l'homme et aux libertés fondamentales, https://api.umami.is/v1/ (ou a
Point final de la région tel que /v1/eu/), UseCloudApi: true, et ApiKey. Le
client préserve ce chemin de base et omet le self-hosted /api Préfixe.
La collection utilise le tracker/hôte de collecte, non les données Cloud authentifiées
API; enregistrez le client de collecte séparément si vos paramètres finaux diffèrent.
Les clés API et les routes d’analyse modernes nécessitent un serveur qui les appuie. Ce paquet ne permet pas à votre serveur Umami, de migrer sa base de données ou d'en assurer la mise à niveau. fonctions du serveur comme le replay de session ou MCP.
Surveillance
Injecter UmamiClient pour la collecte immédiate:
using var response = await client.TrackPageView("/products?utm_source=newsletter", "Products");
response.EnsureSuccessStatusCode();
using var purchase = await client.Track("purchase", new UmamiEventData
{
["revenue"] = 49.95,
["currency"] = "GBP"
}, distinctId: "customer-123");
Méthodes de retour HttpResponseMessage transfert de propriété à l’appelant; disposer
la réponse. *AndDecode Les méthodes permettent de disposer de leur réponse et d'analyse du retour.
les métadonnées en place. SendAsync Ajoute un support d'annulation sans modifier le
d’un organisme Send, Track, TrackPageView ou Identify signatures.
Le collecteur actuel retourne { cache, sessionId, visitId }. Le résultat décodé
Le présent article traite d'une question qui a été soulevée par le Comité. SessionId, VisitId et Cache; Id contient également la session en cours
Les réponses de JWT sont toujours décodées en tant que métadonnées analytiques.
non utilisé comme preuve d'authentification. { "beep": "boop" } Retours BotDetected.
var result = await client.TrackPageViewAndDecode("/landing");
using var next = await client.SendAsync(new UmamiPayload
{
Url = "/pricing",
CacheToken = result?.Cache,
DistinctId = "customer-123",
Tag = "campaign-a",
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}, cancellationToken: cancellationToken);
Les tokens de cache appartiennent à un visiteur particulier. Ils sont transmis explicitement sur le
la charge de travail comme x-umami-cache; le client n'en stocke jamais un dans les titres par défaut partagés
Umami 3.4 identifie les visiteurs en utilisant DistinctId (id sur le fil). SessionId
sur la charge de paye est maintenue pour les clients anciens; serveurs actuels calculer session
IDs plutôt que d’accepter un appelant-session choisie UUID.
Données d'événement explicites, adresse IP et utilisateur- agent surride survie payload
population. Sans surrider, le client saisit la voie de l'agent d'utilisateur inboundM SK2
base, chemin et chaîne de rechercheM SK1 Les chaînes de recherche conservent les paramètres UTM. À l'inverse
déploiement de proxy, configure ASP.NET CoreM SK2s forwarded reliées à -header handling
avant de recueillir des événements, c'est ainsi RemoteIpAddress représente le visiteur visé.
UseDefaultUserAgent est une suppression explicite retenue pour les intégrations existantes;
il stocke l’agent original dans les données d’événement. La collecte normale préserve la réelle
agent visiteur et permet à Umami's bot filtration d’appliquer.
Web Vitals
using var response = await client.TrackPerformanceAsync(new UmamiPayload
{
Url = "/checkout", Title = "Checkout",
Lcp = 1800, Inp = 120, Cls = 0.04, Fcp = 950, Ttfb = 180
});
Les champs de temps sont millisecondes; Le SLC est une note sans unitéM SK1 Les valeurs sont vérifiées
contre le collecteur courant's rangesM SK1 Timestamp est Unix 2 secondes;
les dates de demande d'analyse sont converties en Unix % d'heures.
Présentation de l'information
Injecter UmamiBackgroundSender pour capturer les métadonnées de la demande et encadrer la livraison:
await sender.TrackPageView("/products", "Products");
await sender.Track("checkout", new UmamiEventData { ["cartSize"] = 3 });
await sender.Identify(distinctId: "customer-123");
La queue de mémoire in- maintient jusqu’à 1,024 événements. Les opérations de la queue attendent le moment où elles sont remplies. fournissant une pression de recul au lieu d'une croissance de mémoire non limitée. Fermeture complète le rédacteur et drains les événements en suspens jusqu’à la date limite d’annulation de l’hôte, puis annule la livraison. Les défaillances de processus peuvent perdre des données en queue; utiliser une boîte à l'extérieur durable si qui est inacceptable. La livraison manquante est enregistrée sans arrêter le lecteur. Les écritures de suivi ne sont pas repérées automatiquement, parce qu'une reconnaissance perdue peut autrement doubler-compter un événementM SK1
Méthodes d'analyse existantes
Injecter UmamiDataService:
var request = new MetricsRequest
{
StartAtDate = DateTime.UtcNow.AddDays(-7),
EndAtDate = DateTime.UtcNow,
Type = MetricType.path,
Limit = 20,
UtmSource = "newsletter"
};
var metrics = await data.GetMetrics(request);
if (metrics.Status == HttpStatusCode.OK)
foreach (var row in metrics.Data ?? []) Console.WriteLine($"{row.x}: {row.y}");
Les méthodes existantes restent disponibles: GetStats, GetPageViews, GetMetrics,
GetExpandedMetrics, GetEventsSeries, et GetActiveUsers. Les statistiques se normalisent
les deux héritages { value, prev } Les champs et les totaux numériques actuels plus comparison
à l’intérieur de la .pageviews.value / .pageviews.prev modèle. Visiteurs actifs
accepter le patrimoine x et actuels visitors champs.
ApiVersion par défaut à V3. MetricType.url Les cartes à type=path dans la série V3.
V2 pour conserver l'ancienne valeur métrique; V1 pour l'héritage explicite
path → url et hostname → host mapping des paramètres. La détection de la version est
non automatique. Les demandes invalides 400 ne sont pas rédigées et rejetées silencieusement
Les cartes de compatibilité ne promettent pas toutes les fonctionnalités existantes sur des serveurs plus anciens.
Les dimensions métriques comprennent distinctId et utmSource, utmMedium,
utmCampaign, utmContent, utmTerm; leur camelCase wire names are preserved .
MetricType.event liste les noms des événements sans exiger un filtre d'événement.
Les buckets de minutes sont supportés; par défaut, la série d’événements est versée à UTC quand aucune zone horaire n’est fournie.
Les demandes courantes comprennent des événements distincts-identité , UTMM SK2 événementMSC3type, match et bouclage
Filtres. Les filtres sont URLM SK1encodés une fois; les opérateurs et les commas regex sont conservésMSC3
Analyse du site Web actuel
Umami 3.4 expose le site Web-établissent les paramètres de l’EEG applicables aux calculs des rapports :
var report = await data.GetAnalyticsAsync(AnalyticsEndpoint.PerformanceChart,
new AnalyticsRequest
{
StartAtDate = DateTime.UtcNow.AddDays(-7), EndAtDate = DateTime.UtcNow,
Unit = Unit.day, Timezone = "Europe/London", Metric = "lcp"
}, cancellationToken);
AnalyticsEndpoint attribution, répartitionM SK1 funnel / statistiques d’objectifs, cartes thermiquesMSC4
voyages, retenue, statistiques de rendementM SK2 tableaux /métriques, métriques UTMMSC5 revenus
Statistiques/chartM SK1métriques, séances et événements . Les critères varient selon le point final
contrats officiels.
Par exemple, les revenus de Currency, UTM exige un Type , par exemple
utm_source, et une prévision du funnel prend JSON Steps plus un Window.
Le résultat par défaut est UmamiResult<JsonElement>, préservation de chaque ensemble de données
forme réelle. GetAnalyticsAsync<T> supports caller-modèles typés définis.
Ces méthodes exposent les ensembles de données d'analyse; ils ne rendent pas l'interface Umami ou
enregistrement de la session du navigateur replay streams.
Annotations
var note = await data.CreateAnnotationAsync(new AnnotationInput(
DateTimeOffset.UtcNow, "Website refresh deployed", AllDay: false), cancellationToken);
var notes = await data.GetAnnotationsAsync(page: 1, pageSize: 20, cancellationToken: cancellationToken);
// UpdateAnnotationAsync(id, input), GetAnnotationAsync(id), DeleteAnnotationAsync(id)
Les annotations ont typé des entrées, réponses et listes pagenées. CréationM SK2 mise à jour utilise POST, et la suppression utilise DELETEM SK1 correspondant aux contrats courants. Les notes doivent contiennent 1–500 caractères. Ces méthodes exécutent des écritures seulement lorsqu'elles sont explicitement appeléesM SK2
Autres ressources du site Web
SendWebsiteRequestAsync<T> gère les ressources supplémentaires documentées, y compris
Détails de la session et replay des métadonnées
Voie relative des ressources du site web; il rejette les URLs absolus, traversation et embedded
query strings. L'ID du site Web et l'hôte proviennent toujours de la configuration.
var goals = await data.SendWebsiteRequestAsync<JsonElement>(HttpMethod.Get, "goals",
new Dictionary<string, string?> { ["page"] = "1", ["pageSize"] = "20" },
cancellationToken: cancellationToken);
La méthode préserve la JSON structurée et permet des modèles de résultat typés. Ressource Les schémas et les droits demeurent ceux du serveur Umami configuré. Il le fait ne pas exposer le compte API-gestion des clés ou des voies administratives.
Pour l'accès assisté, Umami 3.4 fournit son propre Server MCP. Utiliser ce serveur avec une clé API appropriée; Ce paquet est un analyse HTTP client, n'est pas une mise en œuvre de MCP.
Défaillances et annulation
Retour des pannes de transport d'analyse UmamiResult<T>: 503 pour un panneau de réseau,
504 pour un temps-out HTTPM SK1 502 pour une réponse JSON invalide, et le statut du serveur
pour les défaillances HTTP. L'annulation explicite de l'appelant se propage comme
OperationCanceledException. Émettre des arguments locaux et des intervalles de dates non valides
avant d’envoyer des demandes. Les réponses et les tentatives de répétition manquantes sont éliminées.
Seules les demandes d’EEG reçoivent des retraits transitoires limités/429 dans l’enregistrement de l’ID. 401 la réauthentication est séparément limitée à une seule requête. Les demandes de mutation sont n'est pas réexaminé pour les erreurs transitoires de transport. API-key M SK2 les réponses ne déclenchent pas a password fallback. Les logs omettent auth /tokens de cacheM SK2 charges payantes et chaînes de demandes.
Validation et libération
dotnet test Umami.Net.Test/Umami.Net.Test.csproj
scripts/test-umami-integration.sh # Spins up disposable Umami 3.4.0 + PostgreSQL; 51 real cases per target
dotnet build Umami.Net/Umami.Net.csproj -c Release
dotnet pack Umami.Net/Umami.Net.csproj -c Release --no-build -o /tmp/umami-pack
Le paquet cible net8.0, net 9.0 et NetM SK2 Utilisation des balises de diffusion umamiv*.*.*;
site Web release-* tags ne publient pas les paquets NuGet. Voir
Examen des conclusions et vérification pour la portée de la mise à jour actuelle.
Installation
Le paquet est disponible sur NuGet...
This keeps documentation synchronized automatically. When I update the README on GitHub, the blog post stays in sync (within 24 hours).
Conclusion
Building this extension taught me several things:
- Preprocessing > Parsing - Handling fetch tags before Markdig ensures consistency
- Stale-While-Revalidate - This pattern is incredibly valuable for reliability
- Pluggable Storage - Different deployments need different solutions
- Events for Observability - Being able to monitor fetch operations is crucial
- Thread Safety Matters - Concurrent access needs careful handling
- Bonus Features - The package now includes a separate TOC extension for generating tables of contents
The extension is open source and available on NuGet:
- mostlylucid.Markdig.FetchExtension
- mostlylucid.Markdig.FetchExtension.Postgres
- mostlylucid.Markdig.FetchExtension.Sqlite
- mostlylucid.Markdig.FetchExtension.SqlServer
Source code is on GitHub: scottgal/mostlylucidweb
If you're building a documentation site, blog, or any application that needs to include external markdown content, give it a try. It's designed to be simple to use but powerful enough for production deployments.