-- [ ] IsPostBack guards unwrapped (simple) or TODO'd (complex)
-- [ ] .aspx URL literals cleaned up in code-behind
-- [ ] ConfigurationManager / BundleConfig / RouteConfig usings retained for BWFC shims
-
-### Layer 2 — Structural
+- [ ] URLs converted
+- [ ] Content wrappers removed
+- [ ] IsPostBack guards unwrapped/TODO'd
+- [ ] .aspx URL literals cleaned up
+
+### L2 — Copilot Transforms (per TODO category)
+- [ ] TODO(bwfc-session-state) items resolved
+- [ ] TODO(bwfc-viewstate) items resolved
+- [ ] TODO(bwfc-page-lifecycle) items resolved
+- [ ] TODO(bwfc-data-migration) items resolved or delegated
+- [ ] TODO(bwfc-identity-migration) items resolved or delegated
+- [ ] TODO(bwfc-manual) items documented
- [ ] SelectMethod string → SelectHandler delegate
-- [ ] ItemType preserved (strip namespace prefix only)
-- [ ] Data loading in OnInitializedAsync
-- [ ] Event handlers converted
-- [ ] Template Context="Item" added
-- [ ] Navigation calls converted
+- [ ] Template Context="Item" verified
+- [ ] @inject directives added
### Verification
-- [ ] Builds without errors
-- [ ] Renders correctly
+- [ ] `dotnet build` succeeds
+- [ ] Page renders correctly
- [ ] Interactive features work
- [ ] No browser console errors
```
diff --git a/mkdocs.yml b/mkdocs.yml
index 124d3cf60..23e432192 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -174,6 +174,9 @@ nav:
- NamingContainer: UtilityFeatures/NamingContainer.md
- Page System: UtilityFeatures/PageService.md
- Response.Redirect: UtilityFeatures/ResponseRedirect.md
+ - Request: UtilityFeatures/RequestShim.md
+ - Server & Path Resolution: UtilityFeatures/ServerShim.md
+ - Cache: UtilityFeatures/CacheShim.md
- Service Registration: UtilityFeatures/ServiceRegistration.md
- Styling Components: UtilityFeatures/StylingComponents.md
- ViewState: UtilityFeatures/ViewState.md
diff --git a/samples/AfterBlazorServerSide.Tests/Migration/CacheDemoTests.cs b/samples/AfterBlazorServerSide.Tests/Migration/CacheDemoTests.cs
new file mode 100644
index 000000000..223104ab4
--- /dev/null
+++ b/samples/AfterBlazorServerSide.Tests/Migration/CacheDemoTests.cs
@@ -0,0 +1,153 @@
+using Microsoft.Playwright;
+
+namespace AfterBlazorServerSide.Tests.Migration;
+
+///
+/// Integration tests for the CacheShim sample page at /migration/cache.
+/// Verifies Cache["key"] set/get, typed access, removal, and expiration demos.
+///
+[Collection(nameof(PlaywrightCollection))]
+public class CacheDemoTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public CacheDemoTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// Verifies that storing a value via Cache["key"] and retrieving it works.
+ ///
+ [Fact]
+ public async Task Cache_SetAndGetValue()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/cache", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='cache-setget-demo']");
+
+ // Fill key and value
+ var inputs = card.Locator("input[type='text']");
+ var keyInput = inputs.Nth(0);
+ var valueInput = inputs.Nth(1);
+
+ await keyInput.FillAsync("TestKey");
+ await keyInput.PressAsync("Tab");
+ await valueInput.FillAsync("TestValue");
+ await valueInput.PressAsync("Tab");
+
+ // Store in cache
+ await card.Locator("button:has-text('Store in Cache')").ClickAsync();
+ await page.WaitForTimeoutAsync(1000);
+
+ // Get from cache
+ await card.Locator("button:has-text('Get from Cache')").ClickAsync();
+ await page.WaitForTimeoutAsync(1000);
+
+ var output = await card.Locator("strong").TextContentAsync();
+ Assert.NotNull(output);
+ Assert.Contains("TestValue", output!);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that the typed counter increments correctly using Cache.Get<int>().
+ ///
+ [Fact]
+ public async Task Cache_TypedCounterIncrements()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/cache", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='cache-typed-demo']");
+ await card.WaitForAsync(new LocatorWaitForOptions { Timeout = 10000 });
+
+ var counterDisplay = card.Locator("strong");
+ var incrementButton = card.Locator("button:has-text('Increment Counter')");
+
+ // Read initial value
+ var initialText = await counterDisplay.TextContentAsync();
+ var initialValue = int.Parse(initialText!.Trim());
+
+ // Click increment 3 times
+ for (var i = 0; i < 3; i++)
+ {
+ await incrementButton.ClickAsync();
+ await page.WaitForTimeoutAsync(500);
+ }
+
+ // Verify counter increased by 3
+ var finalText = await counterDisplay.TextContentAsync();
+ var finalValue = int.Parse(finalText!.Trim());
+ Assert.Equal(initialValue + 3, finalValue);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that Cache.Remove() evicts an item and subsequent checks return null.
+ ///
+ [Fact]
+ public async Task Cache_RemoveEvictsItem()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/cache", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='cache-remove-demo']");
+
+ // Store an item
+ await card.Locator("button:has-text('Store')").ClickAsync();
+ await page.WaitForTimeoutAsync(1000);
+
+ var statusAfterStore = await card.Locator("strong").TextContentAsync();
+ Assert.Contains("Stored", statusAfterStore!);
+
+ // Remove the item
+ await card.Locator("button:has-text('Remove')").ClickAsync();
+ await page.WaitForTimeoutAsync(1000);
+
+ var statusAfterRemove = await card.Locator("strong").TextContentAsync();
+ Assert.Contains("Removed", statusAfterRemove!);
+
+ // Check — should be not found
+ await card.Locator("button:has-text('Check')").ClickAsync();
+ await page.WaitForTimeoutAsync(1000);
+
+ var statusAfterCheck = await card.Locator("strong").TextContentAsync();
+ Assert.Contains("Not found", statusAfterCheck!);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+}
diff --git a/samples/AfterBlazorServerSide.Tests/Migration/IsPostBackTests.cs b/samples/AfterBlazorServerSide.Tests/Migration/IsPostBackTests.cs
new file mode 100644
index 000000000..9506f85ec
--- /dev/null
+++ b/samples/AfterBlazorServerSide.Tests/Migration/IsPostBackTests.cs
@@ -0,0 +1,149 @@
+using Microsoft.Playwright;
+
+namespace AfterBlazorServerSide.Tests.Migration;
+
+///
+/// Integration tests for the IsPostBack sample page at /migration/ispostback.
+/// Verifies IsPostBack status display, guard pattern, and HttpContext check.
+///
+[Collection(nameof(PlaywrightCollection))]
+public class IsPostBackTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public IsPostBackTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// Verifies that the IsPostBack status displays a boolean value.
+ ///
+ [Fact]
+ public async Task IsPostBack_DisplaysStatus()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/ispostback", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='ispostback-status-demo']");
+ var strongElements = card.Locator("strong");
+ var statusText = await strongElements.Nth(0).TextContentAsync();
+
+ Assert.NotNull(statusText);
+ var trimmed = statusText!.Trim();
+ Assert.True(trimmed == "True" || trimmed == "False",
+ $"Expected 'True' or 'False' but got '{trimmed}'");
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that the guard pattern initializes data on first load.
+ ///
+ [Fact]
+ public async Task IsPostBack_GuardPatternInitializesData()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/ispostback", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='ispostback-guard-demo']");
+ var strongElements = card.Locator("strong");
+
+ // Verify data was loaded
+ var dataText = await strongElements.Nth(0).TextContentAsync();
+ Assert.NotNull(dataText);
+ Assert.Contains("Alpha", dataText!);
+ Assert.Contains("Bravo", dataText);
+ Assert.Contains("Charlie", dataText);
+
+ // Verify init count
+ var initCountText = await strongElements.Nth(1).TextContentAsync();
+ Assert.NotNull(initCountText);
+ Assert.Equal("1", initCountText!.Trim());
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that re-rendering does not re-initialize data (guard pattern works).
+ ///
+ [Fact]
+ public async Task IsPostBack_RerenderDoesNotReinitialize()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/ispostback", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='ispostback-guard-demo']");
+
+ // Click Re-render button
+ await card.Locator("button:has-text('Re-render')").ClickAsync();
+ await page.WaitForTimeoutAsync(1000);
+
+ // Init count should still be 1 (guard prevented re-init)
+ var initCountText = await card.Locator("strong").Nth(1).TextContentAsync();
+ Assert.NotNull(initCountText);
+ Assert.Equal("1", initCountText!.Trim());
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that the HttpContext guard demo displays a boolean value.
+ ///
+ [Fact]
+ public async Task HttpContextGuard_DisplaysAvailability()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/ispostback", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='httpcontext-guard-demo']");
+ var strongText = await card.Locator("strong").TextContentAsync();
+
+ Assert.NotNull(strongText);
+ var trimmed = strongText!.Trim();
+ Assert.True(trimmed == "True" || trimmed == "False",
+ $"Expected 'True' or 'False' but got '{trimmed}'");
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+}
diff --git a/samples/AfterBlazorServerSide.Tests/Migration/RequestDemoTests.cs b/samples/AfterBlazorServerSide.Tests/Migration/RequestDemoTests.cs
new file mode 100644
index 000000000..49b0ea757
--- /dev/null
+++ b/samples/AfterBlazorServerSide.Tests/Migration/RequestDemoTests.cs
@@ -0,0 +1,108 @@
+using Microsoft.Playwright;
+
+namespace AfterBlazorServerSide.Tests.Migration;
+
+///
+/// Integration tests for the RequestShim sample page at /migration/request.
+/// Verifies QueryString parsing, URL display, and Cookies graceful degradation.
+///
+[Collection(nameof(PlaywrightCollection))]
+public class RequestDemoTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public RequestDemoTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// Verifies that Request.Url displays the current URL on the page.
+ ///
+ [Fact]
+ public async Task Request_UrlDisplaysCurrentUrl()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/request", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='url-demo']");
+ var urlText = await card.Locator("strong").TextContentAsync();
+
+ Assert.NotNull(urlText);
+ Assert.Contains("/migration/request", urlText!);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that Request.QueryString parses query parameters from the URL.
+ ///
+ [Fact]
+ public async Task Request_QueryStringParsesParameters()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/request?name=TestUser&id=99", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='querystring-demo']");
+ var strongElements = card.Locator("strong");
+
+ var nameText = await strongElements.Nth(0).TextContentAsync();
+ var idText = await strongElements.Nth(1).TextContentAsync();
+
+ Assert.Contains("TestUser", nameText!);
+ Assert.Contains("99", idText!);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that the Cookies demo renders and shows the IsHttpContextAvailable status.
+ ///
+ [Fact]
+ public async Task Request_CookiesDemoRendersGracefully()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/request", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='cookies-demo']");
+ var strongText = await card.Locator("strong").TextContentAsync();
+
+ // Should display either True or False — just verify it renders
+ Assert.NotNull(strongText);
+ var trimmed = strongText!.Trim();
+ Assert.True(trimmed == "True" || trimmed == "False",
+ $"Expected 'True' or 'False' but got '{trimmed}'");
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+}
diff --git a/samples/AfterBlazorServerSide.Tests/Migration/ResponseRedirectTests.cs b/samples/AfterBlazorServerSide.Tests/Migration/ResponseRedirectTests.cs
new file mode 100644
index 000000000..3632d4c9c
--- /dev/null
+++ b/samples/AfterBlazorServerSide.Tests/Migration/ResponseRedirectTests.cs
@@ -0,0 +1,112 @@
+using Microsoft.Playwright;
+
+namespace AfterBlazorServerSide.Tests.Migration;
+
+///
+/// Integration tests for the ResponseShim sample page at /migration/response-redirect.
+/// Verifies redirect navigation, tilde/aspx stripping, and ResolveUrl output.
+///
+[Collection(nameof(PlaywrightCollection))]
+public class ResponseRedirectTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public ResponseRedirectTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// Verifies that clicking the basic redirect button navigates to the session page.
+ ///
+ [Fact]
+ public async Task Redirect_NavigatesToSessionPage()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/response-redirect", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='redirect-basic-demo']");
+ await card.Locator("button:has-text('Response.Redirect')").ClickAsync();
+
+ // Wait for navigation to complete
+ await page.WaitForURLAsync("**/migration/session", new PageWaitForURLOptions
+ {
+ Timeout = 10000
+ });
+
+ Assert.Contains("/migration/session", page.Url);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that the tilde stripping demo shows the cleaned URL.
+ ///
+ [Fact]
+ public async Task Redirect_TildeStrippingShowsCleanUrl()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/response-redirect", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='redirect-tilde-demo']");
+ var strippedText = await card.Locator("strong").TextContentAsync();
+
+ Assert.NotNull(strippedText);
+ Assert.Equal("/migration/session", strippedText!.Trim());
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that ResolveUrl strips ~/ and .aspx to produce clean URLs.
+ ///
+ [Fact]
+ public async Task ResolveUrl_ProducesCleanUrls()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/response-redirect", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='resolveurl-strip-demo']");
+ var strongElements = card.Locator("strong");
+
+ var productsUrl = await strongElements.Nth(0).TextContentAsync();
+ var sessionUrl = await strongElements.Nth(1).TextContentAsync();
+ var logoUrl = await strongElements.Nth(2).TextContentAsync();
+
+ Assert.Equal("/Products", productsUrl!.Trim());
+ Assert.Equal("/migration/session", sessionUrl!.Trim());
+ Assert.Equal("/images/logo.png", logoUrl!.Trim());
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+}
diff --git a/samples/AfterBlazorServerSide.Tests/Migration/ServerMapPathTests.cs b/samples/AfterBlazorServerSide.Tests/Migration/ServerMapPathTests.cs
new file mode 100644
index 000000000..3b368b10e
--- /dev/null
+++ b/samples/AfterBlazorServerSide.Tests/Migration/ServerMapPathTests.cs
@@ -0,0 +1,145 @@
+using Microsoft.Playwright;
+
+namespace AfterBlazorServerSide.Tests.Migration;
+
+///
+/// Integration tests for the ServerShim sample page at /migration/server-mappath.
+/// Verifies MapPath, HtmlEncode, UrlEncode, and ResolveUrl demos.
+///
+[Collection(nameof(PlaywrightCollection))]
+public class ServerMapPathTests
+{
+ private readonly PlaywrightFixture _fixture;
+
+ public ServerMapPathTests(PlaywrightFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ ///
+ /// Verifies that Server.MapPath("~/images") resolves and displays a non-empty path.
+ ///
+ [Fact]
+ public async Task MapPath_DisplaysResolvedPath()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/server-mappath", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var mapPathCard = page.Locator("[data-audit-control='mappath-demo']");
+ var pathText = await mapPathCard.Locator("strong").TextContentAsync();
+
+ Assert.NotNull(pathText);
+ Assert.True(pathText!.Trim().Length > 0, "MapPath should return a non-empty path");
+ Assert.Contains("images", pathText, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that Server.HtmlEncode encodes HTML characters correctly.
+ ///
+ [Fact]
+ public async Task HtmlEncode_EncodesInput()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/server-mappath", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='htmlencode-demo']");
+ var input = card.Locator("input[type='text']");
+ await input.FillAsync("
Hello ");
+ await input.PressAsync("Tab");
+
+ await card.Locator("button:has-text('HtmlEncode')").ClickAsync();
+ await page.WaitForTimeoutAsync(1000);
+
+ var output = await card.Locator("strong").TextContentAsync();
+ Assert.NotNull(output);
+ Assert.Contains("<b>", output!);
+ Assert.Contains("</b>", output);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that Server.UrlEncode encodes spaces and special characters.
+ ///
+ [Fact]
+ public async Task UrlEncode_EncodesInput()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/server-mappath", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='urlencode-demo']");
+ var input = card.Locator("input[type='text']");
+ await input.FillAsync("hello world & more");
+ await input.PressAsync("Tab");
+
+ await card.Locator("button:has-text('UrlEncode')").ClickAsync();
+ await page.WaitForTimeoutAsync(1000);
+
+ var output = await card.Locator("strong").TextContentAsync();
+ Assert.NotNull(output);
+ // URL encoding replaces spaces with + and & with %26
+ Assert.Contains("+", output!);
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+
+ ///
+ /// Verifies that ResolveUrl("~/Products.aspx") strips ~/ and .aspx to produce /Products.
+ ///
+ [Fact]
+ public async Task ResolveUrl_StripsAspxAndTilde()
+ {
+ var page = await _fixture.NewPageAsync();
+
+ try
+ {
+ await page.GotoAsync($"{_fixture.BaseUrl}/migration/server-mappath", new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.NetworkIdle,
+ Timeout = 30000
+ });
+
+ var card = page.Locator("[data-audit-control='resolveurl-demo']");
+ var output = await card.Locator("strong").TextContentAsync();
+
+ Assert.NotNull(output);
+ Assert.Equal("/Products", output!.Trim());
+ }
+ finally
+ {
+ await page.CloseAsync();
+ }
+ }
+}
diff --git a/samples/AfterBlazorServerSide/ComponentCatalog.cs b/samples/AfterBlazorServerSide/ComponentCatalog.cs
index c554c4edf..8a7a588b2 100644
--- a/samples/AfterBlazorServerSide/ComponentCatalog.cs
+++ b/samples/AfterBlazorServerSide/ComponentCatalog.cs
@@ -186,6 +186,16 @@ public static class ComponentCatalog
Keywords: new[] { "ajax", "script", "proxy", "migration" }),
new("Substitution", "Migration Helpers", "/ControlSamples/Substitution", "Post-cache dynamic content substitution",
Keywords: new[] { "cache", "dynamic", "callback", "substitution" }),
+ new("Server Utilities", "Migration Helpers", "/migration/server-mappath", "ServerShim providing MapPath, HtmlEncode, UrlEncode, and ResolveUrl",
+ Keywords: new[] { "server", "mappath", "htmlencode", "urlencode", "resolveurl", "migration", "shim" }),
+ new("Cache", "Migration Helpers", "/migration/cache", "CacheShim providing Web Forms-compatible Cache[\"key\"] access backed by IMemoryCache",
+ Keywords: new[] { "cache", "memorycache", "insert", "remove", "expiration", "migration", "shim" }),
+ new("Request", "Migration Helpers", "/migration/request", "RequestShim providing QueryString, Url, and Cookies with SSR fallback",
+ Keywords: new[] { "request", "querystring", "url", "cookies", "httpcontext", "migration", "shim" }),
+ new("Response.Redirect", "Migration Helpers", "/migration/response-redirect", "ResponseShim providing Redirect with automatic ~/ and .aspx stripping",
+ Keywords: new[] { "response", "redirect", "navigation", "tilde", "aspx", "migration", "shim" }),
+ new("IsPostBack", "Migration Helpers", "/migration/ispostback", "IsPostBack status, guard pattern, and IsHttpContextAvailable check",
+ Keywords: new[] { "ispostback", "postback", "guard", "httpcontext", "lifecycle", "migration", "shim" }),
// Cross-Cutting / Base Properties
new("BaseProperties", "Utility", "/ControlSamples/BaseProperties", "AccessKey, ToolTip, BackColor, ForeColor and other base class properties",
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/CacheDemo.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/CacheDemo.razor
new file mode 100644
index 000000000..0d6012d87
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/CacheDemo.razor
@@ -0,0 +1,205 @@
+@page "/migration/cache"
+@inherits WebFormsPageBase
+
+
Cache Migration Demo
+
+
Cache Migration
+
+
This sample demonstrates the CacheShim that provides Web Forms-compatible
+ Cache["key"] access in Blazor, backed by ASP.NET Core IMemoryCache.
+
+
+
+
1. Setting & Getting Cache Values
+
In Web Forms, you cached data with HttpRuntime.Cache["key"] = value.
+ The BWFC shim supports the identical pattern.
+
+
+
+
Before (Web Forms)
+
// Store a value
+HttpRuntime.Cache["UserName"] = txtName.Text;
+
+// Retrieve a value
+var name = (string)Cache["UserName"];
+
+
+
After (Blazor with BWFC)
+
// Same pattern!
+Cache["UserName"] = userName;
+
+// Retrieve a value
+var name = (string)Cache["UserName"];
+
+
+
+
+
+
Live Demo - Set & Get
+
+
Key:
+
+
Value:
+
+
+ Store in Cache
+ Get from Cache
+
+
+
+ Retrieved value: @cacheRetrieved
+
+
+
+
+
+
+
2. Type-Safe Access with Get<T>()
+
The shim provides Cache.Get<T>(key) for strongly-typed retrieval.
+
+
+
+
Before (Web Forms)
+
// Requires manual cast
+var count = (int)Cache["HitCount"];
+Cache["HitCount"] = count + 1;
+
+
+
After (Blazor with BWFC)
+
// Type-safe access
+var count = Cache.Get<int>("HitCount");
+Cache["HitCount"] = count + 1;
+
+
+
+
+
+
Live Demo - Typed Counter
+
Hit count (stored as int): @(Cache.Get("HitCount"))
+
Increment Counter
+
+
+
+
+
+
3. Remove Cache Items
+
Call Cache.Remove(key) to evict an item, just like in Web Forms.
+ The method returns the removed value.
+
+
+
+
Live Demo - Remove
+
+ Store "TestItem"
+ Remove "TestItem"
+ Check "TestItem"
+
+
+ Status: @removeStatus
+
+
+
+
+
+
+
4. Sliding Expiration
+
Cache items can be stored with a sliding expiration, just like Web Forms.
+ Items expire if not accessed within the sliding window.
+
+
+
+
Before (Web Forms)
+
Cache.Insert("data", value, null,
+ Cache.NoAbsoluteExpiration,
+ TimeSpan.FromSeconds(30));
+
+
+
After (Blazor with BWFC)
+
// Simplified API
+Cache.Insert("data", value,
+ TimeSpan.FromSeconds(30));
+
+
+
+
+
+
Live Demo - Sliding Expiration
+
+ Store with 5s Sliding Expiration
+ Check Value
+
+
+ Status: @expirationStatus
+
+
+
+
+@code {
+ private string cacheKey = "MyKey";
+ private string cacheValue = "";
+ private string cacheRetrieved = "(empty)";
+ private string removeStatus = "(click Store, then Remove, then Check)";
+ private string expirationStatus = "(click Store, wait 5+ seconds, then Check)";
+
+ private void SetCacheValue()
+ {
+ if (!string.IsNullOrWhiteSpace(cacheKey))
+ {
+ Cache[cacheKey] = cacheValue;
+ cacheRetrieved = $"Stored '{cacheValue}' with key '{cacheKey}'";
+ }
+ }
+
+ private void GetCacheValue()
+ {
+ if (!string.IsNullOrWhiteSpace(cacheKey))
+ {
+ var value = (string?)Cache[cacheKey];
+ cacheRetrieved = value ?? "(null - key not found)";
+ }
+ }
+
+ private void IncrementCounter()
+ {
+ var count = Cache.Get
("HitCount");
+ Cache["HitCount"] = count + 1;
+ }
+
+ private void StoreForRemoval()
+ {
+ Cache["TestItem"] = "I exist!";
+ removeStatus = "Stored: 'I exist!'";
+ }
+
+ private void RemoveCacheItem()
+ {
+ var removed = Cache.Remove("TestItem");
+ removeStatus = removed != null
+ ? $"Removed: '{removed}'"
+ : "Nothing to remove (key not found)";
+ }
+
+ private void CheckCacheItem()
+ {
+ var value = (string?)Cache["TestItem"];
+ removeStatus = value != null
+ ? $"Found: '{value}'"
+ : "Not found (null)";
+ }
+
+ private void StoreWithExpiration()
+ {
+ Cache.Insert("ExpiringItem", "I will expire!", TimeSpan.FromSeconds(5));
+ expirationStatus = "Stored with 5-second sliding expiration. Wait 5+ seconds and click Check.";
+ }
+
+ private void CheckExpiration()
+ {
+ var value = (string?)Cache["ExpiringItem"];
+ expirationStatus = value != null
+ ? $"Still cached: '{value}'"
+ : "Expired! (null)";
+ }
+}
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/IsPostBackDemo.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/IsPostBackDemo.razor
new file mode 100644
index 000000000..92c276ec1
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/IsPostBackDemo.razor
@@ -0,0 +1,151 @@
+@page "/migration/ispostback"
+@inherits WebFormsPageBase
+
+IsPostBack Migration Demo
+
+IsPostBack Migration
+
+This sample demonstrates IsPostBack behavior in Blazor. In Web Forms,
+ Page.IsPostBack was false on the initial GET and true on form POSTs.
+ The BWFC shim adapts this for Blazor: false on first render, true on subsequent renders
+ (interactive mode) or on POST requests (SSR mode).
+
+
+
+1. IsPostBack Status
+In Web Forms, IsPostBack indicates whether the page is being loaded for the first time
+ or in response to a postback.
+
+
+
+
Before (Web Forms)
+
if (!IsPostBack)
+{
+ // Initial load — bind data
+ GridView1.DataBind();
+}
+
+
+
After (Blazor with BWFC)
+
// Same pattern works!
+if (!IsPostBack)
+{
+ // Initial load — bind data
+ LoadData();
+}
+
+
+
+
+
+
Live Demo - IsPostBack Status
+
+ Current IsPostBack value: @IsPostBack
+
+
+ Render count: @renderCount
+
+
Trigger Re-render
+
+ In interactive mode, IsPostBack is false on first render and
+ true after OnInitialized completes.
+ Click the button to see the current value after re-rendering.
+
+
+
+
+
+
+2. Guard Pattern
+The classic if (!IsPostBack) guard ensures initialization code runs only once,
+ on the first page load.
+
+
+
+
Live Demo - Guard Pattern
+
+ Data loaded on initial render: @string.Join(", ", initialData)
+
+
+ Initialization ran: @initCount time(s)
+
+
Re-render Page
+
+ The data list is populated only when !IsPostBack.
+ Clicking the button re-renders but does not re-initialize the data.
+
+
+
+
+
+
+3. IsHttpContextAvailable Guard
+Some Web Forms features (cookies, headers) require HttpContext, which is only
+ available during SSR. Use IsHttpContextAvailable to guard those calls.
+
+
+
+
Before (Web Forms)
+
// HttpContext always available
+string ip = Request.UserHostAddress;
+
+
+
After (Blazor with BWFC)
+
// Guard for availability
+if (IsHttpContextAvailable)
+{
+ // Safe to use HTTP features
+ var cookies = Request.Cookies;
+}
+
+
+
+
+
+
Live Demo - HttpContext Guard
+
+ IsHttpContextAvailable: @IsHttpContextAvailable
+
+ @if (IsHttpContextAvailable)
+ {
+
+ HttpContext is available — HTTP-level features (cookies, headers) work normally.
+
+ }
+ else
+ {
+
+ HttpContext is not available (interactive rendering via WebSocket).
+ HTTP-level features degrade gracefully: cookies return empty,
+ QueryString and Url fall back to NavigationManager.
+
+ }
+
+
+
+@code {
+ private int renderCount;
+ private List initialData = new();
+ private int initCount;
+
+ protected override void OnInitialized()
+ {
+ base.OnInitialized();
+
+ if (!IsPostBack)
+ {
+ initialData = new List { "Alpha", "Bravo", "Charlie" };
+ initCount++;
+ }
+ }
+
+ protected override void OnAfterRender(bool firstRender)
+ {
+ // Track renders for display
+ }
+
+ private void TriggerRerender()
+ {
+ renderCount++;
+ }
+}
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/RequestDemo.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/RequestDemo.razor
new file mode 100644
index 000000000..cc50d1ac3
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/RequestDemo.razor
@@ -0,0 +1,104 @@
+@page "/migration/request"
+@inherits WebFormsPageBase
+
+Request Migration Demo
+
+Request Migration
+
+This sample demonstrates the RequestShim that provides Web Forms-compatible
+ Request.QueryString, Request.Url, and Request.Cookies
+ access in Blazor. The shim falls back to NavigationManager when
+ HttpContext is unavailable (interactive mode).
+
+
+
+1. Request.QueryString
+Access query string parameters using the same dictionary-style syntax as Web Forms.
+ The shim falls back to parsing from NavigationManager.Uri in interactive mode.
+
+
+
+
Before (Web Forms)
+
string name = Request.QueryString["name"];
+string id = Request.QueryString["id"];
+
+
+
After (Blazor with BWFC)
+
// Same code works!
+string name = Request.QueryString["name"];
+string id = Request.QueryString["id"];
+
+
+
+
+
+
Live Demo - QueryString
+
Try navigating to:
+ /migration/request?name=test&id=42
+
+
+ Request.QueryString["name"]: @(Request.QueryString["name"].ToString() is { Length: > 0 } name ? name : "(not set)")
+
+
+ Request.QueryString["id"]: @(Request.QueryString["id"].ToString() is { Length: > 0 } id ? id : "(not set)")
+
+
+
+
+
+
+2. Request.Url
+Access the full request URL. Falls back to NavigationManager.Uri
+ in interactive mode.
+
+
+
+
Live Demo - Request.Url
+
+ Current URL: @Request.Url.ToString()
+
+
+
+
+
+
+3. Request.Cookies (with SSR Guard)
+Cookies require HttpContext and are only available during SSR/pre-render.
+ In interactive mode, Request.Cookies returns an empty collection.
+ Use the IsHttpContextAvailable guard pattern.
+
+
+
+
Before (Web Forms)
+
string theme = Request.Cookies["theme"]?.Value;
+
+
+
After (Blazor with BWFC)
+
// Guard for HttpContext availability
+if (IsHttpContextAvailable)
+{
+ var theme = Request.Cookies["theme"];
+}
+
+
+
+
+
+
Live Demo - Cookies
+
+ IsHttpContextAvailable: @IsHttpContextAvailable
+
+ @if (IsHttpContextAvailable)
+ {
+
HttpContext is available — cookies can be read from the HTTP request.
+ }
+ else
+ {
+
HttpContext is not available (interactive mode).
+ Cookies return empty. This is expected and the shim degrades gracefully.
+ }
+
+
+
+@code {
+}
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ResponseRedirectDemo.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ResponseRedirectDemo.razor
new file mode 100644
index 000000000..3a1c5fe9d
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ResponseRedirectDemo.razor
@@ -0,0 +1,112 @@
+@page "/migration/response-redirect"
+@inherits WebFormsPageBase
+
+Response.Redirect Migration Demo
+
+Response.Redirect Migration
+
+This sample demonstrates the ResponseShim that provides Web Forms-compatible
+ Response.Redirect() in Blazor. The shim automatically strips ~/
+ prefixes and .aspx extensions for clean Blazor routing.
+
+
+
+1. Basic Redirect
+Call Response.Redirect(url) to navigate, just like in Web Forms.
+ The shim wraps NavigationManager.NavigateTo().
+
+
+
+
Before (Web Forms)
+
Response.Redirect("/Session.aspx");
+
+
+
After (Blazor with BWFC)
+
// Same code works!
+Response.Redirect("/migration/session");
+
+
+
+
+
+
Live Demo - Basic Redirect
+
Click the button to navigate to the Session demo page:
+
Response.Redirect("/migration/session")
+
+
+
+
+
+2. Tilde & .aspx Auto-Stripping
+Response.Redirect("~/Products.aspx") automatically strips ~/
+ prefix and .aspx extension, producing a clean Blazor route.
+
+
+
+
Before (Web Forms)
+
Response.Redirect("~/Products.aspx");
+// Navigates to: /Products.aspx
+
+
+
After (Blazor with BWFC)
+
// Same call, auto-cleaned
+Response.Redirect("~/Products.aspx");
+// Navigates to: /Products
+
+
+
+
+
+
Live Demo - Tilde & .aspx Stripping
+
The shim transforms the URL before navigation:
+
+ Input: ~/migration/session.aspx
+ After stripping: @tildeStrippedUrl
+
+
Response.Redirect("~/migration/session.aspx")
+
+
+
+
+
+3. ResolveUrl for Link Generation
+ResolveUrl() performs the same ~/ and .aspx
+ stripping without navigating — useful for building URLs in markup.
+
+
+
+
Live Demo - ResolveUrl
+
+ ResolveUrl("~/Products.aspx") returns:
+ @ResolveUrl("~/Products.aspx")
+
+
+ ResolveUrl("~/migration/session.aspx") returns:
+ @ResolveUrl("~/migration/session.aspx")
+
+
+ ResolveUrl("~/images/logo.png") returns:
+ @ResolveUrl("~/images/logo.png")
+
+
+
+
+@code {
+ private string tildeStrippedUrl = "";
+
+ protected override void OnInitialized()
+ {
+ base.OnInitialized();
+ tildeStrippedUrl = ResolveUrl("~/migration/session.aspx");
+ }
+
+ private void RedirectToSession()
+ {
+ Response.Redirect("/migration/session");
+ }
+
+ private void RedirectWithTilde()
+ {
+ Response.Redirect("~/migration/session.aspx");
+ }
+}
diff --git a/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ServerMapPath.razor b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ServerMapPath.razor
new file mode 100644
index 000000000..f441ebbce
--- /dev/null
+++ b/samples/AfterBlazorServerSide/Components/Pages/ControlSamples/Migration/ServerMapPath.razor
@@ -0,0 +1,166 @@
+@page "/migration/server-mappath"
+@inherits WebFormsPageBase
+
+Server Utilities Migration Demo
+
+Server Utilities Migration
+
+This sample demonstrates the ServerShim that provides Web Forms-compatible
+ Server.MapPath(), Server.HtmlEncode(), Server.UrlEncode(),
+ and ResolveUrl() in Blazor.
+
+
+
+1. Server.MapPath()
+In Web Forms, Server.MapPath("~/uploads") resolved a virtual path to a physical
+ path. The BWFC shim provides the same API backed by IWebHostEnvironment.
+
+
+
+
Before (Web Forms)
+
string path = Server.MapPath("~/uploads");
+// Returns: C:\inetpub\wwwroot\uploads
+
+
+
After (Blazor with BWFC)
+
// Same code works!
+string path = Server.MapPath("~/uploads");
+// Returns: /app/wwwroot/uploads
+
+
+
+
+
+
Live Demo - MapPath
+
+ Server.MapPath("~/images") resolves to:
+
+
@mappedPath
+
+
+
+
+
+2. Server.HtmlEncode()
+Encodes text for safe HTML display, preventing XSS. Same API as Web Forms.
+
+
+
+
Before (Web Forms)
+
string safe = Server.HtmlEncode(userInput);
+lblOutput.Text = safe;
+
+
+
After (Blazor with BWFC)
+
// Same code works!
+string safe = Server.HtmlEncode(userInput);
+
+
+
+
+
+
Live Demo - HtmlEncode
+
+
Enter text with HTML characters:
+
+
+ HtmlEncode
+
+
+
+ Encoded output: @htmlEncoded
+
+
+
+
+
+
+3. Server.UrlEncode()
+Encodes text for safe use in URLs. Same API as Web Forms.
+
+
+
+
Before (Web Forms)
+
string encoded = Server.UrlEncode(searchTerm);
+Response.Redirect("search.aspx?q=" + encoded);
+
+
+
After (Blazor with BWFC)
+
// Same code works!
+string encoded = Server.UrlEncode(searchTerm);
+
+
+
+
+
+
Live Demo - UrlEncode
+
+
Enter text to URL-encode:
+
+
+ UrlEncode
+
+
+
+ Encoded output: @urlEncoded
+
+
+
+
+
+
+4. ResolveUrl()
+ResolveUrl("~/Products.aspx") strips the ~/ prefix and
+ .aspx extension, producing a clean Blazor route.
+
+
+
+
Before (Web Forms)
+
string url = ResolveUrl("~/Products.aspx");
+// Returns: /Products.aspx
+
+
+
After (Blazor with BWFC)
+
// Same call, cleaner output
+string url = ResolveUrl("~/Products.aspx");
+// Returns: /Products
+
+
+
+
+
+
Live Demo - ResolveUrl
+
+ ResolveUrl("~/Products.aspx") returns:
+ @resolvedUrl
+
+
+
+
+@code {
+ private string mappedPath = "";
+ private string htmlInput = "";
+ private string htmlEncoded = "(enter text and click HtmlEncode)";
+ private string urlInput = "";
+ private string urlEncoded = "(enter text and click UrlEncode)";
+ private string resolvedUrl = "";
+
+ protected override void OnInitialized()
+ {
+ base.OnInitialized();
+ mappedPath = Server.MapPath("~/images");
+ resolvedUrl = ResolveUrl("~/Products.aspx");
+ }
+
+ private void EncodeHtml()
+ {
+ htmlEncoded = Server.HtmlEncode(htmlInput);
+ }
+
+ private void EncodeUrl()
+ {
+ urlEncoded = Server.UrlEncode(urlInput);
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/BlazorWebFormsComponents.Cli.csproj b/src/BlazorWebFormsComponents.Cli/BlazorWebFormsComponents.Cli.csproj
new file mode 100644
index 000000000..2330ea2a5
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/BlazorWebFormsComponents.Cli.csproj
@@ -0,0 +1,37 @@
+
+
+
+ Exe
+ net10.0
+ BlazorWebFormsComponents.Cli
+ webforms-to-blazor
+ enable
+ enable
+
+
+ true
+ webforms-to-blazor
+ ./nupkg
+
+
+ Fritz.WebFormsToBlazor
+ Jeffrey T. Fritz
+ A command-line tool to convert ASP.NET Web Forms user controls (.ascx) to Blazor Razor components, with assistance from GitHub Copilot SDK
+ Copyright Jeffrey T. Fritz 2019-2026
+ MIT
+ https://github.com/FritzAndFriends/BlazorWebFormsComponents
+ blazor;webforms;aspnet;migration;cli;tool
+ https://github.com/FritzAndFriends/BlazorWebFormsComponents
+ GitHub
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/BlazorWebFormsComponents.Cli/Config/DatabaseProviderDetector.cs b/src/BlazorWebFormsComponents.Cli/Config/DatabaseProviderDetector.cs
new file mode 100644
index 000000000..44600955a
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Config/DatabaseProviderDetector.cs
@@ -0,0 +1,155 @@
+using System.Xml.Linq;
+
+namespace BlazorWebFormsComponents.Cli.Config;
+
+///
+/// Detects EF database provider from Web.config connectionStrings.
+/// Ported from Find-DatabaseProvider in bwfc-migrate.ps1.
+///
+public class DatabaseProviderDetector
+{
+ private static readonly Dictionary ProviderMap = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["System.Data.SqlClient"] = ("Microsoft.EntityFrameworkCore.SqlServer", "UseSqlServer"),
+ ["System.Data.SQLite"] = ("Microsoft.EntityFrameworkCore.Sqlite", "UseSqlite"),
+ ["Npgsql"] = ("Npgsql.EntityFrameworkCore.PostgreSQL", "UseNpgsql"),
+ ["MySql.Data.MySqlClient"] = ("Pomelo.EntityFrameworkCore.MySql", "UseMySql")
+ };
+
+ public DatabaseProviderInfo Detect(string sourcePath)
+ {
+ var defaultResult = new DatabaseProviderInfo
+ {
+ PackageName = "Microsoft.EntityFrameworkCore.SqlServer",
+ ProviderMethod = "UseSqlServer",
+ DetectedFrom = "Default — no Web.config connectionStrings found",
+ ConnectionString = ""
+ };
+
+ if (string.IsNullOrEmpty(sourcePath))
+ return defaultResult;
+
+ // Look for Web.config in source path and parent directory
+ var candidates = new[]
+ {
+ Path.Combine(sourcePath, "Web.config"),
+ Path.Combine(Path.GetDirectoryName(sourcePath) ?? sourcePath, "Web.config")
+ };
+
+ string? webConfigPath = null;
+ foreach (var candidate in candidates)
+ {
+ if (File.Exists(candidate))
+ {
+ webConfigPath = candidate;
+ break;
+ }
+ }
+
+ if (webConfigPath == null)
+ return defaultResult;
+
+ XDocument doc;
+ try
+ {
+ doc = XDocument.Load(webConfigPath);
+ }
+ catch
+ {
+ return defaultResult;
+ }
+
+ var connStringsElement = doc.Root?.Element("connectionStrings");
+ if (connStringsElement == null)
+ return defaultResult;
+
+ var adds = connStringsElement.Elements("add").ToList();
+ if (adds.Count == 0)
+ return defaultResult;
+
+ // Pass 1: Non-EntityClient entries with explicit providerName
+ foreach (var entry in adds)
+ {
+ var providerName = entry.Attribute("providerName")?.Value;
+ if (string.IsNullOrEmpty(providerName) || providerName == "System.Data.EntityClient")
+ continue;
+
+ if (ProviderMap.TryGetValue(providerName, out var mapped))
+ {
+ return new DatabaseProviderInfo
+ {
+ PackageName = mapped.PackageName,
+ ProviderMethod = mapped.ProviderMethod,
+ DetectedFrom = $"Web.config providerName={providerName}",
+ ConnectionString = entry.Attribute("connectionString")?.Value ?? ""
+ };
+ }
+ }
+
+ // Pass 2: Entries without providerName — detect from connection string content
+ foreach (var entry in adds)
+ {
+ var connString = entry.Attribute("connectionString")?.Value;
+ if (string.IsNullOrEmpty(connString) || connString.StartsWith("metadata=", StringComparison.OrdinalIgnoreCase))
+ continue;
+ if (entry.Attribute("providerName") != null)
+ continue;
+
+ if (System.Text.RegularExpressions.Regex.IsMatch(connString, @"(?i)\(LocalDB\)|Server="))
+ {
+ return new DatabaseProviderInfo
+ {
+ PackageName = "Microsoft.EntityFrameworkCore.SqlServer",
+ ProviderMethod = "UseSqlServer",
+ DetectedFrom = "Web.config connection string pattern (SQL Server)",
+ ConnectionString = connString
+ };
+ }
+
+ if (System.Text.RegularExpressions.Regex.IsMatch(connString, @"(?i)Data Source=.*\.db"))
+ {
+ return new DatabaseProviderInfo
+ {
+ PackageName = "Microsoft.EntityFrameworkCore.Sqlite",
+ ProviderMethod = "UseSqlite",
+ DetectedFrom = "Web.config connection string pattern (SQLite)",
+ ConnectionString = connString
+ };
+ }
+ }
+
+ // Pass 3: EntityClient entries — extract inner provider (EF6 pattern)
+ foreach (var entry in adds)
+ {
+ if (entry.Attribute("providerName")?.Value != "System.Data.EntityClient")
+ continue;
+
+ var connString = entry.Attribute("connectionString")?.Value ?? "";
+ var match = System.Text.RegularExpressions.Regex.Match(connString, @"provider=([^;""]+)");
+ if (match.Success)
+ {
+ var innerProvider = match.Groups[1].Value.Trim();
+ if (ProviderMap.TryGetValue(innerProvider, out var mapped))
+ {
+ return new DatabaseProviderInfo
+ {
+ PackageName = mapped.PackageName,
+ ProviderMethod = mapped.ProviderMethod,
+ DetectedFrom = $"Web.config EntityClient provider={innerProvider}",
+ ConnectionString = ""
+ };
+ }
+ }
+ }
+
+ return defaultResult;
+ }
+}
+
+public class DatabaseProviderInfo
+{
+ public required string PackageName { get; init; }
+ public required string ProviderMethod { get; init; }
+ public required string DetectedFrom { get; init; }
+ public required string ConnectionString { get; init; }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Config/WebConfigTransformer.cs b/src/BlazorWebFormsComponents.Cli/Config/WebConfigTransformer.cs
new file mode 100644
index 000000000..330a465da
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Config/WebConfigTransformer.cs
@@ -0,0 +1,146 @@
+using System.Text.Json;
+using System.Xml.Linq;
+using BlazorWebFormsComponents.Cli.Io;
+
+namespace BlazorWebFormsComponents.Cli.Config;
+
+///
+/// Parses Web.config and generates appsettings.json.
+/// Ported from Convert-WebConfigToAppSettings in bwfc-migrate.ps1 (line ~892).
+///
+public class WebConfigTransformer
+{
+ private static readonly HashSet BuiltInConnectionNames = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "LocalSqlServer",
+ "LocalMySqlServer"
+ };
+
+ ///
+ /// Transforms Web.config appSettings and connectionStrings into appsettings.json content.
+ /// Returns null if no Web.config found or no settings to extract.
+ ///
+ public WebConfigResult? Transform(string sourcePath)
+ {
+ // Find Web.config (case-insensitive search)
+ var webConfigPath = FindWebConfig(sourcePath);
+ if (webConfigPath == null)
+ return null;
+
+ XDocument doc;
+ try
+ {
+ doc = XDocument.Load(webConfigPath);
+ }
+ catch (Exception ex)
+ {
+ return new WebConfigResult
+ {
+ JsonContent = null,
+ AppSettingsCount = 0,
+ ConnectionStringsCount = 0,
+ Error = $"Could not parse Web.config: {ex.Message}"
+ };
+ }
+
+ var appSettings = new Dictionary();
+ var connectionStrings = new Dictionary();
+
+ // Parse
+ var appSettingsNodes = doc.Descendants("appSettings").Elements("add");
+ foreach (var node in appSettingsNodes)
+ {
+ var key = node.Attribute("key")?.Value;
+ var value = node.Attribute("value")?.Value ?? "";
+ if (!string.IsNullOrEmpty(key))
+ {
+ appSettings[key] = value;
+ }
+ }
+
+ // Parse
+ var connStrNodes = doc.Descendants("connectionStrings").Elements("add");
+ foreach (var node in connStrNodes)
+ {
+ var name = node.Attribute("name")?.Value;
+ var connStr = node.Attribute("connectionString")?.Value;
+ if (!string.IsNullOrEmpty(name) && !string.IsNullOrEmpty(connStr))
+ {
+ if (!BuiltInConnectionNames.Contains(name))
+ {
+ connectionStrings[name] = connStr;
+ }
+ }
+ }
+
+ if (appSettings.Count == 0 && connectionStrings.Count == 0)
+ return null;
+
+ // Build JSON structure
+ var jsonObj = new Dictionary();
+
+ if (connectionStrings.Count > 0)
+ {
+ jsonObj["ConnectionStrings"] = connectionStrings;
+ }
+
+ foreach (var entry in appSettings)
+ {
+ jsonObj[entry.Key] = entry.Value;
+ }
+
+ // Add standard Blazor sections
+ if (!jsonObj.ContainsKey("Logging"))
+ {
+ jsonObj["Logging"] = new Dictionary
+ {
+ ["LogLevel"] = new Dictionary
+ {
+ ["Default"] = "Information",
+ ["Microsoft.AspNetCore"] = "Warning"
+ }
+ };
+ }
+
+ if (!jsonObj.ContainsKey("AllowedHosts"))
+ {
+ jsonObj["AllowedHosts"] = "*";
+ }
+
+ var options = new JsonSerializerOptions
+ {
+ WriteIndented = true
+ };
+ var jsonContent = JsonSerializer.Serialize(jsonObj, options);
+
+ return new WebConfigResult
+ {
+ JsonContent = jsonContent,
+ AppSettingsCount = appSettings.Count,
+ ConnectionStringsCount = connectionStrings.Count,
+ AppSettingsKeys = [.. appSettings.Keys],
+ ConnectionStringNames = [.. connectionStrings.Keys]
+ };
+ }
+
+ private static string? FindWebConfig(string sourcePath)
+ {
+ var path1 = Path.Combine(sourcePath, "Web.config");
+ if (File.Exists(path1)) return path1;
+
+ var path2 = Path.Combine(sourcePath, "web.config");
+ if (File.Exists(path2)) return path2;
+
+ return null;
+ }
+}
+
+public class WebConfigResult
+{
+ public string? JsonContent { get; init; }
+ public int AppSettingsCount { get; init; }
+ public int ConnectionStringsCount { get; init; }
+ public List AppSettingsKeys { get; init; } = [];
+ public List ConnectionStringNames { get; init; } = [];
+ public string? Error { get; init; }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/EXAMPLES.md b/src/BlazorWebFormsComponents.Cli/EXAMPLES.md
new file mode 100644
index 000000000..a116323c2
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/EXAMPLES.md
@@ -0,0 +1,88 @@
+# WebForms to Blazor CLI Tool - Conversion Examples
+
+This document shows examples of how the `webforms-to-blazor` CLI tool converts ASP.NET Web Forms user controls to Blazor Razor components.
+
+## Example 1: Simple View Switcher
+
+**Before (ViewSwitcher.ascx):**
+```aspx
+<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="ViewSwitcher.ascx.cs" Inherits="BeforeWebForms.ViewSwitcher" %>
+
+```
+
+**After (ViewSwitcher.razor):**
+```razor
+@inherits BeforeWebForms.ViewSwitcher
+
+
+```
+
+**Conversions Made:**
+- `<%@ Control ... Inherits="..." %>` → `@inherits ...`
+- `<%: expression %>` → `@(expression)`
+
+## Example 2: Product Card with Data Binding
+
+**Before (ProductCard.ascx):**
+```aspx
+<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="ProductCard.ascx.cs" Inherits="MyApp.Controls.ProductCard" %>
+
+
+
+
+
$<%: Item.Price %>
+
+
+
+```
+
+**After (ProductCard.razor):**
+```razor
+@inherits MyApp.Controls.ProductCard
+
+
+
+
+
+
$@(context.Price)
+
+
+
+```
+
+**Conversions Made:**
+- ` ` → ` `
+- ` ` → ` `
+- ` ` → ` `
+- `runat="server"` removed
+- `<%# Item.Property %>` → `@(context.Property)` (fixes Item vs context issue)
+- `<%: expression %>` → `@(expression)`
+
+## Usage
+
+To convert these files, use:
+
+```bash
+# Single file
+webforms-to-blazor --input ProductCard.ascx
+
+# Directory (all .ascx files)
+webforms-to-blazor --input ./Controls --recursive --overwrite
+```
+
+## What Still Needs Manual Work
+
+After conversion, you'll typically need to:
+
+1. **Update event handlers** to use Blazor's event system
+2. **Convert data binding** to use `@bind` syntax where appropriate
+3. **Add @using directives** for namespaces
+4. **Migrate code-behind** logic to `@code` blocks or separate `.razor.cs` files
+5. **Replace ViewState** with component state or parameters
+6. **Update postback logic** to use Blazor's component lifecycle
+
+See the [main README](README.md) for full documentation.
diff --git a/src/BlazorWebFormsComponents.Cli/Io/OutputWriter.cs b/src/BlazorWebFormsComponents.Cli/Io/OutputWriter.cs
new file mode 100644
index 000000000..2d668571c
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Io/OutputWriter.cs
@@ -0,0 +1,69 @@
+using System.Text;
+
+namespace BlazorWebFormsComponents.Cli.Io;
+
+///
+/// Centralized output writer that respects --dry-run and tracks all written files.
+/// UTF-8 encoding without BOM, creates directories as needed.
+///
+public class OutputWriter
+{
+ private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
+ private readonly List _writtenFiles = [];
+
+ public bool DryRun { get; set; }
+ public bool Verbose { get; set; }
+
+ public IReadOnlyList WrittenFiles => _writtenFiles;
+
+ ///
+ /// Write content to a file, respecting dry-run mode.
+ ///
+ public async Task WriteFileAsync(string path, string content, string description)
+ {
+ if (DryRun)
+ {
+ Console.WriteLine($" [dry-run] Would write: {path}");
+ if (Verbose)
+ Console.WriteLine($" ({description})");
+ return;
+ }
+
+ var directory = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ await File.WriteAllTextAsync(path, content, Utf8NoBom);
+ _writtenFiles.Add(path);
+
+ if (Verbose)
+ Console.WriteLine($" ✓ {path} ({description})");
+ }
+
+ ///
+ /// Copy a file to the output, respecting dry-run mode.
+ ///
+ public void CopyFile(string source, string destination, string description)
+ {
+ if (DryRun)
+ {
+ Console.WriteLine($" [dry-run] Would copy: {source} → {destination}");
+ return;
+ }
+
+ var directory = Path.GetDirectoryName(destination);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ File.Copy(source, destination, overwrite: true);
+ _writtenFiles.Add(destination);
+
+ if (Verbose)
+ Console.WriteLine($" ✓ {destination} ({description})");
+ }
+
+ public void Reset()
+ {
+ _writtenFiles.Clear();
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Io/SourceScanner.cs b/src/BlazorWebFormsComponents.Cli/Io/SourceScanner.cs
new file mode 100644
index 000000000..d90722065
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Io/SourceScanner.cs
@@ -0,0 +1,92 @@
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Io;
+
+///
+/// Discovers .aspx, .ascx, .master files and pairs them with code-behind files.
+///
+public class SourceScanner
+{
+ private static readonly string[] MarkupExtensions = [".aspx", ".ascx", ".master"];
+
+ ///
+ /// Scans the input path for Web Forms source files.
+ ///
+ public IReadOnlyList Scan(string inputPath, string outputPath)
+ {
+ var files = new List();
+
+ if (File.Exists(inputPath))
+ {
+ // Single file mode
+ var ext = Path.GetExtension(inputPath).ToLowerInvariant();
+ if (MarkupExtensions.Contains(ext))
+ {
+ files.Add(CreateSourceFile(inputPath, inputPath, outputPath));
+ }
+ return files;
+ }
+
+ if (!Directory.Exists(inputPath))
+ return files;
+
+ foreach (var ext in MarkupExtensions)
+ {
+ foreach (var file in Directory.EnumerateFiles(inputPath, $"*{ext}", SearchOption.AllDirectories))
+ {
+ files.Add(CreateSourceFile(file, inputPath, outputPath));
+ }
+ }
+
+ return files;
+ }
+
+ private static SourceFile CreateSourceFile(string filePath, string inputRoot, string outputRoot)
+ {
+ var ext = Path.GetExtension(filePath).ToLowerInvariant();
+ var fileType = ext switch
+ {
+ ".master" => FileType.Master,
+ ".ascx" => FileType.Control,
+ _ => FileType.Page
+ };
+
+ // Determine output path
+ string outputPath;
+ if (File.Exists(inputRoot))
+ {
+ // Single file — output to outputRoot directory
+ var razorName = Path.GetFileNameWithoutExtension(filePath) + ".razor";
+ outputPath = Path.Combine(outputRoot, razorName);
+ }
+ else
+ {
+ var relativePath = Path.GetRelativePath(inputRoot, filePath);
+ var razorRelPath = ext switch
+ {
+ ".aspx" => Path.ChangeExtension(relativePath, ".razor"),
+ ".ascx" => Path.ChangeExtension(relativePath, ".razor"),
+ ".master" => Path.ChangeExtension(relativePath, ".razor"),
+ _ => relativePath
+ };
+ outputPath = Path.Combine(outputRoot, razorRelPath);
+ }
+
+ // Look for code-behind
+ var codeBehindPath = filePath + ".cs";
+ if (!File.Exists(codeBehindPath))
+ {
+ codeBehindPath = filePath + ".vb";
+ if (!File.Exists(codeBehindPath))
+ codeBehindPath = null;
+ }
+
+ return new SourceFile
+ {
+ MarkupPath = filePath,
+ CodeBehindPath = codeBehindPath,
+ OutputPath = outputPath,
+ FileType = fileType
+ };
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/FileMetadata.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/FileMetadata.cs
new file mode 100644
index 000000000..687851734
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Pipeline/FileMetadata.cs
@@ -0,0 +1,22 @@
+namespace BlazorWebFormsComponents.Cli.Pipeline;
+
+///
+/// Per-file metadata passed to each transform step.
+///
+public class FileMetadata
+{
+ public required string SourceFilePath { get; init; }
+ public required string OutputFilePath { get; init; }
+ public required FileType FileType { get; init; }
+ public required string OriginalContent { get; init; }
+ public string? CodeBehindContent { get; set; }
+ public Dictionary DataBindMap { get; set; } = new();
+ public string FileName => Path.GetFileNameWithoutExtension(SourceFilePath);
+}
+
+public enum FileType
+{
+ Page,
+ Master,
+ Control
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationContext.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationContext.cs
new file mode 100644
index 000000000..ce4db2a01
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationContext.cs
@@ -0,0 +1,45 @@
+namespace BlazorWebFormsComponents.Cli.Pipeline;
+
+///
+/// Per-file metadata and project-wide shared state for the migration pipeline.
+///
+public class MigrationContext
+{
+ public required string SourcePath { get; init; }
+ public required string OutputPath { get; init; }
+ public required MigrationOptions Options { get; init; }
+ public IReadOnlyList SourceFiles { get; set; } = [];
+ public TransformLog Log { get; } = new();
+}
+
+public class MigrationOptions
+{
+ public bool SkipScaffold { get; set; }
+ public bool DryRun { get; set; }
+ public bool Verbose { get; set; }
+ public bool Overwrite { get; set; }
+ public string? ReportPath { get; set; }
+}
+
+public class SourceFile
+{
+ public required string MarkupPath { get; init; }
+ public string? CodeBehindPath { get; init; }
+ public required string OutputPath { get; init; }
+ public required FileType FileType { get; init; }
+
+ public bool HasCodeBehind => CodeBehindPath != null && File.Exists(CodeBehindPath);
+}
+
+public class TransformLog
+{
+ private readonly List _entries = new();
+ public IReadOnlyList Entries => _entries;
+
+ public void Add(string file, string transform, string detail)
+ {
+ _entries.Add(new TransformLogEntry(file, transform, detail));
+ }
+}
+
+public record TransformLogEntry(string File, string Transform, string Detail);
diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs
new file mode 100644
index 000000000..701fce6a2
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs
@@ -0,0 +1,229 @@
+using BlazorWebFormsComponents.Cli.Config;
+using BlazorWebFormsComponents.Cli.Io;
+using BlazorWebFormsComponents.Cli.Scaffolding;
+using BlazorWebFormsComponents.Cli.Transforms;
+
+namespace BlazorWebFormsComponents.Cli.Pipeline;
+
+///
+/// Orchestrates the full file conversion pipeline: scaffold, config, markup transforms, code-behind transforms, output.
+///
+public class MigrationPipeline
+{
+ private readonly IReadOnlyList _markupTransforms;
+ private readonly IReadOnlyList _codeBehindTransforms;
+ private readonly ProjectScaffolder _scaffolder;
+ private readonly GlobalUsingsGenerator _globalUsings;
+ private readonly ShimGenerator _shimGenerator;
+ private readonly WebConfigTransformer _webConfigTransformer;
+ private readonly OutputWriter _outputWriter;
+
+ ///
+ /// Full constructor for DI — used by the CLI commands.
+ ///
+ public MigrationPipeline(
+ IEnumerable markupTransforms,
+ IEnumerable codeBehindTransforms,
+ ProjectScaffolder scaffolder,
+ GlobalUsingsGenerator globalUsings,
+ ShimGenerator shimGenerator,
+ WebConfigTransformer webConfigTransformer,
+ OutputWriter outputWriter)
+ {
+ _markupTransforms = markupTransforms.OrderBy(t => t.Order).ToList();
+ _codeBehindTransforms = codeBehindTransforms.OrderBy(t => t.Order).ToList();
+ _scaffolder = scaffolder;
+ _globalUsings = globalUsings;
+ _shimGenerator = shimGenerator;
+ _webConfigTransformer = webConfigTransformer;
+ _outputWriter = outputWriter;
+ }
+
+ ///
+ /// Lightweight constructor for transform-only usage (tests, single-file convert).
+ ///
+ public MigrationPipeline(
+ IEnumerable markupTransforms,
+ IEnumerable codeBehindTransforms)
+ {
+ _markupTransforms = markupTransforms.OrderBy(t => t.Order).ToList();
+ _codeBehindTransforms = codeBehindTransforms.OrderBy(t => t.Order).ToList();
+ _scaffolder = null!;
+ _globalUsings = null!;
+ _shimGenerator = null!;
+ _webConfigTransformer = null!;
+ _outputWriter = null!;
+ }
+
+ ///
+ /// Run the full migration pipeline on all source files in the context.
+ /// Sequence: 1) Scaffold project, 2) Transform config, 3) Transform source files, 4) Generate report.
+ ///
+ public async Task ExecuteAsync(MigrationContext context)
+ {
+ var report = new MigrationReport();
+ _outputWriter.DryRun = context.Options.DryRun;
+ _outputWriter.Verbose = context.Options.Verbose;
+ _outputWriter.Reset();
+
+ // Step 1: Scaffold project (if not --skip-scaffold)
+ if (!context.Options.SkipScaffold)
+ {
+ await ScaffoldProjectAsync(context, report);
+ }
+
+ // Step 2: Transform config (web.config → appsettings.json)
+ await TransformConfigAsync(context, report);
+
+ // Step 3: For each source file — markup pipeline → code-behind pipeline → write output
+ foreach (var sourceFile in context.SourceFiles)
+ {
+ try
+ {
+ await ProcessSourceFileAsync(sourceFile, context, report);
+ }
+ catch (Exception ex)
+ {
+ report.Errors.Add($"{sourceFile.MarkupPath}: {ex.Message}");
+ }
+ }
+
+ // Step 4: Generate report
+ report.GeneratedFiles.AddRange(_outputWriter.WrittenFiles);
+ report.FilesWritten = _outputWriter.WrittenFiles.Count;
+
+ if (!string.IsNullOrEmpty(context.Options.ReportPath))
+ {
+ await report.WriteReportFileAsync(context.Options.ReportPath);
+ }
+
+ return report;
+ }
+
+ private async Task ScaffoldProjectAsync(MigrationContext context, MigrationReport report)
+ {
+ var projectName = Path.GetFileName(context.SourcePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar));
+ if (string.IsNullOrEmpty(projectName))
+ projectName = "MigratedApp";
+
+ Console.WriteLine($"Scaffolding project: {projectName}");
+
+ var scaffoldResult = _scaffolder.Scaffold(context.SourcePath, context.OutputPath, projectName);
+ await _scaffolder.WriteAsync(scaffoldResult, context.OutputPath, _outputWriter);
+ report.ScaffoldFilesGenerated += scaffoldResult.Files.Count;
+
+ // Generate GlobalUsings.cs
+ await _globalUsings.WriteAsync(context.OutputPath, _outputWriter, scaffoldResult.HasIdentity);
+ report.ScaffoldFilesGenerated++;
+
+ // Generate shim files
+ await _shimGenerator.WriteAsync(context.OutputPath, _outputWriter, scaffoldResult.HasIdentity);
+ report.ScaffoldFilesGenerated++; // WebFormsShims.cs
+ if (scaffoldResult.HasIdentity)
+ report.ScaffoldFilesGenerated++; // IdentityShims.cs
+ }
+
+ private async Task TransformConfigAsync(MigrationContext context, MigrationReport report)
+ {
+ var configResult = _webConfigTransformer.Transform(context.SourcePath);
+ if (configResult == null)
+ {
+ if (context.Options.Verbose)
+ Console.WriteLine(" No Web.config settings found — skipping appsettings.json");
+ return;
+ }
+
+ if (configResult.Error != null)
+ {
+ report.Warnings.Add(configResult.Error);
+ return;
+ }
+
+ if (configResult.JsonContent != null)
+ {
+ var appSettingsPath = Path.Combine(context.OutputPath, "appsettings.json");
+ await _outputWriter.WriteFileAsync(appSettingsPath, configResult.JsonContent,
+ $"appsettings.json ({configResult.AppSettingsCount} app settings, {configResult.ConnectionStringsCount} connection strings)");
+
+ if (configResult.ConnectionStringNames.Count > 0)
+ {
+ var connList = string.Join(", ", configResult.ConnectionStringNames);
+ report.ManualItems.Add($"ConnectionStrings extracted from Web.config — verify for target environment: {connList}");
+ }
+ }
+ }
+
+ private async Task ProcessSourceFileAsync(SourceFile sourceFile, MigrationContext context, MigrationReport report)
+ {
+ var markupContent = await File.ReadAllTextAsync(sourceFile.MarkupPath);
+ var metadata = new FileMetadata
+ {
+ SourceFilePath = sourceFile.MarkupPath,
+ OutputFilePath = sourceFile.OutputPath,
+ FileType = sourceFile.FileType,
+ OriginalContent = markupContent
+ };
+
+ // Read code-behind if present
+ if (sourceFile.HasCodeBehind)
+ {
+ metadata.CodeBehindContent = await File.ReadAllTextAsync(sourceFile.CodeBehindPath!);
+ }
+
+ // Markup pipeline
+ var markup = markupContent;
+ foreach (var transform in _markupTransforms)
+ {
+ markup = transform.Apply(markup, metadata);
+ }
+
+ // Code-behind pipeline
+ string? codeBehind = null;
+ if (sourceFile.HasCodeBehind && metadata.CodeBehindContent != null)
+ {
+ codeBehind = metadata.CodeBehindContent;
+ foreach (var transform in _codeBehindTransforms)
+ {
+ codeBehind = transform.Apply(codeBehind, metadata);
+ }
+ }
+
+ // Write markup output
+ await _outputWriter.WriteFileAsync(sourceFile.OutputPath, markup,
+ $"Converted {Path.GetFileName(sourceFile.MarkupPath)}");
+
+ // Write code-behind output
+ if (codeBehind != null)
+ {
+ var codeOutputPath = sourceFile.OutputPath + ".cs";
+ await _outputWriter.WriteFileAsync(codeOutputPath, codeBehind,
+ $"Code-behind for {Path.GetFileName(sourceFile.MarkupPath)}");
+ }
+
+ report.FilesProcessed++;
+ }
+
+ ///
+ /// Run only the markup pipeline on a single string (useful for single-file convert).
+ ///
+ public string TransformMarkup(string content, FileMetadata metadata)
+ {
+ foreach (var transform in _markupTransforms)
+ {
+ content = transform.Apply(content, metadata);
+ }
+ return content;
+ }
+
+ ///
+ /// Run only the code-behind pipeline on a single string.
+ ///
+ public string TransformCodeBehind(string content, FileMetadata metadata)
+ {
+ foreach (var transform in _codeBehindTransforms)
+ {
+ content = transform.Apply(content, metadata);
+ }
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationReport.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationReport.cs
new file mode 100644
index 000000000..61e0f8c36
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationReport.cs
@@ -0,0 +1,107 @@
+using System.Text.Json;
+
+namespace BlazorWebFormsComponents.Cli.Pipeline;
+
+///
+/// Summary report from a migration run. Supports JSON serialization and console output.
+///
+public class MigrationReport
+{
+ public int FilesProcessed { get; set; }
+ public int FilesWritten { get; set; }
+ public int TransformsApplied { get; set; }
+ public int ScaffoldFilesGenerated { get; set; }
+ public int StaticFilesCopied { get; set; }
+ public List Errors { get; } = [];
+ public List Warnings { get; } = [];
+ public List ManualItems { get; } = [];
+ public List GeneratedFiles { get; } = [];
+
+ ///
+ /// Serialize the report to JSON for the --report flag.
+ ///
+ public string ToJson()
+ {
+ var options = new JsonSerializerOptions { WriteIndented = true };
+ return JsonSerializer.Serialize(new
+ {
+ FilesProcessed,
+ FilesWritten,
+ TransformsApplied,
+ ScaffoldFilesGenerated,
+ StaticFilesCopied,
+ ErrorCount = Errors.Count,
+ WarningCount = Warnings.Count,
+ ManualItemCount = ManualItems.Count,
+ Errors,
+ Warnings,
+ ManualItems,
+ GeneratedFiles
+ }, options);
+ }
+
+ ///
+ /// Print a console summary of the migration.
+ ///
+ public void PrintSummary()
+ {
+ Console.WriteLine();
+ Console.WriteLine("═══════════════════════════════════════════════");
+ Console.WriteLine(" Migration Report");
+ Console.WriteLine("═══════════════════════════════════════════════");
+ Console.WriteLine($" Files processed: {FilesProcessed}");
+ Console.WriteLine($" Files written: {FilesWritten}");
+ Console.WriteLine($" Transforms applied: {TransformsApplied}");
+ Console.WriteLine($" Scaffold files: {ScaffoldFilesGenerated}");
+ Console.WriteLine($" Static files: {StaticFilesCopied}");
+
+ if (ManualItems.Count > 0)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine($" Manual items: {ManualItems.Count}");
+ Console.ResetColor();
+ }
+
+ if (Warnings.Count > 0)
+ {
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine($" Warnings: {Warnings.Count}");
+ foreach (var w in Warnings)
+ Console.WriteLine($" ⚠ {w}");
+ Console.ResetColor();
+ }
+
+ if (Errors.Count > 0)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.WriteLine($" Errors: {Errors.Count}");
+ foreach (var e in Errors)
+ Console.WriteLine($" ✗ {e}");
+ Console.ResetColor();
+ }
+ else
+ {
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine(" Errors: 0 ✓");
+ Console.ResetColor();
+ }
+
+ Console.WriteLine("═══════════════════════════════════════════════");
+ }
+
+ ///
+ /// Write JSON report to file if a path is specified.
+ ///
+ public async Task WriteReportFileAsync(string? reportPath)
+ {
+ if (string.IsNullOrEmpty(reportPath))
+ return;
+
+ var directory = Path.GetDirectoryName(reportPath);
+ if (!string.IsNullOrEmpty(directory))
+ Directory.CreateDirectory(directory);
+
+ await File.WriteAllTextAsync(reportPath, ToJson());
+ Console.WriteLine($" Report written to: {reportPath}");
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/TransformResult.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/TransformResult.cs
new file mode 100644
index 000000000..426608834
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Pipeline/TransformResult.cs
@@ -0,0 +1,11 @@
+namespace BlazorWebFormsComponents.Cli.Pipeline;
+
+///
+/// Immutable result of each transform step.
+///
+public sealed record TransformResult(
+ string TransformName,
+ string Content,
+ bool WasModified,
+ string? Detail = null
+);
diff --git a/src/BlazorWebFormsComponents.Cli/Program.cs b/src/BlazorWebFormsComponents.Cli/Program.cs
new file mode 100644
index 000000000..f4b2cb10e
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Program.cs
@@ -0,0 +1,273 @@
+using System.CommandLine;
+using BlazorWebFormsComponents.Cli.Config;
+using BlazorWebFormsComponents.Cli.Io;
+using BlazorWebFormsComponents.Cli.Pipeline;
+using BlazorWebFormsComponents.Cli.Scaffolding;
+using BlazorWebFormsComponents.Cli.Transforms;
+using BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+using BlazorWebFormsComponents.Cli.Transforms.Directives;
+using BlazorWebFormsComponents.Cli.Transforms.Markup;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace BlazorWebFormsComponents.Cli;
+
+class Program
+{
+ static async Task Main(string[] args)
+ {
+ var rootCommand = new RootCommand("WebForms to Blazor - Convert ASP.NET Web Forms projects to Blazor using BWFC")
+ {
+ Name = "webforms-to-blazor"
+ };
+
+ rootCommand.AddCommand(BuildMigrateCommand());
+ rootCommand.AddCommand(BuildConvertCommand());
+
+ return await rootCommand.InvokeAsync(args);
+ }
+
+ private static ServiceProvider BuildServiceProvider()
+ {
+ var services = new ServiceCollection();
+
+ // Register markup transforms in order
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Register code-behind transforms in order
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Scaffolding
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Config
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // I/O
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Pipeline
+ services.AddSingleton();
+
+ return services.BuildServiceProvider();
+ }
+
+ private static Command BuildMigrateCommand()
+ {
+ var migrateCommand = new Command("migrate", "Full project migration from Web Forms to Blazor");
+
+ var inputOption = new Option(
+ aliases: ["--input", "-i"],
+ description: "Source Web Forms project root (required)")
+ { IsRequired = true };
+
+ var outputOption = new Option(
+ aliases: ["--output", "-o"],
+ description: "Output Blazor project directory (required)")
+ { IsRequired = true };
+
+ var skipScaffoldOption = new Option(
+ name: "--skip-scaffold",
+ description: "Skip .csproj, Program.cs, _Imports.razor generation",
+ getDefaultValue: () => false);
+
+ var dryRunOption = new Option(
+ name: "--dry-run",
+ description: "Show transforms without writing files",
+ getDefaultValue: () => false);
+
+ var verboseOption = new Option(
+ aliases: ["--verbose", "-v"],
+ description: "Detailed per-file transform log",
+ getDefaultValue: () => false);
+
+ var overwriteOption = new Option(
+ name: "--overwrite",
+ description: "Overwrite existing files in output directory",
+ getDefaultValue: () => false);
+
+ var reportOption = new Option(
+ name: "--report",
+ description: "Write JSON migration report to file");
+
+ migrateCommand.AddOption(inputOption);
+ migrateCommand.AddOption(outputOption);
+ migrateCommand.AddOption(skipScaffoldOption);
+ migrateCommand.AddOption(dryRunOption);
+ migrateCommand.AddOption(verboseOption);
+ migrateCommand.AddOption(overwriteOption);
+ migrateCommand.AddOption(reportOption);
+
+ migrateCommand.SetHandler(async (input, output, skipScaffold, dryRun, verbose, overwrite, report) =>
+ {
+ try
+ {
+ using var sp = BuildServiceProvider();
+ var scanner = sp.GetRequiredService();
+ var pipeline = sp.GetRequiredService();
+
+ var context = new MigrationContext
+ {
+ SourcePath = input,
+ OutputPath = output,
+ Options = new MigrationOptions
+ {
+ SkipScaffold = skipScaffold,
+ DryRun = dryRun,
+ Verbose = verbose,
+ Overwrite = overwrite,
+ ReportPath = report
+ }
+ };
+
+ context.SourceFiles = scanner.Scan(input, output);
+
+ Console.WriteLine($"Found {context.SourceFiles.Count} Web Forms file(s) to migrate...");
+ if (dryRun)
+ Console.WriteLine("(dry-run mode — no files will be written)");
+
+ var migrationReport = await pipeline.ExecuteAsync(context);
+
+ migrationReport.PrintSummary();
+
+ if (migrationReport.Errors.Count > 0)
+ Environment.Exit(1);
+ }
+ catch (Exception ex)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.Error.WriteLine($"Error: {ex.Message}");
+ Console.ResetColor();
+ Environment.Exit(1);
+ }
+ }, inputOption, outputOption, skipScaffoldOption, dryRunOption, verboseOption, overwriteOption, reportOption);
+
+ return migrateCommand;
+ }
+
+ private static Command BuildConvertCommand()
+ {
+ var convertCommand = new Command("convert", "Single file conversion from Web Forms to Blazor");
+
+ var inputOption = new Option(
+ aliases: ["--input", "-i"],
+ description: ".aspx, .ascx, or .master file (required)")
+ { IsRequired = true };
+
+ var outputOption = new Option(
+ aliases: ["--output", "-o"],
+ description: "Output directory (default: same directory)");
+
+ var overwriteOption = new Option(
+ name: "--overwrite",
+ description: "Overwrite existing .razor file",
+ getDefaultValue: () => false);
+
+ convertCommand.AddOption(inputOption);
+ convertCommand.AddOption(outputOption);
+ convertCommand.AddOption(overwriteOption);
+
+ convertCommand.SetHandler(async (input, output, overwrite) =>
+ {
+ try
+ {
+ if (!File.Exists(input))
+ throw new FileNotFoundException($"Input file not found: {input}");
+
+ var outputDir = output ?? Path.GetDirectoryName(input) ?? ".";
+ var razorName = Path.GetFileNameWithoutExtension(input) + ".razor";
+ var outputPath = Path.Combine(outputDir, razorName);
+
+ if (File.Exists(outputPath) && !overwrite)
+ {
+ Console.Error.WriteLine($"Output file already exists: {outputPath}. Use --overwrite to replace.");
+ Environment.Exit(1);
+ }
+
+ using var sp = BuildServiceProvider();
+ var pipeline = sp.GetRequiredService();
+
+ var markupContent = await File.ReadAllTextAsync(input);
+ var ext = Path.GetExtension(input).ToLowerInvariant();
+ var fileType = ext switch
+ {
+ ".master" => FileType.Master,
+ ".ascx" => FileType.Control,
+ _ => FileType.Page
+ };
+
+ var metadata = new FileMetadata
+ {
+ SourceFilePath = input,
+ OutputFilePath = outputPath,
+ FileType = fileType,
+ OriginalContent = markupContent
+ };
+
+ var result = pipeline.TransformMarkup(markupContent, metadata);
+
+ // Code-behind transform for convert command
+ var codeBehindPath = input + ".cs";
+ string? codeBehind = null;
+ if (File.Exists(codeBehindPath))
+ {
+ metadata.CodeBehindContent = await File.ReadAllTextAsync(codeBehindPath);
+ codeBehind = pipeline.TransformCodeBehind(metadata.CodeBehindContent, metadata);
+ }
+
+ Directory.CreateDirectory(outputDir);
+ await File.WriteAllTextAsync(outputPath, result);
+
+ if (codeBehind != null)
+ {
+ var codeOutputPath = outputPath + ".cs";
+ await File.WriteAllTextAsync(codeOutputPath, codeBehind);
+ }
+
+ Console.ForegroundColor = ConsoleColor.Green;
+ Console.WriteLine($"✓ {Path.GetFileName(input)} → {razorName}");
+ if (codeBehind != null)
+ Console.WriteLine($"✓ {Path.GetFileName(codeBehindPath)} → {razorName}.cs");
+ Console.ResetColor();
+ }
+ catch (Exception ex)
+ {
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.Error.WriteLine($"Error: {ex.Message}");
+ Console.ResetColor();
+ Environment.Exit(1);
+ }
+ }, inputOption, outputOption, overwriteOption);
+
+ return convertCommand;
+ }
+}
+
diff --git a/src/BlazorWebFormsComponents.Cli/README.md b/src/BlazorWebFormsComponents.Cli/README.md
new file mode 100644
index 000000000..718b2f087
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/README.md
@@ -0,0 +1,160 @@
+# WebForms to Blazor CLI Tool
+
+A command-line tool to convert ASP.NET Web Forms user controls (.ascx) to Blazor Razor components (.razor).
+
+## Installation
+
+### As a Global Tool
+
+Install the tool globally using the .NET CLI:
+
+```bash
+dotnet tool install --global WebformsToBlazor.Cli
+```
+
+Or install from source:
+
+```bash
+cd src/BlazorWebFormsComponents.Cli
+dotnet pack
+dotnet tool install --global --add-source ./nupkg WebformsToBlazor.Cli
+```
+
+### As a Local Tool
+
+Add to your project's local tool manifest:
+
+```bash
+dotnet tool install WebformsToBlazor.Cli
+```
+
+## Usage
+
+### Basic Usage
+
+Convert a single .ascx file:
+
+```bash
+webforms-to-blazor --input path/to/MyControl.ascx
+```
+
+Convert all .ascx files in a directory:
+
+```bash
+webforms-to-blazor --input path/to/controls/
+```
+
+### Options
+
+- `-i, --input ` (Required): Path to .ascx file or directory containing .ascx files
+- `-o, --output `: Output directory for converted files (defaults to input directory)
+- `-r, --recursive`: Process all .ascx files in subdirectories
+- `-f, --overwrite`: Overwrite existing .razor files without prompting
+
+### Examples
+
+Convert all controls in a directory recursively:
+
+```bash
+webforms-to-blazor -i ./UserControls -r -f
+```
+
+Convert to a different output directory:
+
+```bash
+webforms-to-blazor -i ./WebFormsControls -o ./BlazorComponents -r
+```
+
+## What Gets Converted
+
+The tool performs the following conversions:
+
+### Control Directive
+```html
+
+<%@ Control Language="C#" CodeBehind="MyControl.ascx.cs" Inherits="MyNamespace.MyControl" %>
+
+
+@inherits MyNamespace.MyControl
+```
+
+### ASP.NET Server Controls
+```html
+
+
+
+
+
+```
+
+### Expression Syntax
+```html
+
+<%: Model.Name %>
+<%= GetValue() %>
+
+
+@(Model.Name)
+@(GetValue())
+```
+
+### Common Template Issues
+The tool fixes the common `Item` vs `context` issue in Blazor templates:
+
+```html
+
+Item.Name
+
+
+context.Name
+```
+
+## Manual Steps After Conversion
+
+After using this tool, you may need to:
+
+1. **Update Event Handlers**: Convert Web Forms event handlers to Blazor syntax
+ ```html
+
+
+
+
+
+ ```
+
+2. **Update Data Binding**: Convert Web Forms data binding to Blazor's `@bind` syntax
+ ```html
+
+
+
+
+
+ ```
+
+3. **Add Using Directives**: Add necessary `@using` directives at the top of the file
+
+4. **Code-Behind Files**: The tool converts markup only. You'll need to manually convert code-behind (.ascx.cs) files to Razor component code-behind (.razor.cs) or `@code` blocks.
+
+## Limitations
+
+- Code-behind (.ascx.cs) files are not automatically converted
+- Complex data binding expressions may need manual adjustment
+- ViewState references will need to be replaced with component state
+- Postback logic needs to be rewritten using Blazor's event model
+
+## Future Enhancements
+
+- AI-powered conversion using GitHub Copilot SDK (coming soon)
+- Code-behind file conversion
+- Advanced data binding translation
+- Interactive mode with preview
+
+## Contributing
+
+This tool is part of the BlazorWebFormsComponents project. Contributions are welcome!
+
+See the main repository for contribution guidelines: [BlazorWebFormsComponents](https://github.com/FritzAndFriends/BlazorWebFormsComponents)
+
+## License
+
+MIT License - See LICENSE file in the repository root
diff --git a/src/BlazorWebFormsComponents.Cli/Scaffolding/GlobalUsingsGenerator.cs b/src/BlazorWebFormsComponents.Cli/Scaffolding/GlobalUsingsGenerator.cs
new file mode 100644
index 000000000..cae643847
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Scaffolding/GlobalUsingsGenerator.cs
@@ -0,0 +1,48 @@
+using BlazorWebFormsComponents.Cli.Io;
+
+namespace BlazorWebFormsComponents.Cli.Scaffolding;
+
+///
+/// Generates GlobalUsings.cs with Blazor infrastructure usings.
+/// Ported from the GlobalUsings section of New-ProjectScaffold in bwfc-migrate.ps1.
+///
+public class GlobalUsingsGenerator
+{
+ public string Generate(bool hasIdentity = false)
+ {
+ var content = @"// =============================================================================
+// Global using directives for Web Forms → Blazor migration.
+//
+// Type aliases (Page, MasterPage, ImageClickEventArgs) and BWFC namespaces are
+// provided automatically by the BlazorWebFormsComponents .targets file.
+// This file adds Blazor infrastructure usings for code-behind files.
+//
+// Generated by webforms-to-blazor — Layer 1 scaffold
+// =============================================================================
+
+// Blazor component infrastructure — provides [Inject], [Parameter],
+// [SupplyParameterFromQuery], NavigationManager, ComponentBase, etc.
+global using Microsoft.AspNetCore.Components;
+global using Microsoft.AspNetCore.Components.Web;
+global using Microsoft.AspNetCore.Components.Routing;
+";
+
+ if (hasIdentity)
+ {
+ content += @"
+// Identity infrastructure
+global using Microsoft.AspNetCore.Components.Authorization;
+global using Microsoft.AspNetCore.Identity;
+";
+ }
+
+ return content;
+ }
+
+ public async Task WriteAsync(string outputRoot, OutputWriter writer, bool hasIdentity = false)
+ {
+ var content = Generate(hasIdentity);
+ var path = Path.Combine(outputRoot, "GlobalUsings.cs");
+ await writer.WriteFileAsync(path, content, "GlobalUsings.cs (Blazor infrastructure usings)");
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Scaffolding/ProjectScaffolder.cs b/src/BlazorWebFormsComponents.Cli/Scaffolding/ProjectScaffolder.cs
new file mode 100644
index 000000000..2e637e96a
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Scaffolding/ProjectScaffolder.cs
@@ -0,0 +1,281 @@
+using BlazorWebFormsComponents.Cli.Config;
+using BlazorWebFormsComponents.Cli.Io;
+
+namespace BlazorWebFormsComponents.Cli.Scaffolding;
+
+///
+/// Generates the Blazor project scaffold: .csproj, Program.cs, _Imports.razor, App.razor, Routes.razor, launchSettings.json.
+/// Ported from New-ProjectScaffold + New-AppRazorScaffold in bwfc-migrate.ps1.
+///
+public class ProjectScaffolder
+{
+ private readonly DatabaseProviderDetector _dbDetector;
+
+ public ProjectScaffolder(DatabaseProviderDetector dbDetector)
+ {
+ _dbDetector = dbDetector;
+ }
+
+ public ScaffoldResult Scaffold(string sourcePath, string outputRoot, string projectName)
+ {
+ var result = new ScaffoldResult { ProjectName = projectName };
+
+ // Detect features
+ var hasModels = !string.IsNullOrEmpty(sourcePath) &&
+ Directory.Exists(Path.Combine(sourcePath, "Models"));
+ var hasIdentity = DetectIdentity(sourcePath);
+ var dbProvider = _dbDetector.Detect(sourcePath);
+
+ result.HasModels = hasModels;
+ result.HasIdentity = hasIdentity;
+ result.DbProvider = dbProvider;
+
+ // Generate all scaffold files
+ result.Files["csproj"] = new ScaffoldFile
+ {
+ RelativePath = $"{projectName}.csproj",
+ Content = GenerateCsproj(projectName, hasModels, hasIdentity, dbProvider)
+ };
+
+ result.Files["program"] = new ScaffoldFile
+ {
+ RelativePath = "Program.cs",
+ Content = GenerateProgramCs(projectName, hasModels, hasIdentity, dbProvider)
+ };
+
+ result.Files["imports"] = new ScaffoldFile
+ {
+ RelativePath = "_Imports.razor",
+ Content = GenerateImportsRazor(projectName)
+ };
+
+ result.Files["app"] = new ScaffoldFile
+ {
+ RelativePath = Path.Combine("Components", "App.razor"),
+ Content = GenerateAppRazor()
+ };
+
+ result.Files["routes"] = new ScaffoldFile
+ {
+ RelativePath = Path.Combine("Components", "Routes.razor"),
+ Content = GenerateRoutesRazor()
+ };
+
+ result.Files["launchSettings"] = new ScaffoldFile
+ {
+ RelativePath = Path.Combine("Properties", "launchSettings.json"),
+ Content = GenerateLaunchSettings(projectName)
+ };
+
+ return result;
+ }
+
+ public async Task WriteAsync(ScaffoldResult result, string outputRoot, OutputWriter writer)
+ {
+ foreach (var file in result.Files.Values)
+ {
+ var fullPath = Path.Combine(outputRoot, file.RelativePath);
+ await writer.WriteFileAsync(fullPath, file.Content, $"Scaffold: {file.RelativePath}");
+ }
+ }
+
+ private static bool DetectIdentity(string sourcePath)
+ {
+ if (string.IsNullOrEmpty(sourcePath))
+ return false;
+
+ return Directory.Exists(Path.Combine(sourcePath, "Account")) ||
+ File.Exists(Path.Combine(sourcePath, "Login.aspx")) ||
+ File.Exists(Path.Combine(sourcePath, "Register.aspx"));
+ }
+
+ private static string GenerateCsproj(string projectName, bool hasModels, bool hasIdentity, DatabaseProviderInfo dbProvider)
+ {
+ var additionalPackages = "";
+ if (hasModels)
+ {
+ additionalPackages += $"\n ";
+ additionalPackages += "\n ";
+ }
+ if (hasIdentity)
+ {
+ additionalPackages += "\n ";
+ additionalPackages += "\n ";
+ additionalPackages += "\n ";
+ }
+
+ return $@"
+
+
+ net10.0
+ enable
+ enable
+ true
+
+
+
+ {additionalPackages}
+
+
+
+";
+ }
+
+ private static string GenerateProgramCs(string projectName, bool hasModels, bool hasIdentity, DatabaseProviderInfo dbProvider)
+ {
+ var dbContextLine = !string.IsNullOrEmpty(dbProvider.ConnectionString)
+ ? $"// builder.Services.AddDbContextFactory(options => options.{dbProvider.ProviderMethod}(\"{dbProvider.ConnectionString.Replace("\\", "\\\\")}\"));"
+ : $"// builder.Services.AddDbContextFactory(options => options.{dbProvider.ProviderMethod}(\"your-connection-string\"));";
+
+ var identityServiceBlock = "";
+ var identityMiddlewareBlock = "";
+
+ if (hasIdentity)
+ {
+ identityServiceBlock = $@"
+
+// TODO: Configure database connection (use AddDbContextFactory — do NOT also register AddDbContext to avoid DI conflicts)
+{dbContextLine}
+
+// TODO: Configure Identity
+// builder.Services.AddDefaultIdentity(options => options.SignIn.RequireConfirmedAccount = false)
+// .AddEntityFrameworkStores();
+
+// TODO: Configure session for cart/state management
+// builder.Services.AddDistributedMemoryCache();
+// builder.Services.AddSession();
+// builder.Services.AddHttpContextAccessor();
+// builder.Services.AddCascadingAuthenticationState();
+";
+
+ identityMiddlewareBlock = @"
+
+// TODO: Add middleware in the pipeline
+// app.UseSession();
+// app.UseAuthentication();
+// app.UseAuthorization();
+";
+ }
+ else if (hasModels)
+ {
+ identityServiceBlock = $@"
+
+// TODO: Configure database connection (use AddDbContextFactory — do NOT also register AddDbContext to avoid DI conflicts)
+{dbContextLine}
+";
+ }
+
+ return $@"// TODO: Review and adjust this generated Program.cs for your application needs.
+using BlazorWebFormsComponents;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddRazorComponents()
+ .AddInteractiveServerComponents();
+
+builder.Services.AddBlazorWebFormsComponents();
+{identityServiceBlock}
+var app = builder.Build();
+
+if (!app.Environment.IsDevelopment())
+{{
+ app.UseExceptionHandler(""/Error"");
+ app.UseHsts();
+}}
+
+app.UseHttpsRedirection();
+app.MapStaticAssets();
+app.UseAntiforgery();
+{identityMiddlewareBlock}
+app.MapRazorComponents<{projectName}.Components.App>()
+ .AddInteractiveServerRenderMode();
+
+app.Run();
+";
+ }
+
+ private static string GenerateImportsRazor(string projectName)
+ {
+ return $@"@using System.Net.Http
+@using Microsoft.AspNetCore.Authorization
+@using Microsoft.AspNetCore.Components.Authorization
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.JSInterop
+@using BlazorWebFormsComponents
+@using BlazorWebFormsComponents.LoginControls
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@using {projectName}
+@using {projectName}.Models
+@inherits BlazorWebFormsComponents.WebFormsPageBase
+";
+ }
+
+ private static string GenerateAppRazor()
+ {
+ return @"
+
+
+
+
+
+
+
+
+
+@* SSR by default — add @rendermode=""InteractiveServer"" to pages that need interactivity *@
+
+
+
+
+
+
+";
+ }
+
+ private static string GenerateRoutesRazor()
+ {
+ return @"
+
+
+
+
+
+";
+ }
+
+ private static string GenerateLaunchSettings(string projectName)
+ {
+ return $$"""
+{
+ "profiles": {
+ "{{projectName}}": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:5001;http://localhost:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
+""";
+ }
+}
+
+public class ScaffoldResult
+{
+ public required string ProjectName { get; init; }
+ public bool HasModels { get; set; }
+ public bool HasIdentity { get; set; }
+ public DatabaseProviderInfo? DbProvider { get; set; }
+ public Dictionary Files { get; } = [];
+}
+
+public class ScaffoldFile
+{
+ public required string RelativePath { get; init; }
+ public required string Content { get; init; }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Scaffolding/ShimGenerator.cs b/src/BlazorWebFormsComponents.Cli/Scaffolding/ShimGenerator.cs
new file mode 100644
index 000000000..fbecd4949
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Scaffolding/ShimGenerator.cs
@@ -0,0 +1,68 @@
+using BlazorWebFormsComponents.Cli.Io;
+
+namespace BlazorWebFormsComponents.Cli.Scaffolding;
+
+///
+/// Generates shim files that bridge Web Forms types to BWFC equivalents.
+/// WebFormsShims.cs provides using statements and initialization for BWFC's built-in shims.
+/// IdentityShims.cs is only generated when HasIdentity is true.
+///
+public class ShimGenerator
+{
+ public string GenerateWebFormsShims()
+ {
+ return @"// =============================================================================
+// Web Forms compatibility shims for Blazor migration.
+//
+// These shims bridge common Web Forms types and patterns to their Blazor/BWFC
+// equivalents. The BWFC NuGet package provides most type aliases automatically
+// via its .targets file (Page, MasterPage, ImageClickEventArgs).
+//
+// This file adds project-level shims for patterns not covered by the package.
+//
+// Generated by webforms-to-blazor — Layer 1 scaffold
+// =============================================================================
+
+// ConfigurationManager shim — allows Web Forms configuration access patterns
+// to work against ASP.NET Core's IConfiguration.
+// Usage: ConfigurationManager.AppSettings[""key""] maps to IConfiguration[""key""]
+using BlazorWebFormsComponents;
+";
+ }
+
+ public string GenerateIdentityShims()
+ {
+ return @"// =============================================================================
+// Identity compatibility shims for Blazor migration.
+//
+// These shims bridge ASP.NET Web Forms membership/identity patterns to
+// ASP.NET Core Identity equivalents.
+//
+// Generated by webforms-to-blazor — Layer 1 scaffold
+// =============================================================================
+
+// TODO: Configure ASP.NET Core Identity to replace Web Forms Membership
+// The following patterns need manual migration:
+// Membership.GetUser() → UserManager.GetUserAsync()
+// Membership.ValidateUser() → SignInManager.PasswordSignInAsync()
+// FormsAuthentication.SignOut() → SignInManager.SignOutAsync()
+// Roles.IsUserInRole() → UserManager.IsInRoleAsync()
+//
+// See: https://learn.microsoft.com/aspnet/core/security/authentication/identity
+
+using Microsoft.AspNetCore.Identity;
+";
+ }
+
+ public async Task WriteAsync(string outputRoot, OutputWriter writer, bool hasIdentity)
+ {
+ var shimPath = Path.Combine(outputRoot, "WebFormsShims.cs");
+ await writer.WriteFileAsync(shimPath, GenerateWebFormsShims(), "WebFormsShims.cs");
+
+ if (hasIdentity)
+ {
+ var identityPath = Path.Combine(outputRoot, "IdentityShims.cs");
+ await writer.WriteFileAsync(identityPath, GenerateIdentityShims(), "IdentityShims.cs");
+ }
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/BaseClassStripTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/BaseClassStripTransform.cs
new file mode 100644
index 000000000..d45ef1b35
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/BaseClassStripTransform.cs
@@ -0,0 +1,23 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Removes Web Forms base class declarations from code-behind partial classes.
+/// The .razor file handles inheritance via @inherits, so the partial class must not declare a base.
+///
+public class BaseClassStripTransform : ICodeBehindTransform
+{
+ public string Name => "BaseClassStrip";
+ public int Order => 200;
+
+ private static readonly Regex BaseClassRegex = new(
+ @"(partial\s+class\s+\w+)\s*:\s*(System\.Web\.UI\.Page|System\.Web\.UI\.MasterPage|System\.Web\.UI\.UserControl|(?
+/// Cross-file DataBind transform: ctrl.DataSource = expr → field assignment,
+/// .DataBind() removal, field declarations.
+/// Also exposes InjectItemsAttributes() for markup-side injection.
+/// TC17 uses markup-only Bind expressions; this handles code-behind DataBind patterns.
+///
+public class DataBindTransform : ICodeBehindTransform
+{
+ public string Name => "DataBind";
+ public int Order => 800;
+
+ // Matches: controlId.DataSource = expression;
+ private static readonly Regex DataSourceAssignRegex = new(
+ @"(\w+)\.DataSource\s*=\s*(.+?)\s*;",
+ RegexOptions.Compiled);
+
+ // Matches: controlId.DataBind();
+ private static readonly Regex DataBindCallRegex = new(
+ @"\w+\.DataBind\(\)\s*;[ \t]*\r?\n?",
+ RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ // Scan for DataSource assignments and store in metadata for cross-file correlation
+ var dsMatches = DataSourceAssignRegex.Matches(content);
+ foreach (Match m in dsMatches)
+ {
+ var controlId = m.Groups[1].Value;
+ var expression = m.Groups[2].Value;
+ metadata.DataBindMap[controlId] = expression;
+ }
+
+ // Remove .DataBind() calls — Blazor renders automatically
+ content = DataBindCallRegex.Replace(content, "");
+
+ return content;
+ }
+
+ ///
+ /// Injects Items="@fieldName" attributes into markup for controls that had DataSource assigned in code-behind.
+ /// Called by the pipeline after code-behind transforms complete.
+ ///
+ public static string InjectItemsAttributes(string markup, Dictionary dataBindMap)
+ {
+ foreach (var (controlId, expression) in dataBindMap)
+ {
+ // Find the control by id="controlId" and inject Items attribute
+ var controlRegex = new Regex($@"(<\w+[^>]*\bid\s*=\s*""{Regex.Escape(controlId)}""[^>]*?)(\s*/?>)");
+ if (controlRegex.IsMatch(markup))
+ {
+ markup = controlRegex.Replace(markup, $"$1 Items=\"@({expression})\"$2", 1);
+ }
+ }
+ return markup;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/EventHandlerSignatureTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/EventHandlerSignatureTransform.cs
new file mode 100644
index 000000000..0cf91699f
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/EventHandlerSignatureTransform.cs
@@ -0,0 +1,54 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Transforms Web Forms event handler signatures to Blazor-compatible signatures:
+/// - Standard EventArgs → strip both params: Handler()
+/// - Specialized EventArgs → strip sender, keep EventArgs: Handler(SpecializedEventArgs e)
+///
+public class EventHandlerSignatureTransform : ICodeBehindTransform
+{
+ public string Name => "EventHandlerSignature";
+ public int Order => 700;
+
+ // Group 1: everything before parens (modifiers + return type + method name)
+ // Group 2: the EventArgs type name
+ // Group 3: the EventArgs parameter name
+ private static readonly Regex HandlerRegex = new(
+ @"((?:(?:protected|private|public|internal)\s+)?(?:(?:static|virtual|override|new|sealed|abstract|async)\s+)*(?:void|Task(?:<[^>]+>)?)\s+\w+)\s*\(\s*object\s+\w+\s*,\s*(\w*EventArgs)\s+(\w+)\s*\)",
+ RegexOptions.Compiled);
+
+ private const int MaxIterations = 200;
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ var iterations = 0;
+
+ while (HandlerRegex.IsMatch(content) && iterations < MaxIterations)
+ {
+ iterations++;
+ var match = HandlerRegex.Match(content);
+ var prefix = match.Groups[1].Value;
+ var eventArgsType = match.Groups[2].Value;
+ var eventArgsParam = match.Groups[3].Value;
+
+ string replacement;
+ if (eventArgsType == "EventArgs")
+ {
+ // Standard EventArgs — strip both params entirely
+ replacement = $"{prefix}()";
+ }
+ else
+ {
+ // Specialized EventArgs — strip sender, keep EventArgs param
+ replacement = $"{prefix}({eventArgsType} {eventArgsParam})";
+ }
+
+ content = content[..match.Index] + replacement + content[(match.Index + match.Length)..];
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/IsPostBackTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/IsPostBackTransform.cs
new file mode 100644
index 000000000..d1fd14a62
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/IsPostBackTransform.cs
@@ -0,0 +1,121 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Unwraps simple if (!IsPostBack) guards (brace-counting) and adds TODO for complex ones (with else).
+/// In Blazor, OnInitializedAsync runs only once so the guard is unnecessary.
+///
+public class IsPostBackTransform : ICodeBehindTransform
+{
+ public string Name => "IsPostBack";
+ public int Order => 500;
+
+ // Combined pattern for "if not postback" variants
+ private static readonly Regex GuardRegex = new(
+ @"(?:if\s*\(\s*!(?:Page\.|this\.)?IsPostBack\s*\)|if\s*\(\s*(?:Page\.|this\.)?IsPostBack\s*==\s*false\s*\)|if\s*\(\s*false\s*==\s*(?:Page\.|this\.)?IsPostBack\s*\))",
+ RegexOptions.Compiled);
+
+ private const int MaxIterations = 50;
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ var iterations = 0;
+
+ while (GuardRegex.IsMatch(content) && iterations < MaxIterations)
+ {
+ iterations++;
+ var match = GuardRegex.Match(content);
+ var matchStart = match.Index;
+ var afterMatch = matchStart + match.Length;
+
+ // Skip whitespace to find opening brace
+ var braceStart = afterMatch;
+ while (braceStart < content.Length && char.IsWhiteSpace(content[braceStart]))
+ braceStart++;
+
+ if (braceStart >= content.Length || content[braceStart] != '{')
+ {
+ // Single-statement guard — add TODO
+ content = content[..matchStart]
+ + "/* TODO: IsPostBack guard — review for Blazor */ "
+ + content[matchStart..];
+ continue;
+ }
+
+ // Brace-count to find matching close brace
+ var depth = 1;
+ var pos = braceStart + 1;
+ while (pos < content.Length && depth > 0)
+ {
+ if (content[pos] == '{') depth++;
+ else if (content[pos] == '}') depth--;
+ pos++;
+ }
+
+ if (depth != 0)
+ {
+ // Unbalanced braces
+ content = content[..matchStart]
+ + "/* TODO: IsPostBack guard — could not parse */ "
+ + content[matchStart..];
+ continue;
+ }
+
+ var braceEnd = pos - 1; // position of closing brace
+
+ // Check for else clause
+ var checkPos = braceEnd + 1;
+ while (checkPos < content.Length && char.IsWhiteSpace(content[checkPos]))
+ checkPos++;
+
+ var hasElse = (checkPos + 3 < content.Length)
+ && content.Substring(checkPos, 4).StartsWith("else", StringComparison.Ordinal)
+ && (checkPos + 4 >= content.Length || !char.IsLetterOrDigit(content[checkPos + 4]));
+
+ if (hasElse)
+ {
+ // Complex case — add TODO comment
+ var todoComment = "// TODO: BWFC — IsPostBack guard with else clause. In Blazor, OnInitializedAsync runs once (no postback).\n // Review: move 'if' body to OnInitializedAsync and 'else' body to an event handler or remove.\n ";
+ content = content[..matchStart] + todoComment + content[matchStart..];
+ }
+ else
+ {
+ // Simple case — unwrap the guard
+ var body = content.Substring(braceStart + 1, braceEnd - braceStart - 1);
+
+ // Dedent: remove one level of leading whitespace (4 spaces or 1 tab) per line
+ var bodyLines = body.Split('\n');
+ var dedentedLines = bodyLines.Select(line =>
+ {
+ if (line.StartsWith(" ")) return line[4..];
+ if (line.StartsWith("\t")) return line[1..];
+ return line;
+ }).ToArray();
+ var dedentedBody = string.Join("\n", dedentedLines).Trim();
+
+ // Determine indentation of original if statement
+ var lineStart = matchStart;
+ while (lineStart > 0 && content[lineStart - 1] != '\n') lineStart--;
+ var indent = "";
+ var leadingText = content[lineStart..matchStart];
+ var indentMatch = Regex.Match(leadingText, @"^(\s+)");
+ if (indentMatch.Success) indent = indentMatch.Groups[1].Value;
+
+ var replacement = indent + "// BWFC: IsPostBack guard unwrapped — Blazor re-renders on every state change\n";
+ foreach (var line in dedentedBody.Split('\n'))
+ {
+ if (line.Trim().Length > 0)
+ replacement += indent + line + "\n";
+ else
+ replacement += "\n";
+ }
+
+ content = content[..matchStart] + replacement.TrimEnd('\n') + content[(braceEnd + 1)..];
+ }
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/PageLifecycleTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/PageLifecycleTransform.cs
new file mode 100644
index 000000000..d4bef945b
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/PageLifecycleTransform.cs
@@ -0,0 +1,148 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Converts Web Forms page lifecycle methods to Blazor equivalents:
+/// Page_Load → OnInitializedAsync
+/// Page_Init → OnInitialized
+/// Page_PreRender → OnAfterRenderAsync (with firstRender guard)
+///
+public class PageLifecycleTransform : ICodeBehindTransform
+{
+ public string Name => "PageLifecycle";
+ public int Order => 600;
+
+ // Match any access modifier combination + void + Page_Load (case-insensitive method name)
+ private static readonly Regex PageLoadRegex = new(
+ @"(?m)([ \t]*)(?:(?:protected|private|public|internal)\s+)?(?:(?:virtual|override|new|static|sealed|abstract)\s+)*void\s+(?i:Page_Load)\s*\(\s*object\s+\w+\s*,\s*EventArgs\s+\w+\s*\)",
+ RegexOptions.Compiled);
+
+ private static readonly Regex PageInitRegex = new(
+ @"(?m)([ \t]*)(?:(?:protected|private|public|internal)\s+)?(?:(?:virtual|override|new|static|sealed|abstract)\s+)*void\s+(?i:Page_Init)\s*\(\s*object\s+\w+\s*,\s*EventArgs\s+\w+\s*\)",
+ RegexOptions.Compiled);
+
+ private static readonly Regex PreRenderRegex = new(
+ @"(?m)([ \t]*)(?:(?:protected|private|public|internal)\s+)?(?:(?:virtual|override|new|static|sealed|abstract)\s+)*void\s+(?i:Page_PreRender)\s*\(\s*object\s+\w+\s*,\s*EventArgs\s+\w+\s*\)",
+ RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ content = ConvertPageLoad(content);
+ content = ConvertPageInit(content);
+ content = ConvertPreRender(content);
+ return content;
+ }
+
+ private static string ConvertPageLoad(string content)
+ {
+ var match = PageLoadRegex.Match(content);
+ if (!match.Success) return content;
+
+ var indent = match.Groups[1].Value;
+ var matchStart = match.Index;
+ var matchEnd = matchStart + match.Length;
+
+ var newSig = $"{indent}protected override async Task OnInitializedAsync()";
+ content = content[..matchStart] + newSig + content[matchEnd..];
+
+ // Find opening brace after signature and inject base call
+ var sigEnd = matchStart + newSig.Length;
+ var bracePos = sigEnd;
+ while (bracePos < content.Length && char.IsWhiteSpace(content[bracePos])) bracePos++;
+
+ if (bracePos < content.Length && content[bracePos] == '{')
+ {
+ var injection = $"\n{indent} // TODO: Review lifecycle conversion — verify async behavior\n{indent} await base.OnInitializedAsync();\n";
+ content = content[..(bracePos + 1)] + injection + content[(bracePos + 1)..];
+ }
+
+ return content;
+ }
+
+ private static string ConvertPageInit(string content)
+ {
+ var match = PageInitRegex.Match(content);
+ if (!match.Success) return content;
+
+ var indent = match.Groups[1].Value;
+ var matchStart = match.Index;
+ var matchEnd = matchStart + match.Length;
+
+ var newSig = $"{indent}protected override void OnInitialized()";
+ content = content[..matchStart] + newSig + content[matchEnd..];
+
+ // Find opening brace and inject TODO
+ var sigEnd = matchStart + newSig.Length;
+ var bracePos = sigEnd;
+ while (bracePos < content.Length && char.IsWhiteSpace(content[bracePos])) bracePos++;
+
+ if (bracePos < content.Length && content[bracePos] == '{')
+ {
+ var injection = $"\n{indent} // TODO: Review lifecycle conversion — verify async behavior\n";
+ content = content[..(bracePos + 1)] + injection + content[(bracePos + 1)..];
+ }
+
+ return content;
+ }
+
+ private static string ConvertPreRender(string content)
+ {
+ var match = PreRenderRegex.Match(content);
+ if (!match.Success) return content;
+
+ var indent = match.Groups[1].Value;
+ var matchStart = match.Index;
+ var matchEnd = matchStart + match.Length;
+
+ var newSig = $"{indent}protected override async Task OnAfterRenderAsync(bool firstRender)";
+ content = content[..matchStart] + newSig + content[matchEnd..];
+
+ // Find opening brace
+ var sigEnd = matchStart + newSig.Length;
+ var braceStart = sigEnd;
+ while (braceStart < content.Length && char.IsWhiteSpace(content[braceStart])) braceStart++;
+
+ if (braceStart < content.Length && content[braceStart] == '{')
+ {
+ // Brace-count to find matching close brace
+ var depth = 1;
+ var pos = braceStart + 1;
+ while (pos < content.Length && depth > 0)
+ {
+ if (content[pos] == '{') depth++;
+ else if (content[pos] == '}') depth--;
+ pos++;
+ }
+
+ if (depth == 0)
+ {
+ var braceEnd = pos - 1;
+ var body = content.Substring(braceStart + 1, braceEnd - braceStart - 1);
+ var bodyIndent = indent + " ";
+
+ // Build wrapped body with firstRender guard
+ var newBody = $"\n{bodyIndent}// TODO: Review lifecycle conversion — verify async behavior";
+ newBody += $"\n{bodyIndent}if (firstRender)";
+ newBody += $"\n{bodyIndent}{{";
+
+ // Re-indent original body lines by one level
+ var bodyLines = body.Split('\n');
+ foreach (var line in bodyLines)
+ {
+ var trimmed = line.TrimEnd();
+ if (trimmed.Length > 0)
+ newBody += $"\n {trimmed}";
+ }
+
+ newBody += $"\n{bodyIndent}}}";
+ newBody += $"\n{indent}";
+
+ content = content[..(braceStart + 1)] + newBody + content[braceEnd..];
+ }
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ResponseRedirectTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ResponseRedirectTransform.cs
new file mode 100644
index 000000000..a6c25a02e
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ResponseRedirectTransform.cs
@@ -0,0 +1,89 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Converts Response.Redirect() calls to NavigationManager.NavigateTo() and injects
+/// [Inject] NavigationManager property into the class.
+///
+public class ResponseRedirectTransform : ICodeBehindTransform
+{
+ public string Name => "ResponseRedirect";
+ public int Order => 300;
+
+ // Pattern 1: Response.Redirect("url", bool)
+ private static readonly Regex RedirectLitBoolRegex = new(
+ @"Response\.Redirect\(\s*""([^""]*)""\s*,\s*(?:true|false)\s*\)",
+ RegexOptions.Compiled);
+
+ // Pattern 2: Response.Redirect("url")
+ private static readonly Regex RedirectLitRegex = new(
+ @"Response\.Redirect\(\s*""([^""]*)""\s*\)",
+ RegexOptions.Compiled);
+
+ // Pattern 3: Response.Redirect(expr, bool)
+ private static readonly Regex RedirectExprBoolRegex = new(
+ @"Response\.Redirect\(\s*([^,)]+)\s*,\s*(?:true|false)\s*\)",
+ RegexOptions.Compiled);
+
+ // Pattern 4: Response.Redirect(expr)
+ private static readonly Regex RedirectExprRegex = new(
+ @"Response\.Redirect\(\s*([^)]+)\s*\)",
+ RegexOptions.Compiled);
+
+ // For injecting [Inject] NavigationManager
+ private static readonly Regex ClassOpenRegex = new(
+ @"((?:public|internal|private)\s+(?:partial\s+)?class\s+\w+[^{]*\{)",
+ RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ var hasRedirectConversion = false;
+
+ // Pattern 1: literal URL with endResponse bool
+ if (RedirectLitBoolRegex.IsMatch(content))
+ {
+ content = RedirectLitBoolRegex.Replace(content, m =>
+ {
+ var url = Regex.Replace(m.Groups[1].Value, @"^~/", "/");
+ return $"NavigationManager.NavigateTo(\"{url}\")";
+ });
+ hasRedirectConversion = true;
+ }
+
+ // Pattern 2: simple literal URL
+ if (RedirectLitRegex.IsMatch(content))
+ {
+ content = RedirectLitRegex.Replace(content, m =>
+ {
+ var url = Regex.Replace(m.Groups[1].Value, @"^~/", "/");
+ return $"NavigationManager.NavigateTo(\"{url}\")";
+ });
+ hasRedirectConversion = true;
+ }
+
+ // Pattern 3: expression with endResponse bool
+ if (RedirectExprBoolRegex.IsMatch(content))
+ {
+ content = RedirectExprBoolRegex.Replace(content, "NavigationManager.NavigateTo($1) /* TODO: Verify navigation target */");
+ hasRedirectConversion = true;
+ }
+
+ // Pattern 4: remaining expression URLs
+ if (RedirectExprRegex.IsMatch(content))
+ {
+ content = RedirectExprRegex.Replace(content, "NavigationManager.NavigateTo($1) /* TODO: Verify navigation target */");
+ hasRedirectConversion = true;
+ }
+
+ // Inject [Inject] NavigationManager if conversions were made
+ if (hasRedirectConversion && ClassOpenRegex.IsMatch(content))
+ {
+ var injectLine = "\n [Inject] private NavigationManager NavigationManager { get; set; } // TODO: Add @using Microsoft.AspNetCore.Components to _Imports.razor if needed\n";
+ content = ClassOpenRegex.Replace(content, "$1" + injectLine, 1);
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/SessionDetectTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/SessionDetectTransform.cs
new file mode 100644
index 000000000..193d4f7a3
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/SessionDetectTransform.cs
@@ -0,0 +1,55 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Detects Session["key"] patterns and generates migration guidance block.
+///
+public class SessionDetectTransform : ICodeBehindTransform
+{
+ public string Name => "SessionDetect";
+ public int Order => 400;
+
+ private static readonly Regex SessionKeyRegex = new(
+ @"Session\[""([^""]*)""\]",
+ RegexOptions.Compiled);
+
+ private const string TodoEndMarker = "// =============================================================================";
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ var matches = SessionKeyRegex.Matches(content);
+ if (matches.Count == 0) return content;
+
+ // Collect unique keys in order of appearance
+ var sessionKeys = new List();
+ foreach (Match m in matches)
+ {
+ var key = m.Groups[1].Value;
+ if (!sessionKeys.Contains(key)) sessionKeys.Add(key);
+ }
+
+ // Build guidance block
+ var sessionBlock = "// --- Session State Migration ---\n"
+ + $"// Session keys found: {string.Join(", ", sessionKeys)}\n"
+ + "// Options:\n"
+ + "// (1) ProtectedSessionStorage (Blazor Server) — persists across circuits\n"
+ + "// (2) Scoped service via DI — lifetime matches user circuit\n"
+ + "// (3) Cascading parameter from a root-level state provider\n"
+ + "// See: https://learn.microsoft.com/aspnet/core/blazor/state-management\n\n";
+
+ // Insert after the TODO header end marker
+ var lastTodoIdx = content.LastIndexOf(TodoEndMarker);
+ if (lastTodoIdx >= 0)
+ {
+ var insertPos = lastTodoIdx + TodoEndMarker.Length;
+ // Skip past newlines after marker
+ while (insertPos < content.Length && (content[insertPos] == '\r' || content[insertPos] == '\n'))
+ insertPos++;
+ content = content[..insertPos] + "\n" + sessionBlock + content[insertPos..];
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/TodoHeaderTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/TodoHeaderTransform.cs
new file mode 100644
index 000000000..fbae4dcfe
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/TodoHeaderTransform.cs
@@ -0,0 +1,38 @@
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Injects the TODO migration guidance header at the top of code-behind files.
+/// Must run first so other transforms can reference the header marker.
+///
+public class TodoHeaderTransform : ICodeBehindTransform
+{
+ public string Name => "TodoHeader";
+ public int Order => 10;
+
+ private const string TodoHeader = """
+ // =============================================================================
+ // TODO: This code-behind was copied from Web Forms and needs manual migration.
+ //
+ // Common transforms needed (use the BWFC Copilot skill for assistance):
+ // - Page_Load / Page_Init → OnInitializedAsync / OnParametersSetAsync
+ // - Page_PreRender → OnAfterRenderAsync
+ // - IsPostBack checks → remove or convert to state logic
+ // - ViewState usage → component [Parameter] or private fields
+ // - Session/Cache access → inject IHttpContextAccessor or use DI
+ // - Response.Redirect → NavigationManager.NavigateTo
+ // - Event handlers (Button_Click, etc.) → convert to Blazor event callbacks
+ // - Data binding (DataBind, DataSource) → component parameters or OnInitialized
+ // - ScriptManager code-behind references → remove (Blazor handles updates)
+ // - UpdatePanel markup preserved by BWFC (ContentTemplate supported) — remove only code-behind API calls
+ // - User controls → Blazor component references
+ // =============================================================================
+
+ """;
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ return TodoHeader + content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/UrlCleanupTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/UrlCleanupTransform.cs
new file mode 100644
index 000000000..0b4768f27
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/UrlCleanupTransform.cs
@@ -0,0 +1,45 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Cleans .aspx URL literals in code-behind:
+/// "~/SomePage.aspx" → "/SomePage"
+/// "~/SomePage.aspx?param=val" → "/SomePage?param=val"
+/// Also handles relative .aspx references in NavigateTo calls.
+///
+public class UrlCleanupTransform : ICodeBehindTransform
+{
+ public string Name => "UrlCleanup";
+ public int Order => 900;
+
+ // Pattern 1: "~/SomePage.aspx?query" → "/SomePage?query"
+ private static readonly Regex AspxTildeQsRegex = new(
+ @"""~/([^""]*?)\.aspx\?([^""]*)""",
+ RegexOptions.Compiled);
+
+ // Pattern 2: "~/SomePage.aspx" → "/SomePage"
+ private static readonly Regex AspxTildeRegex = new(
+ @"""~/([^""]*?)\.aspx""",
+ RegexOptions.Compiled);
+
+ // Pattern 3: NavigationManager.NavigateTo("SomePage.aspx?q") → relative + query
+ private static readonly Regex AspxRelQsRegex = new(
+ @"(NavigationManager\.NavigateTo\(\s*"")([^""~/][^""]*?)\.aspx\?([^""]*)""",
+ RegexOptions.Compiled);
+
+ // Pattern 4: NavigationManager.NavigateTo("SomePage.aspx") → relative only
+ private static readonly Regex AspxRelRegex = new(
+ @"(NavigationManager\.NavigateTo\(\s*"")([^""~/][^""]*?)\.aspx""",
+ RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ content = AspxTildeQsRegex.Replace(content, @"""/$1?$2""");
+ content = AspxTildeRegex.Replace(content, @"""/$1""");
+ content = AspxRelQsRegex.Replace(content, @"$1/$2?$3""");
+ content = AspxRelRegex.Replace(content, @"$1/$2""");
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/UsingStripTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/UsingStripTransform.cs
new file mode 100644
index 000000000..da72d145a
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/UsingStripTransform.cs
@@ -0,0 +1,33 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Strips Web Forms-specific using declarations from code-behind files.
+/// Removes System.Web.UI.*, System.Web.Security, System.Web.*, Microsoft.AspNet.*, Microsoft.Owin.*, and Owin.
+///
+public class UsingStripTransform : ICodeBehindTransform
+{
+ public string Name => "UsingStrip";
+ public int Order => 100;
+
+ // Ordered from most specific to least specific to avoid double-matching
+ private static readonly Regex WebUIUsingsRegex = new(@"using\s+System\.Web\.UI(\.\w+)*;\s*\r?\n?", RegexOptions.Compiled);
+ private static readonly Regex WebSecurityRegex = new(@"using\s+System\.Web\.Security;\s*\r?\n?", RegexOptions.Compiled);
+ private static readonly Regex WebUsingsRegex = new(@"using\s+System\.Web(\.\w+)*;\s*\r?\n?", RegexOptions.Compiled);
+ private static readonly Regex AspNetUsingsRegex = new(@"using\s+Microsoft\.AspNet(\.\w+)*;\s*\r?\n?", RegexOptions.Compiled);
+ private static readonly Regex OwinUsingsRegex = new(@"using\s+Microsoft\.Owin(\.\w+)*;\s*\r?\n?", RegexOptions.Compiled);
+ private static readonly Regex BareOwinRegex = new(@"using\s+Owin;\s*\r?\n?", RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ content = WebUIUsingsRegex.Replace(content, "");
+ content = WebSecurityRegex.Replace(content, "");
+ content = WebUsingsRegex.Replace(content, "");
+ content = AspNetUsingsRegex.Replace(content, "");
+ content = OwinUsingsRegex.Replace(content, "");
+ content = BareOwinRegex.Replace(content, "");
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ViewStateDetectTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ViewStateDetectTransform.cs
new file mode 100644
index 000000000..eae56dd4f
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ViewStateDetectTransform.cs
@@ -0,0 +1,58 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind;
+
+///
+/// Detects ViewState["key"] patterns and generates migration guidance with suggested field declarations.
+///
+public class ViewStateDetectTransform : ICodeBehindTransform
+{
+ public string Name => "ViewStateDetect";
+ public int Order => 410;
+
+ private static readonly Regex ViewStateKeyRegex = new(
+ @"ViewState\[""([^""]*)""\]",
+ RegexOptions.Compiled);
+
+ private const string TodoEndMarker = "// =============================================================================";
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ var matches = ViewStateKeyRegex.Matches(content);
+ if (matches.Count == 0) return content;
+
+ // Collect unique keys in order of appearance
+ var vsKeys = new List();
+ foreach (Match m in matches)
+ {
+ var key = m.Groups[1].Value;
+ if (!vsKeys.Contains(key)) vsKeys.Add(key);
+ }
+
+ // Build guidance block with suggested field declarations
+ var vsBlock = "// --- ViewState Migration ---\n"
+ + "// ViewState is in-memory only in Blazor (does not survive navigation).\n"
+ + "// Convert to private fields or [Parameter] properties:\n";
+
+ foreach (var key in vsKeys)
+ {
+ var fieldName = "_" + char.ToLower(key[0]) + key[1..];
+ vsBlock += $"// private object {fieldName}; // was ViewState[\"{key}\"]\n";
+ }
+
+ vsBlock += "// Note: BaseWebFormsComponent.ViewState exists as an [Obsolete] compatibility shim.\n\n";
+
+ // Insert after the TODO header end marker
+ var lastTodoIdx = content.LastIndexOf(TodoEndMarker);
+ if (lastTodoIdx >= 0)
+ {
+ var insertPos = lastTodoIdx + TodoEndMarker.Length;
+ while (insertPos < content.Length && (content[insertPos] == '\r' || content[insertPos] == '\n'))
+ insertPos++;
+ content = content[..insertPos] + "\n" + vsBlock + content[insertPos..];
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Directives/ControlDirectiveTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/ControlDirectiveTransform.cs
new file mode 100644
index 000000000..2ddddc2e4
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/ControlDirectiveTransform.cs
@@ -0,0 +1,20 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Directives;
+
+///
+/// Removes <%@ Control ... %> directives.
+///
+public class ControlDirectiveTransform : IMarkupTransform
+{
+ public string Name => "ControlDirective";
+ public int Order => 120;
+
+ private static readonly Regex ControlDirectiveRegex = new(@"<%@\s*Control[^%]*%>\s*\r?\n?", RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ return ControlDirectiveRegex.Replace(content, "");
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Directives/ImportDirectiveTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/ImportDirectiveTransform.cs
new file mode 100644
index 000000000..28e7dcd17
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/ImportDirectiveTransform.cs
@@ -0,0 +1,22 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Directives;
+
+///
+/// Converts <%@ Import Namespace="..." %> to @using statements.
+///
+public class ImportDirectiveTransform : IMarkupTransform
+{
+ public string Name => "ImportDirective";
+ public int Order => 200;
+
+ private static readonly Regex ImportRegex = new(
+ @"<%@\s*Import\s+Namespace=""([^""]+)""\s*%>\s*\r?\n?",
+ RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ return ImportRegex.Replace(content, m => $"@using {m.Groups[1].Value}\n");
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Directives/MasterDirectiveTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/MasterDirectiveTransform.cs
new file mode 100644
index 000000000..e83fe02bf
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/MasterDirectiveTransform.cs
@@ -0,0 +1,20 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Directives;
+
+///
+/// Removes <%@ Master ... %> directives.
+///
+public class MasterDirectiveTransform : IMarkupTransform
+{
+ public string Name => "MasterDirective";
+ public int Order => 110;
+
+ private static readonly Regex MasterDirectiveRegex = new(@"<%@\s*Master[^%]*%>\s*\r?\n?", RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ return MasterDirectiveRegex.Replace(content, "");
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Directives/PageDirectiveTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/PageDirectiveTransform.cs
new file mode 100644
index 000000000..abd6d356a
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/PageDirectiveTransform.cs
@@ -0,0 +1,55 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Directives;
+
+///
+/// Converts <%@ Page ... %> directive to @page "/route" and <PageTitle>.
+///
+public class PageDirectiveTransform : IMarkupTransform
+{
+ public string Name => "PageDirective";
+ public int Order => 100;
+
+ private static readonly Regex PageDirectiveRegex = new(@"<%@\s*Page[^%]*%>\s*\r?\n?", RegexOptions.Compiled);
+ private static readonly Regex TitleRegex = new(@"<%@\s*Page[^%]*Title\s*=\s*""([^""]*)""", RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ if (!PageDirectiveRegex.IsMatch(content))
+ return content;
+
+ // Extract title before stripping
+ string? pageTitle = null;
+ var titleMatch = TitleRegex.Match(content);
+ if (titleMatch.Success)
+ pageTitle = titleMatch.Groups[1].Value;
+
+ // Strip the directive
+ content = PageDirectiveRegex.Replace(content, "");
+
+ // Build the route from the file name
+ var fileName = Path.GetFileNameWithoutExtension(metadata.SourceFilePath);
+ var route = "/" + fileName;
+
+ // Home page detection
+ var isHomePage = route is "/Default" or "/default" or "/Index" or "/index";
+ if (Regex.IsMatch(fileName, @"^(Home|home)\.aspx$"))
+ isHomePage = true;
+
+ if (isHomePage && route != "/")
+ route = "/";
+
+ var header = $"@page \"{route}\"\n";
+
+ // Dual-route for home pages that aren't already "/"
+ if (isHomePage && route == "/" && fileName is not ("Default" or "default" or "Index" or "index"))
+ header += "@page \"/\"\n";
+
+ if (pageTitle != null)
+ header += $"{pageTitle} \n";
+
+ content = header + content;
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Directives/RegisterDirectiveTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/RegisterDirectiveTransform.cs
new file mode 100644
index 000000000..552eab3c5
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Directives/RegisterDirectiveTransform.cs
@@ -0,0 +1,20 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Directives;
+
+///
+/// Removes <%@ Register ... %> directives (tag prefixes are handled by component transforms).
+///
+public class RegisterDirectiveTransform : IMarkupTransform
+{
+ public string Name => "RegisterDirective";
+ public int Order => 210;
+
+ private static readonly Regex RegisterRegex = new(@"<%@\s*Register[^%]*%>\s*\r?\n?", RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ return RegisterRegex.Replace(content, "");
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/ICodeBehindTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/ICodeBehindTransform.cs
new file mode 100644
index 000000000..fa4b71e01
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/ICodeBehindTransform.cs
@@ -0,0 +1,14 @@
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms;
+
+///
+/// Interface for code-behind transforms that convert Web Forms .cs code to Blazor .razor.cs code.
+/// Transforms are applied in Order sequence (ascending).
+///
+public interface ICodeBehindTransform
+{
+ string Name { get; }
+ int Order { get; }
+ string Apply(string content, FileMetadata metadata);
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/IMarkupTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/IMarkupTransform.cs
new file mode 100644
index 000000000..82d7144c5
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/IMarkupTransform.cs
@@ -0,0 +1,14 @@
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms;
+
+///
+/// Interface for markup transforms that convert Web Forms markup to Blazor Razor syntax.
+/// Transforms are applied in Order sequence (ascending).
+///
+public interface IMarkupTransform
+{
+ string Name { get; }
+ int Order { get; }
+ string Apply(string content, FileMetadata metadata);
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AjaxToolkitPrefixTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AjaxToolkitPrefixTransform.cs
new file mode 100644
index 000000000..23622bab8
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AjaxToolkitPrefixTransform.cs
@@ -0,0 +1,73 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Converts ajaxToolkit: prefix controls. Known controls get prefix stripped;
+/// unknown controls are replaced with TODO comments.
+/// ToolkitScriptManager is removed entirely.
+/// Must run BEFORE AspPrefixTransform.
+///
+public class AjaxToolkitPrefixTransform : IMarkupTransform
+{
+ public string Name => "AjaxToolkitPrefix";
+ public int Order => 600;
+
+ private static readonly string[] KnownControls =
+ [
+ "Accordion", "AccordionPane",
+ "TabContainer", "TabPanel",
+ "ConfirmButtonExtender", "FilteredTextBoxExtender",
+ "ModalPopupExtender", "CollapsiblePanelExtender",
+ "CalendarExtender", "AutoCompleteExtender",
+ "MaskedEditExtender", "NumericUpDownExtender",
+ "SliderExtender", "ToggleButtonExtender",
+ "PopupControlExtender", "HoverMenuExtender"
+ ];
+
+ // ToolkitScriptManager — block form
+ private static readonly Regex TsmBlockRegex = new(
+ @"(?s)]|(?:%>))*?(?:>.*? )\s*\r?\n?",
+ RegexOptions.Compiled);
+
+ // ToolkitScriptManager — self-closing form
+ private static readonly Regex TsmSelfRegex = new(
+ @"]|(?:%>))*?/>\s*\r?\n?",
+ RegexOptions.Compiled);
+
+ // Unknown self-closing
+ private static readonly Regex UnknownSelfRegex = new(
+ @"(?s)]|(?:%>))*?/>",
+ RegexOptions.Compiled);
+
+ // Unknown block
+ private static readonly Regex UnknownBlockRegex = new(
+ @"(?s)]|(?:%>))*?>.*? ",
+ RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ // 1. Strip ToolkitScriptManager entirely
+ content = TsmBlockRegex.Replace(content, "");
+ content = TsmSelfRegex.Replace(content, "");
+
+ // 2. Convert known controls — strip prefix
+ var knownPattern = string.Join("|", KnownControls.Select(Regex.Escape));
+ var openRegex = new Regex($"");
+
+ content = openRegex.Replace(content, "<$1");
+ content = closeRegex.Replace(content, "$1>");
+
+ // 3. Unknown self-closing → TODO
+ content = UnknownSelfRegex.Replace(content,
+ "@* TODO: Convert ajaxToolkit:$1 — no BWFC equivalent yet *@");
+
+ // 4. Unknown block → TODO
+ content = UnknownBlockRegex.Replace(content,
+ "@* TODO: Convert ajaxToolkit:$1 — no BWFC equivalent yet *@");
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AspPrefixTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AspPrefixTransform.cs
new file mode 100644
index 000000000..1ddd0719f
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AspPrefixTransform.cs
@@ -0,0 +1,45 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Removes asp: prefix from tags, strips ContentTemplate wrappers,
+/// and removes uc: (user control) prefixes.
+///
+public class AspPrefixTransform : IMarkupTransform
+{
+ public string Name => "AspPrefix";
+ public int Order => 610;
+
+ // Opening tags: →
+ private static readonly Regex CloseRegex = new(@"", RegexOptions.Compiled);
+
+ // ContentTemplate wrappers
+ private static readonly Regex ContentTemplateOpenRegex = new(@"", RegexOptions.Compiled);
+ private static readonly Regex ContentTemplateCloseRegex = new(@" ", RegexOptions.Compiled);
+
+ // User control prefixes: ", RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ // asp: prefix
+ content = OpenRegex.Replace(content, "<$1");
+ content = CloseRegex.Replace(content, "$1>");
+
+ // Strip ContentTemplate wrappers
+ content = ContentTemplateOpenRegex.Replace(content, "");
+ content = ContentTemplateCloseRegex.Replace(content, "");
+
+ // uc: prefix
+ content = UcOpenRegex.Replace(content, "<$1");
+ content = UcCloseRegex.Replace(content, "$1>");
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeNormalizeTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeNormalizeTransform.cs
new file mode 100644
index 000000000..75c9e1f05
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeNormalizeTransform.cs
@@ -0,0 +1,91 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Normalizes boolean, enum, and unit attribute values in converted Blazor markup.
+/// 1. Booleans: Visible="True" → Visible="true"
+/// 2. Enums: GridLines="Both" → GridLines="@GridLines.Both"
+/// 3. Units: Width="100px" → Width="100"
+///
+public class AttributeNormalizeTransform : IMarkupTransform
+{
+ public string Name => "AttributeNormalize";
+ public int Order => 810;
+
+ private static readonly Regex BoolRegex = new(
+ @"(\w+)=""(True|False)""",
+ RegexOptions.Compiled);
+
+ // Attributes that contain text content — not booleans even if "True"/"False"
+ private static readonly HashSet TextAttributes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "Text", "Title", "Value", "ToolTip", "HeaderText", "FooterText",
+ "CommandName", "CommandArgument", "ErrorMessage", "InitialValue",
+ "DataField", "DataFormatString", "SortExpression", "NavigateUrl",
+ "DataTextField", "DataValueField", "ValidationExpression"
+ };
+
+ // Enum attribute → enum type mappings
+ private static readonly Dictionary EnumAttrMap = new()
+ {
+ ["GridLines"] = "GridLines",
+ ["BorderStyle"] = "BorderStyle",
+ ["HorizontalAlign"] = "HorizontalAlign",
+ ["VerticalAlign"] = "VerticalAlign",
+ ["TextAlign"] = "TextAlign",
+ ["TextMode"] = "TextBoxMode",
+ ["ImageAlign"] = "ImageAlign",
+ ["Orientation"] = "Orientation",
+ ["BulletStyle"] = "BulletStyle",
+ ["CaptionAlign"] = "TableCaptionAlign",
+ ["SortDirection"] = "SortDirection",
+ ["ScrollBars"] = "ScrollBars",
+ ["ContentDirection"] = "ContentDirection",
+ ["DayNameFormat"] = "DayNameFormat",
+ ["TitleFormat"] = "TitleFormat",
+ ["InsertItemPosition"] = "InsertItemPosition",
+ ["UpdateMode"] = "UpdatePanelUpdateMode",
+ ["FontSize"] = "FontSize"
+ };
+
+ // Dimension attributes for px stripping
+ private static readonly string[] UnitAttributes =
+ ["Width", "Height", "BorderWidth", "CellPadding", "CellSpacing"];
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ // Boolean normalization: True/False → true/false (skip text attributes)
+ content = BoolRegex.Replace(content, m =>
+ {
+ var attr = m.Groups[1].Value;
+ if (TextAttributes.Contains(attr))
+ return m.Value;
+ return $"{attr}=\"{m.Groups[2].Value.ToLowerInvariant()}\"";
+ });
+
+ // Enum type-qualifying
+ foreach (var (attrName, enumType) in EnumAttrMap)
+ {
+ var enumRegex = new Regex($@"(?
+ {
+ var val = m.Groups[1].Value;
+ // Skip boolean values
+ if (val is "True" or "False" or "true" or "false")
+ return m.Value;
+ return $"{attrName}=\"@{enumType}.{val}\"";
+ });
+ }
+
+ // Unit normalization: strip "px" suffix
+ foreach (var attr in UnitAttributes)
+ {
+ var unitRegex = new Regex($@"{Regex.Escape(attr)}=""(\d+)px""");
+ content = unitRegex.Replace(content, $"{attr}=\"$1\"");
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeStripTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeStripTransform.cs
new file mode 100644
index 000000000..cbc167d94
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/AttributeStripTransform.cs
@@ -0,0 +1,67 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Removes Web Forms-specific attributes (runat="server", AutoEventWireup, EnableViewState, etc.).
+/// Converts ItemType to TItem. Adds ItemType="object" fallback to generic BWFC components.
+/// Converts ID= to id=.
+///
+public class AttributeStripTransform : IMarkupTransform
+{
+ public string Name => "AttributeStrip";
+ public int Order => 700;
+
+ private static readonly string[] StripPatterns =
+ [
+ @"runat\s*=\s*""server""",
+ @"AutoEventWireup\s*=\s*""(true|false)""",
+ @"EnableViewState\s*=\s*""(true|false)""",
+ @"ViewStateMode\s*=\s*""[^""]*""",
+ @"ValidateRequest\s*=\s*""(true|false)""",
+ @"MaintainScrollPositionOnPostBack\s*=\s*""(true|false)""",
+ @"ClientIDMode\s*=\s*""[^""]*"""
+ ];
+
+ // ItemType="Namespace.Class" → TItem="Class"
+ private static readonly Regex ItemTypeRegex = new(
+ @"ItemType=""(?:[^""]*\.)?([^""]+)""",
+ RegexOptions.Compiled);
+
+ // Generic BWFC components that need ItemType="object" fallback
+ private static readonly string[] GenericComponents =
+ [
+ "GridView", "DetailsView", "DropDownList", "BoundField", "BulletedList",
+ "Repeater", "ListView", "FormView", "RadioButtonList", "CheckBoxList", "ListBox",
+ "HyperLinkField", "ButtonField", "TemplateField", "DataList", "DataGrid"
+ ];
+
+ // ID="..." → id="..."
+ private static readonly Regex IdRegex = new(@"\bID=""([^""]*)""", RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ // Strip Web Forms attributes
+ foreach (var pattern in StripPatterns)
+ {
+ var regex = new Regex($@"\s*{pattern}");
+ content = regex.Replace(content, "");
+ }
+
+ // ItemType → TItem
+ content = ItemTypeRegex.Replace(content, "TItem=\"$1\"");
+
+ // Add ItemType="object" fallback to generic components missing it
+ foreach (var comp in GenericComponents)
+ {
+ var tagRegex = new Regex($@"(<{comp}\s)(?![^>]*(?:ItemType|TItem)=)([^/>]*)(>|/>)");
+ content = tagRegex.Replace(content, "${1}ItemType=\"object\" ${2}${3}");
+ }
+
+ // ID → id
+ content = IdRegex.Replace(content, "id=\"$1\"");
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs
new file mode 100644
index 000000000..839ebcf81
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs
@@ -0,0 +1,35 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Removes <asp:Content> wrapper tags, preserving inner content.
+/// Handles HeadContent placeholders and TitleContent extraction.
+///
+public class ContentWrapperTransform : IMarkupTransform
+{
+ public string Name => "ContentWrapper";
+ public int Order => 300;
+
+ // Open tags for any ContentPlaceHolderID — strip entirely, keeping content
+ private static readonly Regex ContentOpenRegex = new(
+ @"]*ContentPlaceHolderID\s*=\s*""[^""]*""[^>]*>[ \t]*\r?\n?",
+ RegexOptions.Compiled);
+
+ // Closing tags
+ private static readonly Regex ContentCloseRegex = new(
+ @"\s*\r?\n?",
+ RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ // Strip opening wrappers
+ content = ContentOpenRegex.Replace(content, "");
+
+ // Strip closing tags
+ content = ContentCloseRegex.Replace(content, "");
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/DataSourceIdTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/DataSourceIdTransform.cs
new file mode 100644
index 000000000..7eb4efee3
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/DataSourceIdTransform.cs
@@ -0,0 +1,48 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Detects DataSourceID attributes and data source controls, replaces with TODO warnings.
+/// BWFC uses SelectMethod/Items binding instead.
+///
+public class DataSourceIdTransform : IMarkupTransform
+{
+ public string Name => "DataSourceId";
+ public int Order => 820;
+
+ // DataSourceID attribute
+ private static readonly Regex DataSourceIdAttrRegex = new(
+ @"\s*DataSourceID=""([^""]+)""",
+ RegexOptions.Compiled);
+
+ // Data source controls (asp: prefix already stripped at this point)
+ private static readonly string[] DataSourceControls =
+ [
+ "SqlDataSource", "ObjectDataSource", "LinqDataSource",
+ "EntityDataSource", "XmlDataSource", "SiteMapDataSource", "AccessDataSource"
+ ];
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ // Remove DataSourceID attributes
+ content = DataSourceIdAttrRegex.Replace(content, "");
+
+ // Replace data source control declarations with TODO comments
+ foreach (var ctrl in DataSourceControls)
+ {
+ // Self-closing:
+ var selfCloseRegex = new Regex($@"(?s)<{Regex.Escape(ctrl)}\b.*?/>");
+ content = selfCloseRegex.Replace(content,
+ $"@* TODO: <{ctrl}> has no Blazor equivalent — wire data through code-behind service injection and SelectMethod/Items *@");
+
+ // Open+close: ...
+ var openCloseRegex = new Regex($@"(?s)<{Regex.Escape(ctrl)}\b.*?{Regex.Escape(ctrl)}\s*>");
+ content = openCloseRegex.Replace(content,
+ $"@* TODO: <{ctrl}> has no Blazor equivalent — wire data through code-behind service injection and SelectMethod/Items *@");
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/EventWiringTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/EventWiringTransform.cs
new file mode 100644
index 000000000..bc954eb09
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/EventWiringTransform.cs
@@ -0,0 +1,39 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Converts event handler attribute values: OnClick="Handler" → OnClick="@Handler".
+/// Only applies to known Web Forms server-side event attributes.
+///
+public class EventWiringTransform : IMarkupTransform
+{
+ public string Name => "EventWiring";
+ public int Order => 710;
+
+ // Known Web Forms server-side event attributes
+ private static readonly string[] EventAttributes =
+ [
+ "OnClick", "OnCommand", "OnTextChanged", "OnSelectedIndexChanged",
+ "OnCheckedChanged", "OnRowCommand", "OnRowEditing", "OnRowUpdating",
+ "OnRowCancelingEdit", "OnRowDeleting", "OnRowDataBound",
+ "OnPageIndexChanging", "OnSorting", "OnItemCommand", "OnItemDataBound",
+ "OnDataBound", "OnLoad", "OnInit", "OnPreRender",
+ "OnSelectedDateChanged", "OnDayRender", "OnVisibleMonthChanged",
+ "OnServerValidate", "OnCreatingUser", "OnCreatedUser",
+ "OnAuthenticate", "OnLoggedIn", "OnLoggingIn"
+ ];
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ foreach (var attr in EventAttributes)
+ {
+ // Match attr="HandlerName" where value is NOT already @-prefixed
+ var regex = new Regex($@"{Regex.Escape(attr)}=""(?!@)(\w+)""");
+ content = regex.Replace(content, $"{attr}=\"@$1\"");
+ }
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ExpressionTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ExpressionTransform.cs
new file mode 100644
index 000000000..fd674b918
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ExpressionTransform.cs
@@ -0,0 +1,107 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Converts ASP.NET Web Forms expressions to Razor syntax.
+/// Handles comments, Bind(), Eval(), Item., encoded and unencoded expressions.
+///
+public class ExpressionTransform : IMarkupTransform
+{
+ public string Name => "Expression";
+ public int Order => 500;
+
+ // Comments: <%-- ... --%> → @* ... *@
+ private static readonly Regex CommentRegex = new(@"(?s)<%--(.+?)--%>", RegexOptions.Compiled);
+
+ // Bind() inside attribute values (single-quoted): attr='<%# Bind("Prop") %>' → @bind-Value="context.Prop"
+ private static readonly Regex BindAttrSingleRegex = new(
+ @"(\w+)\s*=\s*'<%#\s*Bind\(""(\w+)""\)\s*%>'",
+ RegexOptions.Compiled);
+
+ // Bind() inside attribute values (double-quoted): attr="<%# Bind("Prop") %>"
+ private static readonly Regex BindAttrDoubleRegex = new(
+ @"(\w+)\s*=\s*""<%#\s*Bind\(""(\w+)""\)\s*%>""",
+ RegexOptions.Compiled);
+
+ // Bind() with HTML-encoded delimiter: <%#: Bind("Prop") %> → @context.Prop
+ private static readonly Regex BindEncodedRegex = new(
+ @"<%#:\s*Bind\(""(\w+)""\)\s*%>",
+ RegexOptions.Compiled);
+
+ // Standalone Bind(): <%# Bind("Prop") %> → @context.Prop
+ private static readonly Regex BindStandaloneRegex = new(
+ @"<%#\s*Bind\(""(\w+)""\)\s*%>",
+ RegexOptions.Compiled);
+
+ // Eval with format string: <%#: Eval("prop", "{0:fmt}") %> → @context.prop.ToString("fmt")
+ private static readonly Regex EvalFmtRegex = new(
+ @"<%#:\s*Eval\(""(\w+)"",\s*""\{0:([^}]+)\}""\)\s*%>",
+ RegexOptions.Compiled);
+
+ // String.Format with Item.Property: <%#: String.Format("{0:fmt}", Item.Prop) %>
+ private static readonly Regex StringFmtRegex = new(
+ @"<%#:\s*String\.Format\(""\{0:([^}]+)\}"",\s*Item\.(\w+)\)\s*%>",
+ RegexOptions.Compiled);
+
+ // Eval: <%#: Eval("prop") %> → @context.prop
+ private static readonly Regex EvalRegex = new(
+ @"<%#:\s*Eval\(""(\w+)""\)\s*%>",
+ RegexOptions.Compiled);
+
+ // Item property: <%#: Item.Prop %> → @context.Prop
+ private static readonly Regex ItemPropRegex = new(
+ @"<%#:\s*Item\.(\w+)\s*%>",
+ RegexOptions.Compiled);
+
+ // Bare Item: <%#: Item %> → @context
+ private static readonly Regex BareItemRegex = new(
+ @"<%#:\s*Item\s*%>",
+ RegexOptions.Compiled);
+
+ // Encoded: <%: expr %> → @(expr)
+ private static readonly Regex EncodedRegex = new(
+ @"<%:\s*(.+?)\s*%>",
+ RegexOptions.Compiled);
+
+ // Unencoded: <%= expr %> → @(expr)
+ private static readonly Regex UnencodedRegex = new(
+ @"<%=\s*(.+?)\s*%>",
+ RegexOptions.Compiled);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ // Comments first
+ content = CommentRegex.Replace(content, "@*$1*@");
+
+ // Bind() transforms (before Eval to avoid conflicts)
+ content = BindAttrSingleRegex.Replace(content, "@bind-Value=\"context.$2\"");
+ content = BindAttrDoubleRegex.Replace(content, "@bind-Value=\"context.$2\"");
+ content = BindEncodedRegex.Replace(content, "@context.$1");
+ content = BindStandaloneRegex.Replace(content, "@context.$1");
+
+ // Eval with format string
+ content = EvalFmtRegex.Replace(content, "@context.$1.ToString(\"$2\")");
+
+ // String.Format with Item
+ content = StringFmtRegex.Replace(content, "@($\"{context.$2:$1}\")");
+
+ // Eval binding
+ content = EvalRegex.Replace(content, "@context.$1");
+
+ // Item property binding
+ content = ItemPropRegex.Replace(content, "@context.$1");
+
+ // Bare Item
+ content = BareItemRegex.Replace(content, "@context");
+
+ // Encoded expressions
+ content = EncodedRegex.Replace(content, "@($1)");
+
+ // Unencoded expressions
+ content = UnencodedRegex.Replace(content, "@($1)");
+
+ return content;
+ }
+}
diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/FormWrapperTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/FormWrapperTransform.cs
new file mode 100644
index 000000000..679d9ad94
--- /dev/null
+++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/FormWrapperTransform.cs
@@ -0,0 +1,40 @@
+using System.Text.RegularExpressions;
+using BlazorWebFormsComponents.Cli.Pipeline;
+
+namespace BlazorWebFormsComponents.Cli.Transforms.Markup;
+
+///
+/// Converts <form runat="server"> to <div>, preserving id attribute for CSS.
+///
+public class FormWrapperTransform : IMarkupTransform
+{
+ public string Name => "FormWrapper";
+ public int Order => 310;
+
+ private static readonly Regex FormOpenRegex = new(
+ @"", RegexOptions.Compiled);
+ private static readonly Regex IdRegex = new(@"id\s*=\s*""([^""]*)""", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ public string Apply(string content, FileMetadata metadata)
+ {
+ var match = FormOpenRegex.Match(content);
+ if (!match.Success)
+ return content;
+
+ // Extract id attribute if present
+ var fullAttrs = match.Groups[1].Value + match.Groups[2].Value;
+ var idMatch = IdRegex.Match(fullAttrs);
+ var idAttr = idMatch.Success ? $" id=\"{idMatch.Groups[1].Value}\"" : "";
+
+ // Replace opening