Skip to content
Closed
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### Added

- **`MarkupString.Mxp`, a new package: MXP's own elements.** `MxpElements` builds what the
specification defines — `SOUND`, `MUSIC`, `IMAGE`, `GAUGE`, `STAT`, `FRAME`, `VAR`, `EXPIRE`,
`RELOCATE`, `USER`, `PASSWORD`, `NOBR`, `SBR` — and `MxpElement` writes one it does not. They render
as MXP tags for `Mxp`, as the nearest thing a browser has for `Html` (an `<img>`, an `<audio>`, a
`data-entity` span a page can draw), and as **nothing at all** for every other format, the zero-width
carrier a standalone element rides on included, so the same text is safe to send to every client.
- An address is written for a browser only when the element carries an absolute `http`/`https` URL of
its own: MXP's `FName` names a file in the game's sound or image directory, which a browser cannot
resolve.
- `WithMxp(element => ...)` holds what is written to what the client answered in MXP's `<SUPPORT>`
exchange; a refused element writes nothing, or its content alone when it wraps, so a `FRAME` a client
cannot open does not take the text inside it. With no predicate every element is written — never
asked is not refused. `new MxpSilentEmitter(MarkupFormat.Html)` after it keeps MXP out of the browser
entirely.

### Added

- **Checked `HtmlMarkup` construction.** `HtmlMarkup.Tag(name, params attributes)` validates the
tag and attribute names and writes each value encoded, so nothing in a value can end the attribute
or the tag. `HtmlMarkup.IsValidTagName`, `IsValidAttributeName` and `TryParseAttributes` (which
Expand Down
2 changes: 2 additions & 0 deletions MarkupString.AotSmoke/MarkupString.AotSmoke.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@
<TrimmerRootAssembly Include="MarkupString" />
<TrimmerRootAssembly Include="MarkupString.Ansi" />
<TrimmerRootAssembly Include="MarkupString.Html" />
<TrimmerRootAssembly Include="MarkupString.Mxp" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\MarkupString\MarkupString.csproj" />
<ProjectReference Include="..\MarkupString.Ansi\MarkupString.Ansi.csproj" />
<ProjectReference Include="..\MarkupString.Html\MarkupString.Html.csproj" />
<ProjectReference Include="..\MarkupString.Mxp\MarkupString.Mxp.csproj" />
</ItemGroup>
</Project>
9 changes: 7 additions & 2 deletions MarkupString.AotSmoke/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
using MarkupString;
using MarkupString.Ansi;
using MarkupString.Html;
using MarkupString.Mxp;

// Native-AOT smoke test. It is published with PublishAot and run in CI: the publish must produce no
// IL2xxx/IL3xxx warning, and the binary must exit 0. What it exercises is the whole pipeline that a
// consumer touches — registry composition, nested markup, every built-in format, and the JSON
// round trip — because those are where reflection or dynamic code would have crept in.

MarkupRegistry.Default = MarkupRegistry.Empty.WithAnsi().WithHtml();
MarkupRegistry.Default = MarkupRegistry.Empty.WithAnsi().WithHtml().WithMxp();

// Bold red on its own, so the plain SGR sequence appears un-merged with anything else.
var red = MarkupText.Wrap(AnsiCodeParser.Parse("hr"), "red");
Expand All @@ -35,7 +36,11 @@
AnsiMarkup.Create(linkUrl: "look", linkKind: LinkKind.Command),
"look");

var text = MarkupText.Concat([red, wide, nested, MarkupText.Space, link, MarkupText.Space, command]);
// One of MXP's own elements: a tag in MXP, an element in HTML, and nothing anywhere else — the
// carrier included, which is the part a trimmed build could get wrong.
var sound = MxpElements.Sound("door.wav", volume: 80, url: "https://example.test/sounds/");

var text = MarkupText.Concat([red, wide, nested, MarkupText.Space, link, MarkupText.Space, command, sound]);

