Claims { get; set; } = [];
+
+ [Parameter] public string Id { get; set; } = "capabilities";
+
+ private int Established => Claims.Count(c => c.IsClaimed);
+
+ ///
+ /// Accent for a documented yes, amber for a documented no, and the unknown tone for the rest —
+ /// the site's one colour rule (measured / declared / absent) applied to a table where every cell
+ /// is on the declared side of it.
+ ///
+ private static string Tone(CapabilityState state) => state switch
+ {
+ CapabilityState.Present => "state-present",
+ CapabilityState.Absent => "state-absent",
+ _ => "state-unknown",
+ };
+
+ ///
+ /// The host, not the whole URL. A citation column of eighty-character links is unreadable, and
+ /// the link itself still goes where it says.
+ ///
+ private static string Host(string source) =>
+ Uri.TryCreate(source, UriKind.Absolute, out var uri) ? uri.Host : source;
+}
diff --git a/src/MUI.Web/Components/Layout/MainLayout.razor b/src/MUI.Web/Components/Layout/MainLayout.razor
index 91e6554..b0ce15a 100644
--- a/src/MUI.Web/Components/Layout/MainLayout.razor
+++ b/src/MUI.Web/Components/Layout/MainLayout.razor
@@ -25,6 +25,7 @@
games
archive
random
+ reference
ecosystem
rankings
about
diff --git a/src/MUI.Web/Components/Pages/Games.razor b/src/MUI.Web/Components/Pages/Games.razor
index 56e3e02..9689e31 100644
--- a/src/MUI.Web/Components/Pages/Games.razor
+++ b/src/MUI.Web/Components/Pages/Games.razor
@@ -30,6 +30,18 @@ else
{
+ @if (Filter.CodebaseFamily is { } family)
+ {
+ @* The filter a reference page links in on. It is a filter and not a search, so it says
+ which family it is showing and offers the way back out — a reader who arrived from
+ /reference/codebases/pennmush should not have to guess why the listing is short. *@
+
+ codebase @family ·
+ what this codebase is ·
+ every game
+
+ }
+
@Listing.Games.Count games · counts measured, never asserted ·
show me a random one
diff --git a/src/MUI.Web/Components/Pages/ReferenceEntry.razor b/src/MUI.Web/Components/Pages/ReferenceEntry.razor
new file mode 100644
index 0000000..8c23aad
--- /dev/null
+++ b/src/MUI.Web/Components/Pages/ReferenceEntry.razor
@@ -0,0 +1,205 @@
+@page "/reference/{Slug}"
+@page "/reference/codebases/{Slug}"
+@page "/reference/clients/{Slug}"
+@page "/reference/protocols/{Slug}"
+@using MUI.Web.Reference
+@inject IGameQueries Queries
+@inject NavigationManager Navigation
+
+@*
+ One reference page, whichever kind it is.
+
+ The four routes resolve through the document's own Path rather than through a kind parameter, so
+ a page's URL has exactly one spelling — the one every link on the site is built from. A kind
+ passed separately would let a link and a route disagree, and the failure would be a page that
+ renders the wrong measurement beside the right prose.
+
+ The prose is ours and the figures are measured, and that split is the reason the section exists.
+ Nothing below reads a number out of a content file; every count is a query made on this request.
+*@
+
+@if (Document is null)
+{
+
Not found
+
+ No reference page here. This section is hand-written, so a gap is work nobody has done rather
+ than something that was removed — see what there is.
+
+}
+else if (Plain)
+{
+ @ReferencePlainText.Render(Document, Codebase, Protocol, Related)
+}
+else
+{
+ @Document.Title — mu*index
+
+
+
+ @ReferencePlainText.Kind(Document.Kind)
+ @Document.Title
+ @Document.Summary
+
+ @if (Document.Home is { } home)
+ {
+ @home
+ }
+ @if (Document.Platforms.Count > 0)
+ {
+ · @string.Join(", ", Document.Platforms)
+ }
+
+
+
+ @if (Codebase is { } codebase)
+ {
+
+ Games running it
+ @if (codebase.Known == 0)
+ {
+ @* Never a zero standing on its own. Zero here is a statement about our coverage
+ and reads as a statement about the codebase unless it is spelled out. *@
+
+ We have not identified any yet. That is a fact about what this crawler has
+ measured, not about what exists — a game we have not reached, or whose
+ codebase we could not read, is not counted here.
+
+ }
+ else
+ {
+
+ @codebase.Listed listed
+ · @codebase.Archived archived · measured, never asserted
+
+
+ Counted from the catalogue on this request, over the same filter the link
+ above carries — so this number and that listing are one query and cannot
+ drift apart.
+
+ @if (codebase.MeasuredProtocols.Count > 0)
+ {
+
+ offered in their handshakes:
+ @string.Join(", ", codebase.MeasuredProtocols)
+
+ }
+ }
+
+ }
+
+ @if (Protocol is { } protocol)
+ {
+
+ Measured adoption
+ @if (protocol.Listed == 0)
+ {
+ Nothing measured yet.
+ }
+ else
+ {
+
+ @protocol.Offering
+
+ of @protocol.Listed listed games — @Wording.Percent(protocol.Share ?? 0)
+
+
+ @ReferencePlainText.ProtocolRemainderCaveat
+
+ @if (MeasuredRows.Count > 0)
+ {
+
+
+ Games observed offering @Document.Title in a handshake, by the
+ codebase we identified them as running.
+
+
+
+ | codebase |
+ offered it |
+ identified |
+
+
+
+ @foreach (var row in MeasuredRows)
+ {
+
+ | @row.Codebase |
+
+ @row.Offering
+ |
+ @row.Identified |
+
+ }
+
+
+ }
+ }
+
+ }
+
+ @if (Document.Kind is ReferenceKind.Client)
+ {
+
+ }
+
+ @((MarkupString)Html)
+
+ @if (Related.Count > 0)
+ {
+ See also
+
+ @foreach (var other in Related)
+ {
+ -
+ @other.Title
+ @other.Summary
+
+ }
+
+ }
+
+ read this page as plain text
+
+}
+
+@code {
+ [Parameter] public string Slug { get; set; } = string.Empty;
+
+ [SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; }
+
+ private bool Plain => Truthy.Is(PlainFlag);
+
+ private ReferenceDocument? Document;
+ private CodebaseFigures? Codebase;
+ private ProtocolFigures? Protocol;
+ private IReadOnlyList Related = [];
+ private IReadOnlyList Claims = [];
+ private string Html = string.Empty;
+
+ private IReadOnlyList MeasuredRows =>
+ [.. (Protocol?.ByCodebase ?? []).Where(r => r.IsMeasured)];
+
+ protected override async Task OnParametersSetAsync()
+ {
+ var library = ReferenceLibrary.Shipped;
+
+ Document = library.Find(new Uri(Navigation.Uri).LocalPath.TrimEnd('/'));
+
+ if (Document is null)
+ {
+ return;
+ }
+
+ Related = library.Related(Document);
+ Claims = ClientCapabilities.For(Document);
+ Html = ReferenceMarkdown.ToHtml(Document.Body);
+
+ Codebase = Document.Codebase is { } family
+ ? await CodebaseFigures.ReadAsync(Queries, family)
+ : null;
+
+ Protocol = Document.Protocol is { } protocol
+ ? await ProtocolFigures.ReadAsync(Queries, protocol, library.OfKind(ReferenceKind.Codebase))
+ : null;
+ }
+}
diff --git a/src/MUI.Web/Components/Pages/ReferenceIndex.razor b/src/MUI.Web/Components/Pages/ReferenceIndex.razor
new file mode 100644
index 0000000..beccbe5
--- /dev/null
+++ b/src/MUI.Web/Components/Pages/ReferenceIndex.razor
@@ -0,0 +1,66 @@
+@page "/reference"
+@using MUI.Web.Reference
+
+@*
+ The reference section's front door.
+
+ Spec §9's argument for this whole section is that it obtains wiki value without wiki governance:
+ the content is curated, single-author and versioned in git. That has to be said on the page —
+ a reader who assumes they are looking at a wiki will read a gap as vandalism rather than as work
+ nobody has done yet, and will look for an edit button that will never exist.
+*@
+
+@if (Plain)
+{
+ @ReferencePlainText.RenderIndex(Library)
+}
+else
+{
+ Reference — mu*index
+
+ Reference
+
+ What the codebases are, what the clients do, and what the protocols mean. Written by hand and
+ kept in the repository beside the crawler — not a wiki, and there is nothing on this page to
+ edit. Every number here is a different thing: it comes from the catalogue and is
+ recomputed each time you load the page.
+
+
+ @foreach (var kind in Kinds)
+ {
+ var documents = Library.OfKind(kind);
+
+ if (documents.Count == 0)
+ {
+ continue;
+ }
+
+
+ @ReferencePlainText.Heading(kind)
+
+ @foreach (var document in documents)
+ {
+ -
+ @document.Title
+ @document.Summary
+
+ }
+
+
+ }
+
+ read this page as plain text
+}
+
+@code {
+ [SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; }
+
+ private bool Plain => Truthy.Is(PlainFlag);
+
+ private static readonly ReferenceKind[] Kinds =
+ [
+ ReferenceKind.Orientation, ReferenceKind.Codebase, ReferenceKind.Client, ReferenceKind.Protocol,
+ ];
+
+ private static ReferenceLibrary Library => ReferenceLibrary.Shipped;
+}
diff --git a/src/MUI.Web/Components/PlainText.cs b/src/MUI.Web/Components/PlainText.cs
index c18345d..ffc40e5 100644
--- a/src/MUI.Web/Components/PlainText.cs
+++ b/src/MUI.Web/Components/PlainText.cs
@@ -618,7 +618,7 @@ private static void Heading(StringBuilder b, string title)
}
/// Wraps to 80 columns, because that is the width a text browser has.
- private static void Wrap(StringBuilder b, string text, string indent = "")
+ internal static void Wrap(StringBuilder b, string text, string indent = "")
{
var width = Columns - indent.Length;
var line = new StringBuilder();
diff --git a/src/MUI.Web/Components/ReferencePlainText.cs b/src/MUI.Web/Components/ReferencePlainText.cs
new file mode 100644
index 0000000..d0669b7
--- /dev/null
+++ b/src/MUI.Web/Components/ReferencePlainText.cs
@@ -0,0 +1,253 @@
+using System.Text;
+
+using MUI.Catalog;
+using MUI.Web.Reference;
+
+namespace MUI.Web.Components;
+
+///
+/// The reference section in plain text.
+///
+///
+///
+/// Same rule as the rest of the plain surface: if a fact cannot survive here, its graphic on the
+/// main page is decoration. For this section that bites hardest on the matrices — a client
+/// capability table carried by a tick, a cross and a blank does not survive, and one carried by
+/// yes, no and unknown does.
+///
+///
+/// The prose goes through the Markdown renderer's text output and the structured half is rendered
+/// here from the same records the graphical page uses, so the two surfaces cannot report different
+/// counts. Nothing here re-reads the content files.
+///
+///
+public static class ReferencePlainText
+{
+ ///
+ /// The one sentence that keeps a client matrix honest, on both surfaces.
+ ///
+ ///
+ /// A client cannot be probed — there is no handshake of ours to observe — so every cell is a
+ /// reading of somebody's documentation. Saying so beside the table is not modesty: this site's
+ /// whole claim is that a reader can tell a measurement from an assertion, and a table that looks
+ /// like the game pages' measured matrix while being neither would spend that credit.
+ ///
+ public const string ClientMatrixCaveat =
+ "Read off each project's own documentation, not measured by us — a client has no handshake "
+ + "for us to observe. \"unknown\" means we looked and did not establish it. It never means no.";
+
+ ///
+ /// The sentence a protocol page's remainder needs, on both surfaces.
+ ///
+ public const string ProtocolRemainderCaveat =
+ "The games not counted here are not games without the protocol. A game is counted when we "
+ + "observed its server offering the option in a handshake; the rest are servers that did not "
+ + "offer it to us and servers whose handshake we have not read, and we cannot tell you which.";
+
+ public static string Render(
+ ReferenceDocument document,
+ CodebaseFigures? codebase = null,
+ ProtocolFigures? protocol = null,
+ IReadOnlyList? related = null)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ var b = new StringBuilder();
+
+ b.AppendLine($"{document.Title.ToUpperInvariant()} [{Kind(document.Kind)}]");
+ PlainText.Wrap(b, document.Summary);
+
+ if (document.Home is { } home)
+ {
+ b.AppendLine(home);
+ }
+
+ if (document.Platforms.Count > 0)
+ {
+ b.AppendLine($"Runs on: {string.Join(", ", document.Platforms)}");
+ }
+
+ if (codebase is not null)
+ {
+ AppendCodebase(b, document, codebase);
+ }
+
+ if (protocol is not null)
+ {
+ AppendProtocol(b, document, protocol);
+ }
+
+ if (document.Kind is ReferenceKind.Client)
+ {
+ AppendClientMatrix(b, document);
+ }
+
+ b.AppendLine();
+ b.AppendLine(ReferenceMarkdown.ToPlainText(document.Body));
+
+ if (related is { Count: > 0 })
+ {
+ b.AppendLine();
+ b.AppendLine("See also");
+ foreach (var other in related)
+ {
+ b.AppendLine($" {other.Title} — {other.Path}");
+ }
+ }
+
+ return b.ToString();
+ }
+
+ ///
+ /// The measured half of a codebase page. It says we identified rather than there
+ /// are, because a game whose codebase we could not read is not a game running something
+ /// else — and the difference is the whole reason the figure is worth printing.
+ ///
+ private static void AppendCodebase(StringBuilder b, ReferenceDocument document, CodebaseFigures figures)
+ {
+ b.AppendLine();
+ b.AppendLine("Games we have identified as running this codebase");
+
+ if (figures.Known == 0)
+ {
+ PlainText.Wrap(b, "None yet. That is a statement about what we have measured, not about "
+ + "what exists — a game we have not reached, or whose codebase we could not read, is "
+ + "not counted here.", " ");
+ return;
+ }
+
+ b.AppendLine($" {figures.Listed} listed, {figures.Archived} archived");
+
+ if (document.GamesPath is { } path)
+ {
+ b.AppendLine($" {path}");
+ }
+
+ b.AppendLine(figures.MeasuredProtocols.Count > 0
+ ? $" Measured in their handshakes: {string.Join(", ", figures.MeasuredProtocols)}"
+ : " Nothing was offered in any handshake we have read from them.");
+ }
+
+ private static void AppendProtocol(StringBuilder b, ReferenceDocument document, ProtocolFigures figures)
+ {
+ b.AppendLine();
+ b.AppendLine("Measured adoption");
+
+ if (figures.Listed == 0)
+ {
+ b.AppendLine(" Nothing measured yet.");
+ return;
+ }
+
+ b.AppendLine($" {figures.Offering} of {figures.Listed} listed games were observed offering it "
+ + $"({Wording.Percent(figures.Share ?? 0)})");
+
+ if (document.GamesPath is { } path)
+ {
+ b.AppendLine($" {path}");
+ }
+
+ b.AppendLine();
+ PlainText.Wrap(b, ProtocolRemainderCaveat, " ");
+
+ var rows = figures.ByCodebase.Where(r => r.IsMeasured).ToList();
+
+ if (rows.Count == 0)
+ {
+ return;
+ }
+
+ b.AppendLine();
+ b.AppendLine(" By codebase, of the games we identified");
+ foreach (var row in rows)
+ {
+ b.AppendLine($" {row.Codebase,-20} {row.Offering,4} of {row.Identified,-4} offered it");
+ }
+ }
+
+ private static void AppendClientMatrix(StringBuilder b, ReferenceDocument document)
+ {
+ b.AppendLine();
+ b.AppendLine("Capabilities");
+ PlainText.Wrap(b, ClientMatrixCaveat, " ");
+ b.AppendLine();
+
+ var claims = ClientCapabilities.For(document);
+
+ foreach (var claim in claims)
+ {
+ b.AppendLine($" {claim.Name,-16} {ClientCapabilities.Word(claim.State)}");
+
+ // The source on its own line, unwrapped. A URL broken across two lines is not a URL, and
+ // this is the one place the eighty-column rule gives way to a thing being usable.
+ if (claim.Source is { } source)
+ {
+ b.AppendLine($" {source}");
+ }
+ }
+
+ var unknown = claims.Count(c => c.State is CapabilityState.Unknown);
+
+ if (unknown > 0)
+ {
+ b.AppendLine();
+ PlainText.Wrap(b, $"{unknown} of {claims.Count} rows are unknown: we did not find the "
+ + "project's own documentation saying either way. A short honest table beats a long "
+ + "guessed one.", " ");
+ }
+ }
+
+ /// The index, which is the only page in the section that is a list of the others.
+ public static string RenderIndex(ReferenceLibrary library)
+ {
+ ArgumentNullException.ThrowIfNull(library);
+
+ var b = new StringBuilder();
+
+ b.AppendLine("REFERENCE");
+ PlainText.Wrap(b, "Hand-written, single-author, and versioned in git. The prose here is ours; "
+ + "every number beside it was measured by the crawler and is recomputed on each request. "
+ + "This is not a wiki, and there is no way to edit it from this page.");
+
+ foreach (var kind in new[]
+ {
+ ReferenceKind.Orientation, ReferenceKind.Codebase, ReferenceKind.Client, ReferenceKind.Protocol,
+ })
+ {
+ var documents = library.OfKind(kind);
+
+ if (documents.Count == 0)
+ {
+ continue;
+ }
+
+ b.AppendLine();
+ b.AppendLine(Heading(kind).ToUpperInvariant());
+
+ foreach (var document in documents)
+ {
+ b.AppendLine($" {document.Title}");
+ b.AppendLine($" {document.Path}");
+ PlainText.Wrap(b, document.Summary, " ");
+ }
+ }
+
+ return b.ToString();
+ }
+
+ public static string Kind(ReferenceKind kind) => kind switch
+ {
+ ReferenceKind.Codebase => "codebase",
+ ReferenceKind.Client => "client",
+ ReferenceKind.Protocol => "protocol",
+ _ => "orientation",
+ };
+
+ public static string Heading(ReferenceKind kind) => kind switch
+ {
+ ReferenceKind.Codebase => "Codebases",
+ ReferenceKind.Client => "Clients",
+ ReferenceKind.Protocol => "Protocols",
+ _ => "Start here",
+ };
+}
diff --git a/src/MUI.Web/MUI.Web.csproj b/src/MUI.Web/MUI.Web.csproj
index 4e5b009..1e058ab 100644
--- a/src/MUI.Web/MUI.Web.csproj
+++ b/src/MUI.Web/MUI.Web.csproj
@@ -11,6 +11,26 @@