From 99210ddc1dd443764ead8d45dec498d093953652 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:41:25 +0000 Subject: [PATCH 1/3] Give clients the rest of the vocabulary: output effects, HTML for menus and prompts, and MarkupString.Web - OutputEffect, a C# union of the points and decoder events a client acts on; OutputEffects.Of reads them out of a line, and EffectDispatcher hands them to IEffectHandlers (AddEffectHandler in DI). - The HTML writer now writes CommandMenuMarkup, HighlightMarkup, LinkGroupMarkup, RelocateMarkup and LoginPromptMarkup. - MarkupString.Web: one stylesheet for every class the HTML writer uses, and an ES module that makes the elements work, with each effect a cancelable DOM event, plugins and host options, and a Blazor bridge. - MarkupString builds for net11.0 too with the .NET 11 SDK; net10.0 uses internal union polyfills and the compiler toolset package. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7TGemgF3PcMsUZkY9E --- .github/workflows/ci.yml | 52 ++- .github/workflows/release.yml | 12 +- .gitignore | 1 + CHANGELOG.md | 22 +- Directory.Build.targets | 2 +- .../PublicAPI.Unshipped.txt | 2 + .../ServiceCollectionExtensions.cs | 23 +- .../Emitters/ElementHtmlEmitter.cs | 55 ++- .../Display/OutputEffectTests.cs | 163 +++++++ .../Html/VocabularyHtmlTests.cs | 97 ++++ MarkupString.Tests/MarkupString.Tests.csproj | 3 + MarkupString.Tests/SharedVocabularyTests.cs | 7 +- MarkupString.Tests/Web/MarkupWebTests.cs | 86 ++++ MarkupString.Web/MarkupString.Web.csproj | 32 ++ MarkupString.Web/MarkupWeb.cs | 45 ++ MarkupString.Web/PublicAPI.Shipped.txt | 1 + MarkupString.Web/PublicAPI.Unshipped.txt | 8 + MarkupString.Web/README.md | 21 + MarkupString.Web/runtime.css | 9 + MarkupString.Web/tests/fragments.json | 16 + MarkupString.Web/tests/package-lock.json | 42 ++ MarkupString.Web/tests/package.json | 6 + MarkupString.Web/tests/runtime.test.cjs | 124 +++++ MarkupString.Web/wwwroot/markupstring.css | 107 +++++ MarkupString.Web/wwwroot/markupstring.js | 433 ++++++++++++++++++ MarkupString.slnx | 1 + MarkupString/Display/EffectDispatcher.cs | 43 ++ MarkupString/Display/OutputEffect.Union.cs | 36 ++ MarkupString/Display/OutputEffect.cs | 82 ++++ MarkupString/Elements/ScreenMarkup.cs | 10 +- MarkupString/MarkupString.csproj | 8 + MarkupString/Polyfills/Unions.cs | 17 + MarkupString/PublicAPI.Unshipped.txt | 31 ++ README.md | 2 + build/Unions.props | 19 + docs/dependency-injection.md | 1 + docs/display.md | 62 ++- docs/formats.md | 9 +- docs/web.md | 141 ++++++ 39 files changed, 1815 insertions(+), 16 deletions(-) create mode 100644 MarkupString.Tests/Display/OutputEffectTests.cs create mode 100644 MarkupString.Tests/Html/VocabularyHtmlTests.cs create mode 100644 MarkupString.Tests/Web/MarkupWebTests.cs create mode 100644 MarkupString.Web/MarkupString.Web.csproj create mode 100644 MarkupString.Web/MarkupWeb.cs create mode 100644 MarkupString.Web/PublicAPI.Shipped.txt create mode 100644 MarkupString.Web/PublicAPI.Unshipped.txt create mode 100644 MarkupString.Web/README.md create mode 100644 MarkupString.Web/runtime.css create mode 100644 MarkupString.Web/tests/fragments.json create mode 100644 MarkupString.Web/tests/package-lock.json create mode 100644 MarkupString.Web/tests/package.json create mode 100644 MarkupString.Web/tests/runtime.test.cjs create mode 100644 MarkupString.Web/wwwroot/markupstring.css create mode 100644 MarkupString.Web/wwwroot/markupstring.js create mode 100644 MarkupString/Display/EffectDispatcher.cs create mode 100644 MarkupString/Display/OutputEffect.Union.cs create mode 100644 MarkupString/Display/OutputEffect.cs create mode 100644 MarkupString/Polyfills/Unions.cs create mode 100644 build/Unions.props create mode 100644 docs/web.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 715f6a1..203bb43 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3bbc139..3a3d8f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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 diff --git a/.gitignore b/.gitignore index 902374c..780cd4f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebfcb3..a955505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,33 @@ # 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()` 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:` 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 diff --git a/Directory.Build.targets b/Directory.Build.targets index 201a538..36f89af 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -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. --> - diff --git a/MarkupString.Extensions.DependencyInjection/PublicAPI.Unshipped.txt b/MarkupString.Extensions.DependencyInjection/PublicAPI.Unshipped.txt index 5f61081..4dbfecd 100644 --- a/MarkupString.Extensions.DependencyInjection/PublicAPI.Unshipped.txt +++ b/MarkupString.Extensions.DependencyInjection/PublicAPI.Unshipped.txt @@ -48,3 +48,5 @@ MarkupString.Extensions.DependencyInjection.ServiceCollectionExtensions static MarkupString.Extensions.DependencyInjection.ServiceCollectionExtensions.AddMarkupString(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action? 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() -> MarkupString.Extensions.DependencyInjection.MarkupStringBuilder! diff --git a/MarkupString.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/MarkupString.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index 70fc5e5..b36e3dc 100644 --- a/MarkupString.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/MarkupString.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -17,7 +17,8 @@ public static class ServiceCollectionExtensions /// Registers, as singletons: the ; the (VT and /// pictures) and an that makes each connection's decoder with the configured limits; /// one every connection shares; the as the - /// , with default ; and the . + /// , with default ; the ; and an + /// over every registered . /// Decode metrics go to the host's , and what decoders drop is logged at debug level /// to its (category MarkupString.Decoding), 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. @@ -51,6 +52,7 @@ public static MarkupStringBuilder AddMarkupString(this IServiceCollection servic services.TryAddSingleton(static sp => DisplayDefaults(Options(sp).Display, sp.GetRequiredService())); services.TryAddSingleton(sp => setup.Markup.Aggregate( MarkupRegistry.Empty.WithAnsi().WithLinks(sp.GetRequiredService()), static (current, step) => step(current))); + services.TryAddSingleton(static sp => new EffectDispatcher(sp.GetServices())); return new MarkupStringBuilder(services, setup); } @@ -131,6 +133,25 @@ public MarkupStringBuilder AddMarkup(Func config return this; } + /// + /// Adds , a singleton, to the handlers the hands output + /// effects to. A handler added later is asked first. + /// + public MarkupStringBuilder AddEffectHandler<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] THandler>() + where THandler : class, IEffectHandler + { + Services.AddSingleton(); + return this; + } + + /// Adds to the handlers the hands output effects to. + public MarkupStringBuilder AddEffectHandler(IEffectHandler handler) + { + ArgumentNullException.ThrowIfNull(handler); + Services.AddSingleton(handler); + return this; + } + /// Binds to the configuration section at . public MarkupStringBuilder BindConfiguration(string path = "MarkupString") { diff --git a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs index 5bc7e78..4868009 100644 --- a/MarkupString.Html/Emitters/ElementHtmlEmitter.cs +++ b/MarkupString.Html/Emitters/ElementHtmlEmitter.cs @@ -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), ]; /// How an element is closed. @@ -135,12 +136,64 @@ public void Emit(IMarkup markup, ReadOnlySpan 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; } } + /// + /// A menu's choices as a JSON array of {"label", "command"} objects, the default first, for the page + /// to build its menu from. + /// + private static string Choices(IReadOnlyList choices) + { + var buffer = new ArrayBufferWriter(); + 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); + } + /// /// Writes one element, held to the policy when there is one. The tag is built as an /// so that it is the same thing a policy sees anywhere else. A tag the diff --git a/MarkupString.Tests/Display/OutputEffectTests.cs b/MarkupString.Tests/Display/OutputEffectTests.cs new file mode 100644 index 0000000..fbcbd2f --- /dev/null +++ b/MarkupString.Tests/Display/OutputEffectTests.cs @@ -0,0 +1,163 @@ +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Display; +using MarkupString.Extensions.DependencyInjection; +using MarkupString.Mxp.Decoding; +using Microsoft.Extensions.DependencyInjection; + +namespace MarkupString.Tests.Display; + +/// Effects read out of text and decoder events, and handed to handlers. +public class OutputEffectTests +{ + private sealed record ChimeMarkup : IPointMarkup; + + private static MarkupText Point(IPointMarkup point) => MarkupText.Wrap(point, point.Carrier); + + [Test] + public async Task Of_ListsEachPoint_InOrder() + { + var text = MarkupText.Concat( + [ + MarkupText.Plain("a"), MarkupText.Sound("door.wav"), MarkupText.Plain("b"), Point(BellMarkup.Instance), + Point(new ExpireLinksMarkup("exits")), Point(ClearScreenMarkup.Instance), Point(new SoundStopMarkup(SoundChannel.Music)), + Point(new RelocateMarkup("h", 23)), Point(new LoginPromptMarkup(LoginField.User)), Point(new PrefetchMarkup("map.png")), + ]); + + await Assert.That(OutputEffects.Of(text)).IsEquivalentTo(new OutputEffect[] + { + new SoundMarkup("door.wav"), BellMarkup.Instance, new ExpireLinksMarkup("exits"), ClearScreenMarkup.Instance, + new SoundStopMarkup(SoundChannel.Music), new RelocateMarkup("h", 23), new LoginPromptMarkup(LoginField.User), + new PrefetchMarkup("map.png"), + }, TUnit.Assertions.Enums.CollectionOrdering.Matching); + } + + [Test] + public async Task Of_CountsEveryCarrier() + { + var bells = MarkupText.Concat([Point(BellMarkup.Instance), Point(BellMarkup.Instance), MarkupText.Plain("x")]); + + await Assert.That(OutputEffects.Of(bells).Count).IsEqualTo(2); + } + + [Test] + public async Task Of_LeavesSoundsOut_ForAnOutputWithoutSound() + { + var text = MarkupText.Concat([MarkupText.Sound("door.wav"), Point(BellMarkup.Instance)]); + + await Assert.That(OutputEffects.Of(text, OutputCapabilities.PlainText)).IsEquivalentTo(new OutputEffect[] { BellMarkup.Instance }); + await Assert.That(OutputEffects.Of(text, OutputCapabilities.Browser).Count).IsEqualTo(2); + } + + [Test] + public async Task Of_GivesAVariable_TheWholeTextItCovers() + { + var hp = new VariableMarkup("hp"); + var text = MarkupText.Concat( + [ + MarkupText.Plain("HP: "), + MarkupText.Wrap(hp, MarkupText.Concat([MarkupText.Plain("4"), MarkupText.Wrap(new HighlightMarkup(new RgbColor(1, 2, 3)), "2")])), + ]); + + await Assert.That(OutputEffects.Of(text)).IsEquivalentTo(new OutputEffect[] { new VariableEvent("hp", "42") }); + } + + [Test] + public async Task Of_PassesAPointItDoesNotKnow_AsItsOwnCase() + { + var chime = new ChimeMarkup(); + var effect = OutputEffects.Of(Point(chime)).Single(); + + await Assert.That(effect.Value).IsSameReferenceAs(chime); + await Assert.That(Describe(effect)).IsEqualTo("point ChimeMarkup"); + } + + /// A host's switch: the specific cases first, the open ones last. + private static string Describe(OutputEffect effect) => effect switch + { + BellMarkup => "bell", + SoundMarkup sound => "sound " + sound.Source, + TitleEvent title => "title " + title.Title, + VariableEvent variable => $"variable {variable.Name}={variable.Value}", + IPointMarkup point => "point " + point.GetType().Name, + DecodeEvent decoded => "event " + decoded.GetType().Name, + _ => "none", + }; + + [Test] + public async Task ASwitch_ReachesEachCase_HoweverTheEffectWasMade() + { + await Assert.That(Describe(BellMarkup.Instance)).IsEqualTo("bell"); + await Assert.That(Describe(OutputEffect.Of((IPointMarkup)BellMarkup.Instance))).IsEqualTo("bell"); + await Assert.That(Describe(new SoundMarkup("door.wav"))).IsEqualTo("sound door.wav"); + await Assert.That(Describe(new TitleEvent("Hall", false))).IsEqualTo("title Hall"); + await Assert.That(Describe(OutputEffect.Of((DecodeEvent)new TitleEvent("Hall", false)))).IsEqualTo("title Hall"); + await Assert.That(Describe(new ReplyEvent("x"))).IsEqualTo("event ReplyEvent"); + await Assert.That(Describe(default)).IsEqualTo("none"); + } + + [Test] + public async Task DecodedOutput_GivesTheSameEffects_AsMarkupFrames() + { + var effects = new List(); + var decoder = DecoderRegistry.Empty.WithVt().WithMxp().CreateDecoder(); + decoder.EventRaised += e => effects.Add(OutputEffect.Of(e)); + decoder.EnableProtocol(MxpDecoding.Protocol); + + foreach (var line in decoder.Feed("\e]2;The Hall\aDing\a\e[1z\n")) effects.AddRange(OutputEffects.Of(line.Content)); + + await Assert.That(effects).Contains(new TitleEvent("The Hall", false)); + await Assert.That(effects).Contains(BellMarkup.Instance); + await Assert.That(effects.Select(e => e.Value).OfType().Single().Source).IsEqualTo("door.wav"); + } + + private sealed class Recorder(string name, Func takes, List log) : IEffectHandler + { + public bool Handle(OutputEffect effect) + { + log.Add(name); + return takes(effect); + } + } + + [Test] + public async Task Dispatcher_AsksTheNewestHandlerFirst_AndReportsWhatNoneTook() + { + var log = new List(); + var unhandled = new List(); + var dispatcher = new EffectDispatcher( + [ + new Recorder("old", static e => e is BellMarkup, log), + new Recorder("new", static e => e is TitleEvent, log), + ]); + dispatcher.Unhandled += unhandled.Add; + + await Assert.That(dispatcher.Dispatch(new TitleEvent("t", false))).IsTrue(); + await Assert.That(dispatcher.Dispatch(BellMarkup.Instance)).IsTrue(); + await Assert.That(dispatcher.Dispatch(ClearScreenMarkup.Instance)).IsFalse(); + await Assert.That(log).IsEquivalentTo(["new", "new", "old", "new", "old"], TUnit.Assertions.Enums.CollectionOrdering.Matching); + await Assert.That(unhandled).IsEquivalentTo(new OutputEffect[] { ClearScreenMarkup.Instance }); + } + + private sealed class TitleHandler : IEffectHandler + { + public string? Title { get; private set; } + + public bool Handle(OutputEffect effect) + { + if (effect is not TitleEvent title) return false; + Title = title.Title; + return true; + } + } + + [Test] + public async Task AddMarkupString_RegistersTheDispatcher_WithTheHandlersAdded() + { + var handler = new TitleHandler(); + using var provider = new ServiceCollection().AddMarkupString().AddEffectHandler(handler).Services.BuildServiceProvider(); + + await Assert.That(provider.GetRequiredService().Dispatch(new TitleEvent("Hall", false))).IsTrue(); + await Assert.That(handler.Title).IsEqualTo("Hall"); + } +} diff --git a/MarkupString.Tests/Html/VocabularyHtmlTests.cs b/MarkupString.Tests/Html/VocabularyHtmlTests.cs new file mode 100644 index 0000000..87325de --- /dev/null +++ b/MarkupString.Tests/Html/VocabularyHtmlTests.cs @@ -0,0 +1,97 @@ +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Html; +using MarkupString.Mxp.Decoding; + +namespace MarkupString.Tests.Html; + +/// The shared vocabulary that used to write nothing in HTML: menus, highlights, link groups, relocation, login prompts. +public class VocabularyHtmlTests +{ + private static readonly MarkupRegistry Registry = MarkupRegistry.Empty.WithAnsi().WithHtml(); + + private static string Html(MarkupText text) => text.Render(MarkupFormat.Html, Registry); + + private static MarkupText Point(IPointMarkup point) => + MarkupText.Concat([MarkupText.Plain("a"), MarkupText.Wrap(point, point.Carrier)]); + + [Test] + public async Task CommandMenu_IsAMenuElementWithItsChoices() + { + var menu = MarkupText.Wrap(new CommandMenuMarkup([new("Look", "look sword"), new("Take", "take sword")], "A sword"), "sword"); + + await Assert.That(Html(menu)).IsEqualTo( + "sword"); + } + + [Test] + public async Task CommandMenu_OfOneChoice_HasNoPopup_AndAPromptIsMarked() + { + var menu = MarkupText.Wrap(new CommandMenuMarkup([new("Say", "say ")], Prompt: true), "talk"); + + await Assert.That(Html(menu)).IsEqualTo( + "talk"); + } + + [Test] + public async Task CommandMenu_EscapesWhatItCarries() + { + var html = Html(MarkupText.Wrap(new CommandMenuMarkup([new("", "say \"hi\" & ")], "\"q\""), "x")); + + await Assert.That(html).DoesNotContain(""); + await Assert.That(html).DoesNotContain(""); + await Assert.That(html).Contains("title=\""q"\""); + } + + [Test] + public async Task MxpSendWithSeveralCommands_IsAMenuAroundItsDefaultLink() + { + var decoder = DecoderRegistry.Empty.WithVt().WithMxp().CreateDecoder(); + decoder.EnableProtocol(MxpDecoding.Protocol); + var line = decoder.Feed("\e[1zsword\n")[0]; + + var html = Html(line.Content); + await Assert.That(html).StartsWith("sword"); + } + + [Test] + public async Task Highlight_PaintsTheBackground() + { + await Assert.That(Html(MarkupText.Wrap(new HighlightMarkup(new RgbColor(10, 20, 30)), "hi"))) + .IsEqualTo("hi"); + } + + [Test] + public async Task LinkGroup_WrapsItsLinks() + { + var link = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "look", linkKind: LinkKind.Command), "x"); + + await Assert.That(Html(MarkupText.Wrap(new LinkGroupMarkup("g1"), link))).IsEqualTo( + "x"); + } + + [Test] + public async Task Relocate_AndLoginPrompt_AreEmptyElements() + { + await Assert.That(Html(Point(new RelocateMarkup("mud.example", 4201, Quiet: true)))) + .IsEqualTo("a"); + await Assert.That(Html(Point(new LoginPromptMarkup(LoginField.Password)))) + .IsEqualTo("a"); + } + + [Test] + public async Task APolicyThatRefusesTheElement_LeavesTheText() + { + var registry = MarkupRegistry.Empty.WithAnsi().WithHtml(new HtmlTagPolicy { AllowedTags = new HashSet { "b" } }); + var menu = MarkupText.Wrap(new CommandMenuMarkup([new("Look", "look")]), "sword"); + + await Assert.That(menu.Render(MarkupFormat.Html, registry)).IsEqualTo("sword"); + await Assert.That(Point(new RelocateMarkup("h", 1)).Render(MarkupFormat.Html, registry)).IsEqualTo("a"); + } +} diff --git a/MarkupString.Tests/MarkupString.Tests.csproj b/MarkupString.Tests/MarkupString.Tests.csproj index 1529ede..c800dcc 100644 --- a/MarkupString.Tests/MarkupString.Tests.csproj +++ b/MarkupString.Tests/MarkupString.Tests.csproj @@ -7,6 +7,8 @@ false TUnit0038;TUnitAssertions0002 + + @@ -29,5 +31,6 @@ + diff --git a/MarkupString.Tests/SharedVocabularyTests.cs b/MarkupString.Tests/SharedVocabularyTests.cs index c13c5b7..38df345 100644 --- a/MarkupString.Tests/SharedVocabularyTests.cs +++ b/MarkupString.Tests/SharedVocabularyTests.cs @@ -162,7 +162,7 @@ await Assert.That(Render(prefetch, MarkupFormat.Pueblo)) } [Test] - public async Task WhatOnlyMxpHasIsWrittenForMxpAndNothingElse() + public async Task WhatOnlyMxpHasIsWrittenForMxpAndForThePageScript() { var points = MarkupText.Concat([ MarkupText.ExpireLinks("exits"), @@ -174,7 +174,10 @@ await Assert.That(Render(points, MarkupFormat.Mxp)) .IsEqualTo(""); await Assert.That(Render(points, MarkupFormat.Pueblo)).IsEqualTo(string.Empty); await Assert.That(Render(points, MarkupFormat.Ansi)).IsEqualTo(string.Empty); - await Assert.That(Render(points, MarkupFormat.Html)).IsEqualTo(""); + await Assert.That(Render(points, MarkupFormat.Html)).IsEqualTo( + "" + + "" + + ""); } [Test] diff --git a/MarkupString.Tests/Web/MarkupWebTests.cs b/MarkupString.Tests/Web/MarkupWebTests.cs new file mode 100644 index 0000000..ba6f966 --- /dev/null +++ b/MarkupString.Tests/Web/MarkupWebTests.cs @@ -0,0 +1,86 @@ +using MarkupString.Ansi; +using MarkupString.Ansi.Decoding; +using MarkupString.Decoding; +using MarkupString.Html; +using MarkupString.Mxp.Decoding; +using MarkupString.Web; + +namespace MarkupString.Tests.Web; + +/// The files the web package serves are the text gives. +public class MarkupWebTests +{ + private static string Shipped(string name, [System.Runtime.CompilerServices.CallerFilePath] string here = "") => + Path.Combine(Path.GetDirectoryName(here)!, "..", "..", "MarkupString.Web", "wwwroot", name); + + private static string Fixture([System.Runtime.CompilerServices.CallerFilePath] string here = "") => + Path.Combine(Path.GetDirectoryName(here)!, "..", "..", "MarkupString.Web", "tests", "fragments.json"); + + private static bool Updating => Environment.GetEnvironmentVariable("MARKUPSTRING_UPDATE_WEB") == "1"; + + [Test] + public async Task Stylesheet_HoldsEveryFixedSheet() + { + await Assert.That(MarkupWeb.Stylesheet).StartsWith(AnsiCss.Fixed + LayoutCss.Fixed); + await Assert.That(MarkupWeb.Stylesheet).EndsWith(MarkupWeb.RuntimeStylesheet); + } + + /// + /// The served stylesheet is generated from the C# sheets. Run the tests with MARKUPSTRING_UPDATE_WEB=1 + /// to write it again after changing one. + /// + [Test] + public async Task ServedStylesheet_IsTheStylesheet() + { + var path = Shipped("markupstring.css"); + if (Updating) await File.WriteAllTextAsync(path, MarkupWeb.Stylesheet); + + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo(MarkupWeb.Stylesheet); + } + + [Test] + public async Task Script_IsTheServedModule() + { + await Assert.That(MarkupWeb.Script).IsEqualTo(await File.ReadAllTextAsync(Shipped("markupstring.js"))); + await Assert.That(MarkupWeb.Script).Contains("export function attach("); + await Assert.That(MarkupWeb.ScriptPath).IsEqualTo("_content/MarkupString.Web/markupstring.js"); + } + + /// + /// What the HTML writer writes for each effect, which the script's browser test (MarkupString.Web/tests) runs + /// against. Written again with MARKUPSTRING_UPDATE_WEB=1. + /// + [Test] + public async Task BrowserTestFragments_AreWhatTheHtmlWriterWrites() + { + var registry = MarkupRegistry.Empty.WithAnsi().WithHtml(); + string Html(MarkupText text) => text.Render(MarkupFormat.Html, registry); + static MarkupText Point(IPointMarkup point) => MarkupText.Wrap(point, point.Carrier); + static MarkupText Command(string command, string text) => MarkupText.Wrap(AnsiMarkup.Create(linkUrl: command, linkKind: LinkKind.Command), text); + + var mxp = DecoderRegistry.Empty.WithVt().WithMxp().CreateDecoder(); + mxp.EnableProtocol(MxpDecoding.Protocol); + + var fragments = new SortedDictionary(StringComparer.Ordinal) + { + ["cmd"] = Html(Command("look", "look")), + ["mxpmenu"] = Html(mxp.Feed("\e[1zsword\n")[0].Content), + ["prompt"] = Html(MarkupText.Wrap(new CommandMenuMarkup([new("Say", "say ")], Prompt: true), "talk")), + ["sound"] = Html(MarkupText.Concat([MarkupText.Plain("door"), MarkupText.Sound("door.wav")])), + ["music"] = Html(MarkupText.Music("theme.ogg")), + ["stop"] = Html(MarkupText.StopSound()), + ["bell"] = Html(MarkupText.Concat([MarkupText.Plain("ding"), Point(BellMarkup.Instance)])), + ["clear"] = Html(MarkupText.Concat([MarkupText.Plain("fresh"), Point(ClearScreenMarkup.Instance)])), + ["grouped"] = Html(MarkupText.Wrap(new LinkGroupMarkup("exits"), Command("north", "north"))), + ["expire"] = Html(Point(new ExpireLinksMarkup("exits"))), + ["gauge"] = Html(MarkupText.Gauge(MarkupText.Plain("HP"), "hp", "maxhp", "Hit Points", "red")), + ["vars"] = Html(MarkupText.Concat([MarkupText.Variable(MarkupText.Plain("50"), "maxhp"), MarkupText.Variable(MarkupText.Plain("25"), "hp")])), + ["pane"] = Html(MarkupText.Pane(MarkupText.Plain("chat line"), "chat", "Chat")), + ["relocate"] = Html(Point(new RelocateMarkup("h", 23))), + }; + var json = System.Text.Json.JsonSerializer.Serialize(fragments, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }) + "\n"; + + if (Updating) await File.WriteAllTextAsync(Fixture(), json); + await Assert.That(await File.ReadAllTextAsync(Fixture())).IsEqualTo(json); + } +} diff --git a/MarkupString.Web/MarkupString.Web.csproj b/MarkupString.Web/MarkupString.Web.csproj new file mode 100644 index 0000000..9830a3f --- /dev/null +++ b/MarkupString.Web/MarkupString.Web.csproj @@ -0,0 +1,32 @@ + + + MarkupString.Web + MarkupString.Web + MarkupString.Web + MarkupString.Web + true + true + CS1591 + + false + The browser side of MarkupString: one stylesheet for every class the HTML writer uses, and a script module that runs command links and menus, plays sounds, rings the bell, clears, expires links, keeps variables and gauges, and routes pane text, with DOM events and plugins for a host to take any of it over. Served at _content/MarkupString.Web/. + + + + + + + + + + + + + + + + + + + + diff --git a/MarkupString.Web/MarkupWeb.cs b/MarkupString.Web/MarkupWeb.cs new file mode 100644 index 0000000..41100e6 --- /dev/null +++ b/MarkupString.Web/MarkupWeb.cs @@ -0,0 +1,45 @@ +using MarkupString.Ansi; +using MarkupString.Html; + +namespace MarkupString.Web; + +/// +/// Where the browser side of MarkupString is, and what it is: a stylesheet for every class +/// writes, and the script module that makes the elements it writes act. +/// +/// +/// An ASP.NET Core or Blazor app serves both from the package as static web assets, at +/// and . A host that serves pages some other way (a +/// WebView, a server writing its own responses) takes and . +/// +public static class MarkupWeb +{ + /// The path, relative to the app's base, the package's files are served under. + public const string ContentRoot = "_content/MarkupString.Web/"; + + /// The stylesheet's path: as a file. + public const string StylesheetPath = ContentRoot + "markupstring.css"; + + /// The script module's path: as a file. + public const string ScriptPath = ContentRoot + "markupstring.js"; + + /// The rules for the elements the script acts on: menus, expired links, gauges, the bell. + public static string RuntimeStylesheet { get; } = Resource("runtime.css"); + + /// + /// The whole stylesheet: , and + /// . The file at is the same text. + /// + public static string Stylesheet { get; } = AnsiCss.Fixed + LayoutCss.Fixed + RuntimeStylesheet; + + /// The script module, an ES module that exports attach and attachDotNet. + public static string Script { get; } = Resource("markupstring.js"); + + private static string Resource(string name) + { + using var stream = typeof(MarkupWeb).Assembly.GetManifestResourceStream("MarkupString.Web." + name) + ?? throw new InvalidOperationException($"MarkupString.Web is missing its {name}."); + using var reader = new StreamReader(stream); + return reader.ReadToEnd(); + } +} diff --git a/MarkupString.Web/PublicAPI.Shipped.txt b/MarkupString.Web/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/MarkupString.Web/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/MarkupString.Web/PublicAPI.Unshipped.txt b/MarkupString.Web/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..4b869d3 --- /dev/null +++ b/MarkupString.Web/PublicAPI.Unshipped.txt @@ -0,0 +1,8 @@ +#nullable enable +MarkupString.Web.MarkupWeb +const MarkupString.Web.MarkupWeb.ContentRoot = "_content/MarkupString.Web/" -> string! +const MarkupString.Web.MarkupWeb.ScriptPath = "_content/MarkupString.Web/markupstring.js" -> string! +const MarkupString.Web.MarkupWeb.StylesheetPath = "_content/MarkupString.Web/markupstring.css" -> string! +static MarkupString.Web.MarkupWeb.RuntimeStylesheet.get -> string! +static MarkupString.Web.MarkupWeb.Script.get -> string! +static MarkupString.Web.MarkupWeb.Stylesheet.get -> string! diff --git a/MarkupString.Web/README.md b/MarkupString.Web/README.md new file mode 100644 index 0000000..5121abd --- /dev/null +++ b/MarkupString.Web/README.md @@ -0,0 +1,21 @@ +# MarkupString.Web + +The browser side of MarkupString's HTML output. + +- `markupstring.css`: a rule for every class `MarkupFormat.Html` writes, and for the script's own elements. +- `markupstring.js`: an ES module. `attach(root, { send })` runs command links and menus, plays and stops + sounds, rings the bell, clears, expires links, keeps variables and gauges, and moves pane text to its pane. + Each effect is a cancelable `ms:` DOM event first, so a page can take any of them over. + +An ASP.NET Core or Blazor app serves both at `_content/MarkupString.Web/`. Other hosts take the text from +`MarkupWeb.Stylesheet` and `MarkupWeb.Script`. + +```html + + +``` + +See [Web pages](https://github.com/SharpMUSH/MarkupString/blob/main/docs/web.md). diff --git a/MarkupString.Web/runtime.css b/MarkupString.Web/runtime.css new file mode 100644 index 0000000..0bc4b6f --- /dev/null +++ b/MarkupString.Web/runtime.css @@ -0,0 +1,9 @@ +.ms-menu { cursor: pointer; text-decoration: underline dotted; } +.ms-menu-popup { position: fixed; z-index: 1000; margin: 0; padding: 2px 0; min-width: 8ch; font: inherit; color: var(--ms-menu-fg, CanvasText); background: var(--ms-menu-bg, Canvas); border: 1px solid var(--ms-menu-border, currentColor); box-shadow: 0 2px 6px rgb(0 0 0 / 0.3); } +.ms-menu-popup button { display: block; width: 100%; margin: 0; padding: 2px 1ch; border: 0; font: inherit; color: inherit; text-align: start; background: none; cursor: pointer; } +.ms-menu-popup button:hover, .ms-menu-popup button:focus-visible { outline: none; color: var(--ms-menu-active-fg, HighlightText); background: var(--ms-menu-active-bg, Highlight); } +.ms-expired, .ms-expired .ms-cmd-link { cursor: default; text-decoration: none; } +.ms-sound, .ms-sound-stop, .ms-bell, .ms-clear, .ms-expire, .ms-relocate, .ms-login { display: none; } +.ms-gauge { background: linear-gradient(var(--ms-gauge, currentColor), var(--ms-gauge, currentColor)) no-repeat left bottom / calc(var(--ms-gauge-fill, 0) * 100%) 2px; } +.ms-bell-ring { animation: ms-bell-ring 0.3s ease-out; } +@keyframes ms-bell-ring { from { background-color: var(--ms-bell, rgb(255 255 255 / 0.25)); } } diff --git a/MarkupString.Web/tests/fragments.json b/MarkupString.Web/tests/fragments.json new file mode 100644 index 0000000..b743d18 --- /dev/null +++ b/MarkupString.Web/tests/fragments.json @@ -0,0 +1,16 @@ +{ + "bell": "ding", + "clear": "fresh", + "cmd": "look", + "expire": "", + "gauge": "HP", + "grouped": "north", + "music": "", + "mxpmenu": "sword", + "pane": "chat line", + "prompt": "talk", + "relocate": "", + "sound": "door", + "stop": "", + "vars": "5025" +} diff --git a/MarkupString.Web/tests/package-lock.json b/MarkupString.Web/tests/package-lock.json new file mode 100644 index 0000000..f0ca786 --- /dev/null +++ b/MarkupString.Web/tests/package-lock.json @@ -0,0 +1,42 @@ +{ + "name": "markupstring-web-tests", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "markupstring-web-tests", + "devDependencies": { + "playwright": "1.63.0" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/MarkupString.Web/tests/package.json b/MarkupString.Web/tests/package.json new file mode 100644 index 0000000..d1029d0 --- /dev/null +++ b/MarkupString.Web/tests/package.json @@ -0,0 +1,6 @@ +{ + "name": "markupstring-web-tests", + "private": true, + "scripts": { "test": "node runtime.test.cjs" }, + "devDependencies": { "playwright": "1.63.0" } +} diff --git a/MarkupString.Web/tests/runtime.test.cjs b/MarkupString.Web/tests/runtime.test.cjs new file mode 100644 index 0000000..c65a931 --- /dev/null +++ b/MarkupString.Web/tests/runtime.test.cjs @@ -0,0 +1,124 @@ +// Runs markupstring.js in Chromium against what the HTML writer writes (fragments.json, kept current by +// MarkupWebTests). Run: npm ci, npx playwright install chromium, npm test +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); +const read = file => fs.readFileSync(path.join(__dirname, file), 'utf8'); +const F = JSON.parse(read('fragments.json')); +const js = read('../wwwroot/markupstring.js'); +const css = read('../wwwroot/markupstring.css'); +let fails = 0; +const check = (ok, name, extra = '') => { console.log((ok ? 'PASS ' : 'FAIL ') + name + (ok ? '' : ' ' + extra)); if (!ok) fails++; }; +(async () => { + const browser = await chromium.launch(); + const page = await browser.newPage(); + page.on('pageerror', e => { console.log('PAGEERROR', e.message); fails++; }); + await page.route('http://t/**', r => { + const u = r.request().url(); + if (u.endsWith('.js')) return r.fulfill({ contentType: 'text/javascript', body: js }); + if (u.endsWith('.css')) return r.fulfill({ contentType: 'text/css', body: css }); + return r.fulfill({ contentType: 'text/html', body: '
old line
' }); + }); + await page.goto('http://t/index.html'); + await page.evaluate(async () => { + window.played = []; window.paused = []; + window.Audio = class { constructor(src) { this.src = src; this.paused = true; this.loop = false; this.volume = 1; this._l = {}; } addEventListener(n, f) { this._l[n] = f; } play() { this.paused = false; window.played.push(this.src); return Promise.resolve(); } pause() { this.paused = true; window.paused.push(this.src); } }; + const m = await import('/_content/MarkupString.Web/markupstring.js'); + window.sent = []; window.prompted = []; window.relocated = []; + window.rt = m.attach(document.getElementById('out'), { send: c => sent.push(c), prompt: c => prompted.push(c), relocate: d => relocated.push(d) }); + window.add = html => { const d = document.createElement('div'); d.innerHTML = html; document.getElementById('out').append(d); return d; }; + }); + const tick = () => page.evaluate(() => new Promise(r => setTimeout(r, 20))); + const ev = f => page.evaluate(f); + const add = async key => { await page.evaluate(h => add(h), F[key]); await tick(); }; + + check((await ev(() => played.length)) === 0, 'replay=false: old sound not played'); + + await add('cmd'); + await page.click('a[xch_cmd="look"]'); + check(JSON.stringify(await ev(() => sent)) === '["look"]', 'command link click sends'); + await page.focus('a[xch_cmd="look"]'); await page.keyboard.press('Enter'); + check((await ev(() => sent.length)) === 2, 'command link Enter sends'); + + await add('mxpmenu'); + await page.click('.ms-menu'); + const items = await page.$$eval('.ms-menu-popup button', bs => bs.map(b => b.textContent)); + check(JSON.stringify(items) === '["Buy","Look"]', 'menu opens with choices', JSON.stringify(items)); + check(await ev(() => document.activeElement.textContent) === 'Buy', 'first item focused'); + await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); + check(JSON.stringify(await ev(() => sent.slice(-1))) === '["look sword"]', 'menu choice by keyboard sends', JSON.stringify(await ev(() => sent))); + check((await page.$$('.ms-menu-popup')).length === 0, 'menu closes after choice'); + await page.click('.ms-menu'); await page.keyboard.press('Escape'); + check((await page.$$('.ms-menu-popup')).length === 0, 'Escape closes menu'); + await page.click('.ms-menu'); await page.mouse.click(5, 500); + check((await page.$$('.ms-menu-popup')).length === 0, 'outside click closes menu'); + + await add('prompt'); + await page.click('.ms-prompt'); + check(JSON.stringify(await ev(() => prompted)) === '["say "]', 'single-choice prompt goes to prompt()'); + + await add('sound'); + check(JSON.stringify(await ev(() => played)) === '["http://t/door.wav"]', 'sound plays, resolved', JSON.stringify(await ev(() => played))); + await add('music'); + await add('music'); + check((await ev(() => played.filter(p => p.endsWith('theme.ogg')).length)) === 2 && (await ev(() => paused.includes('http://t/theme.ogg'))), 'new music replaces old'); + await add('stop'); + check(await ev(() => rt._music === null && rt._effects.size === 0), 'stop silences both'); + + await add('bell'); + check(await ev(() => document.getElementById('out').classList.contains('ms-bell-ring')), 'bell rings'); + + await add('grouped'); + await add('expire'); + check(await ev(() => !document.querySelector('a[xch_cmd="north"]') && !!document.querySelector('.ms-link-group .ms-expired')), 'grouped link expired'); + check(await ev(() => !!document.querySelector('a[xch_cmd="look"]')), 'other links untouched'); + + await add('gauge'); + await add('vars'); + const g = await ev(() => { const e = document.querySelector('.ms-gauge'); return [e.style.getPropertyValue('--ms-gauge-fill'), e.dataset.value, e.title]; }); + check(g[0] === '0.5' && g[1] === '25' && g[2] === 'Hit Points: 25 / 50', 'gauge follows variables', JSON.stringify(g)); + check(await ev(() => document.getElementById('hpshow').textContent) === '25', 'host [data-ms-variable] updated'); + check(await ev(() => rt.variables.get('maxhp')) === '50', 'variables kept'); + + await add('pane'); + check(await ev(() => document.querySelector('#chatpane .ms-pane')?.textContent) === 'chat line', 'pane text moved to its pane'); + + await add('relocate'); + check(JSON.stringify(await ev(() => relocated)) === '[{"host":"h","port":23,"quiet":false}]', 'relocate goes to host option', JSON.stringify(await ev(() => relocated))); + + await ev(() => document.getElementById('out').addEventListener('ms:command', e => { if (e.detail.command === 'look') e.preventDefault(); })); + const before = await ev(() => sent.length); + await page.click('a[xch_cmd="look"]'); + check((await ev(() => sent.length)) === before, 'cancelled ms:command is not sent'); + + await ev(() => { window.hit = []; rt.use({ selector: '.my-thing', handle: el => hit.push(el.textContent) }); rt.use({ effect: 'bell', handle: () => { hit.push('bell'); return true; } }); }); + await page.evaluate(h => { add('x'); document.getElementById('out').classList.remove('ms-bell-ring'); add(h); }, F.bell); await tick(); + check(JSON.stringify(await ev(() => hit)) === '["x","bell"]' && !(await ev(() => document.getElementById('out').classList.contains('ms-bell-ring'))), 'plugins: own selector and effect override', JSON.stringify(await ev(() => hit))); + + await add('cmd'); + check(await ev(() => getComputedStyle(document.querySelector('.ms-cmd-link')).cursor) === 'pointer', 'stylesheet applies'); + check(await ev(() => getComputedStyle(document.querySelector('.ms-menu')).cursor) === 'pointer', 'menu style applies'); + + await add('clear'); + check(await ev(() => document.getElementById('out').children.length === 1 && document.getElementById('out').textContent.includes('fresh')), 'clear removes earlier lines'); + + await ev(async () => { + const m = await import('/_content/MarkupString.Web/markupstring.js'); + const d = document.createElement('div'); d.id = 'dn'; document.body.append(d); + window.calls = []; + const ref = { invokeMethodAsync: (n, ...a) => { calls.push([n, ...a]); return Promise.resolve(); } }; + m.attachDotNet(d, ref, { forward: ['bell'] }); + }); + await page.evaluate(F => { const x = document.createElement('div'); x.innerHTML = F.cmd + F.bell; document.getElementById('dn').append(x); }, F); await tick(); + await page.click('#dn a[xch_cmd]'); + check(JSON.stringify(await ev(() => calls)) === '[["OnEffect","bell",{}],["Send","look"]]', 'attachDotNet forwards', JSON.stringify(await ev(() => calls))); + + await ev(() => rt.detach()); + await add('cmd'); + const n = await ev(() => sent.length); + await page.click('#out a[xch_cmd="look"]'); + check((await ev(() => sent.length)) === n, 'detach stops handling'); + await browser.close(); + console.log(fails ? `${fails} FAILED` : 'ALL PASSED'); + process.exit(fails ? 1 : 0); +})(); diff --git a/MarkupString.Web/wwwroot/markupstring.css b/MarkupString.Web/wwwroot/markupstring.css new file mode 100644 index 0000000..46e4fa4 --- /dev/null +++ b/MarkupString.Web/wwwroot/markupstring.css @@ -0,0 +1,107 @@ +.ms-bold { font-weight: bold; } +.ms-faint { opacity: 0.6; } +.ms-italic { font-style: italic; } +.ms-underline { text-decoration: underline; } +.ms-strike { text-decoration: line-through; } +.ms-overline { text-decoration: overline; } +.ms-conceal { color: transparent; } +.ms-blink { animation: ms-blink 1s step-start infinite; } +.ms-invert { color: var(--ms-bg, #000); background-color: var(--ms-fg, #fff); } +.ms-cmd-link { cursor: pointer; text-decoration: underline dotted; } +@keyframes ms-blink { 50% { opacity: 0; } } +.ms-layout { display: block; max-width: 100%; white-space: normal; } +.ms-text { white-space: pre-wrap; overflow-wrap: anywhere; } +.ms-box { --ms-pad: 1ch; margin: 0; padding: 0 var(--ms-pad); min-width: 0; border: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); } +.ms-box.ms-border-none { border: none; padding: 0; } +.ms-box.ms-border-double { border: 3px double var(--ms-border, var(--ms-border-default, currentColor)); } +.ms-box.ms-border-heavy { border-width: 2px; } +.ms-box.ms-border-rounded { border-radius: 0.5em; } +.ms-box-title { padding: 0 1ch; margin-inline: auto; } +.ms-box-title, .ms-rule-title { color: var(--ms-title, var(--ms-title-default, inherit)); } +.ms-box-title[data-align="left"] { margin-inline: 0 auto; } +.ms-box-title[data-align="right"] { margin-inline: auto 0; } +.ms-rule, .ms-divider { display: flex; align-items: center; gap: 1ch; } +.ms-rule::before, .ms-rule::after, .ms-divider::before, .ms-divider::after { content: ""; flex: 1 1 0; border-top: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); } +.ms-rule[data-align="left"]::before, .ms-divider[data-align="left"]::before { flex: 0 0 1ch; } +.ms-rule[data-align="right"]::after, .ms-divider[data-align="right"]::after { flex: 0 0 1ch; } +.ms-divider { margin: 0 calc(-1 * var(--ms-pad, 1ch)); } +.ms-titles::before, .ms-titles::after { content: none; } +.ms-line { flex: 1 1 0; min-width: 1ch; border-top: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); } +.ms-line.ms-end { flex: 0 0 1ch; } +.ms-border-double.ms-titles > .ms-line { border-top: 3px double var(--ms-border, var(--ms-border-default, currentColor)); } +.ms-border-none.ms-titles > .ms-line { border-top: none; } +.ms-box-title.ms-titles { display: flex; align-items: center; gap: 1ch; flex-wrap: wrap; padding: 0; margin-inline: calc(-1 * var(--ms-pad)); width: calc(100% + 2 * var(--ms-pad)); box-sizing: border-box; } +.ms-box:has(> .ms-box-bottom) { border-bottom: none; } +.ms-box-bottom { display: flex; align-items: center; gap: 1ch; flex-wrap: wrap; margin: 0 calc(-1 * var(--ms-pad, 1ch)); } +.ms-box.ms-border-none > .ms-divider { margin: 0; } +.ms-border-double.ms-rule::before, .ms-border-double.ms-rule::after { border-top: 3px double var(--ms-border, var(--ms-border-default, currentColor)); } +.ms-border-none.ms-rule::before, .ms-border-none.ms-rule::after { border-top: none; } +.ms-flex { display: flex; flex-wrap: wrap; } +.ms-flex.ms-vertical { flex-direction: column; } +.ms-item { min-width: 0; box-sizing: border-box; } +.ms-flex.ms-divided > .ms-item + .ms-item { border-left: 1px solid var(--ms-separator, var(--ms-separator-default, currentColor)); padding-left: 1ch; } +.ms-figure { display: flow-root; } +.ms-figure-image { max-width: 100%; height: auto; vertical-align: top; } +.ms-box.ms-box-picture { padding: 0; width: fit-content; max-width: 100%; box-sizing: border-box; } +.ms-box-picture .ms-figure-image { display: block; } +.ms-figure-art { margin: 0; font: inherit; white-space: pre; } +.ms-float-left > .ms-figure-image, .ms-float-left > .ms-figure-art { float: left; max-width: 50%; margin: 0 2ch 0.5em 0; } +.ms-float-right > .ms-figure-image, .ms-float-right > .ms-figure-art { float: right; max-width: 50%; margin: 0 0 0.5em 2ch; } +.ms-fields { display: grid; grid-template-columns: minmax(min-content, max-content) minmax(0, 1fr); column-gap: 1ch; margin: 0; } +.ms-field { display: contents; } +.ms-field > dt { grid-column: 1; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--ms-label, var(--ms-label-default, inherit)); } +.ms-field > dd { grid-column: 2; margin: 0; min-width: 0; } +.ms-fields.ms-label-right > .ms-field > dt { text-align: right; } +.ms-fields.ms-leader > .ms-field > dt { display: flex; gap: 0.5ch; } +.ms-fields.ms-leader > .ms-field > dt::after { content: ""; flex: 1 0 2ch; border-bottom: 1px dotted currentColor; margin-bottom: 0.3em; } +.ms-fields.ms-striped { column-gap: 0; } +.ms-fields.ms-striped > .ms-field > dt { padding-right: 1ch; } +.ms-fields.ms-striped > .ms-field:nth-child(even) > * { background: var(--ms-stripe, var(--ms-stripe-default, rgba(127, 127, 127, 0.12))); } +.ms-tree, .ms-tree ul { list-style: none; margin: 0; padding: 0; } +.ms-tree ul { margin-left: 1ch; } +.ms-tree ul > li { position: relative; padding-left: 3ch; border-left: 1px solid var(--ms-guide, var(--ms-guide-default, currentColor)); } +.ms-tree ul > li:last-child { border-left-color: transparent; } +.ms-tree ul > li::before { content: ""; position: absolute; left: -1px; top: 0; width: 2ch; height: 0.7em; border-left: 1px solid var(--ms-guide, var(--ms-guide-default, currentColor)); border-bottom: 1px solid var(--ms-guide, var(--ms-guide-default, currentColor)); } +.ms-tree.ms-guide-rounded ul > li:last-child::before { border-bottom-left-radius: 0.4em; } +.ms-tree.ms-guide-heavy ul > li, .ms-tree.ms-guide-heavy ul > li::before { border-width: 2px; } +.ms-tree.ms-guide-double ul > li, .ms-tree.ms-guide-double ul > li::before { border-left-style: double; border-left-width: 3px; } +.ms-tree.ms-guide-none ul > li, .ms-tree.ms-guide-none ul > li::before { border-color: transparent; } +.ms-gauge { display: flex; align-items: center; gap: 1ch; } +.ms-gauge > meter { flex: 1 1 8ch; min-width: 4ch; accent-color: var(--ms-gauge, var(--ms-gauge-default, auto)); } +.ms-gauge-bar { flex: 1 1 8ch; min-width: 4ch; height: 1em; display: flex; border: 1px solid var(--ms-border, var(--ms-border-default, currentColor)); box-sizing: border-box; } +.ms-gauge-fill { height: 100%; } +.ms-bullets { margin: 0; padding-left: 3ch; } +.ms-bullets > li::marker, .ms-bullets .ms-marker { color: var(--ms-bullet, var(--ms-bullet-default, inherit)); } +.ms-bullet-dash { list-style-type: "- "; } +.ms-bullet-star { list-style-type: "* "; } +.ms-bullet-none, .ms-bullet-custom { list-style: none; } +.ms-bullet-custom > li > .ms-marker { display: inline-block; margin-left: -2ch; min-width: 2ch; } +.ms-grid { list-style: none; margin: 0; padding: 0; display: grid; } +.ms-grid.ms-down { display: block; } +.ms-grid > li { break-inside: avoid; white-space: pre-wrap; overflow-wrap: anywhere; } +.ms-pre { margin: 0; font: inherit; white-space: pre; overflow-x: auto; } +.ms-shaded:not(:has(.ms-box, .ms-flex, .ms-rule, .ms-divider, .ms-gauge)) { width: fit-content; max-width: 100%; } +.ms-shaded:not(.ms-shade-borders) { -webkit-background-clip: text; background-clip: text; color: transparent; } +.ms-shaded:not(.ms-shade-borders) :is(.ms-box-title, .ms-rule-title, .ms-field > dt, .ms-table th, .ms-bullets .ms-marker) { color: transparent; } +.ms-shaded:not(.ms-shade-text) :is(.ms-box, .ms-gauge-bar), .ms-shaded:not(.ms-shade-text) :is(.ms-rule, .ms-divider)::before, .ms-shaded:not(.ms-shade-text) :is(.ms-rule, .ms-divider)::after, .ms-shaded:not(.ms-shade-text) .ms-line { border-image: var(--ms-shade) 1; } +.ms-shaded.ms-toned:not(.ms-shade-borders) { background-image: var(--ms-shade); } +@supports (background-image: linear-gradient(in oklch, red, blue)) { .ms-shaded.ms-toned:not(.ms-shade-borders) { background-image: var(--ms-shade-toned); } .ms-shaded.ms-toned:not(.ms-shade-text) :is(.ms-box, .ms-gauge-bar), .ms-shaded.ms-toned:not(.ms-shade-text) :is(.ms-rule, .ms-divider)::before, .ms-shaded.ms-toned:not(.ms-shade-text) :is(.ms-rule, .ms-divider)::after, .ms-shaded.ms-toned:not(.ms-shade-text) .ms-line { border-image: var(--ms-shade-toned) 1; } .ms-gauge-fill.ms-toned { background-image: var(--ms-shade-toned) !important; } } +.ms-table-wrap { max-width: 100%; overflow-x: auto; } +.ms-table { border-collapse: collapse; } +.ms-table.ms-fill { width: 100%; } +.ms-table th, .ms-table td { padding: 0 1ch; vertical-align: top; text-align: left; } +.ms-table :is(th, td) > .ms-text:only-child { display: inline-block; width: 100%; } +.ms-table th { border-bottom: 1px solid var(--ms-header-rule, var(--ms-header-rule-default, currentColor)); color: var(--ms-heading, var(--ms-heading-default, inherit)); } +.ms-table.ms-striped > tbody > tr:nth-child(even) { background: var(--ms-stripe, var(--ms-stripe-default, rgba(127, 127, 127, 0.12))); } +.ms-table .ms-nowrap, .ms-table .ms-nowrap .ms-text { white-space: pre; overflow-wrap: normal; } +@media (max-width: 48em) { .ms-table .ms-p3 { display: none; } .ms-table.ms-fill > colgroup { display: none; } } +@media (max-width: 32em) { .ms-table .ms-p2 { display: none; } } +.ms-menu { cursor: pointer; text-decoration: underline dotted; } +.ms-menu-popup { position: fixed; z-index: 1000; margin: 0; padding: 2px 0; min-width: 8ch; font: inherit; color: var(--ms-menu-fg, CanvasText); background: var(--ms-menu-bg, Canvas); border: 1px solid var(--ms-menu-border, currentColor); box-shadow: 0 2px 6px rgb(0 0 0 / 0.3); } +.ms-menu-popup button { display: block; width: 100%; margin: 0; padding: 2px 1ch; border: 0; font: inherit; color: inherit; text-align: start; background: none; cursor: pointer; } +.ms-menu-popup button:hover, .ms-menu-popup button:focus-visible { outline: none; color: var(--ms-menu-active-fg, HighlightText); background: var(--ms-menu-active-bg, Highlight); } +.ms-expired, .ms-expired .ms-cmd-link { cursor: default; text-decoration: none; } +.ms-sound, .ms-sound-stop, .ms-bell, .ms-clear, .ms-expire, .ms-relocate, .ms-login { display: none; } +.ms-gauge { background: linear-gradient(var(--ms-gauge, currentColor), var(--ms-gauge, currentColor)) no-repeat left bottom / calc(var(--ms-gauge-fill, 0) * 100%) 2px; } +.ms-bell-ring { animation: ms-bell-ring 0.3s ease-out; } +@keyframes ms-bell-ring { from { background-color: var(--ms-bell, rgb(255 255 255 / 0.25)); } } diff --git a/MarkupString.Web/wwwroot/markupstring.js b/MarkupString.Web/wwwroot/markupstring.js new file mode 100644 index 0000000..a0eb660 --- /dev/null +++ b/MarkupString.Web/wwwroot/markupstring.js @@ -0,0 +1,433 @@ +// MarkupString runtime: makes the HTML that MarkupFormat.Html writes do what it asks for. +// +// import { attach } from './_content/MarkupString.Web/markupstring.js'; +// const output = attach(document.querySelector('#output'), { send: command => socket.send(command) }); +// +// It runs command links and menus, plays and stops sounds, rings the bell, clears, expires links, keeps +// variables and the gauges that show them, and moves pane text to its pane. Every effect is first raised +// on its element as a cancelable `ms:` DOM event, then offered to plugins, then to the host's +// option of the same name, and only then given the default behaviour, so a host can take over any of them. + +const BUILT_IN = { + sound: '.ms-sound', + 'sound-stop': '.ms-sound-stop', + bell: '.ms-bell', + clear: '.ms-clear', + expire: '.ms-expire', + relocate: '.ms-relocate', + login: '.ms-login', + variable: '.ms-variable', + pane: '.ms-pane', +}; + +// Effects that happen once, when the output arrives. The others restore state, so they are applied again +// to output that was already on the page when the runtime attached (a reload's scrollback). +const TRANSIENT = new Set(['sound', 'sound-stop', 'bell', 'relocate', 'login']); + +/** + * Starts the runtime over `root`: the element MarkupString's HTML is added to. + * + * Options, all optional: + * - send(command): run a command link or a menu choice. Without it, commands do nothing. + * - prompt(command): put a command in the input line (a prompt link). Falls back to send. + * - sounds: false to play nothing. soundBase: the address relative sound names resolve against. + * - replay: true to also play sounds and ring bells in output already on the page. + * - panes: { name: element } or a function (name, title) => element, for pane text. + * - bell, clear, expire, relocate, login, variable, pane, sound, 'sound-stop', command, menu: + * a function (detail, element) that replaces that effect's default behaviour. + */ +export function attach(root, options = {}) { + return new MarkupRuntime(root, options); +} + +/** + * As `attach`, for a host in .NET (Blazor): commands go to the object's [JSInvokable] `Send(command)` and + * `Prompt(command)`, and each effect named in `options.forward` goes to `OnEffect(name, detail)` instead of + * its default behaviour. + */ +export function attachDotNet(root, dotNetObject, options = {}) { + const forward = new Set(options.forward ?? []); + const forwarded = {}; + for (const name of forward) { + forwarded[name] = detail => { dotNetObject.invokeMethodAsync('OnEffect', name, detail); }; + } + return new MarkupRuntime(root, { + ...options, + ...forwarded, + send: command => dotNetObject.invokeMethodAsync('Send', command), + prompt: command => dotNetObject.invokeMethodAsync('Prompt', command), + }); +} + +export class MarkupRuntime { + constructor(root, options) { + if (!root) throw new TypeError('attach: no root element'); + this.root = root; + this.options = { sounds: true, replay: false, ...options }; + /** The server's variables, by name. */ + this.variables = new Map(); + this._plugins = []; + this._seen = new WeakSet(); + this._music = null; + this._effects = new Set(); + this._menu = null; + + this._onClick = event => this._click(event); + this._onKey = event => this._key(event); + this._onOutside = event => { if (this._menu && !this._menu.contains(event.target)) this.closeMenu(); }; + root.addEventListener('click', this._onClick); + root.addEventListener('keydown', this._onKey); + root.ownerDocument.addEventListener('mousedown', this._onOutside, true); + + this._observer = new MutationObserver(records => { + for (const record of records) { + for (const node of record.addedNodes) { + if (node.nodeType === 1) this.process(node); + } + } + }); + this._observer.observe(root, { childList: true, subtree: true }); + this.process(root, true); + } + + /** + * Adds a plugin: `{ selector, handle(element, runtime, initial) }` acts on elements of a markup type of its + * own; `{ effect, handle(detail, element, runtime) }` takes over a built-in effect when it returns true. + * A plugin added later is asked first. + */ + use(plugin) { + this._plugins.unshift(plugin); + if (plugin.selector) this.process(this.root, true); + return this; + } + + /** Acts on the effects in `node` that have not been acted on yet. The runtime calls it for added nodes. */ + process(node, initial = false) { + for (const [effect, selector] of Object.entries(BUILT_IN)) { + for (const element of matching(node, selector)) { + if (this._seen.has(element)) continue; + this._seen.add(element); + if (initial && !this.options.replay && TRANSIENT.has(effect)) continue; + this._effect(effect, element, read(effect, element)); + } + } + for (const plugin of this._plugins) { + if (!plugin.selector) continue; + for (const element of matching(node, plugin.selector)) { + if (this._seen.has(element)) continue; + this._seen.add(element); + plugin.handle(element, this, initial); + } + } + } + + /** Stops the sounds on `channel` ('effects' or 'music'), or on both. */ + stopSounds(channel) { + if (channel !== 'effects' && this._music) { + this._music.pause(); + this._music = null; + } + if (channel !== 'music') { + for (const audio of this._effects) audio.pause(); + this._effects.clear(); + } + } + + /** Closes an open command menu. */ + closeMenu() { + if (!this._menu) return; + const menu = this._menu; + this._menu = null; + menu.remove(); + menu._opener?.focus?.(); + } + + /** Stops the runtime: no more effects, sounds stopped, listeners removed. */ + detach() { + this._observer.disconnect(); + this.root.removeEventListener('click', this._onClick); + this.root.removeEventListener('keydown', this._onKey); + this.root.ownerDocument.removeEventListener('mousedown', this._onOutside, true); + this.closeMenu(); + this.stopSounds(); + } + + // Raises the effect's event, then asks plugins, then the host's option, then does the default. + _effect(effect, element, detail) { + const event = new CustomEvent(`ms:${effect}`, { bubbles: true, cancelable: true, detail }); + element.dispatchEvent(event); + if (event.defaultPrevented) return; + for (const plugin of this._plugins) { + if (plugin.effect === effect && plugin.handle(detail, element, this)) return; + } + const own = this.options[effect]; + if (typeof own === 'function') { + own(detail, element); + return; + } + DEFAULTS[effect]?.call(this, detail, element); + } + + _click(event) { + if (event.button !== 0 || event.defaultPrevented) return; + const target = event.target; + if (!(target instanceof Element) || target.closest('.ms-menu-popup')) return; + const menu = target.closest('.ms-menu'); + if (menu && this.root.contains(menu) && !menu.classList.contains('ms-expired')) { + event.preventDefault(); + this._openMenu(menu, event.clientX, event.clientY); + return; + } + const link = target.closest('a[xch_cmd]'); + if (link && this.root.contains(link)) { + event.preventDefault(); + this._effect('command', link, { command: link.getAttribute('xch_cmd') }); + } + } + + _key(event) { + const target = event.target; + if (!(target instanceof Element) || target.closest('.ms-menu-popup')) return; + const activate = event.key === 'Enter' || event.key === ' '; + const menuKey = event.key === 'ContextMenu' || (event.key === 'F10' && event.shiftKey); + if (!activate && !menuKey) return; + + // A menu opens from its own text, or with the menu key from a link inside it. + const menu = target.matches('.ms-menu') ? target : menuKey ? target.closest('.ms-menu') : null; + if (menu && this.root.contains(menu) && !menu.classList.contains('ms-expired')) { + event.preventDefault(); + const box = menu.getBoundingClientRect(); + this._openMenu(menu, box.left, box.bottom); + return; + } + if (activate && target.matches('a[xch_cmd]') && this.root.contains(target)) { + event.preventDefault(); + this._effect('command', target, { command: target.getAttribute('xch_cmd') }); + } + } + + _openMenu(element, x, y) { + const detail = { + choices: parseChoices(element.getAttribute('data-commands')), + prompt: element.classList.contains('ms-prompt'), + hint: element.getAttribute('title'), + x, + y, + }; + if (detail.choices.length === 0) return; + this._effect('menu', element, detail); + } + + _run(command, prompt, element) { + this._effect('command', element, { command, prompt }); + } +} + +// What each effect does when nothing took it over. `this` is the runtime. +const DEFAULTS = { + command({ command, prompt }) { + if (!command) return; + const run = prompt ? this.options.prompt ?? this.options.send : this.options.send; + run?.(command); + }, + + menu({ choices, prompt, x, y }, element) { + if (choices.length === 1) { + this._run(choices[0].command, prompt, element); + return; + } + this.closeMenu(); + const doc = this.root.ownerDocument; + const popup = doc.createElement('div'); + popup.className = 'ms-menu-popup'; + popup.setAttribute('role', 'menu'); + popup._opener = element; + for (const choice of choices) { + const item = doc.createElement('button'); + item.type = 'button'; + item.setAttribute('role', 'menuitem'); + item.textContent = choice.label || choice.command; + item.title = choice.command; + item.addEventListener('click', event => { + event.preventDefault(); + this.closeMenu(); + this._run(choice.command, prompt, element); + }); + popup.append(item); + } + popup.addEventListener('keydown', event => { + const items = [...popup.querySelectorAll('button')]; + const at = items.indexOf(doc.activeElement); + if (event.key === 'Escape') { + event.preventDefault(); + this.closeMenu(); + } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + const step = event.key === 'ArrowDown' ? 1 : -1; + items[(at + step + items.length) % items.length].focus(); + } + }); + doc.body.append(popup); + const box = popup.getBoundingClientRect(); + const view = doc.defaultView; + popup.style.left = `${Math.max(0, Math.min(x, view.innerWidth - box.width))}px`; + popup.style.top = `${Math.max(0, Math.min(y, view.innerHeight - box.height))}px`; + this._menu = popup; + popup.querySelector('button')?.focus(); + }, + + sound(detail) { + if (!this.options.sounds || !detail.src) return; + const url = new URL(detail.src, this.options.soundBase ?? this.root.ownerDocument.baseURI).href; + if (detail.channel === 'music') { + if (detail.continues && this._music && this._music.src === url && !this._music.paused) return; + this.stopSounds('music'); + } + const audio = new Audio(url); + if (detail.volume != null) audio.volume = Math.min(1, Math.max(0, detail.volume / 100)); + audio.loop = detail.loops; + let left = detail.loops ? 0 : Math.max(1, detail.repeats ?? 1) - 1; + audio.addEventListener('ended', () => { + if (left-- > 0) { + audio.currentTime = 0; + audio.play().catch(() => {}); + } else { + this._effects.delete(audio); + if (this._music === audio) this._music = null; + } + }); + if (detail.channel === 'music') this._music = audio; + else this._effects.add(audio); + // A browser refuses to play before the reader has used the page; that sound is lost, not queued. + audio.play().catch(() => {}); + }, + + 'sound-stop'({ channel }) { + this.stopSounds(channel ?? undefined); + }, + + bell() { + this.root.classList.remove('ms-bell-ring'); + void this.root.offsetWidth; + this.root.classList.add('ms-bell-ring'); + }, + + clear(_, element) { + // Everything before the line the clear stands in goes. + let line = element; + while (line.parentNode && line.parentNode !== this.root) line = line.parentNode; + if (line.parentNode !== this.root) return; + while (line.previousSibling) line.previousSibling.remove(); + }, + + expire({ group }, element) { + const scope = group == null + ? [this.root] + : [...this.root.querySelectorAll(`.ms-link-group[data-group="${CSS.escape(group)}"]`)]; + for (const container of scope) { + for (const link of container.querySelectorAll('a[xch_cmd], .ms-menu, a[href]')) { + // Only links that came before the expiry. + if (!(link.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) continue; + if (group == null && link.matches('a[href]') && !link.closest('.ms-link-group')) continue; + link.removeAttribute('xch_cmd'); + link.removeAttribute('href'); + link.removeAttribute('data-commands'); + link.removeAttribute('role'); + link.removeAttribute('tabindex'); + link.setAttribute('aria-disabled', 'true'); + link.classList.add('ms-expired'); + } + } + }, + + variable({ name, value }) { + if (value == null) this.variables.delete(name); + else this.variables.set(name, value); + const doc = this.root.ownerDocument; + const quoted = CSS.escape(name); + const shows = `[data-variable="${quoted}"], [data-maximum="${quoted}"], [data-ms-variable="${quoted}"]`; + for (const shown of doc.querySelectorAll(shows)) this._show(shown); + }, + + pane({ name, title }, element) { + const panes = this.options.panes; + const target = typeof panes === 'function' + ? panes(name, title) + : panes?.[name] ?? this.root.ownerDocument.querySelector(`[data-ms-pane-target="${CSS.escape(name)}"]`); + if (target && target !== element.parentNode) target.append(element); + }, +}; + +// Brings a gauge, a status or a host's own [data-ms-variable] element up to date with the variables. +MarkupRuntime.prototype._show = function (element) { + if (element.hasAttribute('data-ms-variable')) { + element.textContent = this.variables.get(element.getAttribute('data-ms-variable')) ?? ''; + return; + } + const value = this.variables.get(element.getAttribute('data-variable')); + if (value == null) return; + element.setAttribute('data-value', value); + const maximum = element.getAttribute('data-maximum'); + const top = maximum == null ? NaN : Number(this.variables.get(maximum) ?? maximum); + if (element.classList.contains('ms-gauge')) { + const fill = top > 0 ? Math.min(1, Math.max(0, Number(value) / top)) : 0; + element.style.setProperty('--ms-gauge-fill', String(Number.isFinite(fill) ? fill : 0)); + const color = element.getAttribute('data-color'); + if (color) element.style.setProperty('--ms-gauge', color); + } + const caption = element.getAttribute('data-caption'); + element.title = `${caption ? `${caption}: ` : ''}${value}${Number.isFinite(top) ? ` / ${top}` : ''}`; +}; + +// The detail of each effect, read from the attributes the HTML emitter writes. +function read(effect, element) { + const number = name => { + const raw = element.getAttribute(name); + return raw == null || raw === '' ? null : Number(raw); + }; + switch (effect) { + case 'sound': + return { + src: element.getAttribute('src'), + channel: element.getAttribute('data-channel') === 'music' ? 'music' : 'effects', + volume: number('data-volume'), + repeats: number('data-repeats'), + loops: element.hasAttribute('loop'), + continues: element.getAttribute('data-continues') === 'true', + }; + case 'sound-stop': + return { channel: element.getAttribute('data-channel') }; + case 'expire': + return { group: element.getAttribute('data-group') }; + case 'relocate': + return { + host: element.getAttribute('data-host'), + port: number('data-port'), + quiet: element.getAttribute('data-quiet') === 'true', + }; + case 'login': + return { field: element.getAttribute('data-field') }; + case 'variable': + return { name: element.getAttribute('data-name'), value: element.textContent }; + case 'pane': + return { name: element.getAttribute('data-pane'), title: element.getAttribute('data-title') }; + default: + return {}; + } +} + +function parseChoices(raw) { + try { + const parsed = JSON.parse(raw ?? '[]'); + return Array.isArray(parsed) + ? parsed.filter(c => c && typeof c.command === 'string').map(c => ({ label: String(c.label ?? ''), command: c.command })) + : []; + } catch { + return []; + } +} + +function* matching(node, selector) { + if (node.matches?.(selector)) yield node; + if (node.querySelectorAll) yield* node.querySelectorAll(selector); +} diff --git a/MarkupString.slnx b/MarkupString.slnx index 9b5c4a4..fbeeb30 100644 --- a/MarkupString.slnx +++ b/MarkupString.slnx @@ -6,5 +6,6 @@ + diff --git a/MarkupString/Display/EffectDispatcher.cs b/MarkupString/Display/EffectDispatcher.cs new file mode 100644 index 0000000..c393a8d --- /dev/null +++ b/MarkupString/Display/EffectDispatcher.cs @@ -0,0 +1,43 @@ +namespace MarkupString.Display; + +/// Acts on output effects of the kinds it knows: plays sounds, sets a title, counts a bell. +public interface IEffectHandler +{ + /// Acts on , or leaves it. + /// True when it was handled, so no older handler is asked. + bool Handle(OutputEffect effect); +} + +/// +/// Hands each to a list of s, newest first, until one +/// handles it, as a decoder tries its plugins. A handler added later can so take over one case and leave +/// the rest to the handlers before it. +/// +/// The handlers, oldest first, as a container lists them. +public sealed class EffectDispatcher(IEnumerable handlers) +{ + private readonly IEffectHandler[] _handlers = [.. (handlers ?? throw new ArgumentNullException(nameof(handlers))).Reverse()]; + + /// Raised for an effect no handler took. + public event Action? Unhandled; + + /// Hands to the handlers. + /// True when one handled it. + public bool Dispatch(OutputEffect effect) + { + foreach (var handler in _handlers) + { + if (handler.Handle(effect)) return true; + } + + Unhandled?.Invoke(effect); + return false; + } + + /// Hands each of to the handlers, in order. + public void Dispatch(IEnumerable effects) + { + ArgumentNullException.ThrowIfNull(effects); + foreach (var effect in effects) Dispatch(effect); + } +} diff --git a/MarkupString/Display/OutputEffect.Union.cs b/MarkupString/Display/OutputEffect.Union.cs new file mode 100644 index 0000000..7ea0c62 --- /dev/null +++ b/MarkupString/Display/OutputEffect.Union.cs @@ -0,0 +1,36 @@ +using MarkupString.Decoding; + +namespace MarkupString.Display; + +/// +/// Something output asks of the client besides text to show: a sound, a bell, a title, links to expire. It is +/// one of the types that already say it: +/// +/// a point from the text: , , , +/// , , , +/// , , or any other , such as a plugin's; +/// what a decoder raised: , , , +/// , , , or any other ; +/// a , from a decoder or from the text a covers. +/// +/// +/// +/// A host handles markup frames, decoded ANSI, MXP and Pueblo with one switch: +/// effect switch { SoundMarkup sound => …, TitleEvent title => …, IPointMarkup other => …, … }. +/// and are the last cases, so a plugin's own point or +/// event still arrives. +/// A value of a case type converts to one implicitly. On .NET 10, where the runtime has no unions, the +/// same type is built with copies of the compiler's union attribute and interface, and Value holds the +/// case. +/// +public union OutputEffect( + BellMarkup, ClearScreenMarkup, SoundMarkup, SoundStopMarkup, ExpireLinksMarkup, PrefetchMarkup, LoginPromptMarkup, + RelocateMarkup, VariableEvent, TitleEvent, PaletteEvent, ClipboardEvent, DownloadEvent, ReplyEvent, ProtocolEvent, + IPointMarkup, DecodeEvent) +{ + /// An effect of a point: a bell, a sound, a plugin's own. + public static OutputEffect Of(IPointMarkup point) => new(point ?? throw new ArgumentNullException(nameof(point))); + + /// An effect of something a decoder raised. + public static OutputEffect Of(DecodeEvent decoded) => new(decoded ?? throw new ArgumentNullException(nameof(decoded))); +} diff --git a/MarkupString/Display/OutputEffect.cs b/MarkupString/Display/OutputEffect.cs new file mode 100644 index 0000000..d9fe7dd --- /dev/null +++ b/MarkupString/Display/OutputEffect.cs @@ -0,0 +1,82 @@ +using MarkupString.Decoding; + +namespace MarkupString.Display; + +/// Reads s out of text. +public static class OutputEffects +{ + /// + /// The effects in , in the order they stand: one for each point (once per carrier), + /// and a for each stretch of text a covers. + /// + /// The line. + /// + /// What the output can do; sounds are left out when it has no . + /// Null keeps everything. + /// + public static IReadOnlyList Of(MarkupText text, OutputCapabilities? capabilities = null) + { + ArgumentNullException.ThrowIfNull(text); + + var effects = new List(); + var source = text.Text; + VariableMarkup? variable = null; + var variableStart = 0; + var variableEnd = 0; + + foreach (var run in text.Runs) + { + VariableMarkup? here = null; + IPointMarkup? point = null; + foreach (var markup in run.Markups) + { + if (markup is IPointMarkup p) point ??= p; + else if (markup is VariableMarkup v) here ??= v; + } + + // A variable's value is the whole stretch it covers, however many runs that is. + if (variable is not null && (!Equals(here, variable) || run.Start != variableEnd)) + { + effects.Add(Variable(variable, source, variableStart, variableEnd)); + variable = null; + } + + if (here is not null) + { + if (variable is null) + { + variable = here; + variableStart = run.Start; + } + variableEnd = run.End; + } + + if (point is not null && (capabilities is null || SpanRenderer.Shows(point, capabilities))) + { + var effect = OutputEffect.Of(point); + for (var count = Count(source.AsSpan(run.Start, run.Length), point.Carrier); count > 0; count--) + { + effects.Add(effect); + } + } + } + + if (variable is not null) effects.Add(Variable(variable, source, variableStart, variableEnd)); + return effects; + } + + private static VariableEvent Variable(VariableMarkup variable, string source, int start, int end) => + new(variable.Name, source[start..end].Replace(MarkupText.PointCarrier, string.Empty, StringComparison.Ordinal)); + + private static int Count(ReadOnlySpan text, string carrier) + { + var count = 0; + for (var at = text.IndexOf(carrier, StringComparison.Ordinal); at >= 0; at = text.IndexOf(carrier, StringComparison.Ordinal)) + { + count++; + text = text[(at + carrier.Length)..]; + } + + return Math.Max(count, 1); + } +} diff --git a/MarkupString/Elements/ScreenMarkup.cs b/MarkupString/Elements/ScreenMarkup.cs index b5c6b02..731a848 100644 --- a/MarkupString/Elements/ScreenMarkup.cs +++ b/MarkupString/Elements/ScreenMarkup.cs @@ -50,7 +50,10 @@ public enum LoginField /// Marks a login prompt, so a client that stores a player's name and password knows which to send. /// builds one. ///
-/// MXP writes <USER> or <PASSWORD>; nothing else has the idea. +/// +/// MXP writes <USER> or <PASSWORD>, and HTML an empty ms-login element for the +/// page's script; nothing else has the idea. +/// /// Which prompt this is. public sealed record LoginPromptMarkup(LoginField Field) : IPointMarkup; @@ -58,7 +61,10 @@ public sealed record LoginPromptMarkup(LoginField Field) : IPointMarkup; /// Asks the client to disconnect and connect to another address. /// builds one. /// -/// MXP writes <RELOCATE>; nothing else has the idea. +/// +/// MXP writes <RELOCATE>, and HTML an empty ms-relocate element for the page's script; +/// nothing else has the idea. +/// /// The host to connect to. /// The port to connect to. /// Whether the client reconnects without telling the player. diff --git a/MarkupString/MarkupString.csproj b/MarkupString/MarkupString.csproj index 4010f80..e205343 100644 --- a/MarkupString/MarkupString.csproj +++ b/MarkupString/MarkupString.csproj @@ -16,6 +16,14 @@ Immutable styled text: a string plus layered markup runs, rendered to ANSI, HTML, Pueblo, MXP, BBCode, Markdown or plain text through a registry of emitters, and round-tripped through JSON. + + + + + net10.0;net11.0 + + diff --git a/MarkupString/Polyfills/Unions.cs b/MarkupString/Polyfills/Unions.cs new file mode 100644 index 0000000..1d96e25 --- /dev/null +++ b/MarkupString/Polyfills/Unions.cs @@ -0,0 +1,17 @@ +#if !NET11_0_OR_GREATER +namespace System.Runtime.CompilerServices; + +/// +/// The compiler marks a union declaration with this attribute. .NET 11 has it; this copy lets the +/// net10.0 build declare unions too. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] +internal sealed class UnionAttribute : Attribute; + +/// A union's value, whichever case it is. .NET 11 has it; this copy is for the net10.0 build. +internal interface IUnion +{ + /// The value, of one of the union's case types. + object? Value { get; } +} +#endif diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index de9ef4b..cb06281 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -765,3 +765,34 @@ MarkupString.Decoding.DecodeDrop.Limit = 3 -> MarkupString.Decoding.DecodeDrop MarkupString.Decoding.DecodeDrop.Picture = 4 -> MarkupString.Decoding.DecodeDrop MarkupString.Decoding.DecodeDrop.UnhandledControl = 2 -> MarkupString.Decoding.DecodeDrop MarkupString.Decoding.DecodeDrop.UnhandledSequence = 1 -> MarkupString.Decoding.DecodeDrop +MarkupString.Display.EffectDispatcher +MarkupString.Display.EffectDispatcher.EffectDispatcher(System.Collections.Generic.IEnumerable! handlers) -> void +MarkupString.Display.IEffectHandler +MarkupString.Display.EffectDispatcher.Dispatch(MarkupString.Display.OutputEffect effect) -> bool +MarkupString.Display.EffectDispatcher.Dispatch(System.Collections.Generic.IEnumerable! effects) -> void +MarkupString.Display.EffectDispatcher.Unhandled -> System.Action? +MarkupString.Display.IEffectHandler.Handle(MarkupString.Display.OutputEffect effect) -> bool +MarkupString.Display.OutputEffect +MarkupString.Display.OutputEffect.OutputEffect() -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.BellMarkup! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.ClearScreenMarkup! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.Decoding.ClipboardEvent! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.Decoding.DecodeEvent! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.Decoding.DownloadEvent! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.Decoding.PaletteEvent! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.Decoding.ProtocolEvent! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.Decoding.ReplyEvent! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.Decoding.TitleEvent! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.Decoding.VariableEvent! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.ExpireLinksMarkup! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.IPointMarkup! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.LoginPromptMarkup! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.PrefetchMarkup! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.RelocateMarkup! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.SoundMarkup! value) -> void +MarkupString.Display.OutputEffect.OutputEffect(MarkupString.SoundStopMarkup! value) -> void +MarkupString.Display.OutputEffect.Value.get -> object? +MarkupString.Display.OutputEffects +static MarkupString.Display.OutputEffect.Of(MarkupString.Decoding.DecodeEvent! decoded) -> MarkupString.Display.OutputEffect +static MarkupString.Display.OutputEffect.Of(MarkupString.IPointMarkup! point) -> MarkupString.Display.OutputEffect +static MarkupString.Display.OutputEffects.Of(MarkupString.MarkupText! text, MarkupString.Display.OutputCapabilities? capabilities = null) -> System.Collections.Generic.IReadOnlyList! diff --git a/README.md b/README.md index a9d54ba..eb8f9bf 100644 --- a/README.md +++ b/README.md @@ -166,12 +166,14 @@ rendering opinions. | [`MarkupString.Mxp`](https://www.nuget.org/packages/MarkupString.Mxp) | The shared vocabulary expressed as client-supported MXP elements. | | [`MarkupString.Pueblo`](https://www.nuget.org/packages/MarkupString.Pueblo) | The shared vocabulary expressed through Pueblo client extensions. | | [`MarkupString.Extensions.DependencyInjection`](https://www.nuget.org/packages/MarkupString.Extensions.DependencyInjection) | `AddMarkupString()` for ASP.NET Core and other `Microsoft.Extensions` hosts, with options bound from configuration. | +| [`MarkupString.Web`](https://www.nuget.org/packages/MarkupString.Web) | The browser side of the HTML output: one stylesheet, and a script that runs links and menus, sounds, the bell, clears, expiry, variables and panes. See [Web pages](docs/web.md). | ```sh dotnet add package MarkupString.Html dotnet add package MarkupString.Mxp dotnet add package MarkupString.Pueblo dotnet add package MarkupString.Extensions.DependencyInjection +dotnet add package MarkupString.Web ``` All packages share one version and are released together. diff --git a/build/Unions.props b/build/Unions.props new file mode 100644 index 0000000..bc4a0ee --- /dev/null +++ b/build/Unions.props @@ -0,0 +1,19 @@ + + + + $([System.Version]::Parse($(NETCoreSdkVersion.Split('-')[0])).Major) + preview + --exclude "**/*.Union.cs" + + + + + + diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md index b0295ab..234eab5 100644 --- a/docs/dependency-injection.md +++ b/docs/dependency-injection.md @@ -38,6 +38,7 @@ your own first replaces it. | `DisplayRegistry`, `IStyleResolver` | `DisplayRegistry.Core.WithAnsi()` and each `AddDisplay` step | | `DisplayOptions` | The configured output, colour level and link policy | | `IUrlPolicy` | `UrlPolicy.WithSchemes(LinkSchemes)` | +| `EffectDispatcher` | Hands [output effects](display.md#effects) to every `IEffectHandler` added with `AddEffectHandler()` or `AddEffectHandler(handler)`, newest first | `UsePictureCache()` and `UseUrlPolicy()` swap in your own as singletons. A cache of your own (on disk, in Redis) must be safe to call from several threads; see [Pictures](decoding.md#pictures). diff --git a/docs/display.md b/docs/display.md index 16bdb41..467bda7 100644 --- a/docs/display.md +++ b/docs/display.md @@ -2,8 +2,11 @@ A decoded line is a `MarkupText` (see [Decoding](decoding.md)). To show it, fold each run's markup into a `DisplayStyle` (RGB colours, attributes, what a click does) degraded to what the output can -show. `SpanRenderer` gives runs of text for a web page; `CellRenderer` gives a grid of cells for a -window that draws its own. +show. `SpanRenderer` gives runs of text for a client that builds its own elements from styles; +`CellRenderer` gives a grid of cells for a window that draws its own. A web page that shows HTML takes +`Render(MarkupFormat.Html)` and [`MarkupString.Web`](web.md) instead: the same classes, layouts and theme +variables as everything else the page renders, and a script that acts on them. What a line asks for +besides text, every renderer's client reads as [effects](#effects). ```csharp using System.Net; @@ -312,6 +315,61 @@ CellRenderer.Layout(row, 0, display, grid).Rows[0].Cells.Select(c => c.Style.Pic Word wrap never breaks at a space inside a picture. +## Effects + +A line can ask for more than text: a sound, a bell, a clear, links to expire. A decoder raises more: +a title, a palette change, a variable. `OutputEffect` is a C# union of the types that already say each of +these, so a client handles all of them in one switch, whatever the line came from: + +```csharp +using MarkupString.Display; + +void Apply(OutputEffect effect) +{ + switch (effect) + { + case SoundMarkup sound: player.Play(sound.Source, sound.Channel, sound.Volume); break; + case SoundStopMarkup stop: player.Stop(stop.Channel); break; + case BellMarkup: window.Flash(); break; + case ClearScreenMarkup: output.Clear(); break; + case ExpireLinksMarkup expire: output.ExpireLinks(expire.Group); break; + case VariableEvent variable: status[variable.Name] = variable.Value; break; + case TitleEvent title: window.Title = title.Title; break; + case ReplyEvent reply: connection.Send(reply.Text); break; + case IPointMarkup or DecodeEvent: break; // a plugin's own, or one this client leaves + } +} + +decoder.EventRaised += decoded => Apply(OutputEffect.Of(decoded)); +foreach (var line in decoder.Feed(text)) +{ + foreach (var effect in OutputEffects.Of(line.Content, capabilities)) Apply(effect); + Show(line); +} +``` + +| Case | Comes from | +|---|---| +| `BellMarkup`, `ClearScreenMarkup`, `SoundMarkup`, `SoundStopMarkup`, `ExpireLinksMarkup`, `PrefetchMarkup`, `LoginPromptMarkup`, `RelocateMarkup` | The points in a line | +| `VariableEvent` | The text a `VariableMarkup` covers, or a decoder (MXP ``, an entity) | +| `TitleEvent`, `PaletteEvent`, `ClipboardEvent`, `DownloadEvent`, `ReplyEvent`, `ProtocolEvent` | A decoder | +| `IPointMarkup`, `DecodeEvent` | Anything else of either kind, such as a plugin's | + +`OutputEffects.Of(text, capabilities)` lists a line's effects in order, one per point, and leaves sounds +out for an output without `Sound`. An MXP `` gives a `VariableEvent` twice, once from the decoder and +once from the line; setting a variable to the value it has changes nothing. + +`EffectDispatcher` hands effects to `IEffectHandler`s, newest first, until one returns true, and raises +`Unhandled` for the rest. `AddMarkupString().AddEffectHandler()` registers one, and the container gives +the dispatcher with every handler added (see [Dependency injection](dependency-injection.md)). + +A web page does not need this: [`MarkupString.Web`](web.md) reads the same effects out of the HTML. + +The library targets .NET 10, which has no unions in its runtime. Its net10.0 build declares them with +internal copies of the two types the compiler needs, and with the .NET 11 SDK the package also carries a +net11.0 build whose unions implement the runtime's own `IUnion`. A client on an older compiler reads the +case from `effect.Value`. + ## Back to ANSI A terminal output takes the decoded line as it is, through `Render(MarkupFormat.Ansi)` with a diff --git a/docs/formats.md b/docs/formats.md index a677d22..79ad58f 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -89,10 +89,15 @@ line.Render(MarkupFormat.Ansi); // A map of the city The door creaks. | `Prefetch` | nothing | `` | `` | nothing | | `ExpireLinks` | `` | nothing | `ms-expire` | nothing | | `Variable`, `Gauge`, `Status` | ``, ``, `` | the text | `ms-variable`, `ms-gauge`, `ms-status` around the text | the text | -| `Relocate`, `LoginPrompt` | ``, `` / `` | nothing | nothing | nothing | +| `Relocate`, `LoginPrompt` | ``, `` / `` | nothing | `ms-relocate`, `ms-login` | nothing | | `Bell` | U+0007 | U+0007 | `ms-bell` | ANSI U+0007 | -An `ms-` element is a hook: the page decides what a clear, a pane or a gauge looks like. Addresses are +`CommandMenuMarkup` is a `ms-menu` element whose `data-commands` holds the choices as JSON, +`HighlightMarkup` a `ms-highlight` with its background, and `LinkGroupMarkup` a `ms-link-group` with +its `data-group`, so an expiry can find its links. + +An `ms-` element is a hook: the page decides what a clear, a pane or a gauge looks like. +[`MarkupString.Web`](web.md) is a script that does it, with a stylesheet for every class. Addresses are written as given — whether a page fetches or plays one is its own policy, not this library's. A link is already part of this vocabulary: `AnsiMarkup` with a `LinkKind` is written as each dialect diff --git a/docs/web.md b/docs/web.md new file mode 100644 index 0000000..ef4660b --- /dev/null +++ b/docs/web.md @@ -0,0 +1,141 @@ +# Web pages + +`Render(MarkupFormat.Html)` writes elements that ask for more than a look: a command link, a menu of +commands, a sound, a bell, a clear, links to expire, a variable, text for another pane. `MarkupString.Web` +is the browser side of that: one stylesheet for every class the HTML writer uses, and a script module that +makes those elements do what they ask. A page renders with the HTML writer, adds the two files, and calls +`attach` once. + +```html + +
+ +``` + +An ASP.NET Core or Blazor app serves both files from the package as static web assets at those paths +(`MarkupWeb.StylesheetPath`, `MarkupWeb.ScriptPath`). A host that serves pages another way takes the text +from `MarkupWeb.Stylesheet` and `MarkupWeb.Script`. + +The stylesheet is `AnsiCss.Fixed`, `LayoutCss.Fixed` and the rules the script's elements need (menus, +expired links, gauges, the bell), in that order. Theme it with the same `--ms-*` variables, and +`--ms-menu-fg`, `--ms-menu-bg`, `--ms-menu-border`, `--ms-menu-active-fg`, `--ms-menu-active-bg` and +`--ms-bell` for what it adds. + +## What the script does + +`attach(root, options)` watches `root` for added elements and acts on each one once. + +| Element | Effect | What happens | +|---|---|---| +| `a[xch_cmd]`, clicked, or Enter or Space | `command` | `send(command)` | +| `.ms-menu`, clicked, or Enter, Space or the menu key | `menu` | A menu of its choices; a choice sends its command. A menu of one choice sends it at once. | +| `.ms-menu.ms-prompt` | `menu`, then `command` with `prompt: true` | `prompt(command)`, which puts it in the input line; `send` when there is no `prompt` | +| `audio.ms-sound` | `sound` | Plays it at its volume, as many times as it says, or looping. Music replaces the music playing, unless it continues the same piece. | +| `.ms-sound-stop` | `sound-stop` | Stops the effects, the music, or both | +| `.ms-bell` | `bell` | Flashes `root` (the `ms-bell-ring` class) | +| `.ms-clear` | `clear` | Removes the children of `root` before the one the clear is in | +| `.ms-expire` | `expire` | Links before it in its `.ms-link-group`, or every command link when it names no group, stop working and take `ms-expired` | +| `.ms-variable` | `variable` | Keeps the value in `runtime.variables`, and brings up to date every `.ms-gauge`, `.ms-status` and `[data-ms-variable]` on the page that shows it | +| `.ms-pane` | `pane` | Moves the text to the pane's element: `panes[name]`, `panes(name, title)`, or `[data-ms-pane-target="name"]` | +| `.ms-relocate`, `.ms-login` | `relocate`, `login` | Nothing but the event and the host's option: only the host knows how to reconnect or what to type | + +A gauge sets `--ms-gauge-fill` (0 to 1) and `data-value`, and the stylesheet draws it as a bar under its +text. A maximum may name another variable. + +Output that is already in `root` when `attach` runs (a reload's scrollback) is treated as state, not news: +expiry, variables, panes and clears are applied, but sounds, bells, relocation and login prompts are not. +`replay: true` acts on all of them. + +### Options + +| Option | | +|---|---| +| `send(command)` | Runs a command. Without it, commands do nothing. | +| `prompt(command)` | Puts a command in the input line | +| `sounds` | `false` plays nothing | +| `soundBase` | The address relative sound names are resolved against; the page's own address when unset | +| `replay` | `true` to also act on transient effects already on the page | +| `panes` | An object of pane elements by name, or a function `(name, title) => element` | +| an effect's name | A function `(detail, element)` that replaces that effect's default: `bell`, `clear`, `relocate`, `login`, `sound`, … | + +## Taking an effect over + +Every effect goes through four steps, and the first that takes it ends it: + +1. A cancelable `ms:` DOM event on the element, which bubbles. `preventDefault()` stops the rest. +2. Plugins, newest first: `runtime.use({ effect: 'bell', handle(detail, element, runtime) { …; return true; } })`. +3. The host's option of the same name. +4. The default above. + +```js +document.addEventListener('ms:sound', event => { + if (muted) event.preventDefault(); +}); +``` + +A markup type of your own needs an emitter that writes an element with a class of its own (see +[Custom markup](custom-markup.md)), and a plugin with a `selector` that acts on it: + +```js +output.use({ + selector: '.my-compass', + handle(element, runtime, initial) { + drawCompass(element.dataset.exits.split(',')); + }, +}); +``` + +`runtime.process(node)` acts on a node at once, without waiting for the observer. `runtime.stopSounds()`, +`runtime.closeMenu()` and `runtime.detach()` do what they say. + +## Blazor + +`attachDotNet(root, dotNetObject, { forward })` sends commands to the object's `[JSInvokable] Send(string)` +and `Prompt(string)`, and each effect named in `forward` to `OnEffect(string name, JsonElement detail)` +instead of its default. + +```csharp +public sealed class OutputPane(IJSRuntime js, GameConnection game) : IAsyncDisposable +{ + private IJSObjectReference? _runtime; + + public async Task AttachAsync(ElementReference output) + { + var module = await js.InvokeAsync("import", "./" + MarkupWeb.ScriptPath); + _runtime = await module.InvokeAsync("attachDotNet", output, DotNetObjectReference.Create(this), + new { forward = new[] { "clear", "relocate" } }); + } + + [JSInvokable] public Task Send(string command) => game.SendAsync(command); + [JSInvokable] public Task Prompt(string command) => game.SetInputAsync(command); + [JSInvokable] public Task OnEffect(string name, JsonElement detail) => name switch + { + "clear" => game.ClearScrollbackAsync(), // the lines are the component's, not the script's + "relocate" => game.RelocateAsync(detail.GetProperty("host").GetString()!, detail.GetProperty("port").GetInt32()), + _ => Task.CompletedTask, + }; + + public async ValueTask DisposeAsync() + { + if (_runtime is not null) await _runtime.InvokeVoidAsync("detach"); + } +} +``` + +Forward `clear` when the lines belong to a component: the default removes elements from the DOM, which +the component did not do. + +A client that does not show HTML reads the same things as [output effects](display.md#effects). From dd590bb6c830326a49fb1e0cf0ab6b19d4a62630 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 01:00:13 +0000 Subject: [PATCH 2/3] Write the browser fixture with LF on Windows too Indented JSON uses Environment.NewLine, so on Windows the fragments came out with CRLF while the checked-in fixture is LF. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7TGemgF3PcMsUZkY9E --- MarkupString.Tests/Web/MarkupWebTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MarkupString.Tests/Web/MarkupWebTests.cs b/MarkupString.Tests/Web/MarkupWebTests.cs index ba6f966..b245a68 100644 --- a/MarkupString.Tests/Web/MarkupWebTests.cs +++ b/MarkupString.Tests/Web/MarkupWebTests.cs @@ -78,7 +78,7 @@ public async Task BrowserTestFragments_AreWhatTheHtmlWriterWrites() ["pane"] = Html(MarkupText.Pane(MarkupText.Plain("chat line"), "chat", "Chat")), ["relocate"] = Html(Point(new RelocateMarkup("h", 23))), }; - var json = System.Text.Json.JsonSerializer.Serialize(fragments, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }) + "\n"; + var json = System.Text.Json.JsonSerializer.Serialize(fragments, new System.Text.Json.JsonSerializerOptions { WriteIndented = true, NewLine = "\n", Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping }) + "\n"; if (Updating) await File.WriteAllTextAsync(Fixture(), json); await Assert.That(await File.ReadAllTextAsync(Fixture())).IsEqualTo(json); From fe9658eb9f868248cf9f3aff3d2bb351b395deff Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 01:04:54 +0000 Subject: [PATCH 3/3] Build the net11.0 target on SDK 11, and restore the AOT smoke app there Directory.Build.props sets TargetFramework, and while it is set MSBuild builds that one framework and ignores TargetFrameworks, so the core package never built or packed net11.0. Clear it where the core project adds net11.0. The .NET 11 RC2 SDK maps net10.0 PublishAot to ILCompiler 10.0.13, which has an advisory (GHSA-p3jp-9v83-76w9) and fails restore under warnings-as-errors. Raise that exact version to 10.0.14, which the .NET 10 SDK already uses. The net11 job's package check passed every nupkg to one unzip, which reads the extra names as members to list and prints nothing. Match only the core package. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7SRHQxQ3BZFSwUcseb --- .github/workflows/ci.yml | 4 ++-- MarkupString.AotSmoke/MarkupString.AotSmoke.csproj | 11 +++++++++++ MarkupString/MarkupString.csproj | 5 ++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 203bb43..664c998 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,8 +88,8 @@ jobs: 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' + 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 diff --git a/MarkupString.AotSmoke/MarkupString.AotSmoke.csproj b/MarkupString.AotSmoke/MarkupString.AotSmoke.csproj index 0306f65..5d1aecd 100644 --- a/MarkupString.AotSmoke/MarkupString.AotSmoke.csproj +++ b/MarkupString.AotSmoke/MarkupString.AotSmoke.csproj @@ -23,6 +23,17 @@
+ + + + + diff --git a/MarkupString/MarkupString.csproj b/MarkupString/MarkupString.csproj index e205343..80fd5ef 100644 --- a/MarkupString/MarkupString.csproj +++ b/MarkupString/MarkupString.csproj @@ -19,9 +19,12 @@ + IUnion; the net10.0 build declares them with the copies in Polyfills/Unions.cs. TargetFramework + is cleared because Directory.Build.props sets it, and while it is set MSBuild builds only that + one framework and ignores TargetFrameworks. --> net10.0;net11.0 +