diff --git a/Directory.Build.props b/Directory.Build.props index 029539129..488f3c4a0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -20,9 +20,9 @@ true snupkg true - netcoreapp3.1 + net10.0 true - NU1901;NU1902;NU1903;NU1904;CA1724 + CA1021;CA1062;CA1724;SA1414; @@ -31,11 +31,11 @@ - - - + + + - + diff --git a/examples/Statiq.Web.Examples/Statiq.Web.Examples.csproj b/examples/Statiq.Web.Examples/Statiq.Web.Examples.csproj index f1d241f36..5c4e01ec0 100644 --- a/examples/Statiq.Web.Examples/Statiq.Web.Examples.csproj +++ b/examples/Statiq.Web.Examples/Statiq.Web.Examples.csproj @@ -2,7 +2,7 @@ Exe - netcoreapp3.1 + net10.0 $(MSBuildProjectDirectory) diff --git a/src/Statiq.Web.Aws/Statiq.Web.Aws.csproj b/src/Statiq.Web.Aws/Statiq.Web.Aws.csproj index cb7ee20fb..44ae5ab36 100644 --- a/src/Statiq.Web.Aws/Statiq.Web.Aws.csproj +++ b/src/Statiq.Web.Aws/Statiq.Web.Aws.csproj @@ -4,7 +4,7 @@ Statiq Static StaticContent StaticSite Blog BlogEngine AmazonWebServices AWS - + diff --git a/src/Statiq.Web.Azure/DeploySearchIndex.cs b/src/Statiq.Web.Azure/DeploySearchIndex.cs index bde24d209..c277611ff 100644 --- a/src/Statiq.Web.Azure/DeploySearchIndex.cs +++ b/src/Statiq.Web.Azure/DeploySearchIndex.cs @@ -1,16 +1,15 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; using System.Threading.Tasks; -using Microsoft.Azure.Search; -using Microsoft.Azure.Search.Models; +using Azure; using Microsoft.Extensions.Logging; using Polly; using Statiq.Common; +using Azure.Search.Documents; +using Azure.Search.Documents.Indexes; +using Azure.Search.Documents.Indexes.Models; +using Azure.Search.Documents.Models; namespace Statiq.Web.Azure { @@ -41,7 +40,7 @@ public DeploySearchIndex( Config searchServiceName, Config indexName, Config apiKey, - Config> fields) + Config> fields) : base( new Dictionary { @@ -59,70 +58,67 @@ protected override async Task> ExecuteConfigAsync(IDocume string searchServiceName = values.GetString(SearchServiceName) ?? throw new ExecutionException("Invalid search service name"); string indexName = values.GetString(IndexName) ?? throw new ExecutionException("Invalid search index name"); string apiKey = values.GetString(ApiKey) ?? throw new ExecutionException("Invalid search API key"); - IList fields = values.GetList(Fields)?.ToList() ?? throw new ExecutionException("Invalid search fields"); + IList fields = values.GetList(Fields)?.ToList() ?? throw new ExecutionException("Invalid search fields"); - SearchServiceClient client = new SearchServiceClient(searchServiceName, new SearchCredentials(apiKey)); - - // Delete the index if it currently exists (recreating is the easiest way to update it) - CorsOptions corsOptions = null; - if (await client.Indexes.ExistsAsync(indexName, null, context.CancellationToken)) + if (!Uri.TryCreate(searchServiceName, UriKind.Absolute, out Uri searchServiceUri)) { - // Get the CORS options because we'll need to recreate those - Microsoft.Azure.Search.Models.Index existingIndex = await client.Indexes.GetAsync(indexName, null, context.CancellationToken); - corsOptions = existingIndex.CorsOptions; - - // Delete the existing index - context.LogDebug($"Deleting existing search index {indexName}"); - await client.Indexes.DeleteAsync(indexName, null, null, context.CancellationToken); + throw new ExecutionException("Invalid search service name"); } + SearchIndexClient searchIndexClient = new SearchIndexClient(searchServiceUri, new AzureKeyCredential(apiKey)); + // Create the index - Microsoft.Azure.Search.Models.Index index = new Microsoft.Azure.Search.Models.Index + SearchIndex index = new SearchIndex(indexName) { - Name = indexName, Fields = fields, - CorsOptions = corsOptions }; context.LogDebug($"Creating search index {indexName}"); - await client.Indexes.CreateAsync(index, null, context.CancellationToken); + await searchIndexClient.CreateOrUpdateIndexAsync(index, true, true, context.CancellationToken); // Upload the documents to the search index in batches context.LogDebug($"Uploading {context.Inputs.Length} documents to search index {indexName}..."); - ISearchIndexClient indexClient = client.Indexes.GetClient(indexName); + SearchClient indexClient = searchIndexClient.GetSearchClient(indexName); int start = 0; do { // Create the dynamic search documents and batch - IndexAction[] indexActions = context.Inputs + IndexDocumentsAction[] indexActions = context.Inputs .Skip(start) .Take(BatchSize) .Select(doc => { - Microsoft.Azure.Search.Models.Document searchDocument = new Microsoft.Azure.Search.Models.Document(); - foreach (Field field in fields) + SearchDocument searchDocument = new SearchDocument(); + foreach (SearchField field in fields) { if (doc.ContainsKey(field.Name)) { searchDocument[field.Name] = doc.Get(field.Name); } } - return IndexAction.Upload(searchDocument); + + return IndexDocumentsAction.Upload(searchDocument); }) .ToArray(); - IndexBatch indexBatch = IndexBatch.New(indexActions); + IndexDocumentsBatch indexBatch = IndexDocumentsBatch.Create(indexActions); // Upload the batch with exponential retry for failures await Policy - .Handle() - .WaitAndRetryAsync( - 5, - attempt => - { - context.LogWarning($"Failure while uploading batch {(start / BatchSize) + 1}, retry number {attempt}"); - return TimeSpan.FromSeconds(Math.Pow(2, attempt)); - }, - (ex, _) => indexBatch = ((IndexBatchException)ex).FindFailedActionsToRetry(indexBatch, fields.Single(x => x.IsKey == true).Name)) - .ExecuteAsync(async ct => await indexClient.Documents.IndexAsync(indexBatch, null, ct), context.CancellationToken); + .Handle() + .OrResult(r => r.Results.Any(result => !result.Succeeded)) + .WaitAndRetryAsync( + 5, + attempt => + { + context.LogWarning($"Failure while uploading batch {(start / BatchSize) + 1}, retry number {attempt}"); + return TimeSpan.FromSeconds(Math.Pow(2, attempt)); + }, + (ex, _) => + { + IEnumerable failedResults = ex.Result.Results.Where(r => !r.Succeeded).Select(result => result.Key); + IEnumerable> failedActions = indexActions.Where(action => action.Document.Keys.Any(key => failedResults.Contains(key))); + indexBatch = IndexDocumentsBatch.Create(failedActions.ToArray()); + }) + .ExecuteAsync(async ct => await indexClient.IndexDocumentsAsync(indexBatch, null, ct), context.CancellationToken); context.LogDebug($"Uploaded {start + indexActions.Length} documents to search index {indexName}"); start += 1000; diff --git a/src/Statiq.Web.Azure/Statiq.Web.Azure.csproj b/src/Statiq.Web.Azure/Statiq.Web.Azure.csproj index ac486ab8c..4db1e0bd5 100644 --- a/src/Statiq.Web.Azure/Statiq.Web.Azure.csproj +++ b/src/Statiq.Web.Azure/Statiq.Web.Azure.csproj @@ -4,8 +4,8 @@ Statiq Static StaticContent StaticSite Blog BlogEngine Azure AppService - - + + diff --git a/src/Statiq.Web.GitHub/Statiq.Web.GitHub.csproj b/src/Statiq.Web.GitHub/Statiq.Web.GitHub.csproj index 29adbb38c..325c361c5 100644 --- a/src/Statiq.Web.GitHub/Statiq.Web.GitHub.csproj +++ b/src/Statiq.Web.GitHub/Statiq.Web.GitHub.csproj @@ -18,8 +18,8 @@ - - + + \ No newline at end of file diff --git a/src/Statiq.Web.Hosting/Server.cs b/src/Statiq.Web.Hosting/Server.cs index 3442c4484..6b6350209 100644 --- a/src/Statiq.Web.Hosting/Server.cs +++ b/src/Statiq.Web.Hosting/Server.cs @@ -11,21 +11,23 @@ using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Statiq.Common; using Statiq.Web.Hosting.LiveReload; using Statiq.Web.Hosting.Middleware; +using Microsoft.AspNetCore.Hosting.Server; namespace Statiq.Web.Hosting { /// /// An HTTP server that can serve static files from a specified directory on disk. /// - public class Server : IWebHost + public class Server : IHost { private readonly IReadOnlyDictionary _contentTypes; private readonly IReadOnlyDictionary _customHeaders; - private readonly IWebHost _host; + private readonly IHost _host; private readonly LiveReloadServer _liveReloadServer; public bool Extensionless { get; } @@ -40,7 +42,8 @@ public class Server : IWebHost /// public string VirtualDirectory { get; } - public IFeatureCollection ServerFeatures => _host.ServerFeatures; + // IWebHost exposed ServerFeatures directly; IHost does not. Resolve the server and return its features if available. + public IFeatureCollection ServerFeatures => _host.Services.GetService()?.Features; public IServiceProvider Services => _host.Services; @@ -50,7 +53,7 @@ public class Server : IWebHost /// The local path to serve files from. /// The port the server will serve HTTP requests on. public Server(string localPath, int port = 5080) - : this(localPath, port, true, null, true, null) + : this(localPath, port, true, null, true, null, null, null) { } @@ -113,29 +116,33 @@ public Server( string currentDirectory = Directory.GetCurrentDirectory(); - _host = new WebHostBuilder() - .UseContentRoot(currentDirectory) - .UseWebRoot(Path.Combine(currentDirectory, "wwwroot")) - .ConfigureLogging(loggingBuilder => + _host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => { - if (loggerProviders is object) - { - foreach (ILoggerProvider loggerProvider in loggerProviders) + webHostBuilder + .UseContentRoot(currentDirectory) + .UseWebRoot(Path.Combine(currentDirectory, "wwwroot")) + .ConfigureLogging(loggingBuilder => { - if (loggerProvider is object) + if (loggerProviders != null) { - loggingBuilder.AddProvider( - new ChangeLevelLoggerProvider( - loggerProvider, - level => level == LogLevel.Information ? LogLevel.Debug : level)); + foreach (ILoggerProvider loggerProvider in loggerProviders) + { + if (loggerProvider != null) + { + loggingBuilder.AddProvider( + new ChangeLevelLoggerProvider( + loggerProvider, + level => level == LogLevel.Information ? LogLevel.Debug : level)); + } + } } - } - } + }) + .UseKestrel() + .ConfigureKestrel(x => x.ListenAnyIP(port)) + .ConfigureServices(ConfigureServices) + .Configure(ConfigureApp); }) - .UseKestrel() - .ConfigureKestrel(x => x.ListenAnyIP(port)) - .ConfigureServices(ConfigureServices) - .Configure(ConfigureApp) .Build(); } @@ -158,7 +165,7 @@ private void ConfigureServices(IServiceCollection services) options.ForwardedHeaders = ForwardedHeaders.All; // Only loopback proxies are allowed by default, clear that restriction - options.KnownNetworks.Clear(); + options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); }); } @@ -172,7 +179,7 @@ private void ConfigureApp(IApplicationBuilder app) IWebHostEnvironment host = app.ApplicationServices.GetService(); host.WebRootFileProvider = compositeFileProvider; - if (_liveReloadServer is object) + if (_liveReloadServer != null) { // Inject LiveReload script tags to HTML documents, needs to run first as it overrides output stream app.UseScriptInjection($"{VirtualDirectory ?? string.Empty}/livereload.js?host=localhost&port={Port}"); @@ -237,7 +244,7 @@ private void ConfigureApp(IApplicationBuilder app) public async Task TriggerReloadAsync() { - if (_liveReloadServer is object) + if (_liveReloadServer != null) { await _liveReloadServer.SendReloadMessageAsync(); } diff --git a/src/Statiq.Web.Hosting/Statiq.Web.Hosting.csproj b/src/Statiq.Web.Hosting/Statiq.Web.Hosting.csproj index 7b972be9c..f4a919f59 100644 --- a/src/Statiq.Web.Hosting/Statiq.Web.Hosting.csproj +++ b/src/Statiq.Web.Hosting/Statiq.Web.Hosting.csproj @@ -11,9 +11,7 @@ - - - + diff --git a/src/Statiq.Web.Netlify/Statiq.Web.Netlify.csproj b/src/Statiq.Web.Netlify/Statiq.Web.Netlify.csproj index 92f8b4294..8938ce4b6 100644 --- a/src/Statiq.Web.Netlify/Statiq.Web.Netlify.csproj +++ b/src/Statiq.Web.Netlify/Statiq.Web.Netlify.csproj @@ -6,7 +6,8 @@ - + + diff --git a/src/Statiq.Web/IExecutionContextXrefExtensions.cs b/src/Statiq.Web/IExecutionContextXrefExtensions.cs index 33e98c39b..30858e1e1 100644 --- a/src/Statiq.Web/IExecutionContextXrefExtensions.cs +++ b/src/Statiq.Web/IExecutionContextXrefExtensions.cs @@ -135,7 +135,7 @@ public static string GetXrefLink(this IExecutionContext context, string xref, bo .SelectMany(x => context.Outputs.FromPipeline(x).Select(y => (PipelineName: x, Document: y))) .Select(x => (x.Document.GetString(WebKeys.Xref), x)) .Where(x => x.Item1 is object) - .GroupBy(x => x.Item1, x => x.Item2, StringComparer.OrdinalIgnoreCase) + .GroupBy(x => x.Item1, x => x.x, StringComparer.OrdinalIgnoreCase) .ToDictionary(x => x.Key, x => (ICollection<(string, IDocument)>)x.ToArray(), StringComparer.OrdinalIgnoreCase); } } \ No newline at end of file diff --git a/src/Statiq.Web/Statiq.Web.csproj b/src/Statiq.Web/Statiq.Web.csproj index e9930a12b..55c4c35eb 100644 --- a/src/Statiq.Web/Statiq.Web.csproj +++ b/src/Statiq.Web/Statiq.Web.csproj @@ -11,7 +11,10 @@ - + + + + diff --git a/src/Statiq.Web/Templates/Templates.cs b/src/Statiq.Web/Templates/Templates.cs index 0524e9b19..adf5875fe 100644 --- a/src/Statiq.Web/Templates/Templates.cs +++ b/src/Statiq.Web/Templates/Templates.cs @@ -15,7 +15,9 @@ namespace Statiq.Web { +#pragma warning disable CA1710 // Rename to TemplatesDictionary or TemplatesCollection public class Templates : IReadOnlyList