diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 715f6a1..664c998 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 ed41d84..b99a240 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,40 @@ # Changelog All notable changes to `MarkupString`, `MarkupString.Ansi`, `MarkupString.Html`, `MarkupString.Mxp`, -`MarkupString.Pueblo` and `MarkupString.Extensions.DependencyInjection`. The packages share one version and are released together. +`MarkupString.Pueblo`, `MarkupString.Extensions.DependencyInjection` and `MarkupString.Web`. The packages share one version and are released together. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- **Output effects.** `OutputEffect` is a C# union of what output asks of a client besides text: the points + (`BellMarkup`, `ClearScreenMarkup`, `SoundMarkup`, `SoundStopMarkup`, `ExpireLinksMarkup`, `PrefetchMarkup`, + `LoginPromptMarkup`, `RelocateMarkup`, any other `IPointMarkup`) and the decoder's events (`TitleEvent`, + `VariableEvent` and the rest, any other `DecodeEvent`). `OutputEffects.Of(text)` reads them out of a line, a + decoder's events convert to one, and `EffectDispatcher` hands them to `IEffectHandler`s, newest first, which + `AddMarkupString().AddEffectHandler()` 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 + specification says: `` is `https://h/img/maps/map.png`. +- An SGR extended colour with too few components (`38;2;40`) no longer reads what is left as codes of their own, + so it does not set a black background. + ## 2.27.0 — Unreleased ### Added 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.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.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..b245a68 --- /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, 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); + } +} 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..80fd5ef 100644 --- a/MarkupString/MarkupString.csproj +++ b/MarkupString/MarkupString.csproj @@ -16,6 +16,17 @@ 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 5c3bad9..c23c282 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -765,6 +765,37 @@ 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! MarkupString.Decoding.Decoder.OpenElements.get -> System.Collections.Generic.IReadOnlyList! MarkupString.Decoding.GaugeEvent MarkupString.Decoding.GaugeEvent.Caption.get -> string? 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).