diff --git a/src/Phalanx.App/Pages/Index.razor b/src/Phalanx.App/Pages/Index.razor
index 5faed24c3..6e83ce936 100644
--- a/src/Phalanx.App/Pages/Index.razor
+++ b/src/Phalanx.App/Pages/Index.razor
@@ -4,4 +4,5 @@
Edit sample roster
+ Format roster
\ No newline at end of file
diff --git a/src/Phalanx.App/Pages/Printing/RosterFormat.cs b/src/Phalanx.App/Pages/Printing/RosterFormat.cs
new file mode 100644
index 000000000..11181a5e5
--- /dev/null
+++ b/src/Phalanx.App/Pages/Printing/RosterFormat.cs
@@ -0,0 +1,8 @@
+namespace Phalanx.App.Pages.Printing;
+
+public class RosterFormat
+{
+ public string? Name { get; set; }
+
+ public string? HandlebarsTemplate { get; set; }
+}
diff --git a/src/Phalanx.App/Pages/Printing/RosterFormatter.cs b/src/Phalanx.App/Pages/Printing/RosterFormatter.cs
new file mode 100644
index 000000000..de30477e0
--- /dev/null
+++ b/src/Phalanx.App/Pages/Printing/RosterFormatter.cs
@@ -0,0 +1,46 @@
+using HandlebarsDotNet;
+using Microsoft.Extensions.Options;
+using WarHub.ArmouryModel.Source;
+
+namespace Phalanx.App.Pages.Printing;
+
+public class RosterFormatter
+{
+ private readonly Options options;
+
+ public RosterFormatter(IOptions options)
+ {
+ this.options = options.Value;
+ }
+
+ ///
+ /// Handlebars template can reference members of
+ /// by accessing the root context's "roster" property: Name: {{roster.name}}
.
+ ///
+ ///
+ ///
+ ///
+ public string Format(RosterNode roster, RosterFormat format)
+ {
+ var templateBuilder = Handlebars.Compile(format.HandlebarsTemplate);
+ var context = new
+ {
+ roster = roster.Core
+ };
+ return templateBuilder(context);
+ }
+
+ public IEnumerable Formats => options.Formats;
+
+ public class Options
+ {
+ public List Formats { get; } = new()
+ {
+ new()
+ {
+ Name = "Default",
+ HandlebarsTemplate = "Roster \"{{roster.name}}\""
+ }
+ };
+ }
+}
diff --git a/src/Phalanx.App/Pages/RosterPrinter.razor b/src/Phalanx.App/Pages/RosterPrinter.razor
new file mode 100644
index 000000000..1d0eb4081
--- /dev/null
+++ b/src/Phalanx.App/Pages/RosterPrinter.razor
@@ -0,0 +1,106 @@
+@page "/print"
+@using Printing
+@using SampleDataset
+@using WarHub.ArmouryModel.Source
+@using WarHub.ArmouryModel.Workspaces.BattleScribe
+
+@inject Printing.RosterFormatter formatter
+
+Format roster
+
+
+
+
+
+
+ Loaded:
+ @if (RosterNode is null)
+ {
+ none
+ }
+ else
+ {
+ @RosterNode.Name
+ }
+
+
+
+
+
+ @foreach (var (format, index) in formatter.Formats.Select((x, i) => (x, i)))
+ {
+ @format.Name
+ }
+
+@if (selectedFormat is not null)
+{
+
+ Handlebars template @selectedFormat.Name:
+ selectedFormat.HandlebarsTemplate = e.Value?.ToString()" resize="both">
+
+
+
+}
+Format
+
+
+ Formatted output:
+
+ @formattedOutput
+
+
+
+@code {
+ private RosterNode? RosterNode;
+ private string? formattedOutput;
+ private RosterFormat? selectedFormat;
+ private string? selectedFormatIndex;
+ private string? SelectedFormatIndex
+ {
+ get => selectedFormatIndex;
+ set
+ {
+ selectedFormatIndex = value;
+ selectedFormat = int.TryParse(value, out var i)
+ ? formatter.Formats.ElementAtOrDefault(i)
+ : null;
+ }
+ }
+
+ protected override async Task OnInitializedAsync()
+ {
+ await base.OnInitializedAsync();
+ selectedFormat = formatter.Formats.FirstOrDefault();
+ var rosterFile = SampleDataResources.CreateXmlWorkspace().DocumentsByKind[XmlDocumentKind.Roster][0];
+ RosterNode = (RosterNode?)(await rosterFile.GetRootAsync());
+ }
+
+ void OnFormatIndexSelected(ChangeEventArgs e)
+ {
+ SelectedFormatIndex = e.Value?.ToString();
+ }
+
+ async Task LoadRosterFile(InputFileChangeEventArgs eventArgs)
+ {
+ // 10MB
+ const long maxSize = 10 << 10 << 10;
+ using var stream = eventArgs.File.OpenReadStream(maxAllowedSize: maxSize);
+ // it's bad but WHAM doesn't support async reading currently :(
+ // TODO fix when wham gains async support, consider migrating?
+ using var memStream = new MemoryStream();
+ await stream.CopyToAsync(memStream);
+ memStream.Position = 0;
+ RosterNode = (RosterNode)await memStream.LoadSourceAuto(eventArgs.File.Name).GetDataOrThrowAsync();
+ }
+
+ void RunFormatter()
+ {
+ if (RosterNode is null || selectedFormat is null)
+ {
+ formattedOutput = null;
+ return;
+ }
+ formattedOutput = formatter.Format(RosterNode, selectedFormat);
+ }
+}
diff --git a/src/Phalanx.App/Phalanx.App.csproj b/src/Phalanx.App/Phalanx.App.csproj
index df9393d58..674aa8be7 100644
--- a/src/Phalanx.App/Phalanx.App.csproj
+++ b/src/Phalanx.App/Phalanx.App.csproj
@@ -6,8 +6,10 @@
+
+
diff --git a/src/Phalanx.App/Program.cs b/src/Phalanx.App/Program.cs
index 0852df0f4..218e95473 100644
--- a/src/Phalanx.App/Program.cs
+++ b/src/Phalanx.App/Program.cs
@@ -1,11 +1,13 @@
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Phalanx.App;
+using Phalanx.App.Pages.Printing;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add("#app");
builder.RootComponents.Add("head::after");
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+builder.Services.AddScoped();
await builder.Build().RunAsync();