MarkupFormat[] formats =
[
Expand Down
92 changes: 92 additions & 0 deletions MarkupString.Mxp/Emitters/MxpElementEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Buffers;

namespace MarkupString.Mxp;

/// <summary>
/// Writes an <see cref="MxpElement"/> as the MXP tag it is: <c>&lt;SOUND door.wav V=80&gt;</c> for one
/// that stands alone, and <c>&lt;FRAME …&gt;body&lt;/FRAME&gt;</c> for one that wraps content.
/// </summary>
/// <remarks>
/// The carrier a standalone element rides on is dropped rather than written: the tag stands in its
/// place, and a zero-width space left behind would travel to clients that never asked for one.
/// </remarks>
public sealed class MxpElementEmitter : IMarkupEmitter
{
private readonly Func<MxpElement, bool>? _supports;

/// <summary>An emitter that writes every element, for a connection whose client was never asked.</summary>
public static readonly MxpElementEmitter Instance = new(null);

/// <summary>
/// An emitter that writes only the elements <paramref name="supports"/> accepts. MXP's
/// <c>&lt;SUPPORT&gt;</c> exchange is what answers that question, and the answer belongs to a
/// connection rather than to a registry, so the predicate is the consumer's — usually a lookup into
/// what one client replied.
/// </summary>
/// <remarks>
/// An element the predicate refuses is written the way a format without MXP writes it: nothing for one
/// that stands alone, and the content alone for one that wraps. That second half is the point of
/// asking — a <c>FRAME</c> a client cannot open would otherwise take the text that followed it with
/// it.
/// </remarks>
public MxpElementEmitter(Func<MxpElement, bool>? supports)
{
_supports = supports;
}

/// <inheritdoc/>
public Type MarkupType => typeof(MxpElement);

/// <inheritdoc/>
public MarkupFormat Format => MarkupFormat.Mxp;

/// <inheritdoc/>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(markup);
ArgumentNullException.ThrowIfNull(output);

var element = (MxpElement)markup;
if (_supports is not null && !_supports(element))
{
if (element.WrapsContent) output.Write(body);
return;
}

output.Write(element.ToString());

if (!element.WrapsContent) return;

output.Write(body);
output.Write("</");
output.Write(element.Name);
output.Write(">");
}
}

/// <summary>
/// Writes an <see cref="MxpElement"/> for a format that has no MXP: the body for an element that wraps
/// content, and nothing at all for one that stands alone, carrier included.
/// </summary>
/// <remarks>
/// Registered for every format this package does not translate, so the same text can go to every client:
/// a terminal is not sent a tag it would show as text, and it is not sent the zero-width space the tag
/// rode on either.
/// </remarks>
public sealed class MxpSilentEmitter(MarkupFormat format) : IMarkupEmitter
{
/// <inheritdoc/>
public Type MarkupType => typeof(MxpElement);

/// <inheritdoc/>
public MarkupFormat Format { get; } = format;

/// <inheritdoc/>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(markup);
ArgumentNullException.ThrowIfNull(output);

if (((MxpElement)markup).WrapsContent) output.Write(body);
}
}
160 changes: 160 additions & 0 deletions MarkupString.Mxp/Emitters/MxpHtmlEmitter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System.Buffers;
using System.Net;

namespace MarkupString.Mxp;

/// <summary>
/// Writes an <see cref="MxpElement"/> as the nearest thing a browser has, for the handful that have one:
/// an image, a sound, a gauge, status text and a frame. Everything else writes nothing, as it does for a
/// terminal. An <c>&lt;audio&gt;</c> element is standard HTML, not a Pueblo tag; it is written without
/// <c>autoplay</c>, because a browser refuses that until the person has interacted with the page.
/// </summary>
/// <remarks>
/// <para>Each carries its MXP name in a <c>data-mxp</c> attribute and an <c>ms-mxp-*</c> class, so a page
/// can find them, style them, or take over an element this emitter renders plainly. A page that wants
/// none of it registers <see cref="MxpSilentEmitter"/> for <see cref="MarkupFormat.Html"/> instead.</para>
/// <para>A source address is written only when it is an absolute <c>http</c> or <c>https</c> URL. MXP's <c>FName</c> is a file on the game's own sound or
/// image directory, which a browser has no way to resolve, so an element with no <c>URL</c> of its own
/// renders as its caption rather than a broken fetch.</para>
/// </remarks>
public sealed class MxpHtmlEmitter : IMarkupEmitter
{
/// <summary>The one instance; the emitter carries no state.</summary>
public static readonly MxpHtmlEmitter Instance = new();

private MxpHtmlEmitter()
{
}

/// <inheritdoc/>
public Type MarkupType => typeof(MxpElement);

/// <inheritdoc/>
public MarkupFormat Format => MarkupFormat.Html;

/// <inheritdoc/>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
{
ArgumentNullException.ThrowIfNull(markup);
ArgumentNullException.ThrowIfNull(output);

var element = (MxpElement)markup;
switch (element.Name.ToUpperInvariant())
{
case "IMAGE":
WriteImage(element, output);
return;
case "SOUND" or "MUSIC":
WriteAudio(element, output);
return;
case "GAUGE":
WriteGauge(element, output);
return;
case "STAT":
WriteStat(element, output);
return;
case "FRAME" or "VAR":
WriteWrapper(element, body, output);
return;
default:
if (element.WrapsContent) output.Write(body);
return;
}
}

private static void WriteImage(MxpElement element, IBufferWriter<char> output)
{
var source = Address(element, "URL");
if (source is null) return;

output.Write("<img class=\"ms-mxp-image\" data-mxp=\"IMAGE\" src=\"");
output.Write(WebUtility.HtmlEncode(source));
output.Write("\" alt=\"");
output.Write(WebUtility.HtmlEncode(Positional(element) ?? string.Empty));
output.Write("\">");
}

private static void WriteAudio(MxpElement element, IBufferWriter<char> output)
{
var source = Address(element, "U");
if (source is null) return;

output.Write("<audio class=\"ms-mxp-");
output.Write(element.Name.ToLowerInvariant());
output.Write("\" data-mxp=\"");
output.Write(element.Name.ToUpperInvariant());
output.Write("\" src=\"");
output.Write(WebUtility.HtmlEncode(source));

// No autoplay: a browser refuses audible autoplay until the person has interacted with the page,
// so it would play nothing and say nothing about why. Whether this sounds, and when, is the page's
// to decide from the data-mxp attribute — as it is for a bell.
output.Write("\" preload=\"none\"></audio>");
}

private static void WriteGauge(MxpElement element, IBufferWriter<char> output)
{
// The entity names are the client's to resolve, and a browser holds none of them, so the page is
// given the names and draws the bar itself.
output.Write("<span class=\"ms-mxp-gauge\" data-mxp=\"GAUGE\" data-entity=\"");
output.Write(WebUtility.HtmlEncode(Positional(element) ?? string.Empty));
output.Write("\" data-max=\"");
output.Write(WebUtility.HtmlEncode(Named(element, "MAX") ?? string.Empty));
output.Write("\">");
output.Write(WebUtility.HtmlEncode(Named(element, "CAPTION") ?? Positional(element) ?? string.Empty));
output.Write("</span>");
}

private static void WriteStat(MxpElement element, IBufferWriter<char> output)
{
output.Write("<span class=\"ms-mxp-stat\" data-mxp=\"STAT\" data-entity=\"");
output.Write(WebUtility.HtmlEncode(Positional(element) ?? string.Empty));
output.Write("\">");
output.Write(WebUtility.HtmlEncode(Named(element, "CAPTION") ?? Positional(element) ?? string.Empty));
output.Write("</span>");
}

private static void WriteWrapper(MxpElement element, ReadOnlySpan<char> body, IBufferWriter<char> output)
{
output.Write("<span class=\"ms-mxp-");
output.Write(element.Name.ToLowerInvariant());
output.Write("\" data-mxp=\"");
output.Write(element.Name.ToUpperInvariant());
output.Write("\" data-name=\"");
output.Write(WebUtility.HtmlEncode(Positional(element) ?? string.Empty));
output.Write("\">");
output.Write(body);
output.Write("</span>");
}

private static string? Positional(MxpElement element)
{
foreach (var argument in element.Arguments)
{
if (argument.Name is null) return argument.Value;
}

return null;
}

private static string? Named(MxpElement element, string name)
{
foreach (var argument in element.Arguments)
{
if (string.Equals(argument.Name, name, StringComparison.OrdinalIgnoreCase)) return argument.Value;
}

return null;
}

/// <summary>The element's own address, when it gave one a browser can fetch.</summary>
private static string? Address(MxpElement element, string name)
{
var value = Named(element, name);
if (value is null) return null;

return Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"
? value
: null;
}
}
25 changes: 25 additions & 0 deletions MarkupString.Mxp/MarkupString.Mxp.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>MarkupString.Mxp</AssemblyName>
<RootNamespace>MarkupString.Mxp</RootNamespace>
<PackageId>MarkupString.Mxp</PackageId>
<Title>MarkupString.Mxp</Title>
<IsAotCompatible>true</IsAotCompatible>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>CS1591</NoWarn>
<Description>MXP's own elements for MarkupString — sounds, images, gauges, status text, frames and the rest — written as MXP tags for a client that negotiated them, as the nearest HTML for a browser, and as nothing at all for a client that has neither.</Description>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="MinVer" Version="8.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="5.6.0" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\MarkupString\MarkupString.csproj" />
</ItemGroup>
</Project>
Loading
Loading