Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:

- name: Pack
run: >-
dotnet pack Imp.csproj
dotnet pack src/Imp/Imp.csproj
--configuration Release
--no-build
--output artifacts
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:

- name: Pack
run: >-
dotnet pack Imp.csproj
dotnet pack src/Imp/Imp.csproj
--configuration Release
--no-build
--output artifacts
Expand Down
18 changes: 18 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
6 changes: 4 additions & 2 deletions Imp.slnx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<Solution>
<Project Path="Imp.csproj" />
<Project Path="Tests/Imp.Tests/Imp.Tests.csproj" />
<Project Path="src/Imp/Imp.csproj" />
<Project Path="src/Imp.Tests/Imp.Tests.csproj" />
<Project Path="Samples/TodoApp/TodoApp.csproj" />
<Project Path="Samples/TodoApp.Tests/TodoApp.Tests.csproj" />
</Solution>
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
<ItemGroup>
<ProjectReference Include="..\Imp\Imp.csproj" />
<PackageReference Include="KitchenPC.Imp" Version="0.1.0" />
</ItemGroup>
```

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
Expand Down
11 changes: 11 additions & 0 deletions Samples/TodoApp.Tests/TodoApp.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="MSTest.Sdk/4.3.3">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="../TodoApp/TodoApp.csproj" />
</ItemGroup>
</Project>
39 changes: 39 additions & 0 deletions Samples/TodoApp.Tests/TodoStoreTests.cs
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => store.Add(" "));
Assert.ThrowsExactly<ArgumentException>(() => store.Add(new string('x', 121)));
}
}
3 changes: 3 additions & 0 deletions Samples/TodoApp/Models/TodoItem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace Imp.Samples.Todo.Models;

public sealed record TodoItem(Guid Id, string Title, bool IsComplete, DateTimeOffset CreatedAt);
62 changes: 62 additions & 0 deletions Samples/TodoApp/Models/TodoStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
namespace Imp.Samples.Todo.Models;

public sealed class TodoStore
{
private readonly object sync = new();
private readonly List<TodoItem> items = [];

public TodoStore()
{
Add("Explore the Imp sample");
Add("Add a new task");
}

public IReadOnlyList<TodoItem> 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);
}
}
27 changes: 27 additions & 0 deletions Samples/TodoApp/PageTemplates/Default.htm
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<PageTemplate>
<Template.Site>
<Content.Title>Imp Todo sample</Content.Title>
<Content.Body>
<p class="eyebrow">ASP.NET Core + Imp</p>
<h1>Keep today manageable.</h1>
<p class="lede">A small in-memory task list demonstrating page routing, dependency injection, query binding, POST handling, loops, dynamic content, and reusable templates.</p>
<Dynamic.Message />
<form class="add-form" method="post">
<Dynamic.Antiforgery />
<input type="hidden" name="action" value="add" />
<label for="title">New task</label>
<div><input id="title" name="title" maxlength="120" required="required" placeholder="What needs doing?" /><button type="submit">Add task</button></div>
</form>
<section class="list-heading">
<div><h2>Tasks</h2><p><Dynamic.Summary /></p></div>
<nav class="filters"><a href="/?Filter=All">All</a><a href="/?Filter=Active">Active</a><a href="/?Filter=Completed">Completed</a></nav>
</section>
<ul class="todo-list"><Loop.Tasks><Dynamic.TaskRow /></Loop.Tasks></ul>
<form method="post">
<Dynamic.Antiforgery />
<input type="hidden" name="action" value="clear" />
<button class="quiet" type="submit">Clear completed</button>
</form>
</Content.Body>
</Template.Site>
</PageTemplate>
11 changes: 11 additions & 0 deletions Samples/TodoApp/PageTemplates/Todo.htm
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<PageTemplate>
<Template.Site>
<Content.Title>Task details · Imp Todo</Content.Title>
<Content.Body>
<a href="/">← All tasks</a>
<p class="eyebrow">Task detail</p>
<h1><Dynamic.Title /></h1>
<Dynamic.Details />
</Content.Body>
</Template.Site>
</PageTemplate>
18 changes: 18 additions & 0 deletions Samples/TodoApp/Pages/About.cs
Original file line number Diff line number Diff line change
@@ -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(
$$"""
<!doctype html>
<html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width" /><title>About Imp Todo</title><link rel="stylesheet" href="/styles/site.css?v=1" /></head>
<body><main class="shell"><a href="/">← Tasks</a><h1>About</h1><p>This page is rendered directly by <code>{{assembly}}.Pages.About.Render</code> without an embedded template.</p></main></body></html>
"""
);
}
}
121 changes: 121 additions & 0 deletions Samples/TodoApp/Pages/Default.cs
Original file line number Diff line number Diff line change
@@ -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(
$"<input type=\"hidden\" name=\"{Html(tokens.FormFieldName)}\" value=\"{Html(tokens.RequestToken)}\" />"
);
}

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 = $"<input type=\"hidden\" name=\"{Html(tokens.FormFieldName)}\" value=\"{Html(tokens.RequestToken)}\" />";

return output.WriteAsync(
$"""
<li class="todo {state}">
<div>
<a class="todo-title" href="/todo/{item.Id}">{Html(item.Title)}</a>
<span class="todo-state">{state}</span>
</div>
<div class="actions">
<form method="post">{token}<input type="hidden" name="action" value="toggle" /><input type="hidden" name="id" value="{item.Id}" /><button type="submit">{action}</button></form>
<form method="post">{token}<input type="hidden" name="action" value="delete" /><input type="hidden" name="id" value="{item.Id}" /><button class="danger" type="submit">Delete</button></form>
</div>
</li>
"""
);
}

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($"<p class=\"message\">{Html(message)}</p>");

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);
}
Loading