├── test ├── Statik.Tests │ ├── Embedded │ │ ├── file1.txt │ │ ├── file2.txt │ │ └── nested │ │ │ ├── file3.txt │ │ │ ├── file4.txt │ │ │ └── nested2 │ │ │ └── file5.txt │ ├── WorkingDirectorySession.cs │ ├── Statik.Tests.csproj │ ├── HelperTests.cs │ ├── HostingTests.cs │ ├── WebBuilderTests.cs │ ├── ExporterTests.cs │ ├── PageDirectoryLoaderTests.cs │ └── EmbeddedFileProviderTests.cs ├── Directory.Build.props ├── Statik.Mvc.Tests │ ├── Statik.Mvc.Tests.csproj │ └── MvcTests.cs └── Statik.Files.Tests │ ├── Statik.Files.Tests.csproj │ ├── EmbeddedFileProviderHostTests.cs │ └── FileProviderTests.cs ├── examples ├── Statik.Examples.Pages │ ├── pages │ │ ├── index.md │ │ ├── nested-dir │ │ │ ├── index.md │ │ │ └── yet-another.md │ │ └── another-page.md │ ├── Views │ │ ├── _ViewStart.cshtml │ │ ├── _ViewImports.cshtml │ │ ├── Shared │ │ │ └── _Layout.cshtml │ │ └── Pages │ │ │ └── Index.cshtml │ ├── Statik.Examples.Pages.csproj │ ├── Models │ │ └── PageModel.cs │ ├── Controllers │ │ └── PagesController.cs │ └── Program.cs ├── Directory.Build.props ├── Statik.Examples.Simple │ ├── Statik.Examples.Simple.csproj │ └── Program.cs └── Statik.Examples.Mvc │ ├── Statik.Examples.Mvc.csproj │ ├── ExampleController.cs │ └── Program.cs ├── src ├── Directory.Build.props ├── Statik │ ├── Properties.cs │ ├── Hosting │ │ ├── IVirtualHost.cs │ │ ├── IWebHost.cs │ │ ├── IHostExporter.cs │ │ ├── IHostBuilder.cs │ │ ├── IHost.cs │ │ ├── IHostModule.cs │ │ └── Impl │ │ │ ├── Startup.cs │ │ │ ├── AppBaseAppendMessageHandler.cs │ │ │ ├── HostBuilder.cs │ │ │ └── HostExporter.cs │ ├── StatikDefaults.cs │ ├── IPageAccessor.cs │ ├── Web │ │ ├── IWebBuilderFactory.cs │ │ ├── Impl │ │ │ ├── WebBuilderFactory.cs │ │ │ └── WebBuilder.cs │ │ ├── IWebBuilder.cs │ │ ├── Page.cs │ │ └── IPageRegistry.cs │ ├── Pages │ │ ├── IPageDirectoryLoader.cs │ │ ├── PageDirectoryLoaderOptions.cs │ │ ├── PageDirectoryLoaderExtensions.cs │ │ ├── PageTreeItem.cs │ │ └── Impl │ │ │ └── PageDirectoryLoader.cs │ ├── Embedded │ │ ├── IAssemblyResourceResolver.cs │ │ ├── EmbeddedFile.cs │ │ ├── Impl │ │ │ └── AssemblyResourceResolver.cs │ │ ├── EmbeddedDirectoryInfo.cs │ │ ├── EmbeddedFileInfo.cs │ │ ├── EnumerableDirectoryContents.cs │ │ └── EmbeddedFileProvider.cs │ ├── HttpContextExtensions.cs │ ├── Statik.csproj │ ├── RedirectExtensions.cs │ ├── Statik.cs │ └── StatikHelpers.cs ├── Statik.Markdown │ ├── IMarkdownParser.cs │ ├── IMarkdownRenderer.cs │ ├── Statik.Markdown.csproj │ ├── MarkdownParseResult.cs │ └── Impl │ │ ├── MarkdownRenderer.cs │ │ └── MarkdownParser.cs ├── Statik.Files │ ├── Statik.Files.csproj │ ├── RegisterOptions.cs │ └── WebBuilderExtensions.cs └── Statik.Mvc │ ├── Statik.Mvc.csproj │ ├── FormRouteDataAttribute.cs │ └── WebBuilderExtensions.cs ├── .gitmodules ├── Statik.sln.DotSettings ├── LICENSE ├── README.md ├── .gitignore └── Statik.sln /test/Statik.Tests/Embedded/file1.txt: -------------------------------------------------------------------------------- 1 | file1content -------------------------------------------------------------------------------- /test/Statik.Tests/Embedded/file2.txt: -------------------------------------------------------------------------------- 1 | file2content -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/pages/index.md: -------------------------------------------------------------------------------- 1 | Test page -------------------------------------------------------------------------------- /test/Statik.Tests/Embedded/nested/file3.txt: -------------------------------------------------------------------------------- 1 | file3content -------------------------------------------------------------------------------- /test/Statik.Tests/Embedded/nested/file4.txt: -------------------------------------------------------------------------------- 1 | file4content -------------------------------------------------------------------------------- /test/Statik.Tests/Embedded/nested/nested2/file5.txt: -------------------------------------------------------------------------------- 1 | file5content -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/pages/nested-dir/index.md: -------------------------------------------------------------------------------- 1 | Nested index -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/pages/another-page.md: -------------------------------------------------------------------------------- 1 | Another test page -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/pages/nested-dir/yet-another.md: -------------------------------------------------------------------------------- 1 | Yet another page, nested -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/Views/_ViewStart.cshtml: -------------------------------------------------------------------------------- 1 | @{ 2 | Layout = "_Layout"; 3 | } 4 | -------------------------------------------------------------------------------- /src/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/Statik/Properties.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly:InternalsVisibleTo("Statik.Tests")] -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "build/scripts/Buildary"] 2 | path = build/scripts/Buildary 3 | url = https://github.com/pauldotknopf/dotnet-buildary.git 4 | -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/Views/_ViewImports.cshtml: -------------------------------------------------------------------------------- 1 | @using Statik.Examples.Pages.Models 2 | @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 3 | -------------------------------------------------------------------------------- /src/Statik/Hosting/IVirtualHost.cs: -------------------------------------------------------------------------------- 1 | namespace Statik.Hosting 2 | { 3 | public interface IVirtualHost : IHost 4 | { 5 | 6 | } 7 | } -------------------------------------------------------------------------------- /src/Statik/Hosting/IWebHost.cs: -------------------------------------------------------------------------------- 1 | namespace Statik.Hosting 2 | { 3 | public interface IWebHost : IHost 4 | { 5 | void Listen(); 6 | } 7 | } -------------------------------------------------------------------------------- /src/Statik/StatikDefaults.cs: -------------------------------------------------------------------------------- 1 | namespace Statik 2 | { 3 | public class StatikDefaults 4 | { 5 | public const int DefaultPort = 8000; 6 | } 7 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/Views/Shared/_Layout.cshtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | @RenderBody() 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Statik/IPageAccessor.cs: -------------------------------------------------------------------------------- 1 | using Statik.Web; 2 | 3 | namespace Statik 4 | { 5 | public interface IPageAccessor 6 | { 7 | Page Page { get; } 8 | } 9 | } -------------------------------------------------------------------------------- /test/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | false 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Statik/Web/IWebBuilderFactory.cs: -------------------------------------------------------------------------------- 1 | using Statik.Web; 2 | 3 | namespace Statik.Web 4 | { 5 | public interface IWebBuilderFactory 6 | { 7 | IWebBuilder CreateWebBuilder(); 8 | } 9 | } -------------------------------------------------------------------------------- /src/Statik.Markdown/IMarkdownParser.cs: -------------------------------------------------------------------------------- 1 | namespace Statik.Markdown 2 | { 3 | public interface IMarkdownParser 4 | { 5 | MarkdownParseResult Parse(string markdown) where T : class; 6 | } 7 | } -------------------------------------------------------------------------------- /src/Statik.Markdown/IMarkdownRenderer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Statik.Markdown 4 | { 5 | public interface IMarkdownRenderer 6 | { 7 | string Render(string markdown, Func linkRewriter = null); 8 | } 9 | } -------------------------------------------------------------------------------- /src/Statik.Files/Statik.Files.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0;net8.0 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/Statik/Pages/IPageDirectoryLoader.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.FileProviders; 2 | 3 | namespace Statik.Pages 4 | { 5 | public interface IPageDirectoryLoader 6 | { 7 | PageTreeItem LoadFiles(IFileProvider fileProvider, PageDirectoryLoaderOptions options); 8 | } 9 | } -------------------------------------------------------------------------------- /src/Statik/Embedded/IAssemblyResourceResolver.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace Statik.Embedded 5 | { 6 | public interface IAssemblyResourceResolver 7 | { 8 | string[] GetManifestResourceNames(); 9 | 10 | Stream GetManifestResourceStream(string name); 11 | } 12 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Simple/Statik.Examples.Simple.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | net6.0 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /examples/Statik.Examples.Mvc/Statik.Examples.Mvc.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | net6.0 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/Statik/Hosting/IHostExporter.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Statik.Hosting 4 | { 5 | public interface IHostExporter 6 | { 7 | Task Export(IHost host, string destinationDirectory); 8 | 9 | Task ExportParallel(IHost host, string destinationDirectory, int? maxThreads = null); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Statik.Markdown/Statik.Markdown.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0;net8.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/Statik/Hosting/IHostBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | 3 | namespace Statik.Hosting 4 | { 5 | public interface IHostBuilder 6 | { 7 | IWebHost BuildWebHost(int port, PathString appBase, params IHostModule[] modules); 8 | 9 | IVirtualHost BuildVirtualHost(PathString appBase, params IHostModule[] modules); 10 | } 11 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Mvc/ExampleController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace Statik.Examples.Mvc 5 | { 6 | public class ExampleController : Controller 7 | { 8 | public ActionResult Index() 9 | { 10 | return Content($"The time is {DateTime.Now.ToLongTimeString()}"); 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/Statik/Embedded/EmbeddedFile.cs: -------------------------------------------------------------------------------- 1 | namespace Statik.Embedded 2 | { 3 | internal class EmbeddedFile 4 | { 5 | public EmbeddedFile(string path, string resourceName) 6 | { 7 | Path = path; 8 | ResourceName = resourceName; 9 | } 10 | 11 | public string Path { get; } 12 | 13 | public string ResourceName { get; } 14 | } 15 | } -------------------------------------------------------------------------------- /src/Statik/Hosting/IHost.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Net.Http; 4 | using Statik.Web; 5 | 6 | namespace Statik.Hosting 7 | { 8 | public interface IHost : IDisposable 9 | { 10 | IReadOnlyCollection Pages { get; } 11 | 12 | HttpClient CreateClient(); 13 | 14 | IServiceProvider ServiceProvider { get; } 15 | } 16 | } -------------------------------------------------------------------------------- /Statik.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | True -------------------------------------------------------------------------------- /src/Statik.Markdown/MarkdownParseResult.cs: -------------------------------------------------------------------------------- 1 | namespace Statik.Markdown 2 | { 3 | public class MarkdownParseResult 4 | { 5 | public MarkdownParseResult(T yaml, string markdown) 6 | { 7 | Yaml = yaml; 8 | Markdown = markdown; 9 | } 10 | 11 | public T Yaml { get; } 12 | 13 | public string Markdown { get; } 14 | } 15 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/Views/Pages/Index.cshtml: -------------------------------------------------------------------------------- 1 | @model PageModel 2 | 3 | @Html.Raw(Model.Content) 4 | 5 | @if (Model.TreeItem.Children.Count > 0) 6 | { 7 | 17 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/Statik.Examples.Pages.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | Exe 4 | net6.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Statik.Files/RegisterOptions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.FileProviders; 2 | using Microsoft.Extensions.FileSystemGlobbing; 3 | 4 | namespace Statik.Files 5 | { 6 | public class RegisterOptions 7 | { 8 | public BuildStateDelegate State { get; set; } 9 | 10 | public Matcher Matcher { get; set; } 11 | 12 | public delegate object BuildStateDelegate(string requestPrefix, string requestFullPath, string filePath, IFileInfo file, IFileProvider fileProvider); 13 | } 14 | } -------------------------------------------------------------------------------- /src/Statik/HttpContextExtensions.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Http; 2 | using Statik.Web; 3 | 4 | namespace Statik 5 | { 6 | public static class HttpContextExtensions 7 | { 8 | public static Page GetCurrentPage(this HttpContext httpContext) 9 | { 10 | if (!httpContext.Items.TryGetValue("_statikPageAccessor", out var pageAccessor)) return null; 11 | if (pageAccessor is IPageAccessor accessor) return accessor.Page; 12 | 13 | return null; 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/Statik/Web/Impl/WebBuilderFactory.cs: -------------------------------------------------------------------------------- 1 | using Statik.Hosting; 2 | 3 | namespace Statik.Web.Impl 4 | { 5 | public class WebBuilderFactory : IWebBuilderFactory 6 | { 7 | readonly IHostBuilder _hostBuilder; 8 | 9 | public WebBuilderFactory(IHostBuilder hostBuilder) 10 | { 11 | _hostBuilder = hostBuilder; 12 | } 13 | 14 | public IWebBuilder CreateWebBuilder() 15 | { 16 | return new WebBuilder(_hostBuilder); 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /src/Statik/Hosting/IHostModule.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Statik.Web; 6 | 7 | namespace Statik.Hosting 8 | { 9 | public interface IHostModule 10 | { 11 | void Configure(IApplicationBuilder app, IWebHostEnvironment env); 12 | 13 | void ConfigureServices(IServiceCollection services); 14 | 15 | IReadOnlyCollection Pages { get; } 16 | } 17 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/Models/PageModel.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.FileProviders; 2 | using Statik.Pages; 3 | 4 | namespace Statik.Examples.Pages.Models 5 | { 6 | public class PageModel 7 | { 8 | public PageModel(PageTreeItem treeItem, string content) 9 | { 10 | TreeItem = treeItem; 11 | Content = content; 12 | } 13 | 14 | public PageTreeItem TreeItem { get; } 15 | 16 | public string Content { get; } 17 | } 18 | } -------------------------------------------------------------------------------- /src/Statik.Mvc/Statik.Mvc.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0;net8.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/Statik/Web/IWebBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Http; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Statik.Hosting; 6 | 7 | namespace Statik.Web 8 | { 9 | public interface IWebBuilder 10 | { 11 | void Register(string path, Func action, object state = null, bool extractExactPath = false); 12 | 13 | void RegisterServices(Action action); 14 | 15 | IWebHost BuildWebHost(string appBase = null, int port = StatikDefaults.DefaultPort); 16 | 17 | IVirtualHost BuildVirtualHost(string appBase = null); 18 | } 19 | } -------------------------------------------------------------------------------- /src/Statik/Embedded/Impl/AssemblyResourceResolver.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Reflection; 3 | 4 | namespace Statik.Embedded.Impl 5 | { 6 | public class AssemblyResourceResolver : IAssemblyResourceResolver 7 | { 8 | readonly Assembly _assembly; 9 | 10 | public AssemblyResourceResolver(Assembly assembly) 11 | { 12 | _assembly = assembly; 13 | } 14 | 15 | public string[] GetManifestResourceNames() 16 | { 17 | return _assembly.GetManifestResourceNames(); 18 | } 19 | 20 | public Stream GetManifestResourceStream(string name) 21 | { 22 | return _assembly.GetManifestResourceStream(name); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /test/Statik.Mvc.Tests/Statik.Mvc.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0;net8.0 4 | 5 | 6 | 7 | 8 | 9 | all 10 | runtime; build; native; contentfiles; analyzers; buildtransitive 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Statik/Web/Page.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace Statik.Web 6 | { 7 | public class Page 8 | { 9 | internal Page(string path, 10 | Func action, 11 | object state, 12 | bool extractExactPath) 13 | { 14 | Path = path; 15 | Action = action; 16 | State = state; 17 | ExtractExactPath = extractExactPath; 18 | } 19 | 20 | public string Path { get; } 21 | 22 | internal Func Action { get; } 23 | 24 | public object State { get; } 25 | 26 | public bool ExtractExactPath { get; } 27 | } 28 | } -------------------------------------------------------------------------------- /src/Statik/Pages/PageDirectoryLoaderOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.FileProviders; 3 | using Microsoft.Extensions.FileSystemGlobbing; 4 | 5 | namespace Statik.Pages 6 | { 7 | public class PageDirectoryLoaderOptions 8 | { 9 | public Matcher IndexPageMatcher { get; set; } 10 | 11 | public Matcher NormalPageMatcher { get; set; } 12 | 13 | public Func PageSlug { get; set; } 14 | 15 | internal PageDirectoryLoaderOptions Clone() 16 | { 17 | return new PageDirectoryLoaderOptions 18 | { 19 | IndexPageMatcher = IndexPageMatcher, 20 | NormalPageMatcher = NormalPageMatcher, 21 | PageSlug = PageSlug 22 | }; 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /src/Statik/Statik.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0;net8.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/Statik.Files.Tests/Statik.Files.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0;net8.0 4 | 5 | 6 | 7 | 8 | 9 | all 10 | runtime; build; native; contentfiles; analyzers; buildtransitive 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /test/Statik.Tests/WorkingDirectorySession.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace Statik.Tests 5 | { 6 | public class WorkingDirectorySession : IDisposable 7 | { 8 | public WorkingDirectorySession() 9 | { 10 | var path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); 11 | while (System.IO.Directory.Exists(path)) 12 | path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Path.GetFileNameWithoutExtension(Path.GetRandomFileName())); 13 | System.IO.Directory.CreateDirectory(path); 14 | Directory = path; 15 | } 16 | 17 | public string Directory { get; } 18 | 19 | public void Dispose() 20 | { 21 | System.IO.Directory.Delete(Directory, true); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /src/Statik/Embedded/EmbeddedDirectoryInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Microsoft.Extensions.FileProviders; 4 | 5 | namespace Statik.Embedded 6 | { 7 | internal class EmbeddedDirectoryInfo : IFileInfo 8 | { 9 | private readonly string _path; 10 | 11 | public EmbeddedDirectoryInfo(string path) 12 | { 13 | _path = path; 14 | } 15 | 16 | public bool Exists => true; 17 | 18 | public long Length => -1; 19 | 20 | public string PhysicalPath => null; 21 | 22 | public string Name => Path.GetFileName(_path); 23 | 24 | public DateTimeOffset LastModified => DateTimeOffset.Now; 25 | 26 | public bool IsDirectory => true; 27 | 28 | public Stream CreateReadStream() 29 | { 30 | throw new InvalidOperationException("Cannot create a stream for a directory."); 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /src/Statik/Web/IPageRegistry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | namespace Statik.Web 6 | { 7 | public interface IPageRegistry 8 | { 9 | IReadOnlyCollection GetPaths(); 10 | 11 | Page GetPage(string path); 12 | 13 | Page FindOne(PageMatchDelegate match); 14 | 15 | Task FindOne(PageMatchDelegateAsync match); 16 | 17 | List FindMany(PageMatchDelegate match); 18 | 19 | Task> FindMany(PageMatchDelegateAsync match); 20 | 21 | void ForEach(PageActionDelegate action); 22 | 23 | Task ForEach(PageActionDelegateAsync action); 24 | } 25 | 26 | public delegate bool PageMatchDelegate(Page page); 27 | 28 | public delegate Task PageMatchDelegateAsync(Page page); 29 | 30 | public delegate void PageActionDelegate(Page page); 31 | 32 | public delegate Task PageActionDelegateAsync(Page page); 33 | } -------------------------------------------------------------------------------- /src/Statik.Mvc/FormRouteDataAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Mvc.ModelBinding; 4 | 5 | namespace Statik.Mvc 6 | { 7 | public class FromRouteDataAttribute : Attribute, IBinderTypeProviderMetadata, IBindingSourceMetadata 8 | { 9 | public FromRouteDataAttribute() 10 | { 11 | BinderType = typeof(Binder); 12 | } 13 | 14 | public BindingSource BindingSource => BindingSource.Custom; 15 | 16 | public Type BinderType { get; } 17 | 18 | private class Binder : IModelBinder 19 | { 20 | public Task BindModelAsync(ModelBindingContext bindingContext) 21 | { 22 | var routeData = bindingContext.ActionContext.RouteData.Values; 23 | bindingContext.Result = ModelBindingResult.Success(routeData[bindingContext.FieldName]); 24 | return Task.CompletedTask; 25 | } 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /test/Statik.Tests/Statik.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net6.0;net8.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | all 12 | runtime; build; native; contentfiles; analyzers; buildtransitive 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/Statik/Pages/PageDirectoryLoaderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.Extensions.FileProviders; 3 | using Microsoft.Extensions.FileSystemGlobbing; 4 | 5 | namespace Statik.Pages 6 | { 7 | public static class PageDirectoryLoaderExtensions 8 | { 9 | public static PageTreeItem LoadFiles(this IPageDirectoryLoader pageDirectoryLoader, 10 | IFileProvider fileProvider, 11 | string pageGlob, 12 | string indexGlob) 13 | { 14 | var pageMatcher = new Matcher(StringComparison.OrdinalIgnoreCase); 15 | pageMatcher.AddInclude(pageGlob); 16 | var indexMatcher = new Matcher(StringComparison.OrdinalIgnoreCase); 17 | indexMatcher.AddInclude(indexGlob); 18 | return pageDirectoryLoader.LoadFiles(fileProvider, new PageDirectoryLoaderOptions 19 | { 20 | NormalPageMatcher = pageMatcher, 21 | IndexPageMatcher = indexMatcher 22 | }); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Paul Knopf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/Controllers/PagesController.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Mvc; 5 | using Microsoft.Extensions.FileProviders; 6 | using Statik.Examples.Pages.Models; 7 | using Statik.Pages; 8 | 9 | namespace Statik.Examples.Pages.Controllers 10 | { 11 | public class PagesController : Controller 12 | { 13 | public async Task Index() 14 | { 15 | var treeItem = RouteData.Values["treeItem"] as PageTreeItem; 16 | if(treeItem == null) throw new InvalidOperationException(); 17 | 18 | string content = null; 19 | if (!treeItem.Data.IsDirectory) 20 | { 21 | using (var stream = treeItem.Data.CreateReadStream()) 22 | using (var streamReader = new StreamReader(stream)) 23 | content = await streamReader.ReadToEndAsync(); 24 | content = Markdig.Markdown.ToHtml(content); 25 | } 26 | 27 | var model = new PageModel(treeItem, content); 28 | 29 | return View(model); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/Statik/RedirectExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Http; 3 | using Statik.Web; 4 | 5 | namespace Statik 6 | { 7 | public static class RedirectExtensions 8 | { 9 | public static void Redirect(this IWebBuilder webBuilder, string from, string to, object state = null, bool extractExactPath = false) 10 | { 11 | webBuilder.Register(from, async (context) => 12 | { 13 | context.Response.ContentType = "text/html"; 14 | await context.Response.WriteAsync($@" 15 | 16 | 17 | Redirecting… 18 | 19 | 20 | 21 | 22 |

Redirecting…

23 | Click here if you are not redirected. 24 | "); 25 | 26 | }, 27 | state, 28 | extractExactPath); 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/Statik/Hosting/Impl/Startup.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Hosting; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace Statik.Hosting.Impl 7 | { 8 | internal class Startup 9 | { 10 | List _modules; 11 | 12 | public Startup(List modules) 13 | { 14 | _modules = modules; 15 | } 16 | 17 | // This method gets called by the runtime. Use this method to add services to the container. 18 | public void ConfigureServices(IServiceCollection services) 19 | { 20 | foreach(var module in _modules) 21 | { 22 | module.ConfigureServices(services); 23 | } 24 | } 25 | 26 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. 27 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 28 | { 29 | app.UseDeveloperExceptionPage(); 30 | foreach(var module in _modules) 31 | { 32 | module.Configure(app, env); 33 | } 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Simple/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Http; 4 | 5 | namespace Statik.Examples.Simple 6 | { 7 | class Program 8 | { 9 | static void Main(string[] args) 10 | { 11 | var webBuilder = Statik.GetWebBuilder(); 12 | 13 | webBuilder.Register("/hello", async context => 14 | { 15 | await context.Response.WriteAsync($"The time is {DateTime.Now.ToLongTimeString()}"); 16 | }); 17 | 18 | using (var host = webBuilder.BuildWebHost()) 19 | { 20 | host.Listen(); 21 | Console.WriteLine($"Listening on port {StatikDefaults.DefaultPort}..."); 22 | Console.WriteLine($"Try visiting http://localhost:{StatikDefaults.DefaultPort}/hello"); 23 | Console.WriteLine("Press enter to exit..."); 24 | Console.ReadLine(); 25 | 26 | // NOTE: You can export this host to a directory. 27 | // Useful for GitHub pages, etc. 28 | // await Statik.ExportHost(host, "./somewhere"); 29 | } 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/Statik.Markdown/Impl/MarkdownRenderer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Markdig; 4 | using Markdig.Renderers; 5 | 6 | namespace Statik.Markdown.Impl 7 | { 8 | public class MarkdownRenderer : IMarkdownRenderer 9 | { 10 | private readonly MarkdownPipeline _pipeline; 11 | 12 | public MarkdownRenderer(MarkdownPipeline pipeline = null) 13 | { 14 | if (pipeline == null) 15 | { 16 | pipeline = new MarkdownPipelineBuilder() 17 | .UseAdvancedExtensions() 18 | .Build(); 19 | } 20 | 21 | _pipeline = pipeline; 22 | } 23 | 24 | public string Render(string markdown, Func linkRewriter = null) 25 | { 26 | var writer = new StringWriter(); 27 | var renderer = new HtmlRenderer(writer); 28 | renderer.LinkRewriter = linkRewriter; 29 | 30 | _pipeline.Setup(renderer); 31 | 32 | var document = Markdig.Markdown.Parse(markdown, _pipeline); 33 | renderer.Render(document); 34 | writer.Flush(); 35 | 36 | return writer.ToString(); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Mvc/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Statik.Mvc; 4 | 5 | namespace Statik.Examples.Mvc 6 | { 7 | class Program 8 | { 9 | static void Main(string[] args) 10 | { 11 | var webBuilder = Statik.GetWebBuilder(); 12 | 13 | webBuilder.RegisterMvcServices(); 14 | webBuilder.RegisterMvc("/hello", new 15 | { 16 | controller = "Example", 17 | action = "Index", 18 | other = 1 /* You can pass other values to the action, traditional MVC */ 19 | }); 20 | 21 | using (var host = webBuilder.BuildWebHost()) 22 | { 23 | host.Listen(); 24 | Console.WriteLine($"Listening on port {StatikDefaults.DefaultPort}..."); 25 | Console.WriteLine($"Try visiting http://localhost:{StatikDefaults.DefaultPort}/hello"); 26 | Console.WriteLine("Press enter to exit..."); 27 | Console.ReadLine(); 28 | 29 | // NOTE: You can export this host to a directory. 30 | // Useful for GitHub pages, etc. 31 | // await Statik.ExportHost(host, "./somewhere"); 32 | } 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /test/Statik.Tests/HelperTests.cs: -------------------------------------------------------------------------------- 1 | using FluentAssertions; 2 | using Xunit; 3 | 4 | namespace Statik.Tests 5 | { 6 | public class HelperTests 7 | { 8 | [Theory] 9 | [InlineData("/test", "test2", "/test/test2")] 10 | [InlineData("/test", "/test2", "/test2")] 11 | [InlineData("/test", "../test2", "/test2")] 12 | [InlineData("/test", "../test2/../test3", "/test3")] 13 | [InlineData("/test?query=ignored", "test2", "/test/test2")] 14 | [InlineData("/test?query=ignored", "test2?query=preserved", "/test/test2?query=preserved")] 15 | [InlineData("/test?#ignored", "test2", "/test/test2")] 16 | [InlineData("/test?#ignored", "test2#preserved", "/test/test2#preserved")] 17 | public void Can_resolve_relative_path(string input, string relative, string expected) 18 | { 19 | StatikHelpers.ResolvePathPart(input, relative).Should().Be(expected); 20 | } 21 | 22 | [Theory] 23 | [InlineData("test", "test")] 24 | [InlineData("--test--", "test")] 25 | [InlineData(".test.", "test")] 26 | [InlineData("test----test", "test-test")] 27 | public void Can_convert_string_to_slug(string input, string expected) 28 | { 29 | StatikHelpers.ConvertStringToSlug(input).Should().Be(expected); 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /src/Statik/Pages/PageTreeItem.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | namespace Statik.Pages 6 | { 7 | public class PageTreeItem 8 | { 9 | public PageTreeItem() 10 | { 11 | Children = new List>(); 12 | } 13 | 14 | public PageTreeItem(T data, string path, string filePath) 15 | : this() 16 | { 17 | Data = data; 18 | Path = path; 19 | FilePath = filePath; 20 | } 21 | 22 | public T Data { get; set; } 23 | 24 | public string Path { get; set; } 25 | 26 | public string FilePath { get; set; } 27 | 28 | public List> Children { get; set; } 29 | 30 | public PageTreeItem Parent { get; set; } 31 | 32 | public async Task> Convert(Func, Task> convert) 33 | { 34 | var newTree = new PageTreeItem 35 | { 36 | Path = Path, 37 | FilePath = FilePath, 38 | Data = await convert(this) 39 | }; 40 | 41 | foreach (var child in Children) 42 | { 43 | newTree.Children.Add(await child.Convert(convert)); 44 | } 45 | 46 | return newTree; 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /test/Statik.Mvc.Tests/MvcTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Mvc; 4 | using Statik.Hosting.Impl; 5 | using Statik.Web; 6 | using Statik.Web.Impl; 7 | using Xunit; 8 | 9 | namespace Statik.Mvc.Tests 10 | { 11 | public class MvcTests 12 | { 13 | private IWebBuilder _webBuilder; 14 | 15 | public MvcTests() 16 | { 17 | _webBuilder = new WebBuilder(new HostBuilder()); 18 | _webBuilder.RegisterMvcServices(); 19 | } 20 | 21 | [Fact] 22 | public async Task Can_call_mvc_action() 23 | { 24 | _webBuilder.RegisterMvc("/somewhere", new 25 | { 26 | controller = "Test", 27 | action = "Index" 28 | }); 29 | using(var host = _webBuilder.BuildVirtualHost()) 30 | { 31 | using(var client = host.CreateClient()) 32 | { 33 | var responseMessage = await client.GetAsync("/somewhere"); 34 | responseMessage.EnsureSuccessStatusCode(); 35 | var response = await responseMessage.Content.ReadAsStringAsync(); 36 | 37 | Assert.Equal("test", response); 38 | } 39 | } 40 | } 41 | } 42 | 43 | public class TestController : Controller 44 | { 45 | public ActionResult Index() 46 | { 47 | return Content("test", "application/text"); 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /examples/Statik.Examples.Pages/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Microsoft.Extensions.FileProviders; 4 | using Statik.Mvc; 5 | using Statik.Pages; 6 | 7 | namespace Statik.Examples.Pages 8 | { 9 | class Program 10 | { 11 | static void Main(string[] args) 12 | { 13 | var webBuilder = Statik.GetWebBuilder(); 14 | webBuilder.RegisterMvcServices(); 15 | 16 | var pagesDirectory = Path.Combine(Directory.GetCurrentDirectory(), "pages"); 17 | 18 | var rootTreeItem = Statik.GetPageDirectoryLoader().LoadFiles(new PhysicalFileProvider(pagesDirectory), 19 | "*.md", 20 | "index.md"); 21 | 22 | void RegisterTreeItem(PageTreeItem treeItem) 23 | { 24 | if (!treeItem.Data.IsDirectory) 25 | { 26 | webBuilder.RegisterMvc(treeItem.Path, 27 | new 28 | { 29 | controller = "Pages", 30 | action = "Index", 31 | treeItem 32 | }); 33 | } 34 | 35 | foreach (var child in treeItem.Children) 36 | { 37 | RegisterTreeItem(child); 38 | } 39 | } 40 | 41 | RegisterTreeItem(rootTreeItem); 42 | 43 | using (var host = webBuilder.BuildWebHost()) 44 | { 45 | host.Listen(); 46 | Console.WriteLine($"Listening on port {StatikDefaults.DefaultPort}..."); 47 | Console.WriteLine($"Try visiting http://localhost:{StatikDefaults.DefaultPort}/hello"); 48 | Console.WriteLine("Press enter to exit..."); 49 | Console.ReadLine(); 50 | 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Statik/Embedded/EmbeddedFileInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reflection; 4 | using Microsoft.Extensions.FileProviders; 5 | 6 | namespace Statik.Embedded 7 | { 8 | internal class EmbeddedFileInfo : IFileInfo 9 | { 10 | readonly EmbeddedFile _file; 11 | readonly IAssemblyResourceResolver _assembly; 12 | 13 | public EmbeddedFileInfo(EmbeddedFile file, IAssemblyResourceResolver assembly) 14 | { 15 | _file = file; 16 | _assembly = assembly; 17 | } 18 | 19 | public bool Exists => true; 20 | 21 | public long Length 22 | { 23 | get 24 | { 25 | EnsureExists(); 26 | 27 | using(var stream = _assembly.GetManifestResourceStream(_file.ResourceName)) 28 | // ReSharper disable PossibleNullReferenceException 29 | return stream.Length; 30 | // ReSharper restore PossibleNullReferenceException 31 | } 32 | } 33 | 34 | public string PhysicalPath => null; 35 | 36 | public string Name 37 | { 38 | get 39 | { 40 | EnsureExists(); 41 | 42 | return Path.GetFileName(_file.Path); 43 | } 44 | } 45 | 46 | public DateTimeOffset LastModified 47 | { 48 | get 49 | { 50 | EnsureExists(); 51 | 52 | // TODO: 53 | return DateTime.Now; 54 | } 55 | } 56 | 57 | public bool IsDirectory => false; 58 | 59 | public Stream CreateReadStream() 60 | { 61 | EnsureExists(); 62 | 63 | return _assembly.GetManifestResourceStream(_file.ResourceName); 64 | } 65 | 66 | private void EnsureExists() 67 | { 68 | if(!Exists) throw new InvalidOperationException("File doesn't exist."); 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /src/Statik/Statik.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Statik.Hosting; 5 | using Statik.Hosting.Impl; 6 | using Statik.Pages; 7 | using Statik.Pages.Impl; 8 | using Statik.Web; 9 | using Statik.Web.Impl; 10 | 11 | namespace Statik 12 | { 13 | public static class Statik 14 | { 15 | private static readonly object Lock = new object(); 16 | private static IServiceProvider _serviceProvider; 17 | 18 | public static IWebBuilder GetWebBuilder() 19 | { 20 | EnsureServiceProvider(); 21 | return _serviceProvider.GetRequiredService().CreateWebBuilder(); 22 | } 23 | 24 | public static IPageDirectoryLoader GetPageDirectoryLoader() 25 | { 26 | EnsureServiceProvider(); 27 | return _serviceProvider.GetRequiredService(); 28 | } 29 | 30 | public static Task ExportHost(IHost host, string directory) 31 | { 32 | EnsureServiceProvider(); 33 | return _serviceProvider.GetRequiredService().Export(host, directory); 34 | } 35 | 36 | public static void RegisterServices(IServiceCollection services) 37 | { 38 | services.AddSingleton(); 39 | services.AddSingleton(); 40 | services.AddSingleton(); 41 | services.AddSingleton(); 42 | } 43 | 44 | private static void EnsureServiceProvider() 45 | { 46 | if (_serviceProvider != null) return; 47 | lock (Lock) 48 | { 49 | if (_serviceProvider != null) return; 50 | 51 | var services = new ServiceCollection(); 52 | 53 | RegisterServices(services); 54 | 55 | _serviceProvider = services.BuildServiceProvider(); 56 | } 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /src/Statik/Hosting/Impl/AppBaseAppendMessageHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Net.Http; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.Http; 8 | 9 | namespace Statik.Hosting.Impl 10 | { 11 | public class AppBaseAppendMessageHandler : HttpMessageHandler 12 | { 13 | private readonly HttpClient _innerHttpClient; 14 | private readonly PathString _appBase; 15 | 16 | public AppBaseAppendMessageHandler(HttpClient innerHttpClient, PathString appBase) 17 | { 18 | _innerHttpClient = innerHttpClient; 19 | _appBase = appBase; 20 | } 21 | 22 | protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) 23 | { 24 | var copy = new HttpRequestMessage(request.Method, request.RequestUri); 25 | 26 | copy.Content = request.Content; 27 | 28 | foreach (var v in request.Headers) 29 | { 30 | copy.Headers.TryAddWithoutValidation(v.Key, v.Value); 31 | } 32 | 33 | foreach (var v in request.Options) 34 | { 35 | copy.Options.TryAdd(v.Key, v.Value); 36 | } 37 | 38 | copy.Version = request.Version; 39 | 40 | if (!_appBase.HasValue) return _innerHttpClient.SendAsync(copy, cancellationToken); 41 | 42 | var pathString = _appBase; 43 | pathString = pathString.Add(copy.RequestUri.PathAndQuery); 44 | 45 | copy.RequestUri = new Uri(copy.RequestUri.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped) + pathString); 46 | 47 | 48 | return _innerHttpClient.SendAsync(copy, cancellationToken); 49 | } 50 | 51 | protected override void Dispose(bool disposing) 52 | { 53 | base.Dispose(disposing); 54 | 55 | if (disposing) 56 | { 57 | _innerHttpClient.Dispose(); 58 | } 59 | } 60 | } 61 | } -------------------------------------------------------------------------------- /src/Statik.Markdown/Impl/MarkdownParser.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Text.RegularExpressions; 6 | using Markdig; 7 | using Markdig.Syntax; 8 | using YamlDotNet.Serialization; 9 | 10 | namespace Statik.Markdown.Impl 11 | { 12 | public class MarkdownParser : IMarkdownParser 13 | { 14 | public MarkdownParseResult Parse(string markdown) where T : class 15 | { 16 | if (string.IsNullOrEmpty(markdown)) 17 | { 18 | return new MarkdownParseResult(null, null); 19 | } 20 | 21 | markdown = Regex.Replace(markdown, @"(\r\n)|(\n\r)|(\n\r)|(\r)", Environment.NewLine); 22 | 23 | var builder = new MarkdownPipelineBuilder(); 24 | builder.Extensions.Add(new Markdig.Extensions.Yaml.YamlFrontMatterExtension()); 25 | var pipeline = builder.Build(); 26 | var document = Markdig.Markdown.Parse(markdown, pipeline); 27 | var yamlBlocks = document.Descendants() 28 | .ToList(); 29 | 30 | if(yamlBlocks.Count == 0) 31 | { 32 | return new MarkdownParseResult(null, markdown); 33 | } 34 | 35 | if(yamlBlocks.Count > 1) 36 | { 37 | throw new InvalidOperationException(); 38 | } 39 | 40 | var yamlBlock = yamlBlocks.First(); 41 | 42 | var yamlBlockIterator = yamlBlock.Lines.ToCharIterator(); 43 | var yamlString = new StringBuilder(); 44 | while (yamlBlockIterator.CurrentChar != '\0') 45 | { 46 | yamlString.Append(yamlBlockIterator.CurrentChar); 47 | yamlBlockIterator.NextChar(); 48 | } 49 | 50 | var yamlDeserializer = new DeserializerBuilder().Build(); 51 | var yamlObject = yamlDeserializer.Deserialize(new StringReader(yamlString.ToString())); 52 | 53 | markdown = markdown.Substring(yamlBlock.Span.End + 1); 54 | if(markdown.StartsWith(Environment.NewLine)) 55 | markdown = markdown.Substring(Environment.NewLine.Length); 56 | 57 | return new MarkdownParseResult(yamlObject, markdown); 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/Statik/Embedded/EnumerableDirectoryContents.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reflection; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.FileProviders; 8 | 9 | namespace Statik.Embedded 10 | { 11 | internal class EnumerableDirectoryContents : IDirectoryContents 12 | { 13 | readonly List _entries; 14 | readonly string _subPath; 15 | readonly IAssemblyResourceResolver _assembly; 16 | List _matched = null; 17 | 18 | public EnumerableDirectoryContents(List entries, string subPath, IAssemblyResourceResolver assembly) 19 | { 20 | _entries = entries; 21 | _subPath = subPath; 22 | _assembly = assembly; 23 | } 24 | 25 | public bool Exists 26 | { 27 | get 28 | { 29 | EnsureMatched(); 30 | return _matched.Count > 0; 31 | } 32 | } 33 | 34 | public IEnumerator GetEnumerator() 35 | { 36 | EnsureMatched(); 37 | return _matched.GetEnumerator(); 38 | } 39 | 40 | IEnumerator IEnumerable.GetEnumerator() 41 | { 42 | EnsureMatched(); 43 | return _entries.GetEnumerator(); 44 | } 45 | 46 | private void EnsureMatched() 47 | { 48 | if (_matched == null) 49 | { 50 | var matched = new List(); 51 | 52 | var directories = new List(); 53 | foreach (var entry in _entries.Where(x => x.Path.StartsWith(_subPath))) 54 | { 55 | var remaining = entry.Path.Substring(_subPath.Length).TrimStart('/'); 56 | if (remaining.IndexOf("/", StringComparison.Ordinal) >= 0) 57 | { 58 | // This is a directory 59 | var directory = $"/{remaining.Substring(0, remaining.IndexOf("/", StringComparison.OrdinalIgnoreCase))}"; 60 | if(!directories.Contains(directory)) 61 | directories.Add(directory); 62 | } 63 | else 64 | { 65 | matched.Add(new EmbeddedFileInfo(entry, _assembly)); 66 | } 67 | } 68 | 69 | foreach (var directory in directories) 70 | { 71 | matched.Add(new EmbeddedDirectoryInfo(directory)); 72 | } 73 | 74 | _matched = matched; 75 | } 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Statik 2 | 3 | A **dead simple** static site generator, with **no features**, for .NET. 4 | 5 | [![Statik](https://img.shields.io/nuget/v/Statik.svg?style=flat-square&label=Statik)](http://www.nuget.org/packages/Statik/) 6 | [![Statik.Mvc](https://img.shields.io/nuget/v/Statik.Mvc.svg?style=flat-square&label=Statik.Mvc)](http://www.nuget.org/packages/Statik.Mvc/) 7 | [![Statik.Files](https://img.shields.io/nuget/v/Statik.Mvc.svg?style=flat-square&label=Statik.Files)](http://www.nuget.org/packages/Statik.Files/) 8 | 9 | ## No features? 10 | 11 | This is a simple tool/library. There are no opinions or abstractions, aside from the abstraction needed to host and export content. There is nothing preventing you, the developer, from doing what you want with your project. Parse and render markdown files in a directory for a blog? Build a user manual? What ever you want, you can do. 12 | 13 | ## Why? 14 | 15 | I've spent many hours fighting static site generators (Hugo, Gatsby, Jekyll, etc). The time taken to learn the plumbing of a framework to do very simple tasks often doesn't compare to the time it would take me to do the same task myself if there were no opinionated abstractions bogging me down. In the end, I always wind up biting the bullet, telling myself "Hey, It will generate the static website for me, I must plow through". 16 | 17 | ## What does Statik look like? 18 | 19 | ```c# 20 | var webBuilder = Statik.GetWebBuilder(); 21 | 22 | // This is where your meat-and-potatoes go. 23 | webBuilder.Register("/hello", async context => 24 | { 25 | await context.Response.WriteAsync($"The time is {DateTime.Now.ToLongTimeString()}"); 26 | }); 27 | 28 | // All of your endpoints get registered here. 29 | // You could scan a directory for markdown files, 30 | // or register a directory of stylesheets and images. 31 | webBuilder.RegisterDirectory("./assets"); 32 | 33 | using (var host = webBuilder.BuildWebHost()) 34 | { 35 | host.Listen(); 36 | Console.WriteLine("Listening on port 8000..."); 37 | Console.WriteLine("Try visiting http://localhost:8000/hello"); 38 | Console.WriteLine("Press enter to exit..."); 39 | Console.ReadLine(); 40 | 41 | // NOTE: You can export this host to a directory. 42 | // Useful for GitHub pages, etc. 43 | // await Statik.ExportHost(host, "./somewhere"); 44 | } 45 | ``` 46 | 47 | You can also use MVC controllers to handle endpoints. 48 | 49 | ```c# 50 | public class ExampleController : Controller 51 | { 52 | public ActionResult Index(string markdownFile) 53 | { 54 | return Content($"The time is {DateTime.Now.ToLongTimeString()}"); 55 | } 56 | } 57 | 58 | webBuilder.RegisterMvc("/blog/hello-world", new 59 | { 60 | controller = "Example", 61 | action = "Index", 62 | markdownFile = "./somewhere/markdown.md" 63 | }); 64 | ``` 65 | -------------------------------------------------------------------------------- /src/Statik/Embedded/EmbeddedFileProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Microsoft.Extensions.FileProviders; 6 | using Microsoft.Extensions.Primitives; 7 | using Statik.Embedded.Impl; 8 | 9 | namespace Statik.Embedded 10 | { 11 | public class EmbeddedFileProvider : IFileProvider 12 | { 13 | static readonly char[] InvalidFileNameChars = Path.GetInvalidFileNameChars() 14 | .Where(c => c != '/' && c != '\\').ToArray(); 15 | readonly IAssemblyResourceResolver _assembly; 16 | readonly List _files; 17 | 18 | public EmbeddedFileProvider(IAssemblyResourceResolver assembly, string prefix) 19 | { 20 | _assembly = assembly; 21 | _files = _assembly.GetManifestResourceNames() 22 | .Where(x => x.StartsWith(prefix)) 23 | .Select(x => 24 | { 25 | // This bit will will create convert the resource name 26 | // "this.is.a.resource.jpg" to a path of "/this/is/a/resource.jpg". 27 | var parts = x.Substring(prefix.Length).Split('.'); 28 | var directory = parts.Take(parts.Length - 2); 29 | var fileName = parts.Skip(parts.Length - 2); 30 | var path = Path.DirectorySeparatorChar + Path.Combine(Path.Combine(directory.ToArray()), string.Join(".", fileName)); 31 | 32 | return new EmbeddedFile(path, x); 33 | }) 34 | .ToList(); 35 | } 36 | 37 | public EmbeddedFileProvider(Assembly assembly, string prefix) 38 | :this(new AssemblyResourceResolver(assembly), prefix) 39 | { 40 | 41 | } 42 | 43 | public IFileInfo GetFileInfo(string subpath) 44 | { 45 | if (string.IsNullOrEmpty(subpath)) return new NotFoundFileInfo(subpath); 46 | 47 | var file = _files.SingleOrDefault(x => x.Path.Equals(subpath)); 48 | 49 | if (file == null) return new NotFoundFileInfo(subpath); 50 | 51 | return new EmbeddedFileInfo(file, _assembly); 52 | } 53 | 54 | public IDirectoryContents GetDirectoryContents(string subpath) 55 | { 56 | if (string.IsNullOrEmpty(subpath)) return NotFoundDirectoryContents.Singleton; 57 | 58 | return new EnumerableDirectoryContents(_files, subpath, _assembly); 59 | } 60 | 61 | public IChangeToken Watch(string filter) 62 | { 63 | return NullChangeToken.Singleton; 64 | } 65 | 66 | private static bool HasInvalidPathChars(string path) 67 | { 68 | return path.IndexOfAny(InvalidFileNameChars) != -1; 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /test/Statik.Tests/HostingTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Collections.ObjectModel; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.AspNetCore.Http; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Moq; 9 | using Statik.Hosting; 10 | using Statik.Hosting.Impl; 11 | using Statik.Web; 12 | using Xunit; 13 | 14 | namespace Statik.Tests 15 | { 16 | public class HostingTests 17 | { 18 | readonly IHostBuilder _hostBuilder; 19 | 20 | public HostingTests() 21 | { 22 | _hostBuilder = new HostBuilder(); 23 | } 24 | 25 | [Fact] 26 | public async Task Can_create_web_host() 27 | { 28 | using(var host = _hostBuilder.BuildWebHost(5002, "", BuildTestHostModule())) 29 | { 30 | host.Listen(); 31 | using(var client = host.CreateClient()) 32 | { 33 | var responseMessage = await client.GetAsync("/test"); 34 | responseMessage.EnsureSuccessStatusCode(); 35 | var response = await responseMessage.Content.ReadAsStringAsync(); 36 | 37 | Assert.Equal("Hello, World! /test ", response); 38 | } 39 | } 40 | } 41 | 42 | [Fact] 43 | public async Task Can_create_virtual_host() 44 | { 45 | using(var host = _hostBuilder.BuildVirtualHost("", BuildTestHostModule())) 46 | { 47 | using(var client = host.CreateClient()) 48 | { 49 | var responseMessage = await client.GetAsync("/test"); 50 | responseMessage.EnsureSuccessStatusCode(); 51 | var response = await responseMessage.Content.ReadAsStringAsync(); 52 | 53 | Assert.Equal("Hello, World! /test ", response); 54 | } 55 | } 56 | } 57 | 58 | private IHostModule BuildTestHostModule() 59 | { 60 | var hostModule = new Mock(); 61 | hostModule.Setup(x => x.Configure(It.IsAny(), It.IsAny())) 62 | .Callback((IApplicationBuilder app, IWebHostEnvironment env) => { 63 | app.Run(async context => { 64 | await context.Response.WriteAsync("Hello, World! " + context.Request.Path + " " + context.Request.PathBase); 65 | }); 66 | }); 67 | hostModule.Setup(x => x.ConfigureServices(It.IsAny())); 68 | hostModule.Setup(x => x.Pages).Returns(new ReadOnlyCollection(new List 69 | { 70 | new Page("/test", null, null, false) 71 | })); 72 | return hostModule.Object; 73 | } 74 | } 75 | } -------------------------------------------------------------------------------- /src/Statik/StatikHelpers.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text.RegularExpressions; 5 | using Microsoft.AspNetCore.Http; 6 | 7 | namespace Statik 8 | { 9 | public class StatikHelpers 10 | { 11 | public static string ConvertStringToSlug(string value) 12 | { 13 | if (string.IsNullOrEmpty(value)) 14 | { 15 | throw new ArgumentOutOfRangeException(nameof(value)); 16 | } 17 | 18 | var result = Regex.Replace(value, @"[^A-Za-z0-9_~]+", "-"); 19 | 20 | if (result.EndsWith("-")) 21 | { 22 | result = result.Substring(0, result.Length - 1); 23 | } 24 | 25 | if (result.StartsWith("-")) 26 | { 27 | result = result.Substring(1); 28 | } 29 | 30 | return result; 31 | } 32 | 33 | public static string ResolvePathPart(string path, string relative) 34 | { 35 | // Preserve the ending query and segment to append it to the result. 36 | string queryAndSegment = null; 37 | { 38 | var index = Math.Min( 39 | relative.IndexOf("#", StringComparison.OrdinalIgnoreCase), 40 | relative.IndexOf("?", StringComparison.InvariantCultureIgnoreCase)); 41 | if (index > -1) 42 | { 43 | queryAndSegment = relative.Substring(index); 44 | relative = relative.Substring(0, index); 45 | } 46 | } 47 | 48 | if (relative.StartsWith("/")) 49 | path = "/"; 50 | 51 | if (path.Contains("?")) 52 | path = path.Substring(0, path.IndexOf("?", StringComparison.OrdinalIgnoreCase)); 53 | 54 | var fullPathParts = new PathString(path.StartsWith("/") ? path : $"/{path}") 55 | .Add(relative.StartsWith("/") ? relative : $"/{relative}") 56 | .Value.Split('/') 57 | // First entry is empty, because path starts with "/". 58 | .Skip(1) 59 | .ToList(); 60 | 61 | var stack = new List(); 62 | 63 | foreach (var part in fullPathParts) 64 | { 65 | if (part == "..") 66 | { 67 | if(stack.Count == 0) 68 | throw new Exception($"Invalid path '{relative}'"); 69 | stack.RemoveAt(stack.Count - 1); 70 | }else if (part == ".") 71 | { 72 | // Do nothing 73 | } 74 | else 75 | { 76 | stack.Add(part); 77 | } 78 | } 79 | 80 | return $"/{string.Join("/", stack.ToArray())}{queryAndSegment}"; 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /src/Statik.Mvc/WebBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Reflection.Emit; 6 | using Microsoft.AspNetCore.Hosting; 7 | using Microsoft.AspNetCore.Mvc; 8 | using Microsoft.AspNetCore.Mvc.Abstractions; 9 | using Microsoft.AspNetCore.Mvc.Infrastructure; 10 | using Microsoft.AspNetCore.Routing; 11 | using Microsoft.Extensions.DependencyInjection; 12 | using Statik.Web; 13 | 14 | namespace Statik.Mvc 15 | { 16 | public static class WebBuilderExtensions 17 | { 18 | public static void RegisterMvcServices(this IWebBuilder webBuilder, params Assembly[] additionalParts) 19 | { 20 | var callingAssembly = Assembly.GetCallingAssembly(); 21 | webBuilder.RegisterServices(services => 22 | { 23 | var parts = additionalParts.ToList(); 24 | if (!parts.Contains(callingAssembly)) 25 | { 26 | parts.Add(callingAssembly); 27 | } 28 | 29 | var b = services.AddMvc(); 30 | foreach (var part in parts) 31 | { 32 | b.AddApplicationPart(part); 33 | } 34 | 35 | b.AddRazorRuntimeCompilation(); 36 | }); 37 | } 38 | 39 | public static void RegisterMvc(this IWebBuilder webBuilder, string path, object routeData, object state = null) 40 | { 41 | webBuilder.Register(path, async context => 42 | { 43 | var actionSelector = context.RequestServices.GetRequiredService(); 44 | var actionInvokerFactory = context.RequestServices.GetRequiredService(); 45 | 46 | var routeContext = new RouteContext(context); 47 | if (routeData != null) 48 | { 49 | foreach(var value in new RouteValueDictionary(routeData)) 50 | { 51 | routeContext.RouteData.Values[value.Key] = value.Value; 52 | } 53 | } 54 | 55 | var candidates = actionSelector.SelectCandidates(routeContext); 56 | if (candidates == null || candidates.Count == 0) 57 | { 58 | throw new Exception("No actions matched"); 59 | } 60 | 61 | var actionDescriptor = actionSelector.SelectBestCandidate(routeContext, candidates); 62 | if (actionDescriptor == null) 63 | { 64 | throw new Exception("No actions matched"); 65 | } 66 | 67 | var actionContext = new ActionContext(context, routeContext.RouteData, actionDescriptor); 68 | 69 | var invoker = actionInvokerFactory.CreateInvoker(actionContext); 70 | if (invoker == null) 71 | { 72 | throw new InvalidOperationException("Couldn't create invoker"); 73 | } 74 | 75 | await invoker.InvokeAsync(); 76 | }, 77 | state); 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /test/Statik.Tests/WebBuilderTests.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Microsoft.AspNetCore.Http; 3 | using Statik.Web; 4 | using Xunit; 5 | 6 | namespace Statik.Tests 7 | { 8 | public class WebBuilderTests 9 | { 10 | readonly IWebBuilder _webBuilder; 11 | 12 | public WebBuilderTests() 13 | { 14 | _webBuilder = new Web.Impl.WebBuilder(new Hosting.Impl.HostBuilder()); 15 | } 16 | 17 | [Fact] 18 | public async Task Can_use_with_app_base_with_web() 19 | { 20 | _webBuilder.Register("/test", async context => 21 | { 22 | await context.Response.WriteAsync("Hello, World! " + context.Request.Path + " " + context.Request.PathBase); 23 | }); 24 | 25 | using(var host = _webBuilder.BuildWebHost("", 5003)) 26 | { 27 | host.Listen(); 28 | using(var client = host.CreateClient()) 29 | { 30 | var responseMessage = await client.GetAsync("/test"); 31 | responseMessage.EnsureSuccessStatusCode(); 32 | var response = await responseMessage.Content.ReadAsStringAsync(); 33 | 34 | Assert.Equal("Hello, World! /test ", response); 35 | } 36 | } 37 | 38 | using(var host = _webBuilder.BuildWebHost("/appbase", 5003)) 39 | { 40 | host.Listen(); 41 | using(var client = host.CreateClient()) 42 | { 43 | var responseMessage = await client.GetAsync("/test"); 44 | responseMessage.EnsureSuccessStatusCode(); 45 | var response = await responseMessage.Content.ReadAsStringAsync(); 46 | 47 | Assert.Equal("Hello, World! /test /appbase", response); 48 | } 49 | } 50 | } 51 | 52 | [Fact] 53 | public async Task Can_use_with_app_base_with_virtual() 54 | { 55 | _webBuilder.Register("/test", async context => 56 | { 57 | await context.Response.WriteAsync("Hello, World! " + context.Request.Path + " " + context.Request.PathBase); 58 | }); 59 | 60 | using(var host = _webBuilder.BuildVirtualHost("")) 61 | { 62 | using(var client = host.CreateClient()) 63 | { 64 | var responseMessage = await client.GetAsync("/test"); 65 | responseMessage.EnsureSuccessStatusCode(); 66 | var response = await responseMessage.Content.ReadAsStringAsync(); 67 | 68 | Assert.Equal("Hello, World! /test ", response); 69 | } 70 | } 71 | 72 | using(var host = _webBuilder.BuildVirtualHost("/appbase")) 73 | { 74 | using(var client = host.CreateClient()) 75 | { 76 | var responseMessage = await client.GetAsync("/test"); 77 | responseMessage.EnsureSuccessStatusCode(); 78 | var response = await responseMessage.Content.ReadAsStringAsync(); 79 | 80 | Assert.Equal("Hello, World! /test /appbase", response); 81 | } 82 | } 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /test/Statik.Tests/ExporterTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using FluentAssertions; 5 | using Microsoft.AspNetCore.Http; 6 | using Statik.Hosting; 7 | using Statik.Hosting.Impl; 8 | using Statik.Web; 9 | using Xunit; 10 | 11 | namespace Statik.Tests 12 | { 13 | public class ExporterTests : IDisposable 14 | { 15 | readonly IWebBuilder _webBuilder; 16 | readonly IHostExporter _hostExporter; 17 | readonly string _directory; 18 | 19 | public ExporterTests() 20 | { 21 | _webBuilder = new Web.Impl.WebBuilder(new HostBuilder()); 22 | _hostExporter = new HostExporter(); 23 | _directory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Guid.NewGuid().ToString()); 24 | Directory.CreateDirectory(_directory); 25 | } 26 | 27 | [Fact] 28 | public async Task Can_export_path_without_extension() 29 | { 30 | _webBuilder.Register("/test", 31 | context => context.Response.WriteAsync("test content")); 32 | using (var host = _webBuilder.BuildVirtualHost()) 33 | { 34 | await _hostExporter.Export(host, _directory); 35 | } 36 | 37 | File.Exists(Path.Combine(_directory, "test", "index.html")).Should().BeTrue(); 38 | } 39 | 40 | [Fact] 41 | public async Task Can_export_path_with_extension() 42 | { 43 | _webBuilder.Register("/test.css", 44 | context => context.Response.WriteAsync("test content")); 45 | using (var host = _webBuilder.BuildVirtualHost()) 46 | { 47 | await _hostExporter.Export(host, _directory); 48 | } 49 | 50 | File.Exists(Path.Combine(_directory, "test.css")).Should().BeTrue(); 51 | File.ReadAllText(Path.Combine(_directory, "test.css")).Should().Be("test content"); 52 | } 53 | 54 | [Fact] 55 | public async Task Can_export_path_without_extension_as_exact_path() 56 | { 57 | _webBuilder.Register("/test", 58 | context => context.Response.WriteAsync("test content"), 59 | extractExactPath: true); 60 | using (var host = _webBuilder.BuildVirtualHost()) 61 | { 62 | await _hostExporter.Export(host, _directory); 63 | } 64 | 65 | File.Exists(Path.Combine(_directory, "test")).Should().BeTrue(); 66 | File.ReadAllText(Path.Combine(_directory, "test")).Should().Be("test content"); 67 | } 68 | 69 | [Fact] 70 | public async Task Can_export_parallel() 71 | { 72 | for (var x = 0; x < 40; x++) 73 | { 74 | var _ = x; 75 | _webBuilder.Register($"/test{_}.txt", async context => { await context.Response.WriteAsync($"test content {_}"); }); 76 | } 77 | 78 | using (var host = _webBuilder.BuildVirtualHost()) 79 | { 80 | await _hostExporter.ExportParallel(host, _directory); 81 | } 82 | 83 | for (var x = 0; x < 40; x++) 84 | { 85 | File.Exists(Path.Combine(_directory, $"test{x}.txt")).Should().BeTrue(); 86 | File.ReadAllText(Path.Combine(_directory, $"test{x}.txt")).Should().Be($"test content {x}"); 87 | } 88 | } 89 | 90 | 91 | public void Dispose() 92 | { 93 | Directory.Delete(_directory, true); 94 | } 95 | } 96 | } -------------------------------------------------------------------------------- /src/Statik/Pages/Impl/PageDirectoryLoader.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.Extensions.FileProviders; 7 | using Microsoft.Extensions.FileSystemGlobbing; 8 | 9 | namespace Statik.Pages.Impl 10 | { 11 | public class PageDirectoryLoader : IPageDirectoryLoader 12 | { 13 | public PageTreeItem LoadFiles(IFileProvider fileProvider, PageDirectoryLoaderOptions options) 14 | { 15 | if(options == null) throw new ArgumentNullException(nameof(options)); 16 | options = options.Clone(); 17 | if(options.PageSlug == null) 18 | options.PageSlug = fileInfo => StatikHelpers.ConvertStringToSlug(Path.GetFileNameWithoutExtension(fileInfo.Name)); 19 | return LoadDirectory(fileProvider, "/", null, options); 20 | } 21 | 22 | private PageTreeItem LoadDirectory(IFileProvider fileProvider, string basePath, IFileInfo parentDirectory, PageDirectoryLoaderOptions options) 23 | { 24 | PageTreeItem root = null; 25 | var files = fileProvider.GetDirectoryContents(basePath) 26 | .ToList(); 27 | 28 | var children = new List>(); 29 | 30 | // Load all the files 31 | foreach (var file in files.Where(x => !x.IsDirectory)) 32 | { 33 | if (options.IndexPageMatcher != null && options.IndexPageMatcher.Match(file.Name).HasMatches) 34 | { 35 | // This is the index page. 36 | root = new PageTreeItem(file, basePath, Path.Combine(basePath, file.Name)); 37 | } 38 | else 39 | { 40 | var path = basePath == "/" 41 | ? $"/{options.PageSlug(file)}" 42 | : $"{basePath}/{options.PageSlug(file)}"; 43 | 44 | if (options.NormalPageMatcher != null) 45 | { 46 | if (options.NormalPageMatcher.Match(file.Name).HasMatches) 47 | { 48 | children.Add(new PageTreeItem(file, path, Path.Combine(basePath, file.Name))); 49 | } 50 | } 51 | else 52 | { 53 | children.Add(new PageTreeItem(file, path, Path.Combine(basePath, file.Name))); 54 | } 55 | } 56 | } 57 | 58 | if(root == null) 59 | root = new PageTreeItem(parentDirectory, basePath, basePath); 60 | 61 | root.Children.AddRange(children); 62 | 63 | // Load all the child directories 64 | foreach (var directory in files.Where(x => x.IsDirectory)) 65 | { 66 | var path = new PathString().Add(basePath) 67 | .Add("/" + directory.Name); 68 | var treeItem = LoadDirectory(fileProvider, path, directory, options); 69 | if (treeItem != null) 70 | { 71 | if ((treeItem.Data == null || treeItem.Data.IsDirectory) && treeItem.Children.Count == 0) 72 | { 73 | continue; 74 | } 75 | 76 | root.Children.Add(treeItem); 77 | } 78 | } 79 | 80 | foreach (var child in root.Children) 81 | { 82 | child.Parent = root; 83 | } 84 | 85 | return root; 86 | } 87 | } 88 | } -------------------------------------------------------------------------------- /test/Statik.Files.Tests/EmbeddedFileProviderHostTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Http; 4 | using Moq; 5 | using Statik.Embedded; 6 | using Statik.Hosting.Impl; 7 | using Statik.Tests; 8 | using Statik.Web; 9 | using Statik.Web.Impl; 10 | using Xunit; 11 | 12 | namespace Statik.Files.Tests 13 | { 14 | public class EmbeddedFileProviderHostTests 15 | { 16 | [Fact] 17 | public void Can_register_embedded_files() 18 | { 19 | var embeddedFiles = new EmbeddedFileProvider(typeof(EmbeddedFileProviderTests).Assembly, "Statik.Tests.Embedded"); 20 | var webBuilder = new Mock(); 21 | 22 | webBuilder.Setup(x => x.Register("/file1.txt", It.IsAny>(), null, true)); 23 | webBuilder.Setup(x => x.Register("/file2.txt", It.IsAny>(), null, true)); 24 | webBuilder.Setup(x => x.Register("/nested/file3.txt", It.IsAny>(), null, true)); 25 | webBuilder.Setup(x => x.Register("/nested/file4.txt", It.IsAny>(), null, true)); 26 | webBuilder.Setup(x => x.Register("/nested/nested2/file5.txt", It.IsAny>(), null, true)); 27 | 28 | webBuilder.Object.RegisterFileProvider(embeddedFiles); 29 | 30 | webBuilder.Verify(x => x.Register("/file1.txt", It.IsAny>(), null, true), Times.Exactly(1)); 31 | webBuilder.Verify(x => x.Register("/file2.txt", It.IsAny>(), null, true), Times.Exactly(1)); 32 | webBuilder.Verify(x => x.Register("/nested/file3.txt", It.IsAny>(), null, true), Times.Exactly(1)); 33 | webBuilder.Verify(x => x.Register("/nested/file4.txt", It.IsAny>(), null, true), Times.Exactly(1)); 34 | webBuilder.Verify(x => x.Register("/nested/nested2/file5.txt", It.IsAny>(), null, true), Times.Exactly(1)); 35 | } 36 | 37 | [Fact] 38 | public async Task Can_serve_embedded_files() 39 | { 40 | var embeddedFiles = new EmbeddedFileProvider(typeof(EmbeddedFileProviderTests).Assembly, "Statik.Tests.Embedded"); 41 | var webBuilder = new WebBuilder(new HostBuilder()); 42 | 43 | webBuilder.RegisterFileProvider(embeddedFiles); 44 | 45 | using(var host = webBuilder.BuildVirtualHost()) 46 | { 47 | using(var client = host.CreateClient()) 48 | { 49 | var responseMessage = await client.GetAsync("/file1.txt"); 50 | responseMessage.EnsureSuccessStatusCode(); 51 | var response = await responseMessage.Content.ReadAsStringAsync(); 52 | 53 | Assert.Equal("file1content", response); 54 | 55 | responseMessage = await client.GetAsync("/nested/nested2/file5.txt"); 56 | responseMessage.EnsureSuccessStatusCode(); 57 | response = await responseMessage.Content.ReadAsStringAsync(); 58 | 59 | Assert.Equal("file5content", response); 60 | } 61 | } 62 | 63 | using(var host = webBuilder.BuildVirtualHost("/appbase")) 64 | { 65 | using(var client = host.CreateClient()) 66 | { 67 | var responseMessage = await client.GetAsync("/file1.txt"); 68 | responseMessage.EnsureSuccessStatusCode(); 69 | var response = await responseMessage.Content.ReadAsStringAsync(); 70 | 71 | Assert.Equal("file1content", response); 72 | 73 | responseMessage = await client.GetAsync("/nested/nested2/file5.txt"); 74 | responseMessage.EnsureSuccessStatusCode(); 75 | response = await responseMessage.Content.ReadAsStringAsync(); 76 | 77 | Assert.Equal("file5content", response); 78 | } 79 | } 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /src/Statik/Hosting/Impl/HostBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Reflection; 7 | using Microsoft.AspNetCore; 8 | using Microsoft.AspNetCore.Hosting; 9 | using Microsoft.AspNetCore.Http; 10 | using Microsoft.AspNetCore.TestHost; 11 | using Microsoft.Extensions.DependencyInjection; 12 | using Microsoft.Extensions.Logging; 13 | using Statik.Web; 14 | 15 | namespace Statik.Hosting.Impl 16 | { 17 | public class HostBuilder : IHostBuilder 18 | { 19 | public IWebHost BuildWebHost(int port, PathString appBase, params IHostModule[] modules) 20 | { 21 | return new InternalWebHost(appBase, modules.ToList(), port); 22 | } 23 | 24 | public IVirtualHost BuildVirtualHost(PathString appBase, params IHostModule[] modules) 25 | { 26 | return new InternalVirtualHost(appBase, modules.ToList()); 27 | } 28 | 29 | private class InternalWebHost : IWebHost 30 | { 31 | readonly Microsoft.AspNetCore.Hosting.IWebHost _webHost; 32 | readonly PathString _appBase; 33 | readonly int _port; 34 | 35 | public InternalWebHost( 36 | PathString appBase, 37 | List modules, 38 | int port) 39 | { 40 | _appBase = appBase; 41 | _port = port; 42 | Pages = new ReadOnlyCollection(modules.SelectMany(x => x.Pages).ToList()); 43 | _webHost = WebHost.CreateDefaultBuilder(new string[]{}) 44 | .UseUrls($"http://*:{port}") 45 | .UseSetting(WebHostDefaults.ApplicationKey, Assembly.GetEntryAssembly().GetName().Name) 46 | .ConfigureLogging(factory => { 47 | factory.AddConsole(); 48 | }) 49 | .ConfigureServices(services => { 50 | services.AddSingleton(modules); 51 | }) 52 | .UseStartup() 53 | .Build(); 54 | } 55 | 56 | public IReadOnlyCollection Pages { get; } 57 | 58 | public HttpClient CreateClient() 59 | { 60 | var inner = new HttpClient() { 61 | BaseAddress = new Uri($"http://localhost:{_port}") 62 | }; 63 | var wrapper = new HttpClient(new AppBaseAppendMessageHandler(inner, _appBase)); 64 | wrapper.BaseAddress = inner.BaseAddress; 65 | return wrapper; 66 | } 67 | 68 | public IServiceProvider ServiceProvider => _webHost.Services; 69 | 70 | public void Listen() 71 | { 72 | _webHost.Start(); 73 | } 74 | 75 | public void Dispose() 76 | { 77 | _webHost.Dispose(); 78 | } 79 | } 80 | 81 | private class InternalVirtualHost : IVirtualHost 82 | { 83 | readonly PathString _appBase; 84 | readonly TestServer _testServer; 85 | 86 | public InternalVirtualHost( 87 | PathString appBase, 88 | List modules) 89 | { 90 | _appBase = appBase; 91 | Pages = new ReadOnlyCollection(modules.SelectMany(x => x.Pages).ToList()); 92 | _testServer = new TestServer(new WebHostBuilder() 93 | .UseSetting(WebHostDefaults.ApplicationKey, Assembly.GetEntryAssembly().GetName().Name) 94 | .ConfigureLogging(factory => { factory.AddConsole(); }) 95 | .ConfigureServices(services => 96 | { 97 | services.AddSingleton(modules); 98 | }) 99 | .UseStartup()); 100 | } 101 | 102 | public IReadOnlyCollection Pages { get; } 103 | 104 | public HttpClient CreateClient() 105 | { 106 | var inner = _testServer.CreateClient(); 107 | var wrapper = new HttpClient(new AppBaseAppendMessageHandler(inner, _appBase)); 108 | wrapper.BaseAddress = inner.BaseAddress; 109 | return wrapper; 110 | } 111 | 112 | public IServiceProvider ServiceProvider => _testServer.Host.Services; 113 | 114 | public void Dispose() 115 | { 116 | _testServer.Dispose(); 117 | } 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /src/Statik.Files/WebBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Threading.Tasks; 3 | using Microsoft.AspNetCore.Builder; 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.StaticFiles; 7 | using Microsoft.Extensions.FileProviders; 8 | using Microsoft.Extensions.Logging; 9 | using Microsoft.Extensions.Options; 10 | using Microsoft.Extensions.DependencyInjection; 11 | using Microsoft.Extensions.FileSystemGlobbing; 12 | using Statik.Web; 13 | 14 | namespace Statik.Files 15 | { 16 | public static class WebBuilderExtensions 17 | { 18 | public static void RegisterDirectory(this IWebBuilder webBuilder, string directory, RegisterOptions options = null) 19 | { 20 | RegisterFileProvider(webBuilder, new PhysicalFileProvider(directory), options); 21 | } 22 | 23 | public static void RegisterDirectory(this IWebBuilder webBuilder, PathString prefix, string directory, RegisterOptions options = null) 24 | { 25 | RegisterFileProvider(webBuilder, prefix, new PhysicalFileProvider(directory), options); 26 | } 27 | 28 | public static void RegisterFileProvider(this IWebBuilder webBuilder, IFileProvider fileProvider, RegisterOptions options = null) 29 | { 30 | RegisterFileProvider(webBuilder, "/", fileProvider, options); 31 | } 32 | 33 | public static void RegisterFileProvider(this IWebBuilder webBuilder, PathString prefix, IFileProvider fileProvider, RegisterOptions options = null) 34 | { 35 | var contents = fileProvider.GetDirectoryContents("/"); 36 | if(contents == null || !contents.Exists) return; 37 | 38 | foreach(var file in contents) 39 | { 40 | webBuilder.RegisterFileInfo(fileProvider, prefix, "/", file, options); 41 | } 42 | } 43 | 44 | private static void RegisterFileInfo(this IWebBuilder webBuilder, IFileProvider fileProvider, PathString prefix, string basePath, IFileInfo fileInfo, RegisterOptions options) 45 | { 46 | if (fileInfo.IsDirectory) 47 | { 48 | var content = fileProvider.GetDirectoryContents(Path.Combine(basePath, fileInfo.Name)); 49 | 50 | if (content == null || !content.Exists) 51 | { 52 | return; 53 | } 54 | 55 | foreach (var child in content) 56 | { 57 | webBuilder.RegisterFileInfo(fileProvider, prefix, Path.Combine(basePath, fileInfo.Name), child, options); 58 | } 59 | } 60 | else 61 | { 62 | var filePath = Path.Combine(basePath, fileInfo.Name); 63 | var requestPath = new PathString().Add(prefix) 64 | .Add(filePath); 65 | 66 | if (options != null && options.Matcher != null) 67 | { 68 | if (!options.Matcher.Match(filePath.Substring(1)).HasMatches) 69 | { 70 | // We are ignoring this file 71 | return; 72 | } 73 | } 74 | 75 | var builtState = options?.State?.Invoke(prefix, requestPath, filePath, fileInfo, fileProvider); 76 | 77 | webBuilder.Register(requestPath, async context => 78 | { 79 | var env = context.RequestServices.GetRequiredService(); 80 | 81 | var statileFileOptions = Options.Create(new StaticFileOptions()); 82 | statileFileOptions.Value.FileProvider = fileProvider; 83 | statileFileOptions.Value.ServeUnknownFileTypes = true; 84 | 85 | var loggerFactory = context.RequestServices.GetRequiredService(); 86 | var middleware = new StaticFileMiddleware(_ => Task.CompletedTask, env, statileFileOptions, loggerFactory); 87 | 88 | var oldPath = context.Request.Path; 89 | try 90 | { 91 | context.Request.Path = filePath; 92 | await middleware.Invoke(context); 93 | } 94 | finally 95 | { 96 | context.Request.Path = oldPath; 97 | } 98 | }, 99 | builtState, 100 | /*don't convert "/file" to "/file/index.html"*/ 101 | true); 102 | } 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /src/Statik/Hosting/Impl/HostExporter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net.Http; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using System.Threading.Tasks.Dataflow; 7 | using Statik.Web; 8 | 9 | namespace Statik.Hosting.Impl 10 | { 11 | public class HostExporter : IHostExporter 12 | { 13 | public async Task Export(IHost host, string destinationDirectory) 14 | { 15 | await PrepareDirectory(destinationDirectory); 16 | 17 | var context = new SemaphoreSlim(1, 1); 18 | 19 | foreach(var page in host.Pages) 20 | { 21 | using(var client = host.CreateClient()) 22 | { 23 | var destination = $"{destinationDirectory}{page.Path}"; 24 | if (!page.ExtractExactPath) 25 | { 26 | if (string.IsNullOrEmpty(Path.GetExtension(destination))) 27 | { 28 | destination += "/index.html"; 29 | } 30 | } 31 | await SaveUrlToFile(client, page, destination, context); 32 | } 33 | } 34 | } 35 | 36 | public async Task ExportParallel(IHost host, string destinationDirectory, int? maxThreads = null) 37 | { 38 | await PrepareDirectory(destinationDirectory); 39 | 40 | if (!maxThreads.HasValue) 41 | { 42 | maxThreads = Environment.ProcessorCount; 43 | } 44 | 45 | if (maxThreads < 0) 46 | { 47 | throw new ArgumentOutOfRangeException(nameof(maxThreads)); 48 | } 49 | 50 | var context = new SemaphoreSlim(1, 1); 51 | 52 | var exportPage = new ActionBlock(async page => 53 | { 54 | using(var client = host.CreateClient()) 55 | { 56 | var destination = $"{destinationDirectory}{page.Path}"; 57 | if (!page.ExtractExactPath) 58 | { 59 | if (string.IsNullOrEmpty(Path.GetExtension(destination))) 60 | { 61 | destination += "/index.html"; 62 | } 63 | } 64 | await SaveUrlToFile(client, page, destination, context); 65 | } 66 | }, new ExecutionDataflowBlockOptions 67 | { 68 | MaxDegreeOfParallelism = maxThreads.Value 69 | }); 70 | 71 | foreach (var page in host.Pages) 72 | { 73 | await exportPage.SendAsync(page); 74 | } 75 | 76 | exportPage.Complete(); 77 | 78 | await exportPage.Completion; 79 | } 80 | 81 | private async Task PrepareDirectory(string directory) 82 | { 83 | if(!await Task.Run(() => Directory.Exists(directory))) 84 | { 85 | await Task.Run(() => Directory.CreateDirectory(directory)); 86 | } 87 | 88 | // Clean all the files currently in the directory 89 | foreach(var fileToDelete in await Task.Run(() => Directory.GetFiles(directory))) 90 | { 91 | await Task.Run(() => File.Delete(fileToDelete)); 92 | } 93 | foreach(var directoryToDelete in await Task.Run(() => Directory.GetDirectories(directory))) 94 | { 95 | await Task.Run(() => Directory.Delete(directoryToDelete, true)); 96 | } 97 | } 98 | 99 | private async Task SaveUrlToFile(HttpClient client, Page page, string file, SemaphoreSlim context) 100 | { 101 | // Ensure the file's parent directories are created. 102 | var parentDirectory = Path.GetDirectoryName(file); 103 | 104 | // Lock here to prevent multiple threads from creating the same directory. 105 | await context.WaitAsync(); 106 | try 107 | { 108 | if (!string.IsNullOrEmpty(parentDirectory)) 109 | { 110 | if (!(await Task.Run(() => Directory.Exists(parentDirectory)))) 111 | { 112 | await Task.Run(() => Directory.CreateDirectory(parentDirectory)); 113 | } 114 | } 115 | } 116 | finally 117 | { 118 | context.Release(); 119 | } 120 | 121 | var response = await client.GetAsync(page.Path); 122 | response.EnsureSuccessStatusCode(); 123 | using(var requestStream = await response.Content.ReadAsStreamAsync()) 124 | { 125 | using(var fileStream = await Task.Run(() => File.OpenWrite(file))) 126 | await requestStream.CopyToAsync(fileStream); 127 | } 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /test/Statik.Tests/PageDirectoryLoaderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using FluentAssertions; 4 | using Microsoft.Extensions.FileProviders; 5 | using Statik.Pages; 6 | using Xunit; 7 | 8 | namespace Statik.Tests 9 | { 10 | public class PageDirectoryLoaderTests : IDisposable 11 | { 12 | readonly IPageDirectoryLoader _pageDirectoryLoader; 13 | readonly string _directory; 14 | 15 | public PageDirectoryLoaderTests() 16 | { 17 | _pageDirectoryLoader = Statik.GetPageDirectoryLoader(); 18 | _directory = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Guid.NewGuid().ToString()); 19 | Directory.CreateDirectory(_directory); 20 | } 21 | 22 | [Fact] 23 | public void Can_load_with_path_set_correctly() 24 | { 25 | File.WriteAllText(Path.Combine(_directory, "index.md"), "content"); 26 | File.WriteAllText(Path.Combine(_directory, "file.md"), "content"); 27 | Directory.CreateDirectory(Path.Combine(_directory, "test", "nested")); 28 | File.WriteAllText(Path.Combine(_directory, "test", "nested", "index.md"), "content"); 29 | File.WriteAllText(Path.Combine(_directory, "test", "nested", "file2.md"), "content"); 30 | 31 | var treeItem = _pageDirectoryLoader.LoadFiles(new PhysicalFileProvider(_directory), 32 | "*.md", 33 | "index.md"); 34 | 35 | treeItem.FilePath.Should().Be("/index.md"); 36 | treeItem.Children[0].FilePath.Should().Be("/file.md"); 37 | treeItem.Children[1].FilePath.Should().Be("/test"); 38 | treeItem.Children[1].Children.Count.Should().Be(1); 39 | treeItem.Children[1].Children[0].FilePath.Should().Be("/test/nested/index.md"); 40 | treeItem.Children[1].Children[0].Children.Count.Should().Be(1); 41 | treeItem.Children[1].Children[0].Children[0].FilePath.Should().Be("/test/nested/file2.md"); 42 | } 43 | 44 | [Fact] 45 | public void Can_load_pages_with_base_path_set_correctly() 46 | { 47 | File.WriteAllText(Path.Combine(_directory, "index.md"), "content"); 48 | File.WriteAllText(Path.Combine(_directory, "file.md"), "content"); 49 | Directory.CreateDirectory(Path.Combine(_directory, "test")); 50 | File.WriteAllText(Path.Combine(_directory, "test", "index.md"), "content"); 51 | File.WriteAllText(Path.Combine(_directory, "test", "file2.md"), "content"); 52 | 53 | var treeItem = _pageDirectoryLoader.LoadFiles(new PhysicalFileProvider(_directory), 54 | "*.md", 55 | "index.md"); 56 | 57 | treeItem.Path.Should().Be("/"); 58 | treeItem.Children[0].Path.Should().Be("/file"); 59 | treeItem.Children[1].Path.Should().Be("/test"); 60 | treeItem.Children[1].Children[0].Path.Should().Be("/test/file2"); 61 | } 62 | 63 | [Fact] 64 | public void Can_set_parent_node_properly() 65 | { 66 | File.WriteAllText(Path.Combine(_directory, "index.md"), "content"); 67 | Directory.CreateDirectory(Path.Combine(_directory, "child", "grandchild")); 68 | File.WriteAllText(Path.Combine(_directory, "child", "grandchild", "index.md"), "content"); 69 | 70 | var treeItem = _pageDirectoryLoader.LoadFiles(new PhysicalFileProvider(_directory), 71 | "*.md", 72 | "index.md"); 73 | 74 | treeItem.Parent.Should().BeNull(); 75 | treeItem.Children.Count.Should().Be(1); 76 | treeItem.Children[0].Parent.Should().Be(treeItem); 77 | treeItem.Children[0].Children.Count.Should().Be(1); 78 | treeItem.Children[0].Children[0].Parent.Should().Be(treeItem.Children[0]); 79 | } 80 | 81 | [Fact] 82 | public void Can_create_empty_node_if_intermediate_child_has_no_page() 83 | { 84 | File.WriteAllText(Path.Combine(_directory, "index.md"), "content"); 85 | Directory.CreateDirectory(Path.Combine(_directory, "child", "grandchild")); 86 | File.WriteAllText(Path.Combine(_directory, "child", "grandchild", "index.md"), "content"); 87 | 88 | var treeItem = _pageDirectoryLoader.LoadFiles(new PhysicalFileProvider(_directory), 89 | "*.md", 90 | "index.md"); 91 | 92 | treeItem.Path.Should().Be("/"); 93 | treeItem.Children.Count.Should().Be(1); 94 | treeItem.Children[0].Data.IsDirectory.Should().Be(true); 95 | treeItem.Children[0].Data.Name.Should().Be("child"); 96 | treeItem.Children[0].Children.Count.Should().Be(1); 97 | treeItem.Children[0].Children[0].Path.Should().Be("/child/grandchild"); 98 | } 99 | 100 | [Fact] 101 | public void Can_ignore_directories_with_no_files() 102 | { 103 | File.WriteAllText(Path.Combine(_directory, "index.md"), "content"); 104 | Directory.CreateDirectory(Path.Combine(_directory, "child", "grandchild")); 105 | 106 | var treeItem = _pageDirectoryLoader.LoadFiles(new PhysicalFileProvider(_directory), 107 | "*.md", 108 | "index.md"); 109 | 110 | treeItem.Path.Should().Be("/"); 111 | treeItem.Children.Should().HaveCount(0); 112 | } 113 | 114 | public void Dispose() 115 | { 116 | Directory.Delete(_directory, true); 117 | } 118 | } 119 | } -------------------------------------------------------------------------------- /test/Statik.Tests/EmbeddedFileProviderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using Microsoft.Extensions.FileProviders; 5 | using Moq; 6 | using Statik.Embedded; 7 | using Xunit; 8 | 9 | namespace Statik.Tests 10 | { 11 | public class EmbeddedFileProviderTests 12 | { 13 | private IFileProvider _fileProvider; 14 | 15 | public EmbeddedFileProviderTests() 16 | { 17 | _fileProvider = BuildFileProvider("Statik.Tests.Embedded"); 18 | } 19 | 20 | [Fact] 21 | public void Can_get_valid_file() 22 | { 23 | var file = _fileProvider.GetFileInfo("/file1.txt"); 24 | 25 | Assert.True(file.Exists); 26 | Assert.Equal("file1.txt", file.Name); 27 | } 28 | 29 | [Fact] 30 | public void Can_get_invalid_file() 31 | { 32 | var file = _fileProvider.GetFileInfo("/non-existant.txt"); 33 | 34 | Assert.False(file.Exists); 35 | } 36 | 37 | [Fact] 38 | public void Can_get_valid_directory() 39 | { 40 | var directory = _fileProvider.GetDirectoryContents("/nested"); 41 | 42 | Assert.True(directory.Exists); 43 | } 44 | 45 | [Fact] 46 | public void Can_get_invalid_directory() 47 | { 48 | var directory = _fileProvider.GetDirectoryContents("/non-existant"); 49 | 50 | Assert.False(directory.Exists); 51 | } 52 | 53 | [Fact] 54 | public void Can_get_valid_directory_with_both_file_and_directory() 55 | { 56 | var directory = _fileProvider.GetDirectoryContents("/"); 57 | 58 | Assert.True(directory.Exists); 59 | 60 | var files = directory.ToList(); 61 | 62 | Assert.Equal(3, files.Count); 63 | 64 | foreach (var file in files) 65 | { 66 | switch (file.Name) 67 | { 68 | case "file1.txt": 69 | Assert.False(file.IsDirectory); 70 | break; 71 | case "file2.txt": 72 | Assert.False(file.IsDirectory); 73 | break; 74 | case "nested": 75 | Assert.True(file.IsDirectory); 76 | break; 77 | default: 78 | Assert.True(false, $"Invalid file name {file.Name}"); 79 | break; 80 | } 81 | } 82 | 83 | directory = _fileProvider.GetDirectoryContents("/nested"); 84 | 85 | Assert.True(directory.Exists); 86 | 87 | files = directory.ToList(); 88 | 89 | Assert.Equal(3, files.Count); 90 | 91 | foreach (var file in files) 92 | { 93 | switch (file.Name) 94 | { 95 | case "file3.txt": 96 | Assert.False(file.IsDirectory); 97 | break; 98 | case "file4.txt": 99 | Assert.False(file.IsDirectory); 100 | break; 101 | case "nested2": 102 | Assert.True(file.IsDirectory); 103 | break; 104 | default: 105 | Assert.True(false, $"Invalid file name {file.Name}"); 106 | break; 107 | } 108 | } 109 | } 110 | 111 | [Fact] 112 | public void Can_get_files_at_more_root_prefix() 113 | { 114 | _fileProvider = BuildFileProvider("Statik.Tests", BuildTestAssemblyResourceResolver()); 115 | 116 | var directory = _fileProvider.GetDirectoryContents("/"); 117 | Assert.True(directory.Exists); 118 | 119 | var files = directory.ToList(); 120 | Assert.Equal(2, files.Count); 121 | 122 | Assert.Equal("Embedded", files[0].Name); 123 | Assert.Equal("Some", files[1].Name); 124 | } 125 | 126 | private IAssemblyResourceResolver BuildTestAssemblyResourceResolver() 127 | { 128 | var assembly = new Mock(); 129 | assembly.Setup(x => x.GetManifestResourceNames()).Returns(new[] 130 | { 131 | "Statik.Tests.Embedded.file1.txt", 132 | "Statik.Tests.Embedded.file2.txt", 133 | "Statik.Tests.Embedded.nested.file3.txt", 134 | "Statik.Tests.Embedded.nested.file4.txt", 135 | "Statik.Tests.Embedded.nested.nested2.file5.txt", 136 | "Statik.Tests.Some.Other.Prefix.file1.txt" 137 | }); 138 | assembly.Setup(x => x.GetManifestResourceStream(It.IsAny())) 139 | .Returns(new Func(fileName => 140 | { 141 | var stream = new MemoryStream(); 142 | using (var writer = new StreamWriter(stream)) 143 | { 144 | writer.Write(fileName); 145 | } 146 | 147 | return stream; 148 | })); 149 | return assembly.Object; 150 | } 151 | 152 | private IFileProvider BuildFileProvider(string prefix, IAssemblyResourceResolver assembly = null) 153 | { 154 | if (assembly == null) 155 | assembly = BuildTestAssemblyResourceResolver(); 156 | return new Embedded.EmbeddedFileProvider(assembly, prefix); 157 | } 158 | } 159 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.suo 8 | *.user 9 | *.userosscache 10 | *.sln.docstates 11 | 12 | # User-specific files (MonoDevelop/Xamarin Studio) 13 | *.userprefs 14 | 15 | # Build results 16 | [Dd]ebug/ 17 | [Dd]ebugPublic/ 18 | [Rr]elease/ 19 | [Rr]eleases/ 20 | x64/ 21 | x86/ 22 | bld/ 23 | [Bb]in/ 24 | [Oo]bj/ 25 | [Ll]og/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | # Uncomment if you have tasks that create the project's static files in wwwroot 30 | #wwwroot/ 31 | 32 | # MSTest test Results 33 | [Tt]est[Rr]esult*/ 34 | [Bb]uild[Ll]og.* 35 | 36 | # NUNIT 37 | *.VisualState.xml 38 | TestResult.xml 39 | 40 | # Build Results of an ATL Project 41 | [Dd]ebugPS/ 42 | [Rr]eleasePS/ 43 | dlldata.c 44 | 45 | # .NET Core 46 | project.lock.json 47 | project.fragment.lock.json 48 | artifacts/ 49 | **/Properties/launchSettings.json 50 | 51 | *_i.c 52 | *_p.c 53 | *_i.h 54 | *.ilk 55 | *.meta 56 | *.obj 57 | *.pch 58 | *.pdb 59 | *.pgc 60 | *.pgd 61 | *.rsp 62 | *.sbr 63 | *.tlb 64 | *.tli 65 | *.tlh 66 | *.tmp 67 | *.tmp_proj 68 | *.log 69 | *.vspscc 70 | *.vssscc 71 | .builds 72 | *.pidb 73 | *.svclog 74 | *.scc 75 | 76 | # Chutzpah Test files 77 | _Chutzpah* 78 | 79 | # Visual C++ cache files 80 | ipch/ 81 | *.aps 82 | *.ncb 83 | *.opendb 84 | *.opensdf 85 | *.sdf 86 | *.cachefile 87 | *.VC.db 88 | *.VC.VC.opendb 89 | 90 | # Visual Studio profiler 91 | *.psess 92 | *.vsp 93 | *.vspx 94 | *.sap 95 | 96 | # TFS 2012 Local Workspace 97 | $tf/ 98 | 99 | # Guidance Automation Toolkit 100 | *.gpState 101 | 102 | # ReSharper is a .NET coding add-in 103 | _ReSharper*/ 104 | *.[Rr]e[Ss]harper 105 | *.DotSettings.user 106 | 107 | # JustCode is a .NET coding add-in 108 | .JustCode 109 | 110 | # TeamCity is a build add-in 111 | _TeamCity* 112 | 113 | # DotCover is a Code Coverage Tool 114 | *.dotCover 115 | 116 | # Visual Studio code coverage results 117 | *.coverage 118 | *.coveragexml 119 | 120 | # NCrunch 121 | _NCrunch_* 122 | .*crunch*.local.xml 123 | nCrunchTemp_* 124 | 125 | # MightyMoose 126 | *.mm.* 127 | AutoTest.Net/ 128 | 129 | # Web workbench (sass) 130 | .sass-cache/ 131 | 132 | # Installshield output folder 133 | [Ee]xpress/ 134 | 135 | # DocProject is a documentation generator add-in 136 | DocProject/buildhelp/ 137 | DocProject/Help/*.HxT 138 | DocProject/Help/*.HxC 139 | DocProject/Help/*.hhc 140 | DocProject/Help/*.hhk 141 | DocProject/Help/*.hhp 142 | DocProject/Help/Html2 143 | DocProject/Help/html 144 | 145 | # Click-Once directory 146 | publish/ 147 | 148 | # Publish Web Output 149 | *.[Pp]ublish.xml 150 | *.azurePubxml 151 | # TODO: Comment the next line if you want to checkin your web deploy settings 152 | # but database connection strings (with potential passwords) will be unencrypted 153 | *.pubxml 154 | *.publishproj 155 | 156 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 157 | # checkin your Azure Web App publish settings, but sensitive information contained 158 | # in these scripts will be unencrypted 159 | PublishScripts/ 160 | 161 | # NuGet Packages 162 | *.nupkg 163 | # The packages folder can be ignored because of Package Restore 164 | **/packages/* 165 | # except build/, which is used as an MSBuild target. 166 | !**/packages/build/ 167 | # Uncomment if necessary however generally it will be regenerated when needed 168 | #!**/packages/repositories.config 169 | # NuGet v3's project.json files produces more ignorable files 170 | *.nuget.props 171 | *.nuget.targets 172 | 173 | # Microsoft Azure Build Output 174 | csx/ 175 | *.build.csdef 176 | 177 | # Microsoft Azure Emulator 178 | ecf/ 179 | rcf/ 180 | 181 | # Windows Store app package directories and files 182 | AppPackages/ 183 | BundleArtifacts/ 184 | Package.StoreAssociation.xml 185 | _pkginfo.txt 186 | 187 | # Visual Studio cache files 188 | # files ending in .cache can be ignored 189 | *.[Cc]ache 190 | # but keep track of directories ending in .cache 191 | !*.[Cc]ache/ 192 | 193 | # Others 194 | ClientBin/ 195 | ~$* 196 | *~ 197 | *.dbmdl 198 | *.dbproj.schemaview 199 | *.jfm 200 | *.pfx 201 | *.publishsettings 202 | orleans.codegen.cs 203 | 204 | # Since there are multiple workflows, uncomment next line to ignore bower_components 205 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 206 | #bower_components/ 207 | 208 | # RIA/Silverlight projects 209 | Generated_Code/ 210 | 211 | # Backup & report files from converting an old project file 212 | # to a newer Visual Studio version. Backup files are not needed, 213 | # because we have git ;-) 214 | _UpgradeReport_Files/ 215 | Backup*/ 216 | UpgradeLog*.XML 217 | UpgradeLog*.htm 218 | 219 | # SQL Server files 220 | *.mdf 221 | *.ldf 222 | *.ndf 223 | 224 | # Business Intelligence projects 225 | *.rdl.data 226 | *.bim.layout 227 | *.bim_*.settings 228 | 229 | # Microsoft Fakes 230 | FakesAssemblies/ 231 | 232 | # GhostDoc plugin setting file 233 | *.GhostDoc.xml 234 | 235 | # Node.js Tools for Visual Studio 236 | .ntvs_analysis.dat 237 | node_modules/ 238 | 239 | # Typescript v1 declaration files 240 | typings/ 241 | 242 | # Visual Studio 6 build log 243 | *.plg 244 | 245 | # Visual Studio 6 workspace options file 246 | *.opt 247 | 248 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 249 | *.vbw 250 | 251 | # Visual Studio LightSwitch build output 252 | **/*.HTMLClient/GeneratedArtifacts 253 | **/*.DesktopClient/GeneratedArtifacts 254 | **/*.DesktopClient/ModelManifest.xml 255 | **/*.Server/GeneratedArtifacts 256 | **/*.Server/ModelManifest.xml 257 | _Pvt_Extensions 258 | 259 | # Paket dependency manager 260 | .paket/paket.exe 261 | paket-files/ 262 | 263 | # FAKE - F# Make 264 | .fake/ 265 | 266 | # JetBrains Rider 267 | .idea/ 268 | *.sln.iml 269 | 270 | # CodeRush 271 | .cr/ 272 | 273 | # Python Tools for Visual Studio (PTVS) 274 | __pycache__/ 275 | *.pyc 276 | 277 | # Cake - Uncomment if you are using it 278 | # tools/** 279 | # !tools/packages.config 280 | 281 | # Telerik's JustMock configuration file 282 | *.jmconfig 283 | 284 | # BizTalk build output 285 | *.btp.cs 286 | *.btm.cs 287 | *.odx.cs 288 | *.xsd.cs 289 | -------------------------------------------------------------------------------- /Statik.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # 4 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9FD852C3-67B7-46C0-9834-553A3E581B70}" 5 | EndProject 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik", "src\Statik\Statik.csproj", "{748296CB-88BD-43CA-8C67-FC804487139E}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{9492CE6B-0EDD-4016-BB40-628F0955F867}" 9 | EndProject 10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Tests", "test\Statik.Tests\Statik.Tests.csproj", "{3E5863A4-4C00-4E02-8596-C837C0566DBC}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Files", "src\Statik.Files\Statik.Files.csproj", "{1B5E4387-5F40-45E4-821C-1DB80A876665}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Files.Tests", "test\Statik.Files.Tests\Statik.Files.Tests.csproj", "{51A83061-EE20-4CF8-891F-C04F142E4760}" 15 | EndProject 16 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Mvc", "src\Statik.Mvc\Statik.Mvc.csproj", "{529BA049-420B-4B9D-A884-217CEFBEF1C0}" 17 | EndProject 18 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Mvc.Tests", "test\Statik.Mvc.Tests\Statik.Mvc.Tests.csproj", "{5216B3AC-4443-41B9-B2F2-80BD5A757CD3}" 19 | EndProject 20 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{CE9B8FB2-637F-4B0C-85C3-4C9C541F325A}" 21 | EndProject 22 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Examples.Simple", "examples\Statik.Examples.Simple\Statik.Examples.Simple.csproj", "{6E349214-6AF4-474D-B270-F15C7EB03A47}" 23 | EndProject 24 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Examples.Mvc", "examples\Statik.Examples.Mvc\Statik.Examples.Mvc.csproj", "{3EF13426-0E60-4819-B5A1-E89A169CDFA3}" 25 | EndProject 26 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Examples.Pages", "examples\Statik.Examples.Pages\Statik.Examples.Pages.csproj", "{33E2DB92-C979-4669-914B-6D707885B401}" 27 | EndProject 28 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Statik.Markdown", "src\Statik.Markdown\Statik.Markdown.csproj", "{4528D702-0EE9-462B-9DCE-D34886B9885A}" 29 | EndProject 30 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Build", "build\scripts\Build.csproj", "{4496C128-36ED-4799-9614-F30C70C7BA78}" 31 | EndProject 32 | Global 33 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 34 | Debug|Any CPU = Debug|Any CPU 35 | Release|Any CPU = Release|Any CPU 36 | EndGlobalSection 37 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 38 | {748296CB-88BD-43CA-8C67-FC804487139E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {748296CB-88BD-43CA-8C67-FC804487139E}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {748296CB-88BD-43CA-8C67-FC804487139E}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {748296CB-88BD-43CA-8C67-FC804487139E}.Release|Any CPU.Build.0 = Release|Any CPU 42 | {3E5863A4-4C00-4E02-8596-C837C0566DBC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 43 | {3E5863A4-4C00-4E02-8596-C837C0566DBC}.Debug|Any CPU.Build.0 = Debug|Any CPU 44 | {3E5863A4-4C00-4E02-8596-C837C0566DBC}.Release|Any CPU.ActiveCfg = Release|Any CPU 45 | {3E5863A4-4C00-4E02-8596-C837C0566DBC}.Release|Any CPU.Build.0 = Release|Any CPU 46 | {1B5E4387-5F40-45E4-821C-1DB80A876665}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 47 | {1B5E4387-5F40-45E4-821C-1DB80A876665}.Debug|Any CPU.Build.0 = Debug|Any CPU 48 | {1B5E4387-5F40-45E4-821C-1DB80A876665}.Release|Any CPU.ActiveCfg = Release|Any CPU 49 | {1B5E4387-5F40-45E4-821C-1DB80A876665}.Release|Any CPU.Build.0 = Release|Any CPU 50 | {51A83061-EE20-4CF8-891F-C04F142E4760}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 51 | {51A83061-EE20-4CF8-891F-C04F142E4760}.Debug|Any CPU.Build.0 = Debug|Any CPU 52 | {51A83061-EE20-4CF8-891F-C04F142E4760}.Release|Any CPU.ActiveCfg = Release|Any CPU 53 | {51A83061-EE20-4CF8-891F-C04F142E4760}.Release|Any CPU.Build.0 = Release|Any CPU 54 | {529BA049-420B-4B9D-A884-217CEFBEF1C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 55 | {529BA049-420B-4B9D-A884-217CEFBEF1C0}.Debug|Any CPU.Build.0 = Debug|Any CPU 56 | {529BA049-420B-4B9D-A884-217CEFBEF1C0}.Release|Any CPU.ActiveCfg = Release|Any CPU 57 | {529BA049-420B-4B9D-A884-217CEFBEF1C0}.Release|Any CPU.Build.0 = Release|Any CPU 58 | {5216B3AC-4443-41B9-B2F2-80BD5A757CD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 59 | {5216B3AC-4443-41B9-B2F2-80BD5A757CD3}.Debug|Any CPU.Build.0 = Debug|Any CPU 60 | {5216B3AC-4443-41B9-B2F2-80BD5A757CD3}.Release|Any CPU.ActiveCfg = Release|Any CPU 61 | {5216B3AC-4443-41B9-B2F2-80BD5A757CD3}.Release|Any CPU.Build.0 = Release|Any CPU 62 | {6E349214-6AF4-474D-B270-F15C7EB03A47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 63 | {6E349214-6AF4-474D-B270-F15C7EB03A47}.Debug|Any CPU.Build.0 = Debug|Any CPU 64 | {6E349214-6AF4-474D-B270-F15C7EB03A47}.Release|Any CPU.ActiveCfg = Release|Any CPU 65 | {6E349214-6AF4-474D-B270-F15C7EB03A47}.Release|Any CPU.Build.0 = Release|Any CPU 66 | {3EF13426-0E60-4819-B5A1-E89A169CDFA3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 67 | {3EF13426-0E60-4819-B5A1-E89A169CDFA3}.Debug|Any CPU.Build.0 = Debug|Any CPU 68 | {3EF13426-0E60-4819-B5A1-E89A169CDFA3}.Release|Any CPU.ActiveCfg = Release|Any CPU 69 | {3EF13426-0E60-4819-B5A1-E89A169CDFA3}.Release|Any CPU.Build.0 = Release|Any CPU 70 | {33E2DB92-C979-4669-914B-6D707885B401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 71 | {33E2DB92-C979-4669-914B-6D707885B401}.Debug|Any CPU.Build.0 = Debug|Any CPU 72 | {33E2DB92-C979-4669-914B-6D707885B401}.Release|Any CPU.ActiveCfg = Release|Any CPU 73 | {33E2DB92-C979-4669-914B-6D707885B401}.Release|Any CPU.Build.0 = Release|Any CPU 74 | {4528D702-0EE9-462B-9DCE-D34886B9885A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 75 | {4528D702-0EE9-462B-9DCE-D34886B9885A}.Debug|Any CPU.Build.0 = Debug|Any CPU 76 | {4528D702-0EE9-462B-9DCE-D34886B9885A}.Release|Any CPU.ActiveCfg = Release|Any CPU 77 | {4528D702-0EE9-462B-9DCE-D34886B9885A}.Release|Any CPU.Build.0 = Release|Any CPU 78 | {4496C128-36ED-4799-9614-F30C70C7BA78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 79 | {4496C128-36ED-4799-9614-F30C70C7BA78}.Debug|Any CPU.Build.0 = Debug|Any CPU 80 | {4496C128-36ED-4799-9614-F30C70C7BA78}.Release|Any CPU.ActiveCfg = Release|Any CPU 81 | {4496C128-36ED-4799-9614-F30C70C7BA78}.Release|Any CPU.Build.0 = Release|Any CPU 82 | EndGlobalSection 83 | GlobalSection(NestedProjects) = preSolution 84 | {748296CB-88BD-43CA-8C67-FC804487139E} = {9FD852C3-67B7-46C0-9834-553A3E581B70} 85 | {3E5863A4-4C00-4E02-8596-C837C0566DBC} = {9492CE6B-0EDD-4016-BB40-628F0955F867} 86 | {1B5E4387-5F40-45E4-821C-1DB80A876665} = {9FD852C3-67B7-46C0-9834-553A3E581B70} 87 | {51A83061-EE20-4CF8-891F-C04F142E4760} = {9492CE6B-0EDD-4016-BB40-628F0955F867} 88 | {529BA049-420B-4B9D-A884-217CEFBEF1C0} = {9FD852C3-67B7-46C0-9834-553A3E581B70} 89 | {5216B3AC-4443-41B9-B2F2-80BD5A757CD3} = {9492CE6B-0EDD-4016-BB40-628F0955F867} 90 | {6E349214-6AF4-474D-B270-F15C7EB03A47} = {CE9B8FB2-637F-4B0C-85C3-4C9C541F325A} 91 | {3EF13426-0E60-4819-B5A1-E89A169CDFA3} = {CE9B8FB2-637F-4B0C-85C3-4C9C541F325A} 92 | {33E2DB92-C979-4669-914B-6D707885B401} = {CE9B8FB2-637F-4B0C-85C3-4C9C541F325A} 93 | {4528D702-0EE9-462B-9DCE-D34886B9885A} = {9FD852C3-67B7-46C0-9834-553A3E581B70} 94 | EndGlobalSection 95 | EndGlobal 96 | -------------------------------------------------------------------------------- /src/Statik/Web/Impl/WebBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.Builder; 7 | using Microsoft.AspNetCore.Hosting; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Statik.Hosting; 11 | 12 | namespace Statik.Web.Impl 13 | { 14 | public class WebBuilder : IWebBuilder 15 | { 16 | readonly Dictionary _pages = new Dictionary(); 17 | readonly List> _serviceActions = new List>(); 18 | readonly IHostBuilder _hostBuilder; 19 | 20 | public WebBuilder(IHostBuilder hostBuilder) 21 | { 22 | _hostBuilder = hostBuilder; 23 | } 24 | 25 | public void Register(string path, Func action, object state, bool extractExactPath) 26 | { 27 | _pages.Add(path, new Page(path, action, state, extractExactPath)); 28 | } 29 | 30 | public void RegisterServices(Action action) 31 | { 32 | _serviceActions.Add(action); 33 | } 34 | 35 | public Hosting.IWebHost BuildWebHost(string appBase = null, int port = StatikDefaults.DefaultPort) 36 | { 37 | return _hostBuilder.BuildWebHost( 38 | port, 39 | appBase, 40 | new HostModule( 41 | appBase, 42 | _pages.ToDictionary(x => x.Key, x => x.Value), 43 | _serviceActions.ToList())); 44 | } 45 | 46 | public IVirtualHost BuildVirtualHost(string appBase = null) 47 | { 48 | return _hostBuilder.BuildVirtualHost( 49 | appBase, 50 | new HostModule( 51 | appBase, 52 | _pages.ToDictionary(x => x.Key, x => x.Value), 53 | _serviceActions.ToList())); 54 | } 55 | 56 | class PageAccessor : IPageAccessor 57 | { 58 | public PageAccessor(Page page) 59 | { 60 | Page = page; 61 | } 62 | 63 | public Page Page { get; } 64 | } 65 | 66 | class HostModule : IHostModule, IPageRegistry 67 | { 68 | readonly PathString _appBase; 69 | readonly Dictionary _pages; 70 | readonly List> _serviceActions; 71 | 72 | public HostModule( 73 | string appBase, 74 | Dictionary pages, 75 | List> serviceActions) 76 | { 77 | _appBase = appBase; 78 | _pages = pages; 79 | _serviceActions = serviceActions; 80 | Pages = new ReadOnlyCollection(_pages.Values.ToList()); 81 | } 82 | 83 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 84 | { 85 | app.Use(async (context, next) => { 86 | 87 | async Task AttemptRunPage(HttpContext c) 88 | { 89 | _pages.TryGetValue(c.Request.Path, out var page); 90 | if (page == null) return false; 91 | context.Items["_statikPageAccessor"] = new PageAccessor(page); 92 | await page.Action(context); 93 | context.Items["_statikPageAccessor"] = null; 94 | return true; 95 | } 96 | 97 | if (!_appBase.HasValue) 98 | { 99 | // No app base configured, just find and execute our page. 100 | if (!await AttemptRunPage(context)) 101 | { 102 | await next(); 103 | } 104 | } 105 | else 106 | { 107 | // Only serve requests at the app base. 108 | if (context.Request.Path.StartsWithSegments(_appBase, out var matchedPath, out var remainingPath)) 109 | { 110 | var originalPath = context.Request.Path; 111 | var originalPathBase = context.Request.PathBase; 112 | context.Request.Path = remainingPath; 113 | context.Request.PathBase = originalPathBase.Add(matchedPath); 114 | 115 | bool ran; 116 | 117 | try 118 | { 119 | ran = await AttemptRunPage(context); 120 | } 121 | finally 122 | { 123 | context.Request.Path = originalPath; 124 | context.Request.PathBase = originalPathBase; 125 | } 126 | 127 | if (!ran) 128 | { 129 | await next(); 130 | } 131 | } 132 | else 133 | { 134 | await next(); 135 | } 136 | } 137 | }); 138 | } 139 | 140 | public void ConfigureServices(IServiceCollection services) 141 | { 142 | services.AddSingleton(this); 143 | foreach(var serviceAction in _serviceActions) 144 | { 145 | serviceAction(services); 146 | } 147 | } 148 | 149 | public IReadOnlyCollection Pages { get; } 150 | 151 | public IReadOnlyCollection GetPaths() 152 | { 153 | return new ReadOnlyCollection(Pages.Select(x => x.Path).ToList()); 154 | } 155 | 156 | public Page GetPage(string path) 157 | { 158 | return _pages.TryGetValue(path, out Page page) ? page : null; 159 | } 160 | 161 | public Page FindOne(PageMatchDelegate match) 162 | { 163 | foreach (var value in _pages.Values) 164 | { 165 | if (match(value)) return value; 166 | } 167 | 168 | return null; 169 | } 170 | 171 | public async Task FindOne(PageMatchDelegateAsync match) 172 | { 173 | foreach (var value in _pages.Values) 174 | { 175 | if (await match(value)) return value; 176 | } 177 | 178 | return null; 179 | } 180 | 181 | public List FindMany(PageMatchDelegate match) 182 | { 183 | var result = new List(); 184 | 185 | foreach (var value in _pages.Values) 186 | { 187 | if(match(value)) result.Add(value); 188 | } 189 | 190 | return result; 191 | } 192 | 193 | public async Task> FindMany(PageMatchDelegateAsync match) 194 | { 195 | var result = new List(); 196 | 197 | foreach (var value in _pages.Values) 198 | { 199 | if(await match(value)) result.Add(value); 200 | } 201 | 202 | return result; 203 | } 204 | 205 | public void ForEach(PageActionDelegate action) 206 | { 207 | foreach (var value in _pages.Values) 208 | { 209 | action(value); 210 | } 211 | } 212 | 213 | public async Task ForEach(PageActionDelegateAsync action) 214 | { 215 | foreach (var value in _pages.Values) 216 | { 217 | await action(value); 218 | } 219 | } 220 | } 221 | } 222 | } -------------------------------------------------------------------------------- /test/Statik.Files.Tests/FileProviderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading.Tasks; 4 | using Microsoft.AspNetCore.Http; 5 | using Microsoft.Extensions.FileProviders; 6 | using Microsoft.Extensions.FileSystemGlobbing; 7 | using Moq; 8 | using Statik.Hosting; 9 | using Statik.Hosting.Impl; 10 | using Statik.Tests; 11 | using Statik.Web; 12 | using Statik.Web.Impl; 13 | using Statik.Files; 14 | using Xunit; 15 | 16 | namespace Statik.Files.Tests 17 | { 18 | public class FileProviderTests 19 | { 20 | readonly IWebBuilder _webBuilder; 21 | 22 | public FileProviderTests() 23 | { 24 | _webBuilder = new WebBuilder(new HostBuilder()); 25 | } 26 | 27 | [Fact] 28 | public void Can_register_files() 29 | { 30 | using (var testDirectory = new WorkingDirectorySession()) 31 | { 32 | File.WriteAllText(Path.Combine(testDirectory.Directory, "file1.txt"), "test"); 33 | File.WriteAllText(Path.Combine(testDirectory.Directory, "file2.txt"), "test"); 34 | Directory.CreateDirectory(Path.Combine(testDirectory.Directory, "nested")); 35 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "file3.txt"), "test"); 36 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "file4.txt"), "test"); 37 | Directory.CreateDirectory(Path.Combine(testDirectory.Directory, "nested", "nested2")); 38 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "nested2", "file5.txt"), "test"); 39 | 40 | var webBuilder = new Mock(); 41 | 42 | webBuilder.Setup(x => x.Register("/file1.txt", It.IsAny>(), null, true)); 43 | webBuilder.Setup(x => x.Register("/file2.txt", It.IsAny>(), null, true)); 44 | webBuilder.Setup(x => x.Register("/nested/file3.txt", It.IsAny>(), null, true)); 45 | webBuilder.Setup(x => x.Register("/nested/file4.txt", It.IsAny>(), null, true)); 46 | webBuilder.Setup(x => x.Register("/nested/nested2/file5.txt", It.IsAny>(), null, true)); 47 | 48 | webBuilder.Object.RegisterDirectory(testDirectory.Directory); 49 | 50 | webBuilder.Verify(x => x.Register("/file1.txt", It.IsAny>(), null, true), Times.Exactly(1)); 51 | webBuilder.Verify(x => x.Register("/file2.txt", It.IsAny>(), null, true), Times.Exactly(1)); 52 | webBuilder.Verify(x => x.Register("/nested/file3.txt", It.IsAny>(), null, true), Times.Exactly(1)); 53 | webBuilder.Verify(x => x.Register("/nested/file4.txt", It.IsAny>(), null, true), Times.Exactly(1)); 54 | webBuilder.Verify(x => x.Register("/nested/nested2/file5.txt", It.IsAny>(), null, true), Times.Exactly(1)); 55 | } 56 | } 57 | 58 | [Fact] 59 | public void Can_register_files_with_include() 60 | { 61 | using (var testDirectory = new WorkingDirectorySession()) 62 | { 63 | File.WriteAllText(Path.Combine(testDirectory.Directory, "file1.txt"), "test"); 64 | File.WriteAllText(Path.Combine(testDirectory.Directory, "file2.txt"), "test"); 65 | Directory.CreateDirectory(Path.Combine(testDirectory.Directory, "nested")); 66 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "file3.txt"), "test"); 67 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "file4.txt"), "test"); 68 | Directory.CreateDirectory(Path.Combine(testDirectory.Directory, "nested", "nested2")); 69 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "nested2", "file5.txt"), "test"); 70 | 71 | var webBuilder = new Mock(); 72 | var options = new RegisterOptions(); 73 | options.Matcher = new Matcher(); 74 | options.Matcher.AddInclude("**/file3.*"); 75 | 76 | webBuilder.Setup(x => x.Register("/file1.txt", It.IsAny>(), null, true)); 77 | webBuilder.Setup(x => x.Register("/file2.txt", It.IsAny>(), null, true)); 78 | webBuilder.Setup(x => x.Register("/nested/file3.txt", It.IsAny>(), null, true)); 79 | webBuilder.Setup(x => x.Register("/nested/file4.txt", It.IsAny>(), null, true)); 80 | webBuilder.Setup(x => x.Register("/nested/nested2/file5.txt", It.IsAny>(), null, true)); 81 | 82 | webBuilder.Object.RegisterDirectory(testDirectory.Directory, options); 83 | 84 | webBuilder.Verify(x => x.Register("/file1.txt", It.IsAny>(), null, true), Times.Never); 85 | webBuilder.Verify(x => x.Register("/file2.txt", It.IsAny>(), null, true), Times.Never); 86 | webBuilder.Verify(x => x.Register("/nested/file3.txt", It.IsAny>(), null, true), Times.Exactly(1)); 87 | webBuilder.Verify(x => x.Register("/nested/file4.txt", It.IsAny>(), null, true), Times.Never); 88 | webBuilder.Verify(x => x.Register("/nested/nested2/file5.txt", It.IsAny>(), null, true), Times.Never); 89 | } 90 | } 91 | 92 | [Fact] 93 | public void Can_register_files_with_exclude() 94 | { 95 | using (var testDirectory = new WorkingDirectorySession()) 96 | { 97 | File.WriteAllText(Path.Combine(testDirectory.Directory, "file1.txt"), "test"); 98 | File.WriteAllText(Path.Combine(testDirectory.Directory, "file2.txt"), "test"); 99 | Directory.CreateDirectory(Path.Combine(testDirectory.Directory, "nested")); 100 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "file3.txt"), "test"); 101 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "file4.txt"), "test"); 102 | Directory.CreateDirectory(Path.Combine(testDirectory.Directory, "nested", "nested2")); 103 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "nested2", "file5.txt"), "test"); 104 | 105 | var webBuilder = new Mock(); 106 | var options = new RegisterOptions(); 107 | options.Matcher = new Matcher(); 108 | options.Matcher.AddInclude("**/*"); 109 | options.Matcher.AddExclude("**/file3.*"); 110 | 111 | webBuilder.Setup(x => x.Register("/file1.txt", It.IsAny>(), null, true)); 112 | webBuilder.Setup(x => x.Register("/file2.txt", It.IsAny>(), null, true)); 113 | webBuilder.Setup(x => x.Register("/nested/file3.txt", It.IsAny>(), null, true)); 114 | webBuilder.Setup(x => x.Register("/nested/file4.txt", It.IsAny>(), null, true)); 115 | webBuilder.Setup(x => x.Register("/nested/nested2/file5.txt", It.IsAny>(), null, true)); 116 | 117 | webBuilder.Object.RegisterDirectory(testDirectory.Directory, options); 118 | 119 | webBuilder.Verify(x => x.Register("/file1.txt", It.IsAny>(), null, true), Times.Exactly(1)); 120 | webBuilder.Verify(x => x.Register("/file2.txt", It.IsAny>(), null, true), Times.Exactly(1)); 121 | webBuilder.Verify(x => x.Register("/nested/file3.txt", It.IsAny>(), null, true), Times.Never); 122 | webBuilder.Verify(x => x.Register("/nested/file4.txt", It.IsAny>(), null, true), Times.Exactly(1)); 123 | webBuilder.Verify(x => x.Register("/nested/nested2/file5.txt", It.IsAny>(), null, true), Times.Exactly(1)); 124 | } 125 | } 126 | 127 | [Fact] 128 | public async Task Can_serve_files() 129 | { 130 | using (var testDirectory = new WorkingDirectorySession()) 131 | { 132 | File.WriteAllText(Path.Combine(testDirectory.Directory, "test.txt"), "test"); 133 | Directory.CreateDirectory(Path.Combine(testDirectory.Directory, "nested")); 134 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "test2.txt"), "test2"); 135 | 136 | _webBuilder.RegisterDirectory(testDirectory.Directory); 137 | 138 | using(var host = _webBuilder.BuildVirtualHost()) 139 | { 140 | using(var client = host.CreateClient()) 141 | { 142 | var responseMessage = await client.GetAsync("/test.txt"); 143 | responseMessage.EnsureSuccessStatusCode(); 144 | var response = await responseMessage.Content.ReadAsStringAsync(); 145 | 146 | Assert.Equal("test", response); 147 | 148 | responseMessage = await client.GetAsync("/nested/test2.txt"); 149 | responseMessage.EnsureSuccessStatusCode(); 150 | response = await responseMessage.Content.ReadAsStringAsync(); 151 | 152 | Assert.Equal("test2", response); 153 | } 154 | } 155 | 156 | using(var host = _webBuilder.BuildVirtualHost("/appbase")) 157 | { 158 | using(var client = host.CreateClient()) 159 | { 160 | var responseMessage = await client.GetAsync("/test.txt"); 161 | responseMessage.EnsureSuccessStatusCode(); 162 | var response = await responseMessage.Content.ReadAsStringAsync(); 163 | 164 | Assert.Equal("test", response); 165 | 166 | responseMessage = await client.GetAsync("/nested/test2.txt"); 167 | responseMessage.EnsureSuccessStatusCode(); 168 | response = await responseMessage.Content.ReadAsStringAsync(); 169 | 170 | Assert.Equal("test2", response); 171 | } 172 | } 173 | } 174 | } 175 | 176 | [Fact] 177 | public async Task Can_serve_files_at_path() 178 | { 179 | using (var testDirectory = new WorkingDirectorySession()) 180 | { 181 | File.WriteAllText(Path.Combine(testDirectory.Directory, "test.txt"), "test"); 182 | Directory.CreateDirectory(Path.Combine(testDirectory.Directory, "nested")); 183 | File.WriteAllText(Path.Combine(testDirectory.Directory, "nested", "test2.txt"), "test2"); 184 | 185 | _webBuilder.RegisterDirectory("/prefix", testDirectory.Directory); 186 | 187 | using(var host = _webBuilder.BuildVirtualHost()) 188 | { 189 | using(var client = host.CreateClient()) 190 | { 191 | var responseMessage = await client.GetAsync("/prefix/test.txt"); 192 | responseMessage.EnsureSuccessStatusCode(); 193 | var response = await responseMessage.Content.ReadAsStringAsync(); 194 | 195 | Assert.Equal("test", response); 196 | 197 | responseMessage = await client.GetAsync("/prefix/nested/test2.txt"); 198 | responseMessage.EnsureSuccessStatusCode(); 199 | response = await responseMessage.Content.ReadAsStringAsync(); 200 | 201 | Assert.Equal("test2", response); 202 | } 203 | } 204 | 205 | using(var host = _webBuilder.BuildVirtualHost("/appbase")) 206 | { 207 | using(var client = host.CreateClient()) 208 | { 209 | var responseMessage = await client.GetAsync("/prefix/test.txt"); 210 | responseMessage.EnsureSuccessStatusCode(); 211 | var response = await responseMessage.Content.ReadAsStringAsync(); 212 | 213 | Assert.Equal("test", response); 214 | 215 | responseMessage = await client.GetAsync("/prefix/nested/test2.txt"); 216 | responseMessage.EnsureSuccessStatusCode(); 217 | response = await responseMessage.Content.ReadAsStringAsync(); 218 | 219 | Assert.Equal("test2", response); 220 | } 221 | } 222 | } 223 | } 224 | } 225 | } --------------------------------------------------------------------------------