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
52 changes: 50 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ jobs:
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
# The .NET 11 SDK: the .NET 10 SDK's formatter cannot parse a `union` declaration (*.Union.cs).
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
dotnet-version: 11.0.x
dotnet-quality: preview
# Folder mode, not solution mode: `dotnet format` cannot load a solution on the .NET 11
# SDK (the MSBuild build host crashes). Whitespace rules are syntactic, so nothing is lost.
- name: Verify formatting
run: |
for dir in MarkupString MarkupString.Ansi MarkupString.Extensions.DependencyInjection MarkupString.Html MarkupString.Mxp MarkupString.Pueblo MarkupString.Tests MarkupString.AotSmoke; do
for dir in MarkupString MarkupString.Ansi MarkupString.Extensions.DependencyInjection MarkupString.Html MarkupString.Mxp MarkupString.Pueblo MarkupString.Web MarkupString.Tests MarkupString.AotSmoke; do
dotnet format whitespace --folder "$dir" --exclude "**/bin/**" --exclude "**/obj/**" --verify-no-changes
done

Expand Down Expand Up @@ -60,6 +62,52 @@ jobs:
- name: Pack
run: dotnet pack --configuration Release --no-build -p:SkipFormatVerification=true -o packages

net11:
name: Build and test on the .NET 11 SDK
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# With the .NET 11 SDK the core package also builds for net11.0, where its unions implement the
# runtime's IUnion. The release builds this way, so this job proves what ships.
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-dotnet@v6
with:
dotnet-version: |
10.0.x
11.0.x
dotnet-quality: preview
- name: Restore
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore -p:SkipFormatVerification=true
- name: Test
run: dotnet run --project MarkupString.Tests --configuration Release --no-build
- name: Pack
run: dotnet pack --configuration Release --no-build -p:SkipFormatVerification=true -o packages
- name: The core package carries net10.0 and net11.0
run: |
unzip -l packages/MarkupString.[0-9]*.nupkg | grep -q 'lib/net10.0/MarkupString.dll'
unzip -l packages/MarkupString.[0-9]*.nupkg | grep -q 'lib/net11.0/MarkupString.dll'

web:
name: Web runtime in a browser
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: MarkupString.Web/tests
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v4
with:
node-version: 22
# fragments.json is what the HTML writer writes; MarkupWebTests fails when it is out of date.
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm test

aot:
name: AOT publish
runs-on: ubuntu-latest
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
# The .NET 11 SDK builds the core package for net11.0 as well as net10.0.
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
dotnet-version: |
10.0.x
11.0.x
dotnet-quality: preview

- name: Restore
run: dotnet restore
Expand All @@ -50,9 +54,13 @@ jobs:
with:
fetch-depth: 0

# The .NET 11 SDK builds the core package for net11.0 as well as net10.0.
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
dotnet-version: |
10.0.x
11.0.x
dotnet-quality: preview

- name: Restore
run: dotnet restore
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,4 @@ tools/responsive-sweep/out/
# Verify snapshot output: *.received.* is a failing test's actual result, never committed.
# The reviewed *.verified.* files are.
*.received.*
MarkupString.Web/tests/node_modules/
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,40 @@
# Changelog

All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`, `MarkupString.Mxp`,
`MarkupString.Pueblo` and `MarkupString.Extensions.DependencyInjection`. The packages share one version and are released together.
`MarkupString.Pueblo`, `MarkupString.Extensions.DependencyInjection` and `MarkupString.Web`. The packages share one version and are released together.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- **Output effects.** `OutputEffect` is a C# union of what output asks of a client besides text: the points
(`BellMarkup`, `ClearScreenMarkup`, `SoundMarkup`, `SoundStopMarkup`, `ExpireLinksMarkup`, `PrefetchMarkup`,
`LoginPromptMarkup`, `RelocateMarkup`, any other `IPointMarkup`) and the decoder's events (`TitleEvent`,
`VariableEvent` and the rest, any other `DecodeEvent`). `OutputEffects.Of(text)` reads them out of a line, a
decoder's events convert to one, and `EffectDispatcher` hands them to `IEffectHandler`s, newest first, which
`AddMarkupString().AddEffectHandler<T>()` registers. See [Display](docs/display.md#effects).
- **`MarkupString.Web`**: `markupstring.css` (every class the HTML writer uses) and `markupstring.js`, an ES
module whose `attach(root, { send })` runs command links and `SEND` menus, plays and stops sounds by channel,
rings the bell, clears, expires link groups, keeps variables and the gauges that show them, and moves pane
text to its pane. Each effect is a cancelable `ms:<effect>` DOM event first, then a plugin's, then the host's
option. `attachDotNet` does the same for a Blazor host. See [Web pages](docs/web.md).
- HTML for the vocabulary that wrote nothing there: `CommandMenuMarkup` (`ms-menu`, with its choices in
`data-commands`), `HighlightMarkup` (`ms-highlight`), `LinkGroupMarkup` (`ms-link-group`), `RelocateMarkup`
(`ms-relocate`) and `LoginPromptMarkup` (`ms-login`). An MXP `SEND` with several commands was its text alone.
- With the .NET 11 SDK, `MarkupString` also builds for net11.0, where its unions implement the runtime's
`IUnion`. The net10.0 build declares them with internal copies of the compiler's union types; with the
.NET 10 SDK the repository builds through `Microsoft.Net.Compilers.Toolset`.

### Fixed

- MXP `IMAGE` and `SOUND` put the file in the `T=` type's directory under the base address, as the MXP
specification says: `<IMAGE map.png URL=https://h/img/ T=maps>` is `https://h/img/maps/map.png`.
- An SGR extended colour with too few components (`38;2;40`) no longer reads what is left as codes of their own,
so it does not set a black background.

