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
Jag har bloggade många gånger om hur jag använder Markdown för att skapa mina blogginlägg; Jag gillar verkligen detta tillvägagångssätt men det har en stor nackdel - det innebär att jag måste göra en komplett Docker bygga cykel för att uppdatera ett inlägg. Detta var fint medan jag skapade fler funktioner som jag sedan bloggade om men det är ganska begränsande nu. Jag vill kunna uppdatera mina blogginlägg utan att behöva göra en full bygga cykel. Så nu har jag lagt till en ny del funktionalitet till min blogg som tillåter mig att göra just det.
Med "hybrid" tillvägagångssätt menar jag denna livscykel; återigen ganska förenklad men ganska cool (på ett super nördigt sätt!)..............................................................................................
Så det är ganska enkelt.
Detta gör att jag kan fortsätta att använda Rider lokalt för att skapa blogginlägg (i framtiden kommer jag sannolikt att flytta för att också låta detta hända i själva webbplatsen), alla översättningar sker dynamiskt på webbplatsen själv och jag kan uppdatera inlägg utan att behöva göra en full bygga cykel.
Koden för detta är YET ANothER IHostedService
, den här gången använder den FileSystemWatcher
klass för att titta på en katalog för nya filer. När en ny fil upptäcks läser den filen, bearbetar den och infogar den i databasen. ELLER om jag tar bort ett engelskt språk inlägg det kommer att ta bort alla översättningar av det inlägget också.
Hela koden är under men jag ska bryta ner den lite här.
using Mostlylucid.Config.Markdown;
using Polly;
using Serilog.Events;
namespace Mostlylucid.Blog.WatcherService;
public class MarkdownDirectoryWatcherService(MarkdownConfig markdownConfig, IServiceScopeFactory serviceScopeFactory,
ILogger<MarkdownDirectoryWatcherService> logger)
: IHostedService
{
private Task _awaitChangeTask = Task.CompletedTask;
private FileSystemWatcher _fileSystemWatcher;
public Task StartAsync(CancellationToken cancellationToken)
{
_fileSystemWatcher = new FileSystemWatcher
{
Path = markdownConfig.MarkdownPath,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime |
NotifyFilters.Size,
Filter = "*.md", // Watch all markdown files
IncludeSubdirectories = true // Enable watching subdirectories
};
// Subscribe to events
_fileSystemWatcher.EnableRaisingEvents = true;
_awaitChangeTask = Task.Run(()=> AwaitChanges(cancellationToken));
logger.LogInformation("Started watching directory {Directory}", markdownConfig.MarkdownPath);
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
// Stop watching
_fileSystemWatcher.EnableRaisingEvents = false;
_fileSystemWatcher.Dispose();
Console.WriteLine($"Stopped watching directory: {markdownConfig.MarkdownPath}");
return Task.CompletedTask;
}
private async Task AwaitChanges(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var fileEvent = _fileSystemWatcher.WaitForChanged(WatcherChangeTypes.All);
if (fileEvent.ChangeType == WatcherChangeTypes.Changed ||
fileEvent.ChangeType == WatcherChangeTypes.Created)
{
await OnChangedAsync(fileEvent);
}
else if (fileEvent.ChangeType == WatcherChangeTypes.Deleted)
{
OnDeleted(fileEvent);
}
else if (fileEvent.ChangeType == WatcherChangeTypes.Renamed)
{
}
}
}
private async Task OnChangedAsync(WaitForChangedResult e)
{
if (e.Name == null) return;
var activity = Log.Logger.StartActivity("Markdown File Changed {Name}", e.Name);
var retryPolicy = Policy
.Handle<IOException>() // Only handle IO exceptions (like file in use)
.WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromMilliseconds(500 * retryAttempt),
(exception, timeSpan, retryCount, context) =>
{
activity?.Activity?.SetTag("Retry Attempt", retryCount);
// Log the retry attempt
logger.LogWarning("File is in use, retrying attempt {RetryCount} after {TimeSpan}", retryCount, timeSpan);
});
try
{
var fileName = e.Name;
var isTranslated = Path.GetFileNameWithoutExtension(e.Name).Contains(".");
var language = MarkdownBaseService.EnglishLanguage;
var directory = markdownConfig.MarkdownPath;
if (isTranslated)
{
language = Path.GetFileNameWithoutExtension(e.Name).Split('.').Last();
fileName = Path.GetFileName(fileName);
directory = markdownConfig.MarkdownTranslatedPath;
}
var filePath = Path.Combine(directory, fileName);
var scope = serviceScopeFactory.CreateScope();
var markdownBlogService = scope.ServiceProvider.GetRequiredService<IMarkdownBlogService>();
// Use the Polly retry policy for executing the operation
await retryPolicy.ExecuteAsync(async () =>
{
var blogModel = await markdownBlogService.GetPage(filePath);
activity?.Activity?.SetTag("Page Processed", blogModel.Slug);
blogModel.Language = language;
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
await blogService.SavePost(blogModel);
if (language == MarkdownBaseService.EnglishLanguage)
{
var translateService = scope.ServiceProvider.GetRequiredService<BackgroundTranslateService>();
await translateService.TranslateForAllLanguages(
new PageTranslationModel(){OriginalFileName = filePath, OriginalMarkdown = blogModel.Markdown,Persist = true});
}
activity?.Activity?.SetTag("Page Saved", blogModel.Slug);
});
activity?.Complete();
}
catch (Exception exception)
{
activity?.Complete(LogEventLevel.Error, exception);
}
}
private void OnDeleted(WaitForChangedResult e)
{
if(e.Name == null) return;
var isTranslated = Path.GetFileNameWithoutExtension(e.Name).Contains(".");
var language = MarkdownBaseService.EnglishLanguage;
var slug = Path.GetFileNameWithoutExtension(e.Name);
if (isTranslated)
{
var name = Path.GetFileNameWithoutExtension(e.Name).Split('.');
language = name.Last();
slug = name.First();
}
else
{
var translatedFiles = Directory.GetFiles(markdownConfig.MarkdownTranslatedPath, $"{slug}.*.*");
_fileSystemWatcher.EnableRaisingEvents = false;
foreach (var file in translatedFiles)
{
File.Delete(file);
}
_fileSystemWatcher.EnableRaisingEvents = true;
}
var scope = serviceScopeFactory.CreateScope();
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
blogService.Delete(slug, language);
}
private void OnRenamed(object sender, RenamedEventArgs e)
{
Console.WriteLine($"File renamed: {e.OldFullPath} to {e.FullPath}");
}
}
Så allt det gör är att starta en ny uppgift som använder FileSystemWatcher
i det är StartAsync
Metod. I en IHostedService
Detta är ingångspunkten som ASP.NET Core-ramverket använder för att starta tjänsten.
private FileSystemWatcher _fileSystemWatcher;
public Task StartAsync(CancellationToken cancellationToken)
{
_fileSystemWatcher = new FileSystemWatcher
{
Path = markdownConfig.MarkdownPath,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime |
NotifyFilters.Size,
Filter = "*.md", // Watch all markdown files
IncludeSubdirectories = true // Enable watching subdirectories
};
// Subscribe to events
_fileSystemWatcher.EnableRaisingEvents = true;
_awaitChangeTask = Task.Run(()=> AwaitChanges(cancellationToken));
logger.LogInformation("Started watching directory {Directory}", markdownConfig.MarkdownPath);
return Task.CompletedTask;
}
Jag har en lokal uppgift till tjänsten som jag avfyrar växelslingan på
private Task _awaitChangeTask = Task.CompletedTask;
_awaitChangeTask = Task.Run(()=> AwaitChanges(cancellationToken));
Detta är viktigt eftersom annars detta blir blockerande, du måste se till att detta körs i bakgrunden (fråga mig hur jag vet detta).
Här inne har vi satt upp en FileSystemWatcher
för att lyssna på händelser i min Markdown-katalog (klippt till en värdkatalog av skäl som vi får se senare!)
_fileSystemWatcher = new FileSystemWatcher
{
Path = markdownConfig.MarkdownPath,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime |
NotifyFilters.Size,
Filter = "*.md", // Watch all markdown files
IncludeSubdirectories = true // Enable watching subdirectories
};
// Subscribe to events
_fileSystemWatcher.EnableRaisingEvents = true;
Du kan se att jag tittar efter FileName
, LastWrite
, CreationTime
och Size
förändringar. Detta beror på att jag vill veta om en fil skapas, uppdateras eller tas bort.
Jag har också en Filter
inställd till *.md
så jag bara titta efter markeringsfiler och ange Jag vill titta på underkataloger också (för översättningar).
Inuti denna kod har jag en ändringsslinga som väntar på ändringar i filsystemet. Observera att du också kan bara koppla in sig i förändring händelser här men detta kändes renare för mig.
private async Task AwaitChanges(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var fileEvent = _fileSystemWatcher.WaitForChanged(WatcherChangeTypes.All);
if (fileEvent.ChangeType == WatcherChangeTypes.Changed ||
fileEvent.ChangeType == WatcherChangeTypes.Created)
{
await OnChangedAsync(fileEvent);
}
else if (fileEvent.ChangeType == WatcherChangeTypes.Deleted)
{
OnDeleted(fileEvent);
}
else if (fileEvent.ChangeType == WatcherChangeTypes.Renamed)
{
}
}
}
Återigen ganska enkelt, allt det gör är att vänta på en förändring händelse och sedan kallar OnChangedAsync
Metod. ELLER om filen tas bort det kallar OnDeleted
Metod.
Det är här "magiken" händer. Vad den gör är att lyssna på förändringshändelsen. Det behandlar sedan Markdown-filen med hjälp av min pipeline (för att få HTML-kategorier, datum, titel etc) och indertar detta i databasen. Det Then upptäcker om filen är på engelska (så jag skrev den :)) och om det är det startar en översättningsprocess. Se också detta inlägg för hur detta fungerar.
await retryPolicy.ExecuteAsync(async () =>
{
var blogModel = await markdownBlogService.GetPage(filePath);
activity?.Activity?.SetTag("Page Processed", blogModel.Slug);
blogModel.Language = language;
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
await blogService.SavePost(blogModel);
if (language == MarkdownBaseService.EnglishLanguage)
{
var translateService = scope.ServiceProvider.GetRequiredService<BackgroundTranslateService>();
await translateService.TranslateForAllLanguages(
new PageTranslationModel(){OriginalFileName = filePath, OriginalMarkdown = blogModel.Markdown,Persist = true});
}
activity?.Activity?.SetTag("Page Saved", blogModel.Slug);
});
Observera att jag använder Polly
här för att hantera filhanteringen; detta säkerställer att filen verkligen har laddats upp / sparats innan den försöker behandla den.
var retryPolicy = Policy
.Handle<IOException>() // Only handle IO exceptions (like file in use)
.WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromMilliseconds(500 * retryAttempt),
(exception, timeSpan, retryCount, context) =>
{
activity?.Activity?.SetTag("Retry Attempt", retryCount);
// Log the retry attempt
logger.LogWarning("File is in use, retrying attempt {RetryCount} after {TimeSpan}", retryCount, timeSpan);
});
Jag använder också SerilogTracing
aktiviteter för att hjälpa mig att hitta några problem när det är i "produktion" lättare (I har en artikel På det också!)..............................................................................................
private async Task OnChangedAsync(WaitForChangedResult e)
{
if (e.Name == null) return;
var activity = Log.Logger.StartActivity("Markdown File Changed {Name}", e.Name);
var retryPolicy = Policy
.Handle<IOException>() // Only handle IO exceptions (like file in use)
.WaitAndRetryAsync(5, retryAttempt => TimeSpan.FromMilliseconds(500 * retryAttempt),
(exception, timeSpan, retryCount, context) =>
{
activity?.Activity?.SetTag("Retry Attempt", retryCount);
// Log the retry attempt
logger.LogWarning("File is in use, retrying attempt {RetryCount} after {TimeSpan}", retryCount, timeSpan);
});
try
{
var fileName = e.Name;
var isTranslated = Path.GetFileNameWithoutExtension(e.Name).Contains(".");
var language = MarkdownBaseService.EnglishLanguage;
var directory = markdownConfig.MarkdownPath;
if (isTranslated)
{
language = Path.GetFileNameWithoutExtension(e.Name).Split('.').Last();
fileName = Path.GetFileName(fileName);
directory = markdownConfig.MarkdownTranslatedPath;
}
var filePath = Path.Combine(directory, fileName);
var scope = serviceScopeFactory.CreateScope();
var markdownBlogService = scope.ServiceProvider.GetRequiredService<IMarkdownBlogService>();
// Use the Polly retry policy for executing the operation
await retryPolicy.ExecuteAsync(async () =>
{
var blogModel = await markdownBlogService.GetPage(filePath);
activity?.Activity?.SetTag("Page Processed", blogModel.Slug);
blogModel.Language = language;
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
await blogService.SavePost(blogModel);
if (language == MarkdownBaseService.EnglishLanguage)
{
var translateService = scope.ServiceProvider.GetRequiredService<BackgroundTranslateService>();
await translateService.TranslateForAllLanguages(
new PageTranslationModel(){OriginalFileName = filePath, OriginalMarkdown = blogModel.Markdown,Persist = true});
}
activity?.Activity?.SetTag("Page Saved", blogModel.Slug);
});
activity?.Complete();
}
catch (Exception exception)
{
activity?.Complete(LogEventLevel.Error, exception);
}
}
Den här är verkligen enkel, den bara upptäcker när en fil tas bort och tar bort den från databasen; Jag använder det här som en idiotsäker åtgärd som jag är benägna att ladda upp artiklar sedan omedelbart se att de har fel. Som du kan se det testar för att se om filen är översatt och om det är det tar bort alla översättningar av den filen.
private void OnDeleted(WaitForChangedResult e)
{
if (e.Name == null) return;
var activity = Log.Logger.StartActivity("Markdown File Deleting {Name}", e.Name);
try
{
var isTranslated = Path.GetFileNameWithoutExtension(e.Name).Contains(".");
var language = MarkdownBaseService.EnglishLanguage;
var slug = Path.GetFileNameWithoutExtension(e.Name);
if (isTranslated)
{
var name = Path.GetFileNameWithoutExtension(e.Name).Split('.');
language = name.Last();
slug = name.First();
}
else
{
var translatedFiles = Directory.GetFiles(markdownConfig.MarkdownTranslatedPath, $"{slug}.*.*");
_fileSystemWatcher.EnableRaisingEvents = false;
foreach (var file in translatedFiles)
{
File.Delete(file);
}
_fileSystemWatcher.EnableRaisingEvents = true;
}
var scope = serviceScopeFactory.CreateScope();
var blogService = scope.ServiceProvider.GetRequiredService<IBlogService>();
blogService.Delete(slug, language);
activity?.Activity?.SetTag("Page Deleted", slug);
activity?.Complete();
logger.LogInformation("Deleted blog post {Slug} in {Language}", slug, language);
}
catch (Exception exception)
{
activity?.Complete(LogEventLevel.Error, exception);
logger.LogError("Error deleting blog post {Slug}", e.Name);
}
}
Jag använder WinSCP för att ladda upp filerna till min webbplats. Jag kan bara göra en "synkronisering" för att ladda upp eventuella markdown-filer då min tjänst kommer att lägga till dem till DB. Detta är katalogen som FileSystemWatcher
Jag tittar. I FRAMTIDEN lägger jag till en bilduppladdningskapacitet till detta också, där lägger jag till lite förbehandling och hantering av större filer.
Så detta är ett ganska enkelt sätt att lägga till nya blogginlägg till min webbplats utan att behöva göra en full bygga cykel. I det här inlägget visade jag hur man använder FileSystemWatcher
för att upptäcka ändringar i en katalog och bearbeta dem. Jag visade också hur man använder Polly
för hantering av retries och Serilog
för att logga processen.