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

## Unreleased

### 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
reads a raw attribute string with its values decoded, as a client reads them) are public.
- **`HtmlTagPolicy`**, for tags from somewhere untrusted: which tags and attributes are allowed,
which attributes are checked as addresses (against `UrlSafety`), and whether one bad attribute
drops just itself or all of them. `TryCreate` builds a checked tag from a name and a raw attribute
string; `Apply` holds an existing one to the policy. It is machinery, not a posture: `WellFormed`
allows any well-formed tag, address checking is off until you name the attributes (with your own
list or the published `AddressAttributes`), and a policy is a record, so `with` narrows one to
whatever your application considers safe.
- **`WithHtml(HtmlTagPolicy)`** and `HtmlTagEmitter(format, policy)`: every tag rendered in `Html` is
held to the policy as it is written, including markup that arrived deserialised or was built with
the unchecked `HtmlMarkup.Create`. A refused tag leaves its body in place. Pueblo and MXP output is
unchanged unless you register a policy for them too.
- **`ILineFramer`**, registered with `MarkupRegistry.With(ILineFramer)` and found with
`FindLineFramer`: a prefix written at the start of every line that has content, in a slot of its
own beside `IFormatFramer`.
- **`MxpSecureLineFramer`** and `MarkupRegistry.WithMxpSecureLines()`: open every line of
`Mxp` output in secure mode (`ESC[1z`), which an MXP client needs before it reads the tags on a
line. Opt-in, for the registry that renders for a connection.

### Documentation

- The guides no longer show `HtmlMarkup.Create("send", ...)` as a portable link. `<send>` is MXP's
command link; a Pueblo client prints it as text. The formats guide now has a Pueblo-and-MXP table,
and every example builds a link with `AnsiMarkup`'s `LinkKind.Command`, which each format writes
in its own dialect.
- The formats guide said `TextEncoding.Html` escapes `"`; it has not since 2.1.0.

## 2.1.0 — 2026-09-08

### Added
Expand Down
10 changes: 10 additions & 0 deletions MarkupString.Ansi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ var back = MarkupTextSerializer.Deserialize(json);
`WithAnsi()` registers emitters for `Ansi`, `Html`, `Pueblo`, `Mxp` and `BBCode`. `Plain` needs
none: the body passes through.

A command link (`linkKind: LinkKind.Command`) is written in each client's own dialect: `<A XCH_CMD>`
for Pueblo, `<SEND HREF>` for MXP, an `ms-cmd-link` anchor carrying `xch_cmd` for HTML, and the bare
text for a terminal. Pueblo and MXP are different dialects, and each client prints the other's
tags as text.

An MXP client reads tags only on a line opened in secure mode. The core package's
`MarkupRegistry.WithMxpSecureLines()` opens every line of `Mxp` output with `ESC[1z`. Use it on the
registry that renders for an MXP connection, and leave it off the one used for tests, logs and
previews.

## Styling the HTML output

The HTML-family emitters write `ms-*` classes for the attributes with a fixed rendering (bold,
Expand Down
36 changes: 33 additions & 3 deletions MarkupString.Html/Emitters/HtmlTagEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,34 @@ namespace MarkupString.Html;
/// want the tag written verbatim, unlike Ansi/BBCode, where <see cref="HtmlMarkup"/> folds a
/// handful of tags into terminal styling instead (see <see cref="HtmlMarkup.TryGetAnsiStyle"/>).
/// </summary>
/// <remarks>Neither the tag name, the attributes, nor the body are encoded — the tag is written raw.</remarks>
public sealed class HtmlTagEmitter(MarkupFormat format) : IMarkupEmitter
/// <remarks>
/// Without a policy the tag name and attributes are written exactly as the markup carries them. With
/// one, every tag is held to it as it is written — see <see cref="HtmlTagPolicy.Apply"/> — and a tag it
/// refuses leaves its body in place, unwrapped. The body is encoded by the renderer either way.
/// </remarks>
public sealed class HtmlTagEmitter : IMarkupEmitter
{
/// <summary>An emitter for <paramref name="format"/> that writes every tag as the markup carries it.</summary>
public HtmlTagEmitter(MarkupFormat format) : this(format, null)
{
}

/// <summary>An emitter for <paramref name="format"/> that holds every tag to <paramref name="policy"/>, when one is given.</summary>
public HtmlTagEmitter(MarkupFormat format, HtmlTagPolicy? policy)
{
ArgumentNullException.ThrowIfNull(format);
Format = format;
Policy = policy;
}

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

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

/// <summary>The policy every tag is held to, or <see langword="null"/> to write tags as given.</summary>
public HtmlTagPolicy? Policy { get; }

/// <inheritdoc/>
public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context, IBufferWriter<char> output)
Expand All @@ -25,6 +45,16 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
ArgumentNullException.ThrowIfNull(output);

var html = (HtmlMarkup)markup;
if (Policy is not null)
{
if (Policy.Apply(html) is not { } held)
{
output.Write(body);
return;
}

html = held;
}

output.Write("<");
output.Write(html.TagName);
Expand Down
24 changes: 24 additions & 0 deletions MarkupString.Html/HtmlAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace MarkupString.Html;

/// <summary>
/// One attribute of an <see cref="HtmlMarkup"/> tag: a name and its unencoded value. It is written
/// as <c>name="value"</c> with the value encoded, so a quote or a <c>&gt;</c> in it cannot end the
/// attribute or the tag.
/// </summary>
/// <param name="Name">The attribute name, checked by <see cref="HtmlMarkup.IsValidAttributeName"/> wherever it is written.</param>
/// <param name="Value">The value as it should read after decoding; the empty string for a bare attribute.</param>
public readonly record struct HtmlAttribute(string Name, string Value)
{
/// <summary>The attribute as written in a tag: <c>name="value"</c>, the value encoded.</summary>
public override string ToString() => $"{Name}=\"{Encode(Value)}\"";

/// <summary>
/// Encodes the four characters that matter inside a double-quoted attribute value: <c>&amp;</c>,
/// <c>"</c>, <c>&lt;</c> and <c>&gt;</c>. Nothing else is touched — a numeric entity for an
/// accented letter is one more thing an MXP or Pueblo client may not decode.
/// </summary>
internal static string Encode(string value) =>
value.AsSpan().IndexOfAny("&\"<>") < 0
? value
: value.Replace("&", "&amp;").Replace("\"", "&quot;").Replace("<", "&lt;").Replace(">", "&gt;");
}
128 changes: 125 additions & 3 deletions MarkupString.Html/HtmlMarkup.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net;
using MarkupString.Ansi;
namespace MarkupString.Html;

Expand All @@ -7,14 +8,135 @@ namespace MarkupString.Html;
/// tagged spans coalesce.
/// </summary>
/// <remarks>
/// Neither <see cref="TagName"/> nor <see cref="Attributes"/> is sanitised or encoded — callers are
/// expected to have already validated them, the same way the format this replaces did.
/// <see cref="Create"/> takes the tag name and attribute string as given and writes them unchecked.
/// <see cref="Tag"/> builds one from a checked name and <see cref="HtmlAttribute"/>s it encodes, and
/// <see cref="HtmlTagPolicy.TryCreate"/> does the same for a raw attribute string from somewhere
/// untrusted, keeping only what the policy allows.
/// </remarks>
public sealed record HtmlMarkup(string TagName, string? Attributes) : IMarkup, IAnsiStyleSource
{
/// <summary>Creates a layer for <paramref name="tagName"/>, optionally with a raw attribute string.</summary>
/// <summary>Creates a layer for <paramref name="tagName"/>, optionally with a raw attribute string, both unchecked.</summary>
public static HtmlMarkup Create(string tagName, string? attributes = null) => new(tagName, attributes);

/// <summary>
/// Creates a layer for <paramref name="tagName"/> with <paramref name="attributes"/>, each written
/// <c>name="value"</c> with its value encoded.
/// </summary>
/// <exception cref="ArgumentException">
/// <paramref name="tagName"/> fails <see cref="IsValidTagName"/>, or an attribute's name fails
/// <see cref="IsValidAttributeName"/>.
/// </exception>
public static HtmlMarkup Tag(string tagName, params ReadOnlySpan<HtmlAttribute> attributes)
{
ArgumentNullException.ThrowIfNull(tagName);
if (!IsValidTagName(tagName))
{
throw new ArgumentException($"'{tagName}' is not a tag name: a letter, then letters, digits or hyphens.", nameof(tagName));
}

foreach (var attribute in attributes)
{
if (attribute.Name is null || !IsValidAttributeName(attribute.Name))
{
throw new ArgumentException($"'{attribute.Name}' is not an attribute name.", nameof(attributes));
}
}

return new HtmlMarkup(tagName, Join(attributes));
}

/// <summary>
/// Whether <paramref name="name"/> can stand as a tag name: an ASCII letter, then ASCII letters,
/// digits or hyphens. Anything else — a space, a quote, a <c>&gt;</c> — would let the name carry
/// attributes of its own or close the tag.
/// </summary>
public static bool IsValidTagName(ReadOnlySpan<char> name)
{
if (name.IsEmpty || !char.IsAsciiLetter(name[0])) return false;
foreach (var c in name[1..])
{
if (!char.IsAsciiLetterOrDigit(c) && c != '-') return false;
}

return true;
}

/// <summary>
/// Whether <paramref name="name"/> can stand as an attribute name: an ASCII letter, <c>_</c> or
/// <c>:</c>, then ASCII letters, digits, <c>_</c>, <c>:</c>, <c>.</c> or <c>-</c>.
/// </summary>
public static bool IsValidAttributeName(ReadOnlySpan<char> name)
{
if (name.IsEmpty || !(char.IsAsciiLetter(name[0]) || name[0] is '_' or ':')) return false;
foreach (var c in name[1..])
{
if (!char.IsAsciiLetterOrDigit(c) && c is not ('_' or ':' or '.' or '-')) return false;
}

return true;
}

/// <summary>
/// Reads a raw attribute string — <c>href="x" title='y' size=3 noshade</c> — into its attributes,
/// with each value as a client reads it: out of its quotes, and with its entities decoded, so
/// <c>&amp;#106;avascript:</c> is checked as the <c>javascript:</c> it is. False when the string is
/// malformed: a name that fails <see cref="IsValidAttributeName"/>, or a quote that is never closed.
/// </summary>
public static bool TryParseAttributes(string? attributes, out IReadOnlyList<HtmlAttribute> parsed)
{
List<HtmlAttribute> found = [];
parsed = found;
var rest = (attributes ?? string.Empty).AsSpan().Trim();
while (!rest.IsEmpty)
{
var nameEnd = rest.IndexOfAny("= \t\r\n");
var name = nameEnd < 0 ? rest : rest[..nameEnd];
if (!IsValidAttributeName(name)) return false;

rest = rest[name.Length..].TrimStart();
if (rest.IsEmpty || rest[0] != '=')
{
found.Add(new HtmlAttribute(name.ToString(), string.Empty));
continue;
}

rest = rest[1..].TrimStart();
if (!TakeValue(ref rest, out var value)) return false;
found.Add(new HtmlAttribute(name.ToString(), value));
rest = rest.TrimStart();
}

return true;
}

private static bool TakeValue(ref ReadOnlySpan<char> rest, out string value)
{
value = string.Empty;
if (rest.IsEmpty) return true;

if (rest[0] is '"' or '\'')
{
var close = rest[1..].IndexOf(rest[0]);
if (close < 0) return false;
value = WebUtility.HtmlDecode(rest.Slice(1, close).ToString());
rest = rest[(close + 2)..];
return true;
}

var end = rest.IndexOfAny(" \t\r\n");
value = WebUtility.HtmlDecode((end < 0 ? rest : rest[..end]).ToString());
rest = end < 0 ? [] : rest[end..];
return true;
}

internal static string? Join(ReadOnlySpan<HtmlAttribute> attributes)
{
if (attributes.IsEmpty) return null;
List<string> written = new(attributes.Length);
foreach (var attribute in attributes) written.Add(attribute.ToString());
return string.Join(' ', written);
}

/// <inheritdoc/>
/// <remarks>
/// Only <see cref="MarkupFormat.Ansi"/> and <see cref="MarkupFormat.BBCode"/> have a terminal
Expand Down
13 changes: 13 additions & 0 deletions MarkupString.Html/HtmlRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,17 @@ public static MarkupRegistry WithHtml(this MarkupRegistry registry)
.With(new HtmlTagEmitter(MarkupFormat.Mxp))
.With(new HtmlMarkupCodec());
}

/// <summary>
/// As <see cref="WithHtml(MarkupRegistry)"/>, but every tag rendered in <see cref="MarkupFormat.Html"/>
/// is held to <paramref name="htmlPolicy"/> — the tags and attributes your application is willing to
/// put in front of a browser. Pueblo and MXP output still carries tags as given: they go to MUD
/// clients, and some of what they need (a command link) is what a browser policy typically refuses.
/// To hold those to a policy too, add <c>new HtmlTagEmitter(format, policy)</c> after this.
/// </summary>
public static MarkupRegistry WithHtml(this MarkupRegistry registry, HtmlTagPolicy htmlPolicy)
{
ArgumentNullException.ThrowIfNull(htmlPolicy);
return registry.WithHtml().With(new HtmlTagEmitter(MarkupFormat.Html, htmlPolicy));
}
}
Loading
Loading