From ba59281f6eab8220c6569947e6116f238155435a Mon Sep 17 00:00:00 2001 From: Mike Christensen Date: Fri, 28 Aug 2026 10:17:58 -0700 Subject: [PATCH] Organize source and add Todo sample --- .github/workflows/build.yml | 2 +- .github/workflows/publish.yml | 2 +- .gitignore | 18 +++ Imp.slnx | 6 +- README.md | 19 ++- Samples/TodoApp.Tests/TodoApp.Tests.csproj | 11 ++ Samples/TodoApp.Tests/TodoStoreTests.cs | 39 ++++++ Samples/TodoApp/Models/TodoItem.cs | 3 + Samples/TodoApp/Models/TodoStore.cs | 62 +++++++++ Samples/TodoApp/PageTemplates/Default.htm | 27 ++++ Samples/TodoApp/PageTemplates/Todo.htm | 11 ++ Samples/TodoApp/Pages/About.cs | 18 +++ Samples/TodoApp/Pages/Default.cs | 121 ++++++++++++++++++ Samples/TodoApp/Pages/NotFound.cs | 14 ++ Samples/TodoApp/Pages/Todo.cs | 46 +++++++ Samples/TodoApp/Program.cs | 26 ++++ Samples/TodoApp/README.md | 27 ++++ Samples/TodoApp/Templates/Site.htm | 18 +++ Samples/TodoApp/TodoApp.csproj | 14 ++ Samples/TodoApp/wwwroot/styles/site.css | 37 ++++++ {Tests => src}/Imp.Tests/Imp.Tests.csproj | 2 +- .../Imp.Tests/ImpConfigurationTests.cs | 0 {Tests => src}/Imp.Tests/ProfilePage.cs | 0 {Tests => src}/Imp.Tests/RequestTests.cs | 0 {Tests => src}/Imp.Tests/TestPages.cs | 0 BasePage.cs => src/Imp/BasePage.cs | 0 Imp.csproj => src/Imp/Imp.csproj | 5 +- .../Imp/ImpConfiguration.cs | 0 ImpExtensions.cs => src/Imp/ImpExtensions.cs | 0 ImpMiddleware.cs => src/Imp/ImpMiddleware.cs | 0 Interfaces.cs => src/Imp/Interfaces.cs | 0 NotFoundPage.cs => src/Imp/NotFoundPage.cs | 0 PageCompiler.cs => src/Imp/PageCompiler.cs | 8 +- Request.cs => src/Imp/Request.cs | 0 .../PageTemplateAttribute.cs | 0 .../ResourceTemplateManager.cs | 0 .../TemplateManagers}/SecurePageAttribute.cs | 0 37 files changed, 520 insertions(+), 16 deletions(-) create mode 100644 .gitignore create mode 100644 Samples/TodoApp.Tests/TodoApp.Tests.csproj create mode 100644 Samples/TodoApp.Tests/TodoStoreTests.cs create mode 100644 Samples/TodoApp/Models/TodoItem.cs create mode 100644 Samples/TodoApp/Models/TodoStore.cs create mode 100644 Samples/TodoApp/PageTemplates/Default.htm create mode 100644 Samples/TodoApp/PageTemplates/Todo.htm create mode 100644 Samples/TodoApp/Pages/About.cs create mode 100644 Samples/TodoApp/Pages/Default.cs create mode 100644 Samples/TodoApp/Pages/NotFound.cs create mode 100644 Samples/TodoApp/Pages/Todo.cs create mode 100644 Samples/TodoApp/Program.cs create mode 100644 Samples/TodoApp/README.md create mode 100644 Samples/TodoApp/Templates/Site.htm create mode 100644 Samples/TodoApp/TodoApp.csproj create mode 100644 Samples/TodoApp/wwwroot/styles/site.css rename {Tests => src}/Imp.Tests/Imp.Tests.csproj (84%) rename {Tests => src}/Imp.Tests/ImpConfigurationTests.cs (100%) rename {Tests => src}/Imp.Tests/ProfilePage.cs (100%) rename {Tests => src}/Imp.Tests/RequestTests.cs (100%) rename {Tests => src}/Imp.Tests/TestPages.cs (100%) rename BasePage.cs => src/Imp/BasePage.cs (100%) rename Imp.csproj => src/Imp/Imp.csproj (91%) rename ImpConfiguration.cs => src/Imp/ImpConfiguration.cs (100%) rename ImpExtensions.cs => src/Imp/ImpExtensions.cs (100%) rename ImpMiddleware.cs => src/Imp/ImpMiddleware.cs (100%) rename Interfaces.cs => src/Imp/Interfaces.cs (100%) rename NotFoundPage.cs => src/Imp/NotFoundPage.cs (100%) rename PageCompiler.cs => src/Imp/PageCompiler.cs (98%) rename Request.cs => src/Imp/Request.cs (100%) rename {TemplateManagers => src/Imp/TemplateManagers}/PageTemplateAttribute.cs (100%) rename {TemplateManagers => src/Imp/TemplateManagers}/ResourceTemplateManager.cs (100%) rename {TemplateManagers => src/Imp/TemplateManagers}/SecurePageAttribute.cs (100%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 628610c..6dcde9f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,7 +41,7 @@ jobs: - name: Pack run: >- - dotnet pack Imp.csproj + dotnet pack src/Imp/Imp.csproj --configuration Release --no-build --output artifacts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 946d212..742329e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -47,7 +47,7 @@ jobs: - name: Pack run: >- - dotnet pack Imp.csproj + dotnet pack src/Imp/Imp.csproj --configuration Release --no-build --output artifacts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca93ad5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# User-specific files +*.suo +*.user +*.sln.docstates +.idea/ +.vs/ + +# Build output +[Dd]ebug/ +[Rr]elease/ +[Bb]in/ +[Oo]bj/ +artifacts/ + +# NuGet packages +*.nupkg +**/packages/* +!**/packages/build/ diff --git a/Imp.slnx b/Imp.slnx index 08aa464..76f12a1 100644 --- a/Imp.slnx +++ b/Imp.slnx @@ -1,4 +1,6 @@ - - + + + + diff --git a/README.md b/README.md index 27666fe..7d4a60f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ Imp (In Memory Pages) is a lightweight page framework built on ASP.NET Core middleware. It maps request paths to .NET classes, creates those classes through ASP.NET Core dependency injection, binds query-string values to page properties, and renders the response. Pages can render HTML directly or use HTML templates embedded in the application's assembly. Embedded templates are compiled once and cached in memory, so no page-template files need to be read from disk while the application is running. +## Repository layout + +- `src/Imp` contains the `KitchenPC.Imp` library. +- `src/Imp.Tests` contains the framework unit tests. +- `Samples/TodoApp` is a runnable ASP.NET Core To Do website. +- `Samples/TodoApp.Tests` tests the sample's application behavior. + ## Building and testing Restore, build, and run the fast unit-test suite from the repository root: @@ -17,7 +24,7 @@ The tests use in-memory request objects and do not start a web server or make ne Create a local NuGet package and symbol package with: ```bash -dotnet pack Imp.csproj --configuration Release --output artifacts +dotnet pack src/Imp/Imp.csproj --configuration Release --output artifacts ``` ## Releasing @@ -33,14 +40,20 @@ Package versions on NuGet are immutable. Never reuse a release tag or version; i ## Getting started -Reference the Imp project from an ASP.NET Core application: +Install Imp in an ASP.NET Core application: ```xml - + ``` +For a complete project-reference example, run the [To Do sample](Samples/TodoApp): + +```bash +dotnet run --project Samples/TodoApp/TodoApp.csproj +``` + Register Imp near the end of the ASP.NET Core pipeline in `Startup.cs`: ```csharp diff --git a/Samples/TodoApp.Tests/TodoApp.Tests.csproj b/Samples/TodoApp.Tests/TodoApp.Tests.csproj new file mode 100644 index 0000000..d89a7e7 --- /dev/null +++ b/Samples/TodoApp.Tests/TodoApp.Tests.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + + + + + + diff --git a/Samples/TodoApp.Tests/TodoStoreTests.cs b/Samples/TodoApp.Tests/TodoStoreTests.cs new file mode 100644 index 0000000..3b603a6 --- /dev/null +++ b/Samples/TodoApp.Tests/TodoStoreTests.cs @@ -0,0 +1,39 @@ +using Imp.Samples.Todo.Models; + +namespace Imp.Samples.Todo.Tests; + +[TestClass] +public sealed class TodoStoreTests +{ + [TestMethod] + public void AddTrimsAndStoresTask() + { + var store = new TodoStore(); + + var item = store.Add(" Review Imp "); + + Assert.AreEqual("Review Imp", item.Title); + Assert.AreEqual(item, store.Get(item.Id)); + } + + [TestMethod] + public void ToggleAndClearCompletedUpdateStore() + { + var store = new TodoStore(); + var item = store.Add("Ship sample"); + + Assert.IsTrue(store.Toggle(item.Id)); + Assert.IsTrue(store.Get(item.Id)?.IsComplete); + Assert.AreEqual(1, store.ClearCompleted()); + Assert.IsNull(store.Get(item.Id)); + } + + [TestMethod] + public void AddRejectsBlankAndOversizedTitles() + { + var store = new TodoStore(); + + Assert.ThrowsExactly(() => store.Add(" ")); + Assert.ThrowsExactly(() => store.Add(new string('x', 121))); + } +} diff --git a/Samples/TodoApp/Models/TodoItem.cs b/Samples/TodoApp/Models/TodoItem.cs new file mode 100644 index 0000000..59ddcbc --- /dev/null +++ b/Samples/TodoApp/Models/TodoItem.cs @@ -0,0 +1,3 @@ +namespace Imp.Samples.Todo.Models; + +public sealed record TodoItem(Guid Id, string Title, bool IsComplete, DateTimeOffset CreatedAt); diff --git a/Samples/TodoApp/Models/TodoStore.cs b/Samples/TodoApp/Models/TodoStore.cs new file mode 100644 index 0000000..2b61778 --- /dev/null +++ b/Samples/TodoApp/Models/TodoStore.cs @@ -0,0 +1,62 @@ +namespace Imp.Samples.Todo.Models; + +public sealed class TodoStore +{ + private readonly object sync = new(); + private readonly List items = []; + + public TodoStore() + { + Add("Explore the Imp sample"); + Add("Add a new task"); + } + + public IReadOnlyList GetAll() + { + lock (sync) + return items.OrderBy(item => item.CreatedAt).ToArray(); + } + + public TodoItem? Get(Guid id) + { + lock (sync) + return items.FirstOrDefault(item => item.Id == id); + } + + public TodoItem Add(string title) + { + var normalized = title.Trim(); + if (normalized.Length is < 1 or > 120) + throw new ArgumentException("A task must contain between 1 and 120 characters.", nameof(title)); + + var item = new TodoItem(Guid.NewGuid(), normalized, false, DateTimeOffset.UtcNow); + lock (sync) + items.Add(item); + return item; + } + + public bool Toggle(Guid id) + { + lock (sync) + { + var index = items.FindIndex(item => item.Id == id); + if (index < 0) + return false; + + items[index] = items[index] with { IsComplete = !items[index].IsComplete }; + return true; + } + } + + public bool Delete(Guid id) + { + lock (sync) + return items.RemoveAll(item => item.Id == id) > 0; + } + + public int ClearCompleted() + { + lock (sync) + return items.RemoveAll(item => item.IsComplete); + } +} diff --git a/Samples/TodoApp/PageTemplates/Default.htm b/Samples/TodoApp/PageTemplates/Default.htm new file mode 100644 index 0000000..1076814 --- /dev/null +++ b/Samples/TodoApp/PageTemplates/Default.htm @@ -0,0 +1,27 @@ + + + Imp Todo sample + +

ASP.NET Core + Imp

+

Keep today manageable.

+

A small in-memory task list demonstrating page routing, dependency injection, query binding, POST handling, loops, dynamic content, and reusable templates.

+ +
+ + + +
+ +
+

Tasks

+ +
+
+
+ + + + +
+
+
diff --git a/Samples/TodoApp/PageTemplates/Todo.htm b/Samples/TodoApp/PageTemplates/Todo.htm new file mode 100644 index 0000000..5533afe --- /dev/null +++ b/Samples/TodoApp/PageTemplates/Todo.htm @@ -0,0 +1,11 @@ + + + Task details · Imp Todo + + ← All tasks +

Task detail

+

+ +
+
+
diff --git a/Samples/TodoApp/Pages/About.cs b/Samples/TodoApp/Pages/About.cs new file mode 100644 index 0000000..74354e3 --- /dev/null +++ b/Samples/TodoApp/Pages/About.cs @@ -0,0 +1,18 @@ +using System.Text.Encodings.Web; + +namespace Imp.Samples.Todo.Pages; + +public sealed class About : BasePage +{ + public override Task Render(HttpResponse response) + { + var assembly = HtmlEncoder.Default.Encode(typeof(About).Assembly.GetName().Name ?? "TodoApp"); + return response.WriteAsync( + $$""" + + About Imp Todo +
← Tasks

About

This page is rendered directly by {{assembly}}.Pages.About.Render without an embedded template.

+ """ + ); + } +} diff --git a/Samples/TodoApp/Pages/Default.cs b/Samples/TodoApp/Pages/Default.cs new file mode 100644 index 0000000..542b02e --- /dev/null +++ b/Samples/TodoApp/Pages/Default.cs @@ -0,0 +1,121 @@ +using System.Collections; +using System.IO; +using System.Text.Encodings.Web; +using Imp.Samples.Todo.Models; +using Imp.TemplateManagers; +using Microsoft.AspNetCore.Antiforgery; + +namespace Imp.Samples.Todo.Pages; + +public enum TodoFilter +{ + All, + Active, + Completed, +} + +[PageTemplate("Imp.Samples.Todo.PageTemplates.Default.htm")] +public sealed class Default(TodoStore store, IAntiforgery antiforgery) : BasePage, IAsyncPostable +{ + private string? message; + + public TodoFilter Filter { get; set; } + + public IEnumerable Tasks() => + store + .GetAll() + .Where(item => + Filter == TodoFilter.All + || (Filter == TodoFilter.Active && !item.IsComplete) + || (Filter == TodoFilter.Completed && item.IsComplete) + ) + .ToArray(); + + public Task Antiforgery(TextWriter output, DynamicContentArgs args) + { + var tokens = antiforgery.GetAndStoreTokens(Request.HttpContext); + return output.WriteAsync( + $"" + ); + } + + public Task TaskRow(TextWriter output, DynamicContentArgs args) + { + var item = (TodoItem)args.LoopValue; + var state = item.IsComplete ? "complete" : "active"; + var action = item.IsComplete ? "Reopen" : "Complete"; + var tokens = antiforgery.GetAndStoreTokens(Request.HttpContext); + var token = $""; + + return output.WriteAsync( + $""" +
  • +
    + {Html(item.Title)} + {state} +
    +
    +
    {token}
    +
    {token}
    +
    +
  • + """ + ); + } + + public Task Summary(TextWriter output, DynamicContentArgs args) + { + var all = store.GetAll(); + var remaining = all.Count(item => !item.IsComplete); + return output.WriteAsync($"{remaining} remaining · {all.Count - remaining} completed"); + } + + public Task Message(TextWriter output, DynamicContentArgs args) => + string.IsNullOrWhiteSpace(message) + ? Task.CompletedTask + : output.WriteAsync($"

    {Html(message)}

    "); + + public async Task PostbackAsync(HttpResponse response) + { + try + { + await antiforgery.ValidateRequestAsync(Request.HttpContext); + var form = await Request.ReadFormAsync(); + var action = form["action"].ToString(); + + if (action == "add") + { + store.Add(form["title"].ToString()); + message = "Task added."; + } + else if (action == "clear") + { + message = $"Removed {store.ClearCompleted()} completed task(s)."; + } + else if (Guid.TryParse(form["id"], out var id) && action == "toggle") + { + message = store.Toggle(id) ? "Task updated." : "Task was not found."; + } + else if (Guid.TryParse(form["id"], out id) && action == "delete") + { + message = store.Delete(id) ? "Task deleted." : "Task was not found."; + } + else + { + message = "The requested action was not recognized."; + } + } + catch (AntiforgeryValidationException) + { + response.StatusCode = StatusCodes.Status400BadRequest; + message = "The form expired. Reload the page and try again."; + } + catch (ArgumentException exception) + { + response.StatusCode = StatusCodes.Status400BadRequest; + message = exception.Message; + } + } + + private static string Html(string? value) => HtmlEncoder.Default.Encode(value ?? string.Empty); +} diff --git a/Samples/TodoApp/Pages/NotFound.cs b/Samples/TodoApp/Pages/NotFound.cs new file mode 100644 index 0000000..d059177 --- /dev/null +++ b/Samples/TodoApp/Pages/NotFound.cs @@ -0,0 +1,14 @@ +namespace Imp.Samples.Todo.Pages; + +public sealed class NotFound : BasePage +{ + public override void PreRender(HttpResponse response) => + response.StatusCode = StatusCodes.Status404NotFound; + + public override Task Render(HttpResponse response) => + response.WriteAsync( + """ + Not found

    404

    Page not found

    Imp could not map this URL to a page class or custom route.

    Return to the task list
    + """ + ); +} diff --git a/Samples/TodoApp/Pages/Todo.cs b/Samples/TodoApp/Pages/Todo.cs new file mode 100644 index 0000000..f310845 --- /dev/null +++ b/Samples/TodoApp/Pages/Todo.cs @@ -0,0 +1,46 @@ +using System.IO; +using System.Text.Encodings.Web; +using Imp.Samples.Todo.Models; +using Imp.TemplateManagers; +using Microsoft.AspNetCore.Http; + +namespace Imp.Samples.Todo.Pages; + +[PageTemplate("Imp.Samples.Todo.PageTemplates.Todo.htm")] +public sealed class Todo(TodoStore store) : BasePage +{ + private TodoItem? item; + + public static bool TryMatch(PathString path, out Guid id) + { + var segments = path.Value?.Trim('/').Split('/', StringSplitOptions.RemoveEmptyEntries); + id = Guid.Empty; + return segments is [var route, var value] + && string.Equals(route, "todo", StringComparison.OrdinalIgnoreCase) + && Guid.TryParse(value, out id); + } + + public override void PreRender(HttpResponse response) + { + if (TryMatch(Request.Path, out var id)) + item = store.Get(id); + if (item is null) + response.StatusCode = StatusCodes.Status404NotFound; + } + + public Task Title(TextWriter output, DynamicContentArgs args) => + output.WriteAsync(Html(item?.Title ?? "Task not found")); + + public Task Details(TextWriter output, DynamicContentArgs args) + { + if (item is null) + return output.WriteAsync("

    This task no longer exists.

    "); + + var status = item.IsComplete ? "Completed" : "Active"; + return output.WriteAsync( + $"
    Status
    {status}
    Created
    {item.CreatedAt.LocalDateTime:g}
    " + ); + } + + private static string Html(string value) => HtmlEncoder.Default.Encode(value); +} diff --git a/Samples/TodoApp/Program.cs b/Samples/TodoApp/Program.cs new file mode 100644 index 0000000..03a6e45 --- /dev/null +++ b/Samples/TodoApp/Program.cs @@ -0,0 +1,26 @@ +using Imp; +using Imp.Samples.Todo.Models; +using Imp.Samples.Todo.Pages; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddAntiforgery(); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +app.UseStaticFiles(); +app.UseImp(config => + config + .PageAssembly(typeof(Program).Assembly) + .RootPageNamespace("Imp.Samples.Todo.Pages") + .RootTemplateNamespace("Imp.Samples.Todo.Templates") + .NotFoundPageType() + .OnNotFound(request => Todo.TryMatch(request.Path, out _) ? typeof(Todo) : null) + .CdnPrefix(string.Empty) + .OnBuildCdnPath(path => path + "?v=1") + .OnPreRender((_, response) => response.Headers["X-Rendered-By"] = "Imp") +); + +app.Run(); + +public partial class Program { } diff --git a/Samples/TodoApp/README.md b/Samples/TodoApp/README.md new file mode 100644 index 0000000..4445932 --- /dev/null +++ b/Samples/TodoApp/README.md @@ -0,0 +1,27 @@ +# Imp Todo sample + +A small ASP.NET Core website demonstrating Imp with an in-memory task list. It supports adding, completing, reopening, deleting, filtering, and viewing tasks. Data is intentionally process-local and resets whenever the application restarts. + +## Run + +From the repository root: + +```bash +dotnet run --project Samples/TodoApp/TodoApp.csproj +``` + +Open the HTTP URL printed by ASP.NET Core, normally `http://localhost:5000`. + +## Imp features demonstrated + +- `Program.cs` configures page/template namespaces, custom not-found routing, CDN-aware static paths, and a pre-render response header. +- `Pages/Default.cs` demonstrates constructor injection, an enum query property, `IAsyncPostable`, antiforgery validation, dynamic methods, and a loop data source. +- `PageTemplates/Default.htm` is an embedded page template. +- `Templates/Site.htm` is a reusable layout populated through placeholders. +- `/todo/{id}` demonstrates `OnNotFound` as a dynamic permalink route. +- `/about` demonstrates direct rendering without a page template. +- Unknown paths use the configured 404 page. + +Try `/?Filter=Active` and inspect the `X-Rendered-By: Imp` response header. The stylesheet uses a `Cdn.href` template attribute; this sample leaves the prefix local and adds a cache-version query through `OnBuildCdnPath`. + +This sample intentionally does not demonstrate `[SecurePage]`. Authentication should be configured using the host application's real ASP.NET Core authentication scheme rather than a sample-only shortcut. diff --git a/Samples/TodoApp/Templates/Site.htm b/Samples/TodoApp/Templates/Site.htm new file mode 100644 index 0000000..0ebb30e --- /dev/null +++ b/Samples/TodoApp/Templates/Site.htm @@ -0,0 +1,18 @@ + diff --git a/Samples/TodoApp/TodoApp.csproj b/Samples/TodoApp/TodoApp.csproj new file mode 100644 index 0000000..38ecbcd --- /dev/null +++ b/Samples/TodoApp/TodoApp.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + Imp.Samples.Todo + + + + + + + + diff --git a/Samples/TodoApp/wwwroot/styles/site.css b/Samples/TodoApp/wwwroot/styles/site.css new file mode 100644 index 0000000..fb255dd --- /dev/null +++ b/Samples/TodoApp/wwwroot/styles/site.css @@ -0,0 +1,37 @@ +:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #17202a; background: #f4f1ea; } +* { box-sizing: border-box; } +body { margin: 0; } +a { color: #245c52; } +button, input { font: inherit; } +.site-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem max(1rem, calc((100% - 760px) / 2)); background: #173f38; color: white; } +.site-header a { color: white; text-decoration: none; } +.site-header nav { display: flex; gap: 1rem; } +.brand { font-weight: 800; letter-spacing: .02em; } +.shell { width: min(760px, calc(100% - 2rem)); margin: 4rem auto; } +.eyebrow { color: #b04c2f; font-size: .78rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; } +h1 { max-width: 650px; margin: .25rem 0 .75rem; font-family: Georgia, serif; font-size: clamp(2.4rem, 8vw, 4.5rem); line-height: .98; } +.lede { max-width: 650px; color: #51605d; line-height: 1.65; } +.message { padding: .75rem 1rem; border-left: 4px solid #d08a3c; background: #fff8e9; } +.add-form { margin: 2.5rem 0; padding: 1.25rem; border: 1px solid #d8d2c5; border-radius: 12px; background: white; box-shadow: 0 12px 35px #273b3510; } +.add-form label { display: block; margin-bottom: .5rem; font-weight: 700; } +.add-form div { display: flex; gap: .6rem; } +.add-form input { flex: 1; min-width: 0; padding: .75rem; border: 1px solid #aaa; border-radius: 7px; } +button { padding: .65rem .9rem; border: 0; border-radius: 7px; background: #245c52; color: white; cursor: pointer; } +button.danger { background: #a33d2a; } +button.quiet { background: transparent; color: #245c52; text-decoration: underline; } +.list-heading { display: flex; justify-content: space-between; align-items: end; gap: 1rem; } +.list-heading h2, .list-heading p { margin: 0; } +.list-heading p { color: #68736f; } +.filters { display: flex; gap: .75rem; } +.todo-list { display: grid; gap: .75rem; padding: 0; list-style: none; } +.todo { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid #d8d2c5; border-radius: 10px; background: white; } +.todo.complete .todo-title { color: #77817e; text-decoration: line-through; } +.todo-title { display: block; color: #17202a; font-weight: 700; text-decoration: none; } +.todo-state { color: #71807c; font-size: .75rem; text-transform: uppercase; } +.actions { display: flex; gap: .4rem; } +.actions form { margin: 0; } +dl { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1rem; padding: 1.25rem; border-radius: 10px; background: white; } +dt { font-weight: 700; } +dd { margin: 0; } +footer { padding: 2rem 1rem; color: #6e7774; text-align: center; } +@media (max-width: 600px) { .shell { margin-top: 2rem; } .list-heading, .todo { align-items: stretch; flex-direction: column; } .actions { flex-wrap: wrap; } .add-form div { flex-direction: column; } } diff --git a/Tests/Imp.Tests/Imp.Tests.csproj b/src/Imp.Tests/Imp.Tests.csproj similarity index 84% rename from Tests/Imp.Tests/Imp.Tests.csproj rename to src/Imp.Tests/Imp.Tests.csproj index 22f898c..9e727d2 100644 --- a/Tests/Imp.Tests/Imp.Tests.csproj +++ b/src/Imp.Tests/Imp.Tests.csproj @@ -7,6 +7,6 @@ - + diff --git a/Tests/Imp.Tests/ImpConfigurationTests.cs b/src/Imp.Tests/ImpConfigurationTests.cs similarity index 100% rename from Tests/Imp.Tests/ImpConfigurationTests.cs rename to src/Imp.Tests/ImpConfigurationTests.cs diff --git a/Tests/Imp.Tests/ProfilePage.cs b/src/Imp.Tests/ProfilePage.cs similarity index 100% rename from Tests/Imp.Tests/ProfilePage.cs rename to src/Imp.Tests/ProfilePage.cs diff --git a/Tests/Imp.Tests/RequestTests.cs b/src/Imp.Tests/RequestTests.cs similarity index 100% rename from Tests/Imp.Tests/RequestTests.cs rename to src/Imp.Tests/RequestTests.cs diff --git a/Tests/Imp.Tests/TestPages.cs b/src/Imp.Tests/TestPages.cs similarity index 100% rename from Tests/Imp.Tests/TestPages.cs rename to src/Imp.Tests/TestPages.cs diff --git a/BasePage.cs b/src/Imp/BasePage.cs similarity index 100% rename from BasePage.cs rename to src/Imp/BasePage.cs diff --git a/Imp.csproj b/src/Imp/Imp.csproj similarity index 91% rename from Imp.csproj rename to src/Imp/Imp.csproj index c2950d1..57e0daf 100644 --- a/Imp.csproj +++ b/src/Imp/Imp.csproj @@ -31,9 +31,8 @@ - - - + + diff --git a/ImpConfiguration.cs b/src/Imp/ImpConfiguration.cs similarity index 100% rename from ImpConfiguration.cs rename to src/Imp/ImpConfiguration.cs diff --git a/ImpExtensions.cs b/src/Imp/ImpExtensions.cs similarity index 100% rename from ImpExtensions.cs rename to src/Imp/ImpExtensions.cs diff --git a/ImpMiddleware.cs b/src/Imp/ImpMiddleware.cs similarity index 100% rename from ImpMiddleware.cs rename to src/Imp/ImpMiddleware.cs diff --git a/Interfaces.cs b/src/Imp/Interfaces.cs similarity index 100% rename from Interfaces.cs rename to src/Imp/Interfaces.cs diff --git a/NotFoundPage.cs b/src/Imp/NotFoundPage.cs similarity index 100% rename from NotFoundPage.cs rename to src/Imp/NotFoundPage.cs diff --git a/PageCompiler.cs b/src/Imp/PageCompiler.cs similarity index 98% rename from PageCompiler.cs rename to src/Imp/PageCompiler.cs index 879ceac..87e7dee 100644 --- a/PageCompiler.cs +++ b/src/Imp/PageCompiler.cs @@ -155,11 +155,9 @@ public CompiledPage(ChunkList chunks) public async Task Render(BasePage page, Stream output) { - using (var writer = new StreamWriter(output)) - { - await Render(page, writer, null); - await writer.FlushAsync(); - } + var writer = new StreamWriter(output, new UTF8Encoding(false), 1024, leaveOpen: true); + await Render(page, writer, null); + await writer.FlushAsync(); } public async Task Render(BasePage page, TextWriter output, object loopValue) diff --git a/Request.cs b/src/Imp/Request.cs similarity index 100% rename from Request.cs rename to src/Imp/Request.cs diff --git a/TemplateManagers/PageTemplateAttribute.cs b/src/Imp/TemplateManagers/PageTemplateAttribute.cs similarity index 100% rename from TemplateManagers/PageTemplateAttribute.cs rename to src/Imp/TemplateManagers/PageTemplateAttribute.cs diff --git a/TemplateManagers/ResourceTemplateManager.cs b/src/Imp/TemplateManagers/ResourceTemplateManager.cs similarity index 100% rename from TemplateManagers/ResourceTemplateManager.cs rename to src/Imp/TemplateManagers/ResourceTemplateManager.cs diff --git a/TemplateManagers/SecurePageAttribute.cs b/src/Imp/TemplateManagers/SecurePageAttribute.cs similarity index 100% rename from TemplateManagers/SecurePageAttribute.cs rename to src/Imp/TemplateManagers/SecurePageAttribute.cs