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
7 changes: 7 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,13 @@ services:

Passkeys__ServerDomain: ${MUI_PASSKEY_DOMAIN:-}

# One profile elsewhere that is this same site, emitted as schema.org sameAs. Empty here and
# empty by default: it is a claim that an account somewhere else is us, and no deployment
# should make it for another. Forwarded rather than left out for the reason
# tools/check-compose-settings.py exists — a variable an operator sets in .env that nothing
# passes through is a knob that silently does nothing, and this one is in docs/deploy.md.
Site__SameAs__0: ${MUI_SITE_SAMEAS:-}

# The port /metrics answers on, and the switch that maps the route at all. Empty here, because
# a local `compose up` has nothing scraping it and the endpoint should not exist unless it was
# asked for. Forwarded rather than left out for the reason tools/check-compose-settings.py
Expand Down
1 change: 1 addition & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ which is why the check exists.
| `Passkeys__ServerDomain` | *(unset)* | The WebAuthn relying-party ID — the **registrable** domain, `mu-index.com`. Only affects sign-in and claiming. |
| `MUI_MCP_TOKEN` | *(unset)* | The bearer secret that gates `/mcp` (see [Administering the site over MCP](#administering-the-site-over-mcp)). **Unset means every request fails authentication** — fail closed, not fail open. Also `Mcp:Token`. |
| `Dataset__LicenceId`, `Dataset__LicenceName`, `Dataset__LicenceUrl`, `Dataset__Attribution`, `Dataset__Notice` | `CC-BY-4.0`, … | The terms the published data goes out under. Configuration rather than a literal because §15.2 is open and the code's licence is not the dataset's. |
| `Site__SameAs__0`, `Site__SameAs__1`, … (compose forwards the first as `MUI_SITE_SAMEAS`) | *(empty)* | Profiles elsewhere that are this same site — a repository, a forum account, a fediverse handle. Emitted as schema.org `sameAs` on the site's `Organization` node, which is what a search engine reads to tie a brand query to a site it already knows. **Empty by default, and that is deliberate**: it is the claim "that account over there is us", and a compiled-in default would have every fork assert it about somebody else's account from its first deploy. An empty list omits the property rather than emitting a wrong one. |
| `MUI_METRICS_PORT` | *(unset)* | The port `/metrics` answers on, and the switch that maps the route at all (see [What this process says about its own memory](#what-this-process-says-about-its-own-memory)). **Unset means the route does not exist.** The endpoint answers on this port and refuses the request on any other, so this is the security boundary, not a convenience — it must not be the port the site is served on. The port also has to be added to `ASPNETCORE_HTTP_PORTS`, or nothing is listening on it. |
| `ASPNETCORE_HTTP_PORTS` | `8080` | Set in the image. Semicolon-separated; production adds the metrics port. |
| `ASPNETCORE_ENVIRONMENT` | `Production` | |
Expand Down
433 changes: 433 additions & 0 deletions docs/seo-2026-09-09.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/MUI.Web/Api/MuiApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ public static IServiceCollection AddMuiApi(
services.Configure<DatasetLicenceOptions>(
configuration.GetSection(DatasetLicenceOptions.Section));

// Read by the head's identity graph rather than by the API, but bound here with the other
// deployment-owned claims so there is one place a deployment states what it is.
services.Configure<SiteIdentityOptions>(
configuration.GetSection(SiteIdentityOptions.Section));

// The section IS the map — SlugAliases:{former} = {current} — rather than a nested key.
services.Configure<SlugAliasOptions>(
options => configuration.GetSection(SlugAliasOptions.Section).Bind(options.Aliases));
Expand Down
88 changes: 88 additions & 0 deletions src/MUI.Web/Components/Copy/CategoryCopy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using MUI.Catalog;
using MUI.Web.Localization;

namespace MUI.Web.Components;

/// <summary>
/// What a faceted listing calls itself once it is a page of its own.
/// </summary>
/// <remarks>
/// A heading, a title and a description per dimension (see <see cref="IndexableFacet"/>). Each
/// description names the kind of statement its facet reads: a protocol was watched on the wire, a
/// genre and a language are the game's own claims, a lineage is a classification of ours. One
/// sentence over all five would report a measurement for the three that never made one (rules 1, 5).
/// The value is machine voice and passed as an argument rather than translated.
/// </remarks>
public static class CategoryCopy
{
/// <summary>The document's first line.</summary>
public static string Heading(string tag, IndexableFacet.Category category) =>
Say(tag, "games.heading.", category);

/// <summary>
/// Whether this category says anything of its own beyond its heading.
/// </summary>
/// <remarks>
/// An unstable facet takes the listing's plain title and description: it is not being indexed,
/// and a title naming a set that has already changed is worse than the general one. The heading
/// still names it, because the reader is looking at the page now.
/// </remarks>
private static bool Describes(IndexableFacet.Category category) => category.IsStable;

/// <summary>The browser-tab noun phrase.</summary>
/// <remarks>A separate id from <see cref="Heading"/> for the reason <c>PreviewCopy.Titles</c> gives.</remarks>
public static string Title(string tag, IndexableFacet.Category category) =>
Describes(category)
? Say(tag, "preview.title.category.", category)
: PreviewCopy.Titles.Games(tag);

/// <summary>What this listing is, for a search result and for a link somebody pasted.</summary>
public static string Description(string tag, IndexableFacet.Category category) =>
Describes(category)
? Say(tag, "preview.desc.category.", category)
: PreviewCopy.Pages.Games(tag);

private static string Say(string tag, string prefix, IndexableFacet.Category category)
{
ArgumentNullException.ThrowIfNull(category);

// The band's five values each name a state rather than a thing, so each gets its own line
// rather than being dropped into one frame — the panel's label for "quiet" carries a
// definition after an em dash, which reads as a footnote in a heading.
var id = category.Key == FacetKeys.Band
? prefix + "band." + category.Value
: prefix + Suffix(category.Key);

return Messages.Say(tag, id, ("value", Value(tag, category)));
}

/// <summary>
/// The value as the sentence needs it.
/// </summary>
/// <remarks>
/// Most facet values are machine voice — a codebase name, a protocol acronym, an encoding — and
/// pass through untranslated. The two switches carry a catalogue token instead (<c>yes</c>,
/// <c>playersNow</c>), which is not a word in any language, so each is put through the same
/// vocabulary the facet panel draws it with.
/// </remarks>
private static string Value(string tag, IndexableFacet.Category category) => category.Key switch
{
FacetKeys.Tls => Messages.For(tag, "facet.tls.yes"),
_ => category.Value,
};

/// <summary>The id fragment for a dimension.</summary>
/// <remarks>Throws rather than falling back, so a dimension added without copy fails a test
/// rather than shipping a page whose heading is a raw message id.</remarks>
private static string Suffix(string key) => key switch
{
FacetKeys.Codebase => "codebase",
FacetKeys.Lineage => "lineage",
FacetKeys.Genre => "genre",
FacetKeys.Language => "language",
FacetKeys.Protocol => "protocol",
FacetKeys.Charset => "charset",
FacetKeys.Tls => "tls",
_ => throw new ArgumentOutOfRangeException(nameof(key), key, "No category copy for this facet."),
};
}
27 changes: 27 additions & 0 deletions src/MUI.Web/Components/Copy/PreviewCopy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ public static class Pages

public static string Reference(string tag) => Messages.For(tag, "preview.desc.reference");

public static string Crawler(string tag) => Messages.For(tag, "preview.desc.crawler");

public static string Find(string tag) => Messages.For(tag, "preview.desc.find");

public static string About(string tag) => Messages.For(tag, "preview.desc.about");

public static string NotFound(string tag) => Messages.For(tag, "preview.desc.notFound");
Expand All @@ -75,6 +79,15 @@ public static class Pages
/// </remarks>
public static class Titles
{
/// <summary>
/// The front page's own title.
/// </summary>
/// <remarks>
/// It had none, so <see cref="Title"/> answered the bare wordmark — the one page on the site
/// whose search result said only what it was called and nothing about what it was.
/// </remarks>
public static string Home(string tag) => Messages.For(tag, "preview.title.home");

public static string Games(string tag) => Messages.For(tag, "preview.title.games");

public static string Archive(string tag) => Messages.For(tag, "preview.title.archive");
Expand All @@ -85,6 +98,8 @@ public static class Titles

public static string Reference(string tag) => Messages.For(tag, "preview.title.reference");

public static string Crawler(string tag) => Messages.For(tag, "preview.title.crawler");

public static string About(string tag) => Messages.For(tag, "preview.title.about");

public static string NotFound(string tag) => Messages.For(tag, "preview.title.notFound");
Expand All @@ -96,6 +111,18 @@ public static class Titles
/// <summary>A claim page names its game, which is the game's own bytes and not ours.</summary>
public static string Claim(string tag, string game) =>
Messages.For(tag, "preview.title.claim", Args(("game", game)));

/// <summary>
/// A game page: the game's name, and what the page under it holds.
/// </summary>
/// <remarks>
/// The name alone made a nineteen-character title on a few thousand pages. The clause after
/// it describes <em>this page</em> rather than making any claim about the game, so it stays
/// true of an archived game and of one we hold no count for; the name comes first so a
/// truncated title keeps the half that identifies it.
/// </remarks>
public static string Game(string tag, string name) =>
Messages.For(tag, "preview.title.game", Args(("game", name)));
}

/// <summary>
Expand Down
24 changes: 23 additions & 1 deletion src/MUI.Web/Components/FacetPanel.razor
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,12 @@
/// </remarks>
private RenderFragment Row(FacetGroup group, FacetValue value) =>
@<div class="@RowClass(group, value)">
@* title for a pointer, and the same words folded into the aria-label — a tooltip a
screen reader never reaches would be a definition only some readers get. *@
<a class="pick" href="@Href(group, value.State is FacetState.Included ? null : value.Token)"
aria-current="@(value.State is FacetState.Included ? "true" : null)"
aria-label="@Name(Single(group) ? "facet.value.choose" : "facet.value.include", value.Count, ("value", FacetWords.Value(Tag, group.Key, value)))">
title="@FacetWords.Meaning(Tag, group.Key, value)"
aria-label="@Labelled(group, value)">
<span class="tick" aria-hidden="true">@PickGlyph(group, value)</span>
<span class="row-name">@FacetWords.Value(Tag, group.Key, value)</span>
<span class="count">@value.Count</span>
Expand All @@ -344,6 +347,25 @@
}
</div>;

/// <summary>
/// One value row as a screen reader hears it: the value, its count, and what it means.
/// </summary>
/// <remarks>
/// The meaning is appended rather than left to <c>title</c>, which browsers surface to a pointer
/// and not reliably to anything else.
/// </remarks>
private string Labelled(FacetGroup group, FacetValue value)
{
var name = Name(
Single(group) ? "facet.value.choose" : "facet.value.include",
value.Count,
("value", FacetWords.Value(Tag, group.Key, value)));

return FacetWords.Meaning(Tag, group.Key, value) is { } meaning
? $"{name} — {meaning}"
: name;
}

/// <summary>Whether this group's values are one ordered scale rather than alternatives.</summary>
private static bool Single(FacetGroup group) => FacetWords.IsSingleChoice(group.Key);

Expand Down
83 changes: 70 additions & 13 deletions src/MUI.Web/Components/GamesView.razor
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
@using Microsoft.AspNetCore.WebUtilities
@using MUI.Web.Api

@* A listing filtered to one facet is about that facet, and is named and described after it.
Whether it is also a page of its own in a search index is the narrower question IsStable
answers: a listing of what is busy right now holds different games by the time a crawler
comes back, so it takes /games's canonical URL and keeps its own heading. *@
@if (Category is { } category)
{
<SitePreview Title="@CategoryCopy.Title(Tag, category)"
Description="@CategoryCopy.Description(Tag, category)"
CanonicalQuery="@Indexed(category)"
Graph="@Collection(Indexed(category))" />
}
else
{
<SitePreview Title="@PreviewCopy.Titles.Games(Tag)" Description="@PreviewCopy.Pages.Games(Tag)"
Graph="@Collection(IndexableFacet.IsUnfiltered(Filter) && Error is null ? string.Empty : null)" />
}

@if (Plain)
{
<pre class="mono plain">@PlainText.RenderListing(Listing, Filter, Now, Tag)</pre>
}
else
{
<SitePreview Title="@PreviewCopy.Titles.Games(Tag)" Description="@PreviewCopy.Pages.Games(Tag)" />

<div class="card-page">

<header class="listing-head">
<h1>@Messages.For(Tag, "games.title")</h1>
</header>
@*
The heading and nothing else. Amazon filters by brand and puts no "About Computers" above
the grid: the heading names the category, the chips say what is filtered and the columns say
what was measured, so anything restating those is padding. The description a search result
needs lives in <meta>.
*@
<h1 class="sr-only">@(Category is { } heading ? CategoryCopy.Heading(Tag, heading) : Messages.For(Tag, "games.title"))</h1>

@if (Error is { } problem)
{
Expand All @@ -29,14 +48,6 @@ else

<div class="listing-side">

@if (Filter.Codebase is { Exclude: false, Value: { } family })
{

<p class="kicker">
<a href="@Http.Link($"/reference/codebases/{family.ToLowerInvariant()}")">@Messages.For(Tag, "listing.aboutCodebase", new Dictionary<string, object?> { ["codebase"] = family })</a>
</p>
}

<div class="listing-bar">
<p class="listing-order">
@((MarkupString)Messages.For(Tag, "listing.sortedBy", new Dictionary<string, object?>
Expand Down Expand Up @@ -134,6 +145,52 @@ else

[Parameter] public string Query { get; set; } = string.Empty;

/// <summary>
/// The one category this listing is, or null where it is the catalogue with the panel touched.
/// </summary>
/// <remarks>
/// Null whenever the filter failed to bind: <see cref="Filter"/> is then the default rather than
/// what was asked for, and a canonical URL built from it would return different games.
/// </remarks>
private IndexableFacet.Category? Category => Error is null && IndexableFacet.Of(Filter) is { } found
? IndexableFacet.AsPublished(found, Listing.Facets)
: null;

/// <summary>
/// The querystring this category should be indexed under, or null where it should not be.
/// </summary>
/// <remarks>
/// Null leaves the canonical URL as <c>/games</c>, which is what an unstable facet wants: the
/// page is still named after what it is filtered to, and still not offered as a separate entry
/// in an index that would be stale before it was read.
/// </remarks>
private static string? Indexed(IndexableFacet.Category category) =>
category.IsStable ? IndexableFacet.Query(category) : null;

/// <summary>
/// This listing as a collection of games, or nothing where the rows are not that collection.
/// </summary>
/// <remarks>
/// The graph names an address and then lists what is at it, so it may only be published where the
/// two agree. They do on a category with a canonical URL of its own, and on the bare listing. They
/// do not on anything that consolidates onto <c>/games</c> while drawing a subset of it — a
/// refinement, a search, a chosen sort, or a category too volatile to index — and the mismatch is
/// not visible to a reader, which is what makes it worth a guard rather than a comment. Same rule
/// the archive follows while a search is narrowing it.
/// </remarks>
/// <param name="query">The canonical querystring, empty for the bare listing, null to publish nothing.</param>
private Func<Uri, string>? Collection(string? query) => query is null
? null
: origin => ListingStructuredData.For(
Listing.Games, origin, "/games" + query, Name(), Description());

/// <summary>What the head calls this page, so the graph cannot name it something else.</summary>
private string Name() =>
Category is { } category ? CategoryCopy.Title(Tag, category) : PreviewCopy.Titles.Games(Tag);

private string Description() =>
Category is { } category ? CategoryCopy.Description(Tag, category) : PreviewCopy.Pages.Games(Tag);

private bool Plain => Truthy.Is(
QueryHelpers.ParseQuery(Query).TryGetValue("plain", out var flag) ? flag.ToString() : null);

Expand Down
4 changes: 3 additions & 1 deletion src/MUI.Web/Components/Pages/About.razor
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@
contradict the argument that provenance beats presentation.
*@

@* Above the plain switch: ?plain=1 is the same document (spec §9) and takes the same head. *@
<SitePreview Title="@PreviewCopy.Titles.About(Tag)" Description="@PreviewCopy.Pages.About(Tag)" />

@if (Plain)
{
<pre class="mono plain">@PlainText.RenderAbout(Page, Tag)</pre>
}
else
{
<SitePreview Title="@PreviewCopy.Titles.About(Tag)" Description="@PreviewCopy.Pages.About(Tag)" />

<h1>@L("about.title")</h1>
<p class="summary">@Page.Lede</p>
Expand Down
Loading