diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 0000000..2f41f89
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,128 @@
+# Blazor WASM Regex Tester - AI Agent Instructions
+
+## Project Overview
+This is a Blazor WebAssembly application that runs a regex tester entirely in the browser. It uses the ASP.NET Core hosted model with three projects: Client (WASM), Server (host), and Shared (common code/services).
+
+**Live Demo**: https://dotnet-regex.com/
+**Key Characteristic**: All regex processing happens client-side in WebAssembly - the server only hosts static files.
+
+## Architecture
+
+### Project Structure
+```
+BlazorWasmRegex/
+├── Client/ # Blazor WASM app (runs in browser)
+├── Server/ # ASP.NET Core host (serves static files)
+└── Shared/ # Shared interfaces and services
+```
+
+### Service Pattern
+The codebase uses a **shared service pattern** where services are defined in `Shared/` and used by both Client and Server:
+
+- **Interfaces**: Define contracts in `Shared/Interfaces/` (e.g., `IRegexService`, `IHtmlHelperService`)
+- **Implementations**: Implement in `Shared/Services/` (e.g., `RegexService`, `HtmlHelperService`)
+- **Registration**: Services are registered in `Client/Program.cs` using `AddTransient` for DI
+
+Example from [Client/Program.cs](BlazorWasmRegex/Client/Program.cs):
+```csharp
+services.AddTransient();
+services.AddTransient();
+```
+
+### Key Components
+- **RegexTester.razor**: Main component handling regex testing UI and logic
+- **RegexService**: Executes regex operations (matches, splits) with timing
+- **HtmlHelperService**: Generates HTML-safe markup with highlighting (using `WebUtility.HtmlEncode`)
+
+## Development Workflows
+
+### Building & Running
+```bash
+# Standard build (from repo root)
+dotnet build
+
+# Run server (serves WASM app)
+dotnet run --project BlazorWasmRegex/Server/BlazorWasmRegex.Server.csproj
+
+# Using NUKE build system
+./build.sh Compile # Linux/Mac
+./build.cmd Compile # Windows
+```
+
+### NUKE Build System
+This project uses **NUKE** for advanced build automation ([build/Build.cs](build/Build.cs)):
+
+Key targets:
+- `Compile`: Standard build
+- `Publish`: Publishes to `.artifacts/publish/`
+- `SetVersion`: Injects version from `Directory.build.props.template` (replaces `$ver` placeholder)
+- `BuildDockerContainer` / `BuildArmDockerContainer`: Build Docker images
+- `InsertCounterCode`: Injects analytics code at `` placeholder in `index.html`
+
+**Version Management**: Versions are generated dynamically: `{Year}.{Month}.{Day}.{BuildNumber}` from `GITHUB_RUN_NUMBER` env var.
+
+### Docker Deployment
+Two Dockerfiles for multi-arch support:
+- `Dockerfile`: x64 builds (uses .NET 7 Alpine images)
+- `Dockerfile-Arm`: ARM builds
+
+**Important**: `Directory.Build.props` is dynamically generated during build - never edit it directly. Modify `Directory.build.props.template` instead.
+
+## Code Conventions
+
+### Blazor Component Patterns
+1. **State Persistence**: Uses `Blazored.LocalStorage` to persist regex and test inputs between sessions
+ - Keys: `REGEX_SESSION_STORAGE_KEY`, `TESTS_SESSION_STORAGE_KEY`
+
+2. **Regex Caching**: Compiles regex once and reuses (`prevRegexText` tracking prevents recompilation)
+
+3. **Error Handling**: Catches both `RegexParseException` (modern) and `ArgumentException` (fallback) for regex parsing
+
+4. **HTML Safety**: Always use `WebUtility.HtmlEncode` when displaying user input in markup (see `HtmlHelperService`)
+
+### UI Framework
+- **BlazorStrap**: Bootstrap components (e.g., ``, ``)
+- **Open Iconic**: Icon font (e.g., `oi oi-beaker`, `oi oi-terminal`)
+
+### Target Framework
+Currently targeting **net10.0** (.NET 10) across all projects - check project files when adding dependencies.
+
+## Integration Points
+
+### Service Worker (PWA)
+The app is a Progressive Web App with offline support:
+- `service-worker.js`: Development service worker
+- `service-worker.published.js`: Production version (NUKE build appends timestamp for cache busting)
+
+### External Dependencies
+- **BlazorStrap** (v5.2.0): Bootstrap UI components
+- **Blazored.LocalStorage** (v4.5.0): Browser storage API
+
+## Common Tasks
+
+### Adding a New Service
+1. Define interface in `Shared/Interfaces/I{ServiceName}.cs`
+2. Implement in `Shared/Services/{ServiceName}.cs`
+3. Register in `Client/Program.cs` ConfigureServices method
+4. Inject via `@inject` directive in Razor components
+
+### Modifying Regex Logic
+Edit [RegexService.cs](BlazorWasmRegex/Shared/Services/RegexService.cs) - methods return tuples including timing data for performance display.
+
+### UI Changes
+Main UI is in [RegexTester.razor](BlazorWasmRegex/Client/Shared/RegexTester.razor) - uses three tabs: Matches, Split list, and Table view.
+
+## Testing
+Currently **no automated tests** exist (see `Build.cs` Test target).
+
+**Planned**: Implement automated tests in a new `tests/` directory following the solution structure. When adding tests, consider:
+- Unit tests for `RegexService` and `HtmlHelperService`
+- Component tests for `RegexTester.razor`
+- Integration tests for the full regex workflow
+
+## Future Development
+- **Testing Infrastructure**: Add comprehensive test coverage (unit, component, and integration tests)
+- **UI/UX Enhancements**: Build a more robust regex tester interface with improved usability and features
+
+## Local Development
+This is a **test/demo application** intended for local development. The build system includes Docker and CI/CD features, but primary development is done locally using `dotnet build` and `dotnet run`.
diff --git a/.github/upgrades/execution_log.md b/.github/upgrades/execution_log.md
new file mode 100644
index 0000000..ee656c3
--- /dev/null
+++ b/.github/upgrades/execution_log.md
@@ -0,0 +1,89 @@
+# .NET 10.0 Upgrade Report
+
+## Project target framework modifications
+
+| Project name | Old Target Framework | New Target Framework | Commits |
+|:--------------------------------------|:--------------------:|:--------------------:|-----------------|
+| BlazorWasmRegex.Shared.csproj | net7.0 | net10.0 | 9e49791 |
+| BlazorWasmRegex.Client.csproj | net7.0 | net10.0 | 1fa8db7, 88b2f28|
+| BlazorWasmRegex.Server.csproj | net7.0 | net10.0 | cd15d8d |
+| _build.csproj | net7.0 | net10.0 | 0fdad61 |
+
+## NuGet Packages
+
+| Package Name | Old Version | New Version | Commit ID |
+|:------------------------------------------------------|:-----------:|:-----------:|-----------|
+| Blazored.LocalStorage | - | 4.5.0 | 1fa8db7 |
+| BlazorStrap | 1.5.1 | 5.2.100 | 1fa8db7 |
+| Cloudcrate.AspNetCore.Blazor.Browser.Storage | 3.0.0 | (removed) | 1fa8db7 |
+| Microsoft.AspNetCore.Components.WebAssembly | 7.0.0 | 10.0.0 | 1fa8db7 |
+| Microsoft.AspNetCore.Components.WebAssembly.DevServer | 7.0.0 | 10.0.0 | 1fa8db7 |
+| Microsoft.AspNetCore.Components.WebAssembly.Server | 7.0.0 | 10.0.0 | cd15d8d |
+| Microsoft.VisualStudio.Azure.Containers.Tools.Targets | 1.17.0 | 1.21.0 | cd15d8d |
+| Nuke.Common | 6.2.1 | 8.1.2 | 0fdad61 |
+| System.Net.Http.Json | 7.0.0 | (removed) | 88b2f28 |
+
+## All commits
+
+| Commit ID | Description |
+|:----------|:---------------------------------------------------------------|
+| 9e49791 | Upgrade BlazorWasmRegex.Shared to .NET 10.0 |
+| 1fa8db7 | Upgrade BlazorWasmRegex.Client to .NET 10.0 and migrate to Blazored.LocalStorage |
+| cd15d8d | Upgrade BlazorWasmRegex.Server to .NET 10.0 and migrate to minimal hosting model |
+| 0fdad61 | Upgrade build project to .NET 10.0 and Nuke.Common 8.1.2 |
+| 88b2f28 | Remove unnecessary System.Net.Http.Json package reference |
+
+## Project feature upgrades
+
+### BlazorWasmRegex.Client
+
+Here is what changed for the project during upgrade:
+
+- **Storage API Migration**: Replaced deprecated `Cloudcrate.AspNetCore.Blazor.Browser.Storage` with `Blazored.LocalStorage`
+ - Updated `SessionStorage` to `ILocalStorageService` in RegexTester.razor
+ - Changed synchronous `SetItem`/`GetItemAsync` calls to async `SetItemAsync`/`GetItemAsync`
+ - Updated method signatures to support async/await pattern
+
+- **BlazorStrap v5 Upgrade**: Updated from BlazorStrap 1.5.1 to 5.2.100
+ - Replaced `AddBootstrapCss()` with `AddBlazorStrap()` in service configuration
+ - Removed `IBootstrapCss` dependency from MainLayout.razor
+
+- **Root Component Registration**: Updated to .NET 10.0 syntax
+ - Changed from `builder.RootComponents.Add("app")` to `builder.RootComponents.Add("#app")`
+
+### BlazorWasmRegex.Server
+
+Here is what changed for this project during upgrade:
+
+- **Minimal Hosting Model Migration**: Converted from Startup.cs pattern to minimal hosting
+ - Consolidated all configuration from Startup.cs into Program.cs
+ - Removed Startup.cs file completely
+ - Updated to use top-level statements and WebApplication builder pattern
+ - Migrated middleware configuration to use `app.Map*` methods instead of `endpoints.Map*`
+
+### build (_build.csproj)
+
+Here is what changed for this project during upgrade:
+
+- **Nuke.Common v8 Compatibility**: Updated to support breaking changes
+ - Removed obsolete `[CheckBuildProjectConfigurations]` attribute
+ - Build warnings indicate deprecated methods (DeleteDirectory, EnsureCleanDirectory) that can be updated to new extension methods in future
+
+## Summary
+
+The upgrade to .NET 10.0 was completed successfully for all four projects in the solution:
+
+✅ **BlazorWasmRegex.Shared** - Straightforward framework upgrade
+✅ **BlazorWasmRegex.Client** - Framework upgrade + package migrations + API updates
+✅ **BlazorWasmRegex.Server** - Framework upgrade + minimal hosting migration
+✅ **build** - Framework upgrade + Nuke.Common v8 compatibility
+
+All projects build successfully with only minor warnings about BlazorStrap version resolution (5.2.100 resolved instead of 5.2.0) and some Razor component naming warnings which are cosmetic.
+
+## Next steps
+
+- Consider testing the application thoroughly to ensure all runtime functionality works as expected
+- The BlazorStrap component warnings can be addressed by adding explicit component registrations if needed
+- Review and test local storage functionality since the API was changed from Cloudcrate to Blazored
+- Consider updating the deprecated Nuke file system methods to their new extension method equivalents
+- Test the Docker build process since the Dockerfile references were maintained but not tested during upgrade
diff --git a/.github/upgrades/plan.md b/.github/upgrades/plan.md
new file mode 100644
index 0000000..0a82dd9
--- /dev/null
+++ b/.github/upgrades/plan.md
@@ -0,0 +1,78 @@
+# .NET 10.0 Upgrade Plan
+
+## Execution Steps
+
+Execute steps below sequentially one by one in the order they are listed.
+
+1. Validate that a .NET 10.0 SDK required for this upgrade is installed on the machine and if not, help to get it installed.
+2. Upgrade BlazorWasmRegex/Shared/BlazorWasmRegex.Shared.csproj
+3. Upgrade BlazorWasmRegex/Client/BlazorWasmRegex.Client.csproj
+4. Upgrade BlazorWasmRegex/Server/BlazorWasmRegex.Server.csproj
+5. Upgrade build/_build.csproj
+
+## Settings
+
+This section contains settings and data used by execution steps.
+
+### Aggregate NuGet packages modifications across all projects
+
+NuGet packages used across all selected projects or their dependencies that need version update in projects that reference them.
+
+| Package Name | Current Version | New Version | Description |
+|:----------------------------------------------------------|:---------------:|:-----------:|:----------------------------------------------|
+| BlazorStrap | 1.5.1 | 5.2.0 | Recommended for .NET 10.0 |
+| Blazored.LocalStorage | | 4.5.0 | Replacement for Cloudcrate.AspNetCore.Blazor.Browser.Storage |
+| Cloudcrate.AspNetCore.Blazor.Browser.Storage | 3.0.0 | | Deprecated - replace with Blazored.LocalStorage |
+| Microsoft.AspNetCore.Components.WebAssembly | 7.0.0 | 10.0.0 | Recommended for .NET 10.0 |
+| Microsoft.AspNetCore.Components.WebAssembly.DevServer | 7.0.0 | 10.0.0 | Recommended for .NET 10.0 |
+| Microsoft.AspNetCore.Components.WebAssembly.Server | 7.0.0 | 10.0.0 | Recommended for .NET 10.0 |
+| Microsoft.VisualStudio.Azure.Containers.Tools.Targets | 1.17.0 | 1.21.0 | Recommended for .NET 10.0 |
+| Nuke.Common | 6.2.1 | 8.1.2 | Recommended for .NET 10.0 |
+| System.Net.Http.Json | 7.0.0 | 10.0.0 | Recommended for .NET 10.0 |
+
+### Project upgrade details
+
+This section contains details about each project upgrade and modifications that need to be done in the project.
+
+#### BlazorWasmRegex.Shared.csproj modifications
+
+Project properties changes:
+ - Target framework should be changed from `net7.0` to `net10.0`
+
+#### BlazorWasmRegex.Client.csproj modifications
+
+Project properties changes:
+ - Target framework should be changed from `net7.0` to `net10.0`
+
+NuGet packages changes:
+ - BlazorStrap should be updated from `1.5.1` to `5.2.0` (*recommended for .NET 10.0*)
+ - Cloudcrate.AspNetCore.Blazor.Browser.Storage should be removed (*deprecated - replace with Blazored.LocalStorage*)
+ - Blazored.LocalStorage should be added with version `4.5.0` (*replacement for deprecated package*)
+ - Microsoft.AspNetCore.Components.WebAssembly should be updated from `7.0.0` to `10.0.0` (*recommended for .NET 10.0*)
+ - Microsoft.AspNetCore.Components.WebAssembly.DevServer should be updated from `7.0.0` to `10.0.0` (*recommended for .NET 10.0*)
+ - System.Net.Http.Json should be updated from `7.0.0` to `10.0.0` (*recommended for .NET 10.0*)
+
+Other changes:
+ - Update Program.cs to use new Blazored.LocalStorage API instead of Cloudcrate storage
+ - Update root component registration syntax for .NET 10.0
+
+#### BlazorWasmRegex.Server.csproj modifications
+
+Project properties changes:
+ - Target framework should be changed from `net7.0` to `net10.0`
+
+NuGet packages changes:
+ - Microsoft.AspNetCore.Components.WebAssembly.Server should be updated from `7.0.0` to `10.0.0` (*recommended for .NET 10.0*)
+ - Microsoft.VisualStudio.Azure.Containers.Tools.Targets should be updated from `1.17.0` to `1.21.0` (*recommended for .NET 10.0*)
+
+Other changes:
+ - Migrate from Startup.cs pattern to minimal hosting model (Program.cs only)
+ - Update to .NET 10.0 hosting patterns
+
+#### _build.csproj modifications
+
+Project properties changes:
+ - Target framework should be changed from `net7.0` to `net10.0`
+
+NuGet packages changes:
+ - Nuke.Common should be updated from `6.2.1` to `8.1.2` (*recommended for .NET 10.0*)
diff --git a/.github/workflows/create-images.yml b/.github/workflows/create-images.yml
index 92dc566..10202c4 100644
--- a/.github/workflows/create-images.yml
+++ b/.github/workflows/create-images.yml
@@ -26,22 +26,28 @@ jobs:
# This workflow contains a single job called "build"
build:
# The type of runner that the job will run on
- runs-on: [self-hosted, Linux]
+ runs-on: [ ubuntu-latest ]
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
- name: Checkout
- uses: actions/checkout@v2
- - name: Setup .NET 7
- uses: actions/setup-dotnet@v2
+ uses: actions/checkout@v4
+ - name: Setup .NET 10
+ uses: actions/setup-dotnet@v4
with:
- dotnet-version: 7.0.x
+ dotnet-version: 10.0.x
- name: Set build script eXecutable
id: builscriptexec
run: chmod +x ./build.sh
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Build ARM container
id: armcontainer
- run: ./build.sh -Target PushArmDockerContainer -DockerPrivateRegistry "${{ secrets.PRIVATE_DOCKER_REGISTRY }}" -DockerLogin "${{ secrets.PRIVATE_DOCKER_REGISTRY_LOGIN }}" -DockerPassword "${{ secrets.PRIVATE_DOCKER_REGISTRY_PASSWORD }}"
+ run: ./build.sh -Target PushArmDockerContainer -GitHubOwner "${{ github.repository_owner }}" -GitHubToken "${{ secrets.GITHUB_TOKEN }}"
- name: Build x64 container
id: x64container
- run: ./build.sh -Target PushDockerContainer -DockerPrivateRegistry "${{ secrets.PRIVATE_DOCKER_REGISTRY }}" -DockerLogin "${{ secrets.PRIVATE_DOCKER_REGISTRY_LOGIN }}" -DockerPassword "${{ secrets.PRIVATE_DOCKER_REGISTRY_PASSWORD }}"
+ run: ./build.sh -Target PushDockerContainer -GitHubOwner "${{ github.repository_owner }}" -GitHubToken "${{ secrets.GITHUB_TOKEN }}"
diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json
index 3dd6413..eefae08 100644
--- a/.nuke/build.schema.json
+++ b/.nuke/build.schema.json
@@ -1,62 +1,68 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
- "title": "Build Schema",
- "$ref": "#/definitions/build",
"definitions": {
- "build": {
- "type": "object",
+ "Host": {
+ "type": "string",
+ "enum": [
+ "AppVeyor",
+ "AzurePipelines",
+ "Bamboo",
+ "Bitbucket",
+ "Bitrise",
+ "GitHubActions",
+ "GitLab",
+ "Jenkins",
+ "Rider",
+ "SpaceAutomation",
+ "TeamCity",
+ "Terminal",
+ "TravisCI",
+ "VisualStudio",
+ "VSCode"
+ ]
+ },
+ "ExecutableTarget": {
+ "type": "string",
+ "enum": [
+ "BuildArmDockerContainer",
+ "BuildDockerContainer",
+ "Clean",
+ "CleanUpCounterCode",
+ "CleanUpSwVersion",
+ "Compile",
+ "InsertCounterCode",
+ "LoginToDockerRegistry",
+ "Publish",
+ "PushArmDockerContainer",
+ "PushDockerContainer",
+ "Restore",
+ "SetVersion",
+ "Test"
+ ]
+ },
+ "Verbosity": {
+ "type": "string",
+ "description": "",
+ "enum": [
+ "Verbose",
+ "Normal",
+ "Minimal",
+ "Quiet"
+ ]
+ },
+ "NukeBuild": {
"properties": {
- "BuildVersion": {
- "type": "string",
- "description": "Build version - Default is '0.1.0'"
- },
- "Configuration": {
- "type": "string",
- "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)",
- "enum": [
- "Debug",
- "Release"
- ]
- },
"Continue": {
"type": "boolean",
"description": "Indicates to continue a previously failed build attempt"
},
- "DockerLogin": {
- "type": "string",
- "description": "Private Docker registry login"
- },
- "DockerPassword": {
- "type": "string",
- "description": "Private Docker registry password"
- },
- "DockerPrivateRegistry": {
- "type": "string",
- "description": "Private docker registry URL (with protocol)"
- },
"Help": {
"type": "boolean",
"description": "Shows the help text for this build assembly"
},
"Host": {
- "type": "string",
"description": "Host for execution. Default is 'automatic'",
- "enum": [
- "AppVeyor",
- "AzurePipelines",
- "Bamboo",
- "Bitrise",
- "GitHubActions",
- "GitLab",
- "Jenkins",
- "Rider",
- "SpaceAutomation",
- "TeamCity",
- "Terminal",
- "TravisCI",
- "VisualStudio",
- "VSCode"
- ]
+ "$ref": "#/definitions/Host"
},
"NoLogo": {
"type": "boolean",
@@ -85,67 +91,62 @@
"type": "array",
"description": "List of targets to be skipped. Empty list skips all dependencies",
"items": {
- "type": "string",
- "enum": [
- "BuildArmDockerContainer",
- "BuildDockerContainer",
- "Clean",
- "CleanUpCounterCode",
- "CleanUpSwVersion",
- "Compile",
- "InsertCounterCode",
- "LoginToDockerRegistry",
- "Publish",
- "PushArmDockerContainer",
- "PushDockerContainer",
- "Restore",
- "SetVersion",
- "Test"
- ]
+ "$ref": "#/definitions/ExecutableTarget"
}
},
- "Solution": {
- "type": "string",
- "description": "Path to a solution file that is automatically loaded"
- },
"Target": {
"type": "array",
"description": "List of targets to be invoked. Default is '{default_target}'",
"items": {
- "type": "string",
- "enum": [
- "BuildArmDockerContainer",
- "BuildDockerContainer",
- "Clean",
- "CleanUpCounterCode",
- "CleanUpSwVersion",
- "Compile",
- "InsertCounterCode",
- "LoginToDockerRegistry",
- "Publish",
- "PushArmDockerContainer",
- "PushDockerContainer",
- "Restore",
- "SetVersion",
- "Test"
- ]
+ "$ref": "#/definitions/ExecutableTarget"
}
},
"Verbosity": {
- "type": "string",
"description": "Logging verbosity during build execution. Default is 'Normal'",
+ "$ref": "#/definitions/Verbosity"
+ }
+ }
+ }
+ },
+ "allOf": [
+ {
+ "properties": {
+ "BuildVersion": {
+ "type": "string",
+ "description": "Build number override"
+ },
+ "Configuration": {
+ "type": "string",
+ "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)",
"enum": [
- "Minimal",
- "Normal",
- "Quiet",
- "Verbose"
+ "Debug",
+ "Release"
]
},
- "YandexCounterCode": {
+ "CounterCode": {
"type": "string",
- "description": "Yandex Counter code: will replace "
+ "description": "Counter code: will replace "
+ },
+ "DockerLogin": {
+ "type": "string",
+ "description": "Private Docker registry login"
+ },
+ "DockerPassword": {
+ "type": "string",
+ "description": "Private Docker registry password"
+ },
+ "DockerPrivateRegistry": {
+ "type": "string",
+ "description": "Private docker registry URL (with protocol)"
+ },
+ "Solution": {
+ "type": "string",
+ "description": "Path to a solution file that is automatically loaded"
}
}
+ },
+ {
+ "$ref": "#/definitions/NukeBuild"
}
- }
-}
\ No newline at end of file
+ ]
+}
diff --git a/BlazorWasmRegex/Client/BlazorWasmRegex.Client.csproj b/BlazorWasmRegex/Client/BlazorWasmRegex.Client.csproj
index eaee932..b040628 100644
--- a/BlazorWasmRegex/Client/BlazorWasmRegex.Client.csproj
+++ b/BlazorWasmRegex/Client/BlazorWasmRegex.Client.csproj
@@ -1,7 +1,7 @@
- net7.0
+ net10.0
service-worker-assets.js
@@ -10,11 +10,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/BlazorWasmRegex/Client/Program.cs b/BlazorWasmRegex/Client/Program.cs
index b01ff6d..ef7b631 100644
--- a/BlazorWasmRegex/Client/Program.cs
+++ b/BlazorWasmRegex/Client/Program.cs
@@ -10,7 +10,7 @@
using BlazorStrap;
using BlazorWasmRegex.Shared.Interfaces;
using BlazorWasmRegex.Shared.Services;
-using Cloudcrate.AspNetCore.Blazor.Browser.Storage;
+using Blazored.LocalStorage;
namespace BlazorWasmRegex.Client
{
@@ -29,8 +29,8 @@ public static async Task Main(string[] args)
public static void ConfigureServices(IServiceCollection services)
{
- services.AddBootstrapCss();
- services.AddStorage();
+ services.AddBlazorStrap();
+ services.AddBlazoredLocalStorage();
services.AddTransient();
services.AddTransient();
diff --git a/BlazorWasmRegex/Client/Shared/MainLayout.razor b/BlazorWasmRegex/Client/Shared/MainLayout.razor
index 3fd39e0..f6a9f7e 100644
--- a/BlazorWasmRegex/Client/Shared/MainLayout.razor
+++ b/BlazorWasmRegex/Client/Shared/MainLayout.razor
@@ -1,5 +1,4 @@
@inherits LayoutComponentBase
-@inject IBootstrapCss BootstrapCSS
-
-@code {
- protected override async Task OnInitializedAsync()
- {
- await BootstrapCSS.SetBootstrapCss();
- }
-}
\ No newline at end of file
+
\ No newline at end of file
diff --git a/BlazorWasmRegex/Client/Shared/RegexTester.razor b/BlazorWasmRegex/Client/Shared/RegexTester.razor
index d7d3be7..3801729 100644
--- a/BlazorWasmRegex/Client/Shared/RegexTester.razor
+++ b/BlazorWasmRegex/Client/Shared/RegexTester.razor
@@ -1,13 +1,47 @@
@using System.Text.RegularExpressions
-@using Cloudcrate.AspNetCore.Blazor.Browser.Storage;
+@using Blazored.LocalStorage
+@using BlazorWasmRegex.Shared.Services
+@using BlazorStrap
+@using Microsoft.JSInterop
+@using System.Text.Json
@inject BlazorWasmRegex.Shared.Interfaces.IRegexService regexService
@inject BlazorWasmRegex.Shared.Interfaces.IHtmlHelperService htmlHelperService
-@inject SessionStorage SessionStorage
+@inject ILocalStorageService LocalStorage
+@inject IJSRuntime JS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Use $1, $2 for numbered groups or ${name} for named groups
+
@@ -15,7 +49,11 @@
- Test
+ Test
+ Replace
+ JSON
+ CSV
+ Text
@((MarkupString)Message)
@@ -76,6 +114,81 @@
+
+ Capture Groups
+
+ @if (CaptureGroupData.Any())
+ {
+
+ @foreach (var testData in CaptureGroupData)
+ {
+ -
+ @testData.TestString
+
+ @foreach (var matchData in testData.Matches)
+ {
+ -
+
+
Match @matchData.MatchIndex (Index: @matchData.Index, Length: @matchData.Length)
+
+
+
+ }
+ }
+
+ }
+ else
+ {
+
+ No capture groups found. Use named groups like (?<name>pattern) or numbered groups (pattern) to see capture details here.
+
+ }
+
+
+
+ Replace
+
+ @if (ReplacedStrings.Any())
+ {
+
+ }
+ else
+ {
+
+ Enter a replacement pattern and click the Replace button to see results.
+
+ }
+
+
@@ -84,37 +197,123 @@
@code {
const string REGEX_SESSION_STORAGE_KEY = nameof(REGEX_SESSION_STORAGE_KEY);
const string TESTS_SESSION_STORAGE_KEY = nameof(TESTS_SESSION_STORAGE_KEY);
+ private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(2);
protected string Tests { get; set; }
protected string RegexText { get; set; }
+ protected string SelectedSample { get; set; }
+ protected string ReplacementPattern { get; set; }
+ protected bool IgnoreCase { get; set; }
+ protected bool Multiline { get; set; }
+ protected bool Singleline { get; set; }
protected IEnumerable
MatchedStrings { get; set; } = new List();
protected IEnumerable SplitList { get; set; } = new List();
protected IEnumerable> NotEmptyMatches { get; set; } = new Dictionary();
+ protected IDictionary ReplacedStrings { get; set; } = new Dictionary();
+ protected List CaptureGroupData { get; set; } = new();
protected string Message { get; set; }
private Regex testRegex;
- private string prevRegexText;
+ private string prevRegexConfig;
+
+ // Data structures for capture groups display
+ public class TestCaptureGroupData
+ {
+ public string TestString { get; set; }
+ public List Matches { get; set; } = new();
+ }
+
+ public class MatchCaptureGroupData
+ {
+ public int MatchIndex { get; set; }
+ public int Index { get; set; }
+ public int Length { get; set; }
+ public List Groups { get; set; } = new();
+ }
+
+ public class CaptureGroupInfo
+ {
+ public string Name { get; set; }
+ public string Value { get; set; }
+ public int Index { get; set; }
+ public int Length { get; set; }
+ }
protected override async Task OnInitializedAsync()
{
- RegexText = await SessionStorage.GetItemAsync(REGEX_SESSION_STORAGE_KEY);
- Tests = await SessionStorage.GetItemAsync(TESTS_SESSION_STORAGE_KEY);
+ RegexText = await LocalStorage.GetItemAsync(REGEX_SESSION_STORAGE_KEY) ?? string.Empty;
+ Tests = await LocalStorage.GetItemAsync(TESTS_SESSION_STORAGE_KEY) ?? string.Empty;
+ SelectedSample = string.Empty;
+ var savedOptions = await LocalStorage.GetItemAsync("REGEX_OPTIONS");
+ if (!string.IsNullOrEmpty(savedOptions))
+ {
+ // Format: "{RegexText}|{IgnoreCase}|{Multiline}|{Singleline}"
+ var parts = savedOptions.Split('|');
+ if (parts.Length >= 4)
+ {
+ // We do not override RegexText here; it is loaded separately above.
+ bool.TryParse(parts[^3], out var ignoreCase);
+ bool.TryParse(parts[^2], out var multiline);
+ bool.TryParse(parts[^1], out var singleline);
+ IgnoreCase = ignoreCase;
+ Multiline = multiline;
+ Singleline = singleline;
+ }
+ }
+ }
+
+ private async Task OnSampleChanged(ChangeEventArgs e)
+ {
+ SelectedSample = e?.Value?.ToString() ?? string.Empty;
+
+ var pattern = GetPatternForSample(SelectedSample);
+ if (!string.IsNullOrEmpty(pattern))
+ {
+ RegexText = pattern;
+ // Force recompile next Test_Click by resetting prev config
+ prevRegexConfig = null;
+ await Test_Click(null);
+ }
+ }
+
+ private static string GetPatternForSample(string sample)
+ {
+ if (string.IsNullOrWhiteSpace(sample)) return string.Empty;
+
+ switch (sample)
+ {
+ case "Email":
+ return SampleRegexLibrary.EmailPattern;
+ case "Phone":
+ return SampleRegexLibrary.PhonePattern;
+ case "Url":
+ return SampleRegexLibrary.UrlPattern;
+ default:
+ return string.Empty;
+ }
}
- protected void Test_Click(object e)
+ protected async Task Test_Click(object e)
{
if (string.IsNullOrEmpty(RegexText))
return;
- if (prevRegexText != RegexText)
+ var currentConfig = $"{RegexText}|{IgnoreCase}|{Multiline}|{Singleline}";
+ if (prevRegexConfig != currentConfig)
{
try
{
Console.WriteLine("Changing RegEx");
- testRegex = new Regex(RegexText, RegexOptions.Compiled);
- prevRegexText = RegexText;
+ var options = RegexOptions.Compiled;
+ if (IgnoreCase) options |= RegexOptions.IgnoreCase;
+ if (Multiline) options |= RegexOptions.Multiline;
+ if (Singleline) options |= RegexOptions.Singleline;
+
+ testRegex = new Regex(RegexText, options, RegexTimeout);
+ prevRegexConfig = currentConfig;
- SessionStorage.SetItem(REGEX_SESSION_STORAGE_KEY, RegexText);
+ await LocalStorage.SetItemAsync(REGEX_SESSION_STORAGE_KEY, RegexText);
+ await LocalStorage.SetItemAsync("REGEX_OPTIONS", currentConfig);
}
catch (RegexParseException regexParseEx)
{
@@ -134,19 +333,34 @@
try
{
var tests = Tests?
- .Split(new string[] { Environment.NewLine }, StringSplitOptions.RemoveEmptyEntries)
+ .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct();
var (elapsedMilliseconds, matches) = regexService.GetMatches(tests, testRegex);
- NotEmptyMatches = matches;
+ NotEmptyMatches = matches
+ .Where(kvp => kvp.Value.Count > 0)
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
Message = $"{NotEmptyMatches.Count()} matches in {elapsedMilliseconds} ms";
+ // Only show inputs that have matches
MatchedStrings = matches
+ .Where(mc => mc.Value.Count > 0)
.Select(mc => htmlHelperService.GetMarkedSpans(mc.Key, mc.Value, "mark-yellow"));
- SplitList = regexService.GetSplitList(tests, testRegex).Select(m => htmlHelperService.GetDelimeteredString(m, " "));
- SessionStorage.SetItem(TESTS_SESSION_STORAGE_KEY, Tests);
+ // Only show inputs that have split results (where split actually occurred)
+ var splitResults = regexService.GetSplitList(tests, testRegex);
+ SplitList = splitResults.Select(m => htmlHelperService.GetDelimeteredString(m, " "));
+
+ // Extract capture group data
+ CaptureGroupData = ExtractCaptureGroupData(matches);
+
+ await LocalStorage.SetItemAsync(TESTS_SESSION_STORAGE_KEY, Tests);
+ }
+ catch (RegexMatchTimeoutException timeoutEx)
+ {
+ Console.WriteLine($"RegEx timed out: {timeoutEx.Message}");
+ Message = "⏱ Expression timed out - possible catastrophic backtracking";
}
catch (Exception ex)
{
@@ -154,5 +368,188 @@
Message = "Error evaluating expression ☠";
}
}
+
+ private List ExtractCaptureGroupData(IDictionary matches)
+ {
+ var result = new List();
+
+ foreach (var kvp in matches.Where(m => m.Value.Count > 0))
+ {
+ var testData = new TestCaptureGroupData
+ {
+ TestString = kvp.Key
+ };
+
+ int matchIndex = 0;
+ foreach (Match match in kvp.Value)
+ {
+ var matchData = new MatchCaptureGroupData
+ {
+ MatchIndex = matchIndex++,
+ Index = match.Index,
+ Length = match.Length
+ };
+
+ // Get group names from the regex
+ var groupNames = testRegex.GetGroupNames();
+
+ foreach (var groupName in groupNames)
+ {
+ var group = match.Groups[groupName];
+ if (group.Success)
+ {
+ matchData.Groups.Add(new CaptureGroupInfo
+ {
+ Name = groupName,
+ Value = group.Value,
+ Index = group.Index,
+ Length = group.Length
+ });
+ }
+ }
+
+ // Only add match data if it has groups (besides the default "0" group)
+ if (matchData.Groups.Count > 0)
+ {
+ testData.Matches.Add(matchData);
+ }
+ }
+
+ // Only add test data if it has matches with groups
+ if (testData.Matches.Count > 0)
+ {
+ result.Add(testData);
+ }
+ }
+
+ return result;
+ }
+
+ protected async Task Replace_Click(object e)
+ {
+ if (string.IsNullOrEmpty(RegexText) || string.IsNullOrEmpty(ReplacementPattern) || testRegex == null)
+ return;
+
+ try
+ {
+ var tests = Tests?
+ .Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Distinct();
+
+ ReplacedStrings = regexService.GetReplacedStrings(tests, testRegex, ReplacementPattern);
+ Message = $"Replaced in {ReplacedStrings.Count} test strings";
+ }
+ catch (RegexMatchTimeoutException timeoutEx)
+ {
+ Console.WriteLine($"RegEx timed out: {timeoutEx.Message}");
+ Message = "⏱ Expression timed out - possible catastrophic backtracking";
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Replace error: {ex.Message}");
+ Message = "Error during replace operation ☠";
+ }
+ }
+
+ private async Task ExportAsJson(object e)
+ {
+ try
+ {
+ var exportData = NotEmptyMatches.ToDictionary(
+ kvp => kvp.Key,
+ kvp => kvp.Value.Cast().Select(m => new
+ {
+ Value = m.Value,
+ Index = m.Index,
+ Length = m.Length,
+ Groups = m.Groups.Cast().Skip(1).Select(g => new
+ {
+ Value = g.Value,
+ Index = g.Index,
+ Length = g.Length
+ }).ToList()
+ }).ToList()
+ );
+
+ var json = JsonSerializer.Serialize(exportData, new JsonSerializerOptions
+ {
+ WriteIndented = true
+ });
+
+ await JS.InvokeVoidAsync("downloadFile", "matches.json", json, "application/json");
+ Message = "✓ Exported matches as JSON";
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Export JSON error: {ex.Message}");
+ Message = "Error exporting JSON ☠";
+ }
+ }
+
+ private async Task ExportAsCsv(object e)
+ {
+ try
+ {
+ var csv = new System.Text.StringBuilder();
+ csv.AppendLine("Test String,Match Value,Match Index,Match Length");
+
+ foreach (var kvp in NotEmptyMatches)
+ {
+ foreach (Match match in kvp.Value)
+ {
+ csv.AppendLine($"\"{EscapeCsv(kvp.Key)}\",\"{EscapeCsv(match.Value)}\",{match.Index},{match.Length}");
+ }
+ }
+
+ await JS.InvokeVoidAsync("downloadFile", "matches.csv", csv.ToString(), "text/csv");
+ Message = "✓ Exported matches as CSV";
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Export CSV error: {ex.Message}");
+ Message = "Error exporting CSV ☠";
+ }
+ }
+
+ private async Task ExportAsText(object e)
+ {
+ try
+ {
+ var text = new System.Text.StringBuilder();
+ text.AppendLine($"Regex Pattern: {RegexText}");
+ text.AppendLine($"Total Matches: {NotEmptyMatches.Sum(kvp => kvp.Value.Count())}");
+ text.AppendLine($"Test Strings with Matches: {NotEmptyMatches.Count()}");
+ text.AppendLine();
+
+ foreach (var kvp in NotEmptyMatches)
+ {
+ text.AppendLine($"Test String: {kvp.Key}");
+ text.AppendLine($"Matches ({kvp.Value.Count}):");
+
+ foreach (Match match in kvp.Value)
+ {
+ text.AppendLine($" - Value: \"{match.Value}\" (Index: {match.Index}, Length: {match.Length})");
+ }
+
+ text.AppendLine();
+ }
+
+ await JS.InvokeVoidAsync("downloadFile", "matches.txt", text.ToString(), "text/plain");
+ Message = "✓ Exported matches as Text";
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Export Text error: {ex.Message}");
+ Message = "Error exporting Text ☠";
+ }
+ }
+
+ private static string EscapeCsv(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ return value.Replace("\"", "\"\"");
+ }
}
diff --git a/BlazorWasmRegex/Client/_Imports.razor b/BlazorWasmRegex/Client/_Imports.razor
index 8b81136..59031c3 100644
--- a/BlazorWasmRegex/Client/_Imports.razor
+++ b/BlazorWasmRegex/Client/_Imports.razor
@@ -8,4 +8,6 @@
@using BlazorWasmRegex.Client
@using BlazorWasmRegex.Client.Shared
-@using BlazorStrap
\ No newline at end of file
+@using BlazorStrap
+@using BlazorStrap.V5
+@using BlazorStrap.V5.Components
\ No newline at end of file
diff --git a/BlazorWasmRegex/Client/wwwroot/index.html b/BlazorWasmRegex/Client/wwwroot/index.html
index 7184f5d..9ea53b2 100644
--- a/BlazorWasmRegex/Client/wwwroot/index.html
+++ b/BlazorWasmRegex/Client/wwwroot/index.html
@@ -27,9 +27,44 @@
🗙
-
-
-
+