## 2.27.0 — Unreleased

### Added
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
`dotnet format` genuinely does fail on this repo in solution mode, so this is not
hypothetical. Output importance is normal so a tool failure explains itself.
-->
<Exec Command="dotnet format whitespace --folder &quot;$(MSBuildProjectDirectory)&quot; --exclude &quot;**/bin/**&quot; --exclude &quot;**/obj/**&quot; --verify-no-changes"
<Exec Command="dotnet format whitespace --folder &quot;$(MSBuildProjectDirectory)&quot; --exclude &quot;**/bin/**&quot; --exclude &quot;**/obj/**&quot; $(FormatVerificationExclude) --verify-no-changes"
ContinueOnError="true"
IgnoreStandardErrorWarningFormat="true">
<Output TaskParameter="ExitCode" PropertyName="FormatVerificationExitCode" />
Expand Down
11 changes: 11 additions & 0 deletions MarkupString.AotSmoke/MarkupString.AotSmoke.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@
<TrimmerRootAssembly Include="MarkupString.Pueblo" />
</ItemGroup>

<!--
The .NET 11 RC2 SDK ships 10.0.13 as the net10.0 ILCompiler, which has a high-severity advisory
(GHSA-p3jp-9v83-76w9), so restore fails under warnings-as-errors. global.json rolls forward to
the newest SDK, so anyone with SDK 11 installed hits this. Raise exactly that version to the patched
10.0.14 the .NET 10 SDK already uses; on any other SDK the update matches nothing.
-->
<ItemGroup>
<KnownILCompilerPack Update="@(KnownILCompilerPack->WithMetadataValue('TargetFramework', 'net10.0')->WithMetadataValue('ILCompilerPackVersion', '10.0.13'))"
ILCompilerPackVersion="10.0.14" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.0" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ MarkupString.Extensions.DependencyInjection.ServiceCollectionExtensions
static MarkupString.Extensions.DependencyInjection.ServiceCollectionExtensions.AddMarkupString(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action<MarkupString.Extensions.DependencyInjection.MarkupStringOptions!>? configure = null) -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder!
MarkupString.Extensions.DependencyInjection.MarkupDecoderFactory.CreateDecoderFor(MarkupString.Decoding.ClientIdentity! identity) -> MarkupString.Decoding.Decoder!
MarkupString.Extensions.DependencyInjection.MarkupDecoderFactory.MarkupDecoderFactory(MarkupString.Decoding.DecoderRegistry! registry, MarkupString.Extensions.DependencyInjection.MarkupStringOptions! options, MarkupString.IUrlPolicy! links, System.Diagnostics.Metrics.IMeterFactory? meters = null, Microsoft.Extensions.Logging.ILoggerFactory? loggers = null) -> void
MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.AddEffectHandler(MarkupString.Display.IEffectHandler! handler) -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder!
MarkupString.Extensions.DependencyInjection.MarkupStringBuilder.AddEffectHandler<THandler>() -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder!
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ public static class ServiceCollectionExtensions
/// Registers, as singletons: the <see cref="MarkupRegistry"/>; the <see cref="DecoderRegistry"/> (VT and
/// pictures) and an <see cref="IDecoderFactory"/> that makes each connection's decoder with the configured limits;
/// one <see cref="IPictureCache"/> every connection shares; the <see cref="DisplayRegistry"/> as the
/// <see cref="IStyleResolver"/>, with default <see cref="DisplayOptions"/>; and the <see cref="IUrlPolicy"/>.
/// <see cref="IStyleResolver"/>, with default <see cref="DisplayOptions"/>; the <see cref="IUrlPolicy"/>; and an
/// <see cref="EffectDispatcher"/> over every registered <see cref="IEffectHandler"/>.
/// Decode metrics go to the host's <see cref="IMeterFactory"/>, and what decoders drop is logged at debug level
/// to its <see cref="ILoggerFactory"/> (category <c>MarkupString.Decoding</c>), when it has them. A service registered before this
/// call is kept, so any of them can be replaced. Calling it again adds to the same setup.
Expand Down Expand Up @@ -51,6 +52,7 @@ public static MarkupStringBuilder AddMarkupString(this IServiceCollection servic
services.TryAddSingleton(static sp => DisplayDefaults(Options(sp).Display, sp.GetRequiredService<IUrlPolicy>()));
services.TryAddSingleton(sp => setup.Markup.Aggregate(
MarkupRegistry.Empty.WithAnsi().WithLinks(sp.GetRequiredService<IUrlPolicy>()), static (current, step) => step(current)));
services.TryAddSingleton(static sp => new EffectDispatcher(sp.GetServices<IEffectHandler>()));
return new MarkupStringBuilder(services, setup);
}

Expand Down Expand Up @@ -131,6 +133,25 @@ public MarkupStringBuilder AddMarkup(Func<MarkupRegistry, MarkupRegistry> config
return this;
}

/// <summary>
/// Adds <typeparamref name="THandler"/>, a singleton, to the handlers the <see cref="EffectDispatcher"/> hands output
/// effects to. A handler added later is asked first.
/// </summary>
public MarkupStringBuilder AddEffectHandler<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>()
where THandler : class, IEffectHandler
{
Services.AddSingleton<IEffectHandler, THandler>();
return this;
}

/// <summary>Adds <paramref name="handler"/> to the handlers the <see cref="EffectDispatcher"/> hands output effects to.</summary>
public MarkupStringBuilder AddEffectHandler(IEffectHandler handler)
{
ArgumentNullException.ThrowIfNull(handler);
Services.AddSingleton(handler);
return this;
}

/// <summary>Binds <see cref="MarkupStringOptions"/> to the configuration section at <paramref name="path"/>.</summary>
public MarkupStringBuilder BindConfiguration(string path = "MarkupString")
{
Expand Down
55 changes: 54 additions & 1 deletion MarkupString.Html/Emitters/ElementHtmlEmitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ internal sealed class ElementHtmlEmitter(Type markupType, HtmlTagPolicy? policy
[
typeof(SoundMarkup), typeof(SoundStopMarkup), typeof(ClearScreenMarkup), typeof(ExpireLinksMarkup),
typeof(PrefetchMarkup), typeof(ImageMarkup), typeof(PaneMarkup), typeof(VariableMarkup),
typeof(GaugeMarkup), typeof(StatusMarkup), typeof(PreformattedMarkup),
typeof(GaugeMarkup), typeof(StatusMarkup), typeof(PreformattedMarkup), typeof(CommandMenuMarkup),
typeof(HighlightMarkup), typeof(LinkGroupMarkup), typeof(RelocateMarkup), typeof(LoginPromptMarkup),
];

/// <summary>How an element is closed.</summary>
Expand Down Expand Up @@ -135,12 +136,64 @@ public void Emit(IMarkup markup, ReadOnlySpan<char> body, in EmitContext context
("data-maximum", status.Maximum), ("data-caption", status.Caption));
return;

case CommandMenuMarkup menu:
Write(markup, output, "span", Shape.Wrapping, body, context,
("class", menu.Prompt ? "ms-menu ms-prompt" : "ms-menu"), ("role", "button"), ("tabindex", "0"),
("aria-haspopup", menu.Choices.Count > 1 ? "menu" : null), ("title", menu.Hint),
("data-commands", Choices(menu.Choices)));
return;

case HighlightMarkup highlight:
Write(markup, output, "span", Shape.Wrapping, body, context,
("class", "ms-highlight"),
("style", string.Create(CultureInfo.InvariantCulture,
$"background-color: #{highlight.Color.R:x2}{highlight.Color.G:x2}{highlight.Color.B:x2}")));
return;

case LinkGroupMarkup group:
Write(markup, output, "span", Shape.Wrapping, body, context, ("class", "ms-link-group"), ("data-group", group.Group));
return;

case RelocateMarkup relocate:
Write(markup, output, "span", Shape.Empty, body, context,
("class", "ms-relocate"), ("data-host", relocate.Host),
("data-port", Number(relocate.Port)), ("data-quiet", relocate.Quiet ? "true" : null));
return;

case LoginPromptMarkup login:
Write(markup, output, "span", Shape.Empty, body, context,
("class", "ms-login"), ("data-field", login.Field == LoginField.Password ? "password" : "user"));
return;

default:
output.Write(body);
return;
}
}

/// <summary>
/// A menu's choices as a JSON array of <c>{"label", "command"}</c> objects, the default first, for the page
/// to build its menu from.
/// </summary>
private static string Choices(IReadOnlyList<CommandChoice> choices)
{
var buffer = new ArrayBufferWriter<byte>();
using (var json = new System.Text.Json.Utf8JsonWriter(buffer))
{
json.WriteStartArray();
foreach (var choice in choices)
{
json.WriteStartObject();
json.WriteString("label", choice.Label);
json.WriteString("command", choice.Command);
json.WriteEndObject();
}
json.WriteEndArray();
}

return System.Text.Encoding.UTF8.GetString(buffer.WrittenSpan);
}

/// <summary>
/// Writes one element, held to the policy when there is one. The tag is built as an
/// <see cref="HtmlMarkup"/> so that it is the same thing a policy sees anywhere else. A tag the
Expand Down
Loading
Loading