Back to "Établissement d'un attacheteur de marquage à distance pour Markdig"

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

AI-Article API ASP.NET Core C# FetchExtension MarkDig Markdown Nuget

É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.

NuGet NuGet

Pourquoi construire ce modèle?

Avant de se plonger dans les détails techniques, permettez-moi d’expliquer le problème

  1. 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
  2. Documentation API: Docs API externes qui changent fréquemment doivent rester en sync
  3. Contenu partagé: Documentation qui vit dans un même répertoire mais doit apparaître à plusieurs endroits
  4. 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

  1. Examiner pour <fetch> Les étiquettes
  2. Résoudre le contenu (du cache ou à distance)
  3. Remplacer les balises avec le marquage effectif
  4. 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:

  1. Crée une clé de cache à partir du URL et de l'ID d'affichage du blog
  2. Vérifie si nous avons caché le contenu et s’il' est frais
  3. Si la cache est fraîche,, elle le retourne immédiatement
  4. Si stale, essaie de récupérer du contenu frais
  5. Sur succès, mise à jour du cache
  6. En cas de défaillance avec un contenu caché, retourne le cache inexistant M SK1stale-while -revalidateMSC4
  7. 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:

  1. Utilisations SemaphoreSlim pour le thread-accès sécurisé aux fichiers
  2. Hashe l’URL + blog post ID pour créer des noms de fichier sûrs
  3. Utilise le temps de modification des fichiers pour déterminer la fraîcheur
  4. Persistent à travers la réouverture de l'application
  5. 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:

  1. Votre démarrage contient <fetch> Les étiquettes
  2. Le préprocesseur les résout à un marquage effectif
  3. Le marquage combiné passe par Markdig
  4. 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:

  1. 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

  2. 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"
  3. Structure nichée: Le rédacteur construit un emplacement approprié ul/li structure 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:

  1. Cache fraîche - Retour immédiat , aucun coup de réseau
  2. Cache permanente - Essayez de récupérer des données fraîches, mais retournez à l'ancienneté si le téléchargement échoue
  3. Cache manquante - Il faut récupérer ou retourner une erreur
  4. 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:

  1. Dictionnaire concurrent - Utilisations de la mise en œuvre de mémoire dans l'- ConcurrentDictionary pour thread-accès sécuritaire
  2. SémaphoreSlim - Dossier- basé sur le blocage async pour prévenir les conditions de la course
  3. 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
  4. Répartition des clients HTTP - Utilisations IHttpClientFactory pour une réutilisation efficace des connexions
  5. 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:

  1. Preprocessing > Parsing - Handling fetch tags before Markdig ensures consistency
  2. Stale-While-Revalidate - This pattern is incredibly valuable for reliability
  3. Pluggable Storage - Different deployments need different solutions
  4. Events for Observability - Being able to monitor fetch operations is crucial
  5. Thread Safety Matters - Concurrent access needs careful handling
  6. Bonus Features - The package now includes a separate TOC extension for generating tables of contents

The extension is open source and available on NuGet:

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.

logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.