From e4ba87d8c9e83fb2ca9fd32b10d2755eac7f65b8 Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Tue, 28 Apr 2026 22:01:17 -0400 Subject: [PATCH 01/38] feat(cli): compile page code-behind safely Merge the page runtime shim, emit compile-safe page code-behind into the generated app, and add explicit BWFC global usings for local project-reference scaffolds. Include focused test updates plus Wingtip run29-run31 reports documenting the benchmark progression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../wingtiptoys/run29/report.md | 121 ++++++++++ .../wingtiptoys/run30/report.md | 50 ++++ .../wingtiptoys/run31/report.md | 54 +++++ docs/UtilityFeatures/PageService.md | 39 ++-- docs/UtilityFeatures/WebFormsPage.md | 14 +- .../Pipeline/MigrationPipeline.cs | 26 ++- .../Pipeline/PageCodeBehindEmissionPlanner.cs | 64 +++++ .../Scaffolding/GlobalUsingsGenerator.cs | 16 +- src/BlazorWebFormsComponents/Page.razor | 16 +- src/BlazorWebFormsComponents/Page.razor.cs | 76 +----- .../WebFormsPage.razor | 18 +- .../WebFormsPage.razor.cs | 94 +------- .../WebFormsPageBase.cs | 218 ++++++++++++++---- .../PipelineIntegrationTests.cs | 56 ++++- .../ScaffoldingTests.cs | 4 + 15 files changed, 593 insertions(+), 273 deletions(-) create mode 100644 dev-docs/migration-tests/wingtiptoys/run29/report.md create mode 100644 dev-docs/migration-tests/wingtiptoys/run30/report.md create mode 100644 dev-docs/migration-tests/wingtiptoys/run31/report.md create mode 100644 src/BlazorWebFormsComponents.Cli/Pipeline/PageCodeBehindEmissionPlanner.cs diff --git a/dev-docs/migration-tests/wingtiptoys/run29/report.md b/dev-docs/migration-tests/wingtiptoys/run29/report.md new file mode 100644 index 000000000..222d76faa --- /dev/null +++ b/dev-docs/migration-tests/wingtiptoys/run29/report.md @@ -0,0 +1,121 @@ +# WingtipToys Migration Test - Run 29 + +**Date:** 2026-04-28 17:23:32 -04:00 +**Branch:** `feature/wingtip-next-features-review` +**Operator:** Copilot +**Requested by:** user + +--- + +## Summary + +| Metric | Value | +|--------|-------| +| Source project | `samples/WingtipToys/WingtipToys` | +| Output project | `samples/AfterWingtipToys` | +| Toolkit entry point | `migration-toolkit/scripts/bwfc-migrate.ps1` | +| Report folder | `dev-docs/migration-tests/wingtiptoys/run29` | +| Total wall-clock time | `~00:02:47` | +| Build result | `Failed: 183 errors, 54 warnings` | +| Acceptance tests | `Not run; blocked by build failure` | +| Final status | `FAILED` | + +## Executive Summary + +Run 29 was a valid fresh benchmark run from `samples\WingtipToys\WingtipToys` through the toolkit wrapper into a cleared `samples\AfterWingtipToys`, but it did not reach the acceptance bar. The toolkit correctly resolved the nested Wingtip source root, produced a full scaffold with static assets and quarantined legacy compile-surface artifacts, then stopped at a large compile break where several routed pages still depended on quarantined code-behind members and unresolved Web Forms constructs. + +## Timing + +| Phase | Duration | Notes | +|-------|----------|-------| +| Preparation | `00:00:00.19` | Run numbering, folder cleanup, report folder creation | +| Layer 1 toolkit migration | `00:00:14.72` | `bwfc-migrate.ps1` invocation | +| Repair / migration skill work | `00:02:27` | Failure analysis and toolkit-gap triage only; no successful in-place recovery | +| Build validation | `00:00:04.70` | First build of fresh output | +| Acceptance tests | `00:00:00` | Blocked by build failure | +| Screenshots + report | `00:00:00` | Screenshots blocked because the app never became runnable | +| **Total** | `~00:02:47` | | + +## Commands + +```powershell +# Clear output +Get-ChildItem samples\AfterWingtipToys -Force | Remove-Item -Recurse -Force + +# Run migration toolkit +pwsh -File migration-toolkit\scripts\bwfc-migrate.ps1 -Path samples\WingtipToys -Output samples\AfterWingtipToys -Verbose + +# Build +dotnet build samples\AfterWingtipToys\WingtipToys.csproj --nologo +``` + +## What Worked Well + +1. The toolkit correctly **resolved the nested effective app root**: `samples\WingtipToys\WingtipToys`. +2. Layer 1 produced a **complete scaffold plus migrated page set**: `32` files processed and `176` files written. +3. Static assets and legacy infrastructure handling improved materially: `80` static files copied, `6` compile-surface artifacts quarantined, and `4` App_Start files quarantined instead of poisoning the build immediately. + +## What Didn't Work Well + +1. Several routed pages still render markup that depends on **quarantined code-behind members** rather than a compiled partial class or a completed semantic rewrite. +2. The generated master shell still contains unresolved **Web Forms runtime constructs** such as `Scripts.Render`, `webopt:bundlereference`, `HttpContext.Current`, `GetUserName()`, and undeclared chrome elements like `adminLink` / `cartCount`. +3. Validator-heavy account pages still hit **generic inference / markup normalization failures** (`RZ10001`) instead of producing acceptance-ready SSR forms. + +## Build Result + +The first build of the freshly generated app failed: + +- **Result:** `FAILED` +- **Errors:** `183` +- **Warnings:** `54` + +The dominant error classes were: + +1. **Missing code-behind members in compiled pages** (`CS0103`) + Examples: `Site.razor`, `ProductDetails.razor`, `ShoppingCart.razor`, and `Admin\AdminPage.razor` still reference methods, refs, or fields that only exist in `migration-artifacts\codebehind\*.txt`. +2. **Unnormalized Web Forms data-binding constructs** (`CS0103`, `CS1061`, `CS1662`) + Examples: `ShoppingCart.razor` still contains `Item`-style template expressions and object-typed fields that no longer line up with the generated templated controls. +3. **Validator/component generic inference failures** (`RZ10001`) + Account pages such as `Account\AddPhoneNumber.razor` and `Account\Forgot.razor` still emit validators that Razor cannot infer cleanly. +4. **Master-shell runtime drift** + `Site.razor` still mixes preserved shell markup with APIs and members that were not normalized into runnable Blazor/BWFC equivalents. + +## Acceptance Test Result + +| Metric | Value | +|--------|-------| +| Total | `0` | +| Passed | `0` | +| Failed | `0` | +| Skipped | `0` | + +Acceptance tests were **not run** because `samples\AfterWingtipToys\WingtipToys.csproj` did not build. This run does not meet the benchmark success criteria. + +## Toolkit Gaps Exposed by This Run + +1. **Code-behind recovery is still incomplete for benchmark-critical pages.** + The toolkit quarantines code-behind correctly, but it still leaves generated markup in `Site`, `ProductDetails`, `ShoppingCart`, and `AdminPage` depending on members that never get reintroduced as runnable Blazor code. +2. **Master-page shell migration still needs a Wingtip-specific hardening pass.** + The shell contract is present, but the generated `Site.razor` still preserves unresolved bundles, OWIN/auth hooks, and HTML elements whose behavior was previously supplied by master-page code-behind. +3. **Data-bound action/detail pages still need deeper semantic normalization.** + `FormView`, `GridView`, and template output still carry Web Forms expressions, row-oriented control lookup, and event wiring that are not yet converted into acceptance-ready SSR patterns. +4. **Account-form migration still needs validator and event normalization.** + The toolkit now recognizes account pages better, but validator-heavy forms still stop at compile-time rather than migrating into a buildable SSR shape. + +## Screenshot Gallery + +No screenshots were captured for this run because the migrated app never reached a runnable state. + +## Notes + +- Layer 1 evidence is captured in `migrate-output.md`. +- Build failure evidence is captured in `build-output.md`. +- Migration summary from Layer 1: + - `Resolved source root: D:\BlazorWebFormsComponents\samples\WingtipToys\WingtipToys` + - `Files processed: 32` + - `Files written: 176` + - `Static files copied: 80` + - `Source files copied: 9` + - `Compile-surface artifacts quarantined: 6` + - `App_Start files quarantined: 4` +- This run is still useful: it confirms the toolkit is substantially better at scaffolding and compile-surface isolation, but the next feature work should focus on the remaining **runnable page semantics** rather than more scaffolding changes. diff --git a/dev-docs/migration-tests/wingtiptoys/run30/report.md b/dev-docs/migration-tests/wingtiptoys/run30/report.md new file mode 100644 index 000000000..dac5e6681 --- /dev/null +++ b/dev-docs/migration-tests/wingtiptoys/run30/report.md @@ -0,0 +1,50 @@ +# WingtipToys Migration Run 30 + +## Summary + +Run 30 validated the new **compiled page code-behind path** and the merged page runtime shim. The migration still does not build cleanly, but the generated WingtipToys app dropped from **183 build errors in run29 to 60 build errors in run30**. + +The important behavior change is that the CLI no longer quarantines every page code-behind file. In this run it emitted **20 compiled `.razor.cs` files** and quarantined only **12 page/user-control/master-page code-behind artifacts** that still contain unsupported patterns. + +## Migration Result + +- Files processed: **32** +- Files written: **176** +- Static files copied: **80** +- Source files copied: **9** +- Compile-surface artifacts quarantined: **6** +- App_Start files quarantined: **4** + +## Build Result + +- Build target: `samples\AfterWingtipToys\WingtipToys.csproj` +- Result: **failed** +- Warnings: **23** +- Errors: **60** + +## What Improved + +1. Shim-compatible pages now emit real compiled code-behind files instead of always landing in `migration-artifacts\codebehind\`. +2. The merged `Page` / `WebFormsPageBase` runtime keeps head rendering on the shared page shim surface without forcing layout wrappers to duplicate page-service logic. +3. The fresh Wingtip run now compiles far enough that the dominant failures are narrower and more actionable than the run29 “missing page members everywhere” failure wall. + +## Remaining Top Gaps + +1. **Missing generated usings/type imports in emitted `.razor.cs` files** + Several compiled page partials now fail on BWFC control types such as `Button`, `Label`, `Panel`, `GridView<>`, `FormView<>`, and `RequiredFieldValidator<>`. The compiled-codebehind path needs companion using/import normalization. + +2. **Legacy page-model attributes still leak into compiled code-behind** + `ProductDetails.razor.cs` still contains Web Forms model-binding attributes like `[QueryString]` and ambiguous `RouteData` references. Those need transform-time normalization before emission. + +3. **Validator inference and templated markup normalization are still incomplete** + Account pages still fail with `RZ10001` for `RequiredFieldValidator`, and pages like `CheckoutReview.razor` / `ProductList.razor` still contain invalid templated markup shapes. + +4. **Unresolved server-block markup still escapes into generated pages** + `Manage.razor`, `ProductList.razor`, and `ShoppingCart.razor` still contain `%`/`<%#:` fragments or malformed HTML. Those pages were correctly quarantined or left uncompiled, but the markup transforms still need stronger cleanup. + +5. **Master-page shell still leaves unresolved script-manager style markup** + `Site.razor` now avoids compile-surface code-behind failure, but it still carries unresolved `Scripts` / `ScriptReference` markup that blocks the generated app build. + +## Conclusion + +Run 30 confirms that the new page-codebehind architecture is worth keeping: it materially reduces the Wingtip compile surface failure count and exposes the next concentrated gaps. The next follow-up should focus on **compiled code-behind import normalization** plus the existing **validator/template/server-block markup** cleanup work. diff --git a/dev-docs/migration-tests/wingtiptoys/run31/report.md b/dev-docs/migration-tests/wingtiptoys/run31/report.md new file mode 100644 index 000000000..7a4b8d044 --- /dev/null +++ b/dev-docs/migration-tests/wingtiptoys/run31/report.md @@ -0,0 +1,54 @@ +# WingtipToys Migration Run 31 + +## Summary + +Run 31 validated the **compiled page imports fix**. The generated scaffold now writes BWFC global usings into `GlobalUsings.cs`, which means emitted page `.razor.cs` files no longer depend on NuGet-only package targets to resolve BWFC component types. + +That fix worked for the original target bucket: the deduplicated `CS0246` failures dropped from **41 in run30 to 7 in run31**. The remaining `CS0246` errors are no longer the broad page-control import problem; they are now limited to deeper support-code issues such as `HttpException` and copied identity/application types in `Logic\RoleActions.cs`. + +## Migration Result + +- Files processed: **32** +- Files written: **176** +- Compile-surface artifacts quarantined: **6** +- App_Start files quarantined: **4** + +## Build Result + +- Build target: `samples\AfterWingtipToys\WingtipToys.csproj` +- Result: **failed** +- Reported build totals: **205 errors**, **112 warnings** + +## What Improved + +1. `GlobalUsings.cs` now includes: + - `global using BlazorWebFormsComponents;` + - `global using BlazorWebFormsComponents.Enums;` + - `global using BlazorWebFormsComponents.LoginControls;` + - `global using BlazorWebFormsComponents.Validations;` +2. Emitted page code-behind no longer fails en masse on missing `Button`, `Label`, `TextBox`, `GridView`, `FormView`, `RequiredFieldValidator`, and similar BWFC types. +3. The prior “compiled page imports” bucket is now effectively cleared, exposing the next true blockers. + +## New Dominant Failure Classes + +Deduplicated run31 errors are now dominated by: + +1. **Missing members / semantic migration gaps (`CS0103`)** — 71 errors + Generated markup and code-behind still disagree on members/events in pages like `CheckoutReview`, `CheckoutComplete`, `ShoppingCart`, `Site`, and account flows. + +2. **Analyzer/style noise promoted to errors (`IDE0007`)** — 70 errors + Copied support files such as `Logic\PayPalFunctions.cs` now enter the build and fail on style analyzers, which should not be a migration blocker. + +3. **API/runtime surface mismatches (`CS1061`, `CS0234`, `CS1929`, etc.)** + These now stand out more clearly in copied support code and identity-related logic. + +4. **Validator inference and markup cleanup remain** + The existing `RZ10001`, `RZ9980`, `RZ9981`, and `RZ9996` buckets are still present and unchanged from run30. + +## Conclusion + +Run 31 confirms the imports fix should be kept. It solved the specific page-codebehind namespace problem and moved the benchmark forward to the next layers: + +1. semantic member alignment between generated markup and emitted code-behind, +2. compile-surface filtering or warning suppression for copied support code, +3. validator/template/server-block markup cleanup. diff --git a/docs/UtilityFeatures/PageService.md b/docs/UtilityFeatures/PageService.md index 6f3995d1b..e1210d672 100644 --- a/docs/UtilityFeatures/PageService.md +++ b/docs/UtilityFeatures/PageService.md @@ -32,9 +32,9 @@ The Page system uses three complementary pieces: ```mermaid flowchart LR - A["WebFormsPageBase\n(code-behind API)\n\n• Title\n• MetaDescription\n• MetaKeywords\n• IsPostBack\n• Page (self-ref)"] + A["WebFormsPageBase\n(shared page shim)\n\n• Title / Meta*\n• IsPostBack\n• Request / Response\n• Session / Cache\n• ClientScript / ViewState"] B["IPageService\n(scoped bridge)\n\n• Title\n• MetaDescription\n• MetaKeywords\n• Change events"] - C["WebFormsPage\n(layout wrapper)\n\n• <PageTitle>\n• <meta> tags\n• NamingContainer\n• ThemeProvider"] + C["Page + WebFormsPage\n(renderers)\n\n• <PageTitle>\n• <meta> tags\n• NamingContainer\n• ThemeProvider"] A -->|writes| B -->|reads| C @@ -46,14 +46,15 @@ flowchart LR | Piece | Role | Where it lives | |---|---|---| -| **`WebFormsPageBase`** | Abstract base class for converted pages. Provides `Title`, `MetaDescription`, `MetaKeywords`, `IsPostBack`, and `Page` (self-reference). This is the **code-behind API** your pages inherit. | `@inherits` in `_Imports.razor` | +| **`WebFormsPageBase`** | Abstract base class for converted pages and the shared runtime shim behind ``. Provides `Title`, `MetaDescription`, `MetaKeywords`, `IsPostBack`, `Page`, `Request`, `Response`, `Session`, `Server`, `Cache`, `ClientScript`, and `ViewState`. | `@inherits` in `_Imports.razor`, inherited by `` | | **`IPageService` / `PageService`** | Scoped service that bridges the base class and the renderer. Property setters fire change events. | Registered by `AddBlazorWebFormsComponents()` | -| **`WebFormsPage`** | Unified layout wrapper that provides NamingContainer (ID mangling), ThemeProvider (skin cascading), AND page head rendering (`` + `` tags). Subscribes to `IPageService` events. | `` wrapping `@Body` in layout | +| **`Page`** | Head renderer that inherits `WebFormsPageBase`, reads the current title/meta values, and disables postback interop because it only renders `` and `` tags. | `` | +| **`WebFormsPage`** | Unified layout wrapper that provides NamingContainer (ID mangling), ThemeProvider (skin cascading), and optionally composes `` for head rendering. | `` wrapping `@Body` in layout | -**Key point:** `WebFormsPageBase` and `WebFormsPage` are complementary, not redundant. `WebFormsPageBase` *writes* data to `IPageService`; `WebFormsPage` *reads* from `IPageService` and renders HTML. Both are needed. +**Key point:** `WebFormsPageBase` is now the shared page shim. Converted pages inherit it directly, `` reuses it for head rendering, and `` composes `` instead of duplicating page-service subscription logic. -!!! tip "All-in-one layout component" - `` combines naming, theming, and head rendering into a single component. You no longer need a separate `` component — `` handles everything. +!!! tip "Default layout pattern" + `` is still the simplest layout wrapper. It now renders `` internally by default, so you get naming, theming, and page-head rendering from one component. ## One-Time Setup @@ -86,10 +87,7 @@ This registers `IPageService` as a scoped service. - **Page head rendering** — Subscribes to `IPageService` and renders `` + `` tags !!! note "RenderPageHead parameter" - If you need to handle head rendering separately (e.g., with a standalone `` component), set `RenderPageHead="false"` on `` to disable head rendering. - -!!! note "Standalone Page.razor" - The `` component is still available as a standalone head renderer for apps that don't use ``. However, when using ``, the standalone `` is unnecessary — don't use both, or you'll get duplicate head content. + If you need to handle head rendering separately, set `RenderPageHead="false"` on `` and place `` yourself. Otherwise, let `` own that composition to avoid duplicate head tags. ## Primary Approach: WebFormsPageBase @@ -131,7 +129,7 @@ With `WebFormsPageBase` as your base class, Web Forms code-behind patterns work This works because: - **`Page`** returns `this` (a self-reference), so `Page.Title` resolves to `this.Title` -- **`Title`**, **`MetaDescription`**, and **`MetaKeywords`** delegate to the injected `IPageService` +- **`Title`**, **`MetaDescription`**, and **`MetaKeywords`** delegate to the scoped `IPageService` when available - **`IsPostBack`** always returns `false` — so `if (!IsPostBack)` blocks always execute, matching first-load behavior ### Properties Available @@ -143,16 +141,13 @@ This works because: | `MetaKeywords` | `string` | Gets/sets the meta keywords. Delegates to `IPageService.MetaKeywords`. | | `IsPostBack` | `bool` | Always returns `false`. Blazor has no postback model. | | `Page` | `WebFormsPageBase` | Returns `this`. Enables `Page.Title` syntax. | - -### Properties NOT Available - -The following `System.Web.UI.Page` members are **deliberately omitted** to encourage proper Blazor migration: - -- **`Page.Request`** — Use `NavigationManager` or `HttpContext` instead -- **`Page.Response`** — Use `NavigationManager.NavigateTo()` for redirects -- **`Page.Session`** — Use `ProtectedSessionStorage`, `ProtectedLocalStorage`, or a scoped service -- **`Page.Server`** — Use standard .NET APIs (`Path`, `HttpUtility`, etc.) -- **`Page.Cache`** — Use `IMemoryCache` or `IDistributedCache` +| `Request` | `RequestShim` | Compatibility wrapper for query string, cookies, URL, and form data. | +| `Response` | `ResponseShim` | Compatibility wrapper for redirects and cookies. | +| `Session` | `SessionShim` | Dictionary-style session storage with SSR and in-memory fallback behavior. | +| `Server` | `ServerShim` | Compatibility wrapper for `MapPath`, HTML encoding, and URL encoding. | +| `Cache` | `CacheShim` | Compatibility wrapper over `IMemoryCache`. | +| `ClientScript` | `ClientScriptShim` | Registers startup scripts, client script blocks, and postback helpers. | +| `ViewState` | `ViewStateDictionary` | Per-page ViewState-style dictionary. | !!! warning "Dead Code: `if (IsPostBack)`" Code guarded by `if (IsPostBack)` (without `!`) will **never execute** because `IsPostBack` is always `false`. During migration, search for `if (IsPostBack)` (without the negation) and flag those blocks for review — they likely contain logic that needs to be reimplemented as Blazor event handlers. diff --git a/docs/UtilityFeatures/WebFormsPage.md b/docs/UtilityFeatures/WebFormsPage.md index 0cf61ce72..eb10e0b0c 100644 --- a/docs/UtilityFeatures/WebFormsPage.md +++ b/docs/UtilityFeatures/WebFormsPage.md @@ -1,6 +1,6 @@ # WebFormsPage -The `WebFormsPage` component is a unified legacy support wrapper that combines naming container and theming support into a single component. It mirrors `System.Web.UI.Page` — the root class of every Web Forms page — which established the naming scope for all child controls and applied the page-level theme. +The `WebFormsPage` component is a unified legacy support wrapper that combines naming container and theming support into a single component. It also composes the lightweight `` head renderer by default, so layout-level page metadata flows through the same shared page shim used by converted page code-behind. Place `WebFormsPage` in your layout to give all pages automatic ID mangling (naming container) and theme/skin support without per-page configuration. @@ -10,15 +10,17 @@ In ASP.NET Web Forms, the `Page` class provided several structural services to a 1. **Naming Container** — The page was the root `INamingContainer`, generating fully-qualified client IDs like `ctl00_MainContent_MyButton` 2. **Theme Application** — The `<%@ Page Theme="..." %>` directive applied skin files to all controls -3. **ViewState** — The page serialized control state to a hidden field (not replicated — Blazor preserves state in component fields) +3. **Page head rendering** — The page owned the document title and related metadata +4. **ViewState** — The page serialized control state to a hidden field (not replicated — Blazor preserves state in component fields) -`WebFormsPage` combines the first two capabilities into a single Blazor component. +`WebFormsPage` combines naming, theming, and optional page-head rendering into a single Blazor component. ## Features Supported in Blazor - **Naming Container** — Cascades naming scope to child components, prefixing IDs with the container's ID - **UseCtl00Prefix** — Optionally prepends `ctl00` to the naming hierarchy for full Web Forms ID compatibility - **Theme Cascading** — Passes a `ThemeConfiguration` to all child styled components via `CascadingValue` +- **Page Head Rendering** — Renders `` by default so `Title`, `MetaDescription`, and `MetaKeywords` flow to `` and `` tags - **Visible** — Controls whether child content renders (inherited from `BaseWebFormsComponent`) ### Blazor Notes @@ -43,7 +45,7 @@ Place `WebFormsPage` in your `MainLayout.razor` wrapping `@Body`: } ``` -This mirrors how `
` wrapped all page content in Web Forms. Every page automatically gets naming scope and theming. +This mirrors how `` wrapped all page content in Web Forms. Every page automatically gets naming scope, theming, and page-head rendering. ### Per-Page Usage @@ -101,6 +103,7 @@ When you only need ID mangling, omit the `Theme` parameter: | `ID` | `string` | `null` | Sets the naming scope prefix for child component IDs | | `UseCtl00Prefix` | `bool` | `false` | When true, prepends `ctl00` to the naming hierarchy | | `Theme` | `ThemeConfiguration` | `null` | Theme configuration to cascade to child components | +| `RenderPageHead` | `bool` | `true` | When true, renders the shared `` head component before page content | | `Visible` | `bool` | `true` | Controls whether child content renders | | `ChildContent` | `RenderFragment` | — | The page content | @@ -108,9 +111,10 @@ When you only need ID mangling, omit the `Theme` parameter: | Component | Purpose | When to Use | |---|---|---| -| `WebFormsPage` | Combined naming + theming | Layout-level wrapper for full legacy support | +| `WebFormsPage` | Combined naming + theming + optional head rendering | Layout-level wrapper for full legacy support | | `NamingContainer` | Naming scope only | When you need nested naming scopes within a page | | `ThemeProvider` | Theme cascading only | When demonstrating theming in isolation | +| `Page` | Head rendering only | When you need document title/meta rendering without the layout wrapper | ## IsPostBack Property diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs index 150a2cb5c..2bdc7e530 100644 --- a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs +++ b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs @@ -309,14 +309,24 @@ await _outputWriter.WriteFileAsync(sourceFile.OutputPath, finalMarkup, // Write code-behind output if (codeBehind != null) { - var relativeMarkupPath = Path.GetRelativePath(context.OutputPath, sourceFile.OutputPath); - var codeOutputPath = Path.Combine( - context.OutputPath, - "migration-artifacts", - "codebehind", - relativeMarkupPath + ".cs.txt"); - await _outputWriter.WriteFileAsync(codeOutputPath, codeBehind, - $"Manual code-behind artifact for {Path.GetFileName(sourceFile.MarkupPath)}"); + var emissionPlan = PageCodeBehindEmissionPlanner.Create(metadata, finalMarkup, codeBehind); + + if (emissionPlan.EmitToCompileSurface) + { + await _outputWriter.WriteFileAsync(sourceFile.OutputPath + ".cs", codeBehind, + $"Converted code-behind for {Path.GetFileName(sourceFile.MarkupPath)}"); + } + else + { + var relativeMarkupPath = Path.GetRelativePath(context.OutputPath, sourceFile.OutputPath); + var codeOutputPath = Path.Combine( + context.OutputPath, + "migration-artifacts", + "codebehind", + relativeMarkupPath + ".cs.txt"); + await _outputWriter.WriteFileAsync(codeOutputPath, codeBehind, + $"Manual code-behind artifact for {Path.GetFileName(sourceFile.MarkupPath)}"); + } } report.FilesProcessed++; diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/PageCodeBehindEmissionPlanner.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/PageCodeBehindEmissionPlanner.cs new file mode 100644 index 000000000..bf80e51f0 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Pipeline/PageCodeBehindEmissionPlanner.cs @@ -0,0 +1,64 @@ +using System.Text.RegularExpressions; + +namespace BlazorWebFormsComponents.Cli.Pipeline; + +internal static partial class PageCodeBehindEmissionPlanner +{ + private static readonly Regex PartialClassRegex = new(@"\bpartial\s+class\s+\w+", RegexOptions.Compiled); + private static readonly Regex LowercaseHtmlIdRegex = new(@"<([a-z][\w:-]*)\b[^>]*\bid=""\w+""", RegexOptions.Compiled); + + private static readonly (Regex Pattern, string Reason)[] UnsafeMarkupPatterns = + [ + (new Regex(@"<%", RegexOptions.Compiled), "Unresolved Web Forms server blocks remain in markup."), + (new Regex(@" new(true, null); + private static CodeBehindEmissionPlan Artifact(string reason) => new(false, reason); +} + +internal sealed record CodeBehindEmissionPlan(bool EmitToCompileSurface, string? ArtifactReason); diff --git a/src/BlazorWebFormsComponents.Cli/Scaffolding/GlobalUsingsGenerator.cs b/src/BlazorWebFormsComponents.Cli/Scaffolding/GlobalUsingsGenerator.cs index cae643847..9d5f8b5d5 100644 --- a/src/BlazorWebFormsComponents.Cli/Scaffolding/GlobalUsingsGenerator.cs +++ b/src/BlazorWebFormsComponents.Cli/Scaffolding/GlobalUsingsGenerator.cs @@ -3,7 +3,7 @@ namespace BlazorWebFormsComponents.Cli.Scaffolding; /// -/// Generates GlobalUsings.cs with Blazor infrastructure usings. +/// Generates GlobalUsings.cs with Blazor infrastructure and BWFC namespaces. /// Ported from the GlobalUsings section of New-ProjectScaffold in bwfc-migrate.ps1. /// public class GlobalUsingsGenerator @@ -13,9 +13,11 @@ 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. +// Type aliases (Page, MasterPage, ImageClickEventArgs) are provided by the +// BlazorWebFormsComponents .targets file when consuming the NuGet package. +// This file adds the Blazor infrastructure and BWFC namespaces that emitted +// page code-behind files need even when the generated app references the local +// project directly during repo development. // // Generated by webforms-to-blazor — Layer 1 scaffold // ============================================================================= @@ -25,6 +27,12 @@ public string Generate(bool hasIdentity = false) global using Microsoft.AspNetCore.Components; global using Microsoft.AspNetCore.Components.Web; global using Microsoft.AspNetCore.Components.Routing; + +// BWFC namespaces used by generated page code-behind fields and validators. +global using BlazorWebFormsComponents; +global using BlazorWebFormsComponents.Enums; +global using BlazorWebFormsComponents.LoginControls; +global using BlazorWebFormsComponents.Validations; "; if (hasIdentity) diff --git a/src/BlazorWebFormsComponents/Page.razor b/src/BlazorWebFormsComponents/Page.razor index 4f1a6da05..c116c8a11 100644 --- a/src/BlazorWebFormsComponents/Page.razor +++ b/src/BlazorWebFormsComponents/Page.razor @@ -1,16 +1,16 @@ -@inherits ComponentBase -@implements IDisposable -@if (!string.IsNullOrEmpty(_currentTitle)) +@inherits WebFormsPageBase + +@if (!string.IsNullOrEmpty(CurrentTitle)) { - @_currentTitle + @CurrentTitle } -@if (!string.IsNullOrEmpty(_currentMetaDescription)) +@if (!string.IsNullOrEmpty(CurrentMetaDescription)) { - + } -@if (!string.IsNullOrEmpty(_currentMetaKeywords)) +@if (!string.IsNullOrEmpty(CurrentMetaKeywords)) { - + } diff --git a/src/BlazorWebFormsComponents/Page.razor.cs b/src/BlazorWebFormsComponents/Page.razor.cs index f129baeba..dd9b1a608 100644 --- a/src/BlazorWebFormsComponents/Page.razor.cs +++ b/src/BlazorWebFormsComponents/Page.razor.cs @@ -1,78 +1,10 @@ -using Microsoft.AspNetCore.Components; -using System; -using System.Threading.Tasks; - namespace BlazorWebFormsComponents; /// -/// Component that provides Web Forms-style Page object functionality. -/// Use this component to set the page title and meta tags programmatically, -/// similar to Page.Title, Page.MetaDescription, and Page.MetaKeywords in Web Forms. +/// Head-rendering companion for . +/// Renders the current title and meta tags from the same page shim surface used by migrated page code-behind. /// -public partial class Page : ComponentBase, IDisposable +public partial class Page : WebFormsPageBase { - [Inject] - private IPageService PageService { get; set; } = null!; - - private string? _currentTitle; - private string? _currentMetaDescription; - private string? _currentMetaKeywords; - - protected override void OnInitialized() - { - PageService.TitleChanged += OnTitleChanged; - PageService.MetaDescriptionChanged += OnMetaDescriptionChanged; - PageService.MetaKeywordsChanged += OnMetaKeywordsChanged; - - _currentTitle = PageService.Title; - _currentMetaDescription = PageService.MetaDescription; - _currentMetaKeywords = PageService.MetaKeywords; - } - - private async void OnTitleChanged(object? sender, string newTitle) - { - try - { - _currentTitle = newTitle; - await InvokeAsync(StateHasChanged); - } - catch (ObjectDisposedException) - { - // Component was disposed before the state update completed. - // This is expected when navigating away while an event is in flight. - } - } - - private async void OnMetaDescriptionChanged(object? sender, string newMetaDescription) - { - try - { - _currentMetaDescription = newMetaDescription; - await InvokeAsync(StateHasChanged); - } - catch (ObjectDisposedException) - { - // Component was disposed before the state update completed. - } - } - - private async void OnMetaKeywordsChanged(object? sender, string newMetaKeywords) - { - try - { - _currentMetaKeywords = newMetaKeywords; - await InvokeAsync(StateHasChanged); - } - catch (ObjectDisposedException) - { - // Component was disposed before the state update completed. - } - } - - public void Dispose() - { - PageService.TitleChanged -= OnTitleChanged; - PageService.MetaDescriptionChanged -= OnMetaDescriptionChanged; - PageService.MetaKeywordsChanged -= OnMetaKeywordsChanged; - } + protected override bool EnablePostBackInterop => false; } diff --git a/src/BlazorWebFormsComponents/WebFormsPage.razor b/src/BlazorWebFormsComponents/WebFormsPage.razor index fc26d85ac..225ef2b5e 100644 --- a/src/BlazorWebFormsComponents/WebFormsPage.razor +++ b/src/BlazorWebFormsComponents/WebFormsPage.razor @@ -1,22 +1,8 @@ @inherits NamingContainer -@implements IDisposable -@if (RenderPageHead && _pageServiceAvailable) +@if (RenderPageHead) { - @if (!string.IsNullOrEmpty(_currentTitle)) - { - @_currentTitle - } - - @if (!string.IsNullOrEmpty(_currentMetaDescription)) - { - - } - @if (!string.IsNullOrEmpty(_currentMetaKeywords)) - { - - } - + } @if (Visible) diff --git a/src/BlazorWebFormsComponents/WebFormsPage.razor.cs b/src/BlazorWebFormsComponents/WebFormsPage.razor.cs index 1b163949d..a6110f2c8 100644 --- a/src/BlazorWebFormsComponents/WebFormsPage.razor.cs +++ b/src/BlazorWebFormsComponents/WebFormsPage.razor.cs @@ -1,20 +1,13 @@ using BlazorWebFormsComponents.Theming; using Microsoft.AspNetCore.Components; -using Microsoft.Extensions.DependencyInjection; -using System; namespace BlazorWebFormsComponents; /// /// Unified legacy Web Forms support wrapper. Combines NamingContainer (ID mangling), -/// ThemeProvider (skin/theme cascading), and Page head rendering (title + meta tags) -/// into a single component that mirrors System.Web.UI.Page — the root of every -/// Web Forms page. -/// -/// Place in MainLayout.razor wrapping @Body to give all pages naming scope, theming, -/// and automatic page title/meta rendering, or use per-page for area-specific configuration. +/// ThemeProvider (skin/theme cascading), and optional page head rendering into a single component. /// -public partial class WebFormsPage : NamingContainer, IDisposable +public partial class WebFormsPage : NamingContainer { /// /// Optional theme configuration to cascade to all child components. @@ -24,88 +17,9 @@ public partial class WebFormsPage : NamingContainer, IDisposable public ThemeConfiguration Theme { get; set; } /// - /// When true (the default), renders <PageTitle> and <HeadContent> based on - /// IPageService values. Set to false if head rendering is handled separately - /// (e.g., via a standalone <Page /> component). + /// When true (the default), renders the shared component so page title + /// and meta tags come from the same shim surface used by migrated page code-behind. /// [Parameter] public bool RenderPageHead { get; set; } = true; - - [Inject] - private IServiceProvider ServiceProvider { get; set; } = null!; - - private IPageService? _pageService; - private bool _pageServiceAvailable; - private string? _currentTitle; - private string? _currentMetaDescription; - private string? _currentMetaKeywords; - - protected override void OnInitialized() - { - base.OnInitialized(); - - _pageService = ServiceProvider.GetService(); - _pageServiceAvailable = _pageService is not null; - - if (_pageService is not null) - { - _pageService.TitleChanged += OnTitleChanged; - _pageService.MetaDescriptionChanged += OnMetaDescriptionChanged; - _pageService.MetaKeywordsChanged += OnMetaKeywordsChanged; - - _currentTitle = _pageService.Title; - _currentMetaDescription = _pageService.MetaDescription; - _currentMetaKeywords = _pageService.MetaKeywords; - } - } - - private async void OnTitleChanged(object? sender, string newTitle) - { - try - { - _currentTitle = newTitle; - await InvokeAsync(StateHasChanged); - } - catch (ObjectDisposedException) - { - // Component was disposed before the state update completed. - // This is expected when navigating away while an event is in flight. - } - } - - private async void OnMetaDescriptionChanged(object? sender, string newMetaDescription) - { - try - { - _currentMetaDescription = newMetaDescription; - await InvokeAsync(StateHasChanged); - } - catch (ObjectDisposedException) - { - // Component was disposed before the state update completed. - } - } - - private async void OnMetaKeywordsChanged(object? sender, string newMetaKeywords) - { - try - { - _currentMetaKeywords = newMetaKeywords; - await InvokeAsync(StateHasChanged); - } - catch (ObjectDisposedException) - { - // Component was disposed before the state update completed. - } - } - - public void Dispose() - { - if (_pageService is not null) - { - _pageService.TitleChanged -= OnTitleChanged; - _pageService.MetaDescriptionChanged -= OnMetaDescriptionChanged; - _pageService.MetaKeywordsChanged -= OnMetaKeywordsChanged; - } - } } diff --git a/src/BlazorWebFormsComponents/WebFormsPageBase.cs b/src/BlazorWebFormsComponents/WebFormsPageBase.cs index 49f2e431f..b3c0aceda 100644 --- a/src/BlazorWebFormsComponents/WebFormsPageBase.cs +++ b/src/BlazorWebFormsComponents/WebFormsPageBase.cs @@ -5,7 +5,10 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Primitives; using Microsoft.JSInterop; @@ -20,22 +23,36 @@ namespace BlazorWebFormsComponents; /// public abstract class WebFormsPageBase : ComponentBase, IAsyncDisposable { -[Inject] private IPageService _pageService { get; set; } = null!; -[Inject] private NavigationManager _navigationManager { get; set; } = null!; -[Inject] private LinkGenerator _linkGenerator { get; set; } = null!; -[Inject] private IHttpContextAccessor _httpContextAccessor { get; set; } = null!; -[Inject] private ILogger _logger { get; set; } = null!; -[Inject] private SessionShim _sessionShim { get; set; } = null!; -[Inject] private IWebHostEnvironment _webHostEnvironment { get; set; } = null!; -[Inject] private CacheShim _cacheShim { get; set; } = null!; -[Inject] private IJSRuntime _jsRuntime { get; set; } = null!; -[Inject] private ClientScriptShim _clientScriptShim { get; set; } = null!; +[Inject] private IServiceProvider ServiceProvider { get; set; } = null!; + +private SessionShim? _sessionShim; +private CacheShim? _cacheShim; +private ClientScriptShim? _clientScriptShim; +private IMemoryCache? _fallbackMemoryCache; + +private IPageService? _pageService; +private string _currentTitle = string.Empty; +private string _currentMetaDescription = string.Empty; +private string _currentMetaKeywords = string.Empty; +private bool _pageServiceSubscribed; + +private NavigationManager NavigationManager => ServiceProvider.GetRequiredService(); +private IJSRuntime JsRuntime => ServiceProvider.GetRequiredService(); +private LinkGenerator LinkGenerator => ServiceProvider.GetRequiredService(); +private IHttpContextAccessor HttpContextAccessor + => ServiceProvider.GetService() ?? new HttpContextAccessor(); +private ILogger Logger + => ServiceProvider.GetService>() ?? NullLogger.Instance; +private IWebHostEnvironment WebHostEnvironment => ServiceProvider.GetRequiredService(); /// /// Provides access to client script registration methods, emulating /// Page.ClientScript from ASP.NET Web Forms. /// -public ClientScriptShim ClientScript => _clientScriptShim; +public ClientScriptShim ClientScript + => _clientScriptShim ??= ServiceProvider.GetService() + ?? new ClientScriptShim(ServiceProvider.GetService>() + ?? NullLogger.Instance); // ─── PostBack Support ───────────────────────────────────────────── @@ -54,7 +71,10 @@ public abstract class WebFormsPageBase : ComponentBase, IAsyncDisposable /// in SSR mode; falls back to in-memory storage /// in interactive Blazor Server mode. /// -protected SessionShim Session => _sessionShim; +protected SessionShim Session + => _sessionShim ??= ServiceProvider.GetService() + ?? new SessionShim(ServiceProvider.GetService>() + ?? NullLogger.Instance, HttpContextAccessor); /// /// Returns true when an is available @@ -63,7 +83,7 @@ public abstract class WebFormsPageBase : ComponentBase, IAsyncDisposable /// cookies, headers, or . /// protected bool IsHttpContextAvailable -=> _httpContextAccessor.HttpContext is not null; + => HttpContextAccessor.HttpContext is not null; /// /// Gets or sets the title of the page. Delegates to IPageService. @@ -71,8 +91,15 @@ protected bool IsHttpContextAvailable /// public string Title { -get => _pageService.Title; -set => _pageService.Title = value; +get => _pageService?.Title ?? _currentTitle; +set +{ + _currentTitle = value ?? string.Empty; + if (_pageService is not null) + { + _pageService.Title = _currentTitle; + } +} } /// @@ -81,8 +108,15 @@ public string Title /// public string MetaDescription { -get => _pageService.MetaDescription; -set => _pageService.MetaDescription = value; +get => _pageService?.MetaDescription ?? _currentMetaDescription; +set +{ + _currentMetaDescription = value ?? string.Empty; + if (_pageService is not null) + { + _pageService.MetaDescription = _currentMetaDescription; + } +} } /// @@ -91,10 +125,35 @@ public string MetaDescription /// public string MetaKeywords { -get => _pageService.MetaKeywords; -set => _pageService.MetaKeywords = value; +get => _pageService?.MetaKeywords ?? _currentMetaKeywords; +set +{ + _currentMetaKeywords = value ?? string.Empty; + if (_pageService is not null) + { + _pageService.MetaKeywords = _currentMetaKeywords; + } +} } +/// +/// The current page title resolved from or the local fallback state. +/// Intended for components that render page head content. +/// +protected string CurrentTitle => _currentTitle; + +/// +/// The current page meta description resolved from or the local fallback state. +/// Intended for components that render page head content. +/// +protected string CurrentMetaDescription => _currentMetaDescription; + +/// +/// The current page meta keywords resolved from or the local fallback state. +/// Intended for components that render page head content. +/// +protected string CurrentMetaKeywords => _currentMetaKeywords; + /// /// Returns true when the current request is a postback (form POST in SSR mode) /// or after the first initialization (in ServerInteractive mode). @@ -111,7 +170,7 @@ public bool IsPostBack get { // SSR mode: HttpContext is available — check HTTP method - if (_httpContextAccessor?.HttpContext is { } context) + if (HttpContextAccessor.HttpContext is { } context) return HttpMethods.IsPost(context.Request.Method); // ServerInteractive mode: track initialization state @@ -127,13 +186,24 @@ public bool IsPostBack /// protected WebFormsPageBase Page => this; +/// +/// Compatibility shim for Web Forms Context access. +/// Returns the current when available. +/// +protected HttpContext? Context => HttpContextAccessor.HttpContext; + +/// +/// Compatibility property for Web Forms Page.ViewStateUserKey. +/// +public string? ViewStateUserKey { get; set; } + /// /// Compatibility shim for Web Forms Response object. /// Supports Response.Redirect() and Response.Cookies. /// Cookies degrade gracefully to no-op when HttpContext is unavailable. /// protected ResponseShim Response -=> new(_navigationManager, _httpContextAccessor.HttpContext, _logger); + => new(NavigationManager, HttpContextAccessor.HttpContext, Logger); /// /// Compatibility shim for Web Forms Request object. @@ -144,7 +214,7 @@ protected ResponseShim Response /// . /// protected RequestShim Request - => _requestShim ??= new(_httpContextAccessor.HttpContext, _navigationManager, _logger); + => _requestShim ??= new(HttpContextAccessor.HttpContext, NavigationManager, Logger); private RequestShim? _requestShim; /// @@ -175,7 +245,7 @@ public void SetRequestFormData(FormSubmitEventArgs e) /// Supports Server.MapPath(), Server.HtmlEncode(), /// Server.UrlEncode(), etc. /// -protected ServerShim Server => new(_webHostEnvironment); +protected ServerShim Server => new(WebHostEnvironment); /// /// Compatibility shim for Web Forms Cache object @@ -183,7 +253,10 @@ public void SetRequestFormData(FormSubmitEventArgs e) /// Provides dictionary-style Cache["key"] access backed by /// ASP.NET Core . /// -protected CacheShim Cache => _cacheShim; +protected CacheShim Cache + => _cacheShim ??= ServiceProvider.GetService() + ?? new CacheShim(_fallbackMemoryCache ??= new MemoryCache(new MemoryCacheOptions()), + ServiceProvider.GetService>() ?? NullLogger.Instance); /// /// Resolves a relative URL to an application-absolute URL. @@ -227,9 +300,62 @@ protected string ResolveUrl(string relativeUrl) protected override void OnInitialized() { base.OnInitialized(); + EnsurePageService(); _hasInitialized = true; } +private void EnsurePageService() +{ + if (_pageServiceSubscribed) + { + return; + } + + _pageService = ServiceProvider.GetService(); + if (_pageService is null) + { + return; + } + + _currentTitle = _pageService.Title; + _currentMetaDescription = _pageService.MetaDescription; + _currentMetaKeywords = _pageService.MetaKeywords; + + _pageService.TitleChanged += OnTitleChanged; + _pageService.MetaDescriptionChanged += OnMetaDescriptionChanged; + _pageService.MetaKeywordsChanged += OnMetaKeywordsChanged; + _pageServiceSubscribed = true; +} + +private async void OnTitleChanged(object? sender, string newTitle) +{ + _currentTitle = newTitle; + await NotifyPageStateChangedAsync(); +} + +private async void OnMetaDescriptionChanged(object? sender, string newMetaDescription) +{ + _currentMetaDescription = newMetaDescription; + await NotifyPageStateChangedAsync(); +} + +private async void OnMetaKeywordsChanged(object? sender, string newMetaKeywords) +{ + _currentMetaKeywords = newMetaKeywords; + await NotifyPageStateChangedAsync(); +} + +protected virtual async Task NotifyPageStateChangedAsync() +{ + try + { + await InvokeAsync(StateHasChanged); + } + catch (ObjectDisposedException) + { + } +} + /// /// Generates a URL for the named route with the specified parameters. /// Equivalent to Page.GetRouteUrl("RouteName", new { id = 1 }) in Web Forms. @@ -246,8 +372,8 @@ protected string GetRouteUrl(string routeName, object routeParameters = null) if (routeName != null && routeName.EndsWith(".aspx", StringComparison.OrdinalIgnoreCase)) routeName = routeName[..^5]; -return _linkGenerator.GetPathByRouteValues( -_httpContextAccessor.HttpContext, routeName, routeParameters); +return LinkGenerator.GetPathByRouteValues( + HttpContextAccessor.HttpContext, routeName, routeParameters); } /// @@ -255,21 +381,21 @@ protected string GetRouteUrl(string routeName, object routeParameters = null) /// Equivalent to Page.GetRouteUrl(routeParameters) in Web Forms. /// public string GetRouteUrl(object routeParameters) -=> _linkGenerator.GetPathByRouteValues(_httpContextAccessor.HttpContext, null, routeParameters); + => LinkGenerator.GetPathByRouteValues(HttpContextAccessor.HttpContext, null, routeParameters); /// /// Generates a URL for the specified route parameters dictionary. /// Equivalent to Page.GetRouteUrl(routeParameters) in Web Forms. /// public string GetRouteUrl(RouteValueDictionary routeParameters) -=> _linkGenerator.GetPathByRouteValues(_httpContextAccessor.HttpContext, null, routeParameters); + => LinkGenerator.GetPathByRouteValues(HttpContextAccessor.HttpContext, null, routeParameters); /// /// Generates a URL for the specified named route with a parameters dictionary. /// Equivalent to Page.GetRouteUrl(routeName, routeParameters) in Web Forms. /// public string GetRouteUrl(string routeName, RouteValueDictionary routeParameters) -=> _linkGenerator.GetPathByRouteValues(_httpContextAccessor.HttpContext, routeName, routeParameters); + => LinkGenerator.GetPathByRouteValues(HttpContextAccessor.HttpContext, routeName, routeParameters); /// /// Guards a member that requires . @@ -278,7 +404,7 @@ public string GetRouteUrl(string routeName, RouteValueDictionary routeParameters /// The name of the calling member, for diagnostics. private void RequireHttpContext(string memberName) { -if (_httpContextAccessor.HttpContext is null) +if (HttpContextAccessor.HttpContext is null) throw new InvalidOperationException( $"{memberName} requires HttpContext, which is unavailable during interactive " + $"rendering (WebSocket mode). Use {nameof(IsHttpContextAvailable)} to guard " + @@ -349,32 +475,46 @@ protected override async Task OnAfterRenderAsync(bool firstRender) { await base.OnAfterRenderAsync(firstRender); - if (firstRender) + if (firstRender && EnablePostBackInterop) { _postBackTargetId = GetType().Name; _postBackRef = DotNetObjectReference.Create(this); // Bootstrap __doPostBack and registration functions - await _jsRuntime.InvokeVoidAsync("eval", PostBackBootstrapJs); - await _jsRuntime.InvokeVoidAsync("__bwfc.registerPostBackTarget", + await JsRuntime.InvokeVoidAsync("eval", PostBackBootstrapJs); + await JsRuntime.InvokeVoidAsync("__bwfc.registerPostBackTarget", _postBackTargetId, _postBackRef); } // Flush any queued ClientScript registrations - if (_clientScriptShim != null) - { - await _clientScriptShim.FlushAsync(_jsRuntime); - } + if (_clientScriptShim != null) + { + await _clientScriptShim.FlushAsync(JsRuntime); + } } +/// +/// Controls whether the page base should register Web Forms postback JS interop. +/// Head-only helper components can disable this. +/// +protected virtual bool EnablePostBackInterop => true; + /// public virtual async ValueTask DisposeAsync() { + if (_pageServiceSubscribed && _pageService is not null) + { + _pageService.TitleChanged -= OnTitleChanged; + _pageService.MetaDescriptionChanged -= OnMetaDescriptionChanged; + _pageService.MetaKeywordsChanged -= OnMetaKeywordsChanged; + _pageServiceSubscribed = false; + } + if (_postBackTargetId != null && _postBackRef != null) { try { - await _jsRuntime.InvokeVoidAsync( + await JsRuntime.InvokeVoidAsync( "__bwfc.unregisterPostBackTarget", _postBackTargetId); } catch (JSDisconnectedException) { } @@ -385,4 +525,4 @@ await _jsRuntime.InvokeVoidAsync( _postBackRef = null; } } -} \ No newline at end of file +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs index 0614e7e36..364858646 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs @@ -255,7 +255,7 @@ public async Task Pipeline_RazorOutput_ContainsBwfcComponents() } [Fact] - public async Task Pipeline_QuarantinesCodeBehindFilesAsManualArtifacts() + public async Task Pipeline_EmitsShimCompatiblePageCodeBehindIntoCompileSurface() { var (inputDir, outputDir) = CreateTempProjectDir(includeCodeBehind: true); var pipeline = CreateFullPipeline(); @@ -273,11 +273,49 @@ public async Task Pipeline_QuarantinesCodeBehindFilesAsManualArtifacts() var report = await pipeline.ExecuteAsync(context); Assert.Empty(report.Errors); - // Default.aspx has a code-behind → should produce a quarantined manual artifact, not a compile-included .razor.cs file - Assert.False(File.Exists(Path.Combine(outputDir, "Default.razor.cs")), - "Default.razor.cs should not be written directly into the compile surface"); - Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "codebehind", "Default.razor.cs.txt")), - "Default.razor.cs.txt should be created as a manual migration artifact"); + Assert.True(File.Exists(Path.Combine(outputDir, "Default.razor.cs")), + "Default.razor.cs should be written into the compile surface for shim-compatible pages"); + Assert.False(File.Exists(Path.Combine(outputDir, "migration-artifacts", "codebehind", "Default.razor.cs.txt")), + "Default.razor.cs.txt should not be created when the page code-behind is compile-safe"); + } + + [Fact] + public async Task Pipeline_QuarantinesPageCodeBehind_WhenUnsupportedWebFormsPatternsRemain() + { + var (inputDir, outputDir) = CreateTempProjectDir(includeCodeBehind: true); + File.WriteAllText(Path.Combine(inputDir, "Default.aspx.cs"), """ + using System; + + namespace TestApp + { + public partial class _Default + { + protected void Page_Load(object sender, EventArgs e) + { + var row = CartList.Rows[0]; + var remove = row.FindControl("Remove"); + } + } + } + """); + + var pipeline = CreateFullPipeline(); + var scanner = new SourceScanner(); + var sourceFiles = scanner.Scan(inputDir, outputDir); + + var context = new MigrationContext + { + SourcePath = inputDir, + OutputPath = outputDir, + Options = new MigrationOptions { DryRun = false, SkipScaffold = true }, + SourceFiles = sourceFiles + }; + + var report = await pipeline.ExecuteAsync(context); + + Assert.Empty(report.Errors); + Assert.False(File.Exists(Path.Combine(outputDir, "Default.razor.cs"))); + Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "codebehind", "Default.razor.cs.txt"))); } [Fact] @@ -687,9 +725,9 @@ public async Task FullMigration_EndToEnd() Assert.True(File.Exists(Path.Combine(outputDir, "Default.razor")), "Default.razor missing"); Assert.True(File.Exists(Path.Combine(outputDir, "About.razor")), "About.razor missing"); - // Assert — transformed code-behind is quarantined as a manual artifact - Assert.False(File.Exists(Path.Combine(outputDir, "Default.razor.cs")), "Default.razor.cs should not be emitted into the compile surface"); - Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "codebehind", "Default.razor.cs.txt")), "Default.razor.cs.txt missing"); + // Assert — shim-compatible page code-behind is emitted into the compile surface + Assert.True(File.Exists(Path.Combine(outputDir, "Default.razor.cs")), "Default.razor.cs should be emitted into the compile surface"); + Assert.False(File.Exists(Path.Combine(outputDir, "migration-artifacts", "codebehind", "Default.razor.cs.txt")), "Default.razor.cs.txt should not be emitted for compile-safe pages"); // Assert — no identity shims (no Account folder) Assert.False(File.Exists(Path.Combine(outputDir, "IdentityShims.cs")), diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs index 5ccb96590..3941b3070 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs @@ -254,6 +254,10 @@ public void GlobalUsingsGenerator_GeneratesExpectedUsings() Assert.Contains("global using Microsoft.AspNetCore.Components;", content); Assert.Contains("global using Microsoft.AspNetCore.Components.Web;", content); Assert.Contains("global using Microsoft.AspNetCore.Components.Routing;", content); + Assert.Contains("global using BlazorWebFormsComponents;", content); + Assert.Contains("global using BlazorWebFormsComponents.Enums;", content); + Assert.Contains("global using BlazorWebFormsComponents.LoginControls;", content); + Assert.Contains("global using BlazorWebFormsComponents.Validations;", content); Assert.DoesNotContain("Identity", content); } From a27306b74092cb65283b0956f6a90ab473853f0f Mon Sep 17 00:00:00 2001 From: "Jeffrey T. Fritz" Date: Wed, 6 May 2026 10:40:39 -0400 Subject: [PATCH 02/38] feat(cli): port NuGet asset extraction and EDMX conversion to native C# - Add NuGetStaticAssetExtractor service (replaces Migrate-NugetStaticAssets.ps1 shell-out) - Add EdmxToEfCoreConverter service (replaces Convert-EdmxToEfCore.ps1 shell-out) - Add MarkupReferencedMemberStubTransform and ValidatorGenericTypeTransform - Add deprecation warnings to all 4 PS1 scripts - Update CLI docs for direct adoption - CLI now has zero runtime PowerShell dependencies - 506 tests passing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/bishop/history.md | 27 +- .../cli-compile-surface-hardening/SKILL.md | 33 + dev-docs/cli-migration-plan.md | 203 ++++++ docs/cli/index.md | 8 +- docs/cli/transforms.md | 2 + .../scripts/Convert-EdmxToEfCore.ps1 | 10 + .../scripts/Migrate-NugetStaticAssets.ps1 | 10 + migration-toolkit/scripts/bwfc-migrate.ps1 | 9 + migration-toolkit/scripts/bwfc-scan.ps1 | 9 + .../Config/EdmxConverterBridge.cs | 57 +- .../Config/NuGetStaticAssetExtractor.cs | 61 +- .../Interop/PowerShellScriptRunner.cs | 40 +- .../Pipeline/MigrationPipeline.cs | 12 +- src/BlazorWebFormsComponents.Cli/Program.cs | 191 +++++- .../Scaffolding/ProjectScaffolder.cs | 1 + .../Services/EdmxToEfCoreConverter.cs | 582 ++++++++++++++++++ .../Services/NuGetStaticAssetExtractor.cs | 501 +++++++++++++++ .../MarkupReferencedMemberStubTransform.cs | 171 +++++ .../Markup/ValidatorGenericTypeTransform.cs | 32 + .../PipelineIntegrationTests.cs | 6 +- .../ScaffoldingTests.cs | 1 + .../SemanticPatternCatalogTests.cs | 6 +- .../Services/EdmxToEfCoreConverterTests.cs | 186 ++++++ .../NuGetStaticAssetExtractorTests.cs | 124 ++++ .../TestHelpers.cs | 4 +- .../CompiledCodeBehindStubPipelineTests.cs | 30 + ...arkupReferencedMemberStubTransformTests.cs | 50 ++ .../ValidatorGenericTypeTransformTests.cs | 57 ++ 28 files changed, 2265 insertions(+), 158 deletions(-) create mode 100644 .squad/skills/cli-compile-surface-hardening/SKILL.md create mode 100644 dev-docs/cli-migration-plan.md create mode 100644 src/BlazorWebFormsComponents.Cli/Services/EdmxToEfCoreConverter.cs create mode 100644 src/BlazorWebFormsComponents.Cli/Services/NuGetStaticAssetExtractor.cs create mode 100644 src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/MarkupReferencedMemberStubTransform.cs create mode 100644 src/BlazorWebFormsComponents.Cli/Transforms/Markup/ValidatorGenericTypeTransform.cs create mode 100644 tests/BlazorWebFormsComponents.Cli.Tests/Services/EdmxToEfCoreConverterTests.cs create mode 100644 tests/BlazorWebFormsComponents.Cli.Tests/Services/NuGetStaticAssetExtractorTests.cs create mode 100644 tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/CompiledCodeBehindStubPipelineTests.cs create mode 100644 tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/MarkupReferencedMemberStubTransformTests.cs create mode 100644 tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ValidatorGenericTypeTransformTests.cs diff --git a/.squad/agents/bishop/history.md b/.squad/agents/bishop/history.md index 4d34995c6..b0d8019df 100644 --- a/.squad/agents/bishop/history.md +++ b/.squad/agents/bishop/history.md @@ -67,4 +67,29 @@ - Executed full test suite: 486 passed, 0 failed - Verified all tests passing before archival -**Outcome:** All semantic pattern contracts approved and production-ready. \ No newline at end of file +**Outcome:** All semantic pattern contracts approved and production-ready. + +## Learnings + +### 2026-05-05T15:02:36-04:00: CLI compile-surface hardening for migrated apps (Bishop) + +- `src\BlazorWebFormsComponents.Cli\Scaffolding\ProjectScaffolder.cs` now emits `false` so copied legacy C# files are not blocked by repo-level IDE style analyzers during generated-app builds. +- Validator generic arguments must match BWFC component generic parameter names, not generic Razor conventions: `RequiredFieldValidator` needs `Type="string"`, while `CompareValidator` and `RangeValidator` need `InputType="string"`. The deterministic transform lives in `src\BlazorWebFormsComponents.Cli\Transforms\Markup\ValidatorGenericTypeTransform.cs`. +- Compiled `.razor.cs` emission is safer when the pipeline scans transformed markup for unresolved `@Method()`, `@_field`, and `OnClick="@Handler"` references and appends fallback stubs after the main code-behind transforms. The implementation lives in `src\BlazorWebFormsComponents.Cli\Transforms\CodeBehind\MarkupReferencedMemberStubTransform.cs` and depends on `MigrationPipeline.TransformMarkup()` persisting `metadata.MarkupContent`. +- Regression coverage for these fixes lives in `tests\BlazorWebFormsComponents.Cli.Tests\TransformUnit\ValidatorGenericTypeTransformTests.cs`, `tests\BlazorWebFormsComponents.Cli.Tests\TransformUnit\MarkupReferencedMemberStubTransformTests.cs`, `tests\BlazorWebFormsComponents.Cli.Tests\TransformUnit\CompiledCodeBehindStubPipelineTests.cs`, and `tests\BlazorWebFormsComponents.Cli.Tests\ScaffoldingTests.cs`. +- Verified command: `dotnet test .\tests\BlazorWebFormsComponents.Cli.Tests --nologo` (499 passed). + +### 2026-05-06T09:16:32-04:00: CLI-only deprecation audit for migration tooling (Bishop) + +- `migration-toolkit\scripts\bwfc-migrate.ps1` is now only a thin wrapper: it resolves the CLI project, forwards to `dotnet run --project ... -- migrate|prescan`, and returns before the legacy PowerShell implementation executes. +- The C# `MigrationPipeline` already owns scaffold/config/markup/code-behind/static/source/App_Start/redirect orchestration, but it still bridges `Migrate-NugetStaticAssets.ps1` and `Convert-EdmxToEfCore.ps1` through `NuGetStaticAssetExtractor` and `EdmxConverterBridge`. +- `bwfc-scan.ps1` still has no CLI equivalent; `PrescanAnalyzer` only covers C# migration-pattern scanning and currently omits the old PowerShell `BWFC021` master-page rule. +- The deprecation roadmap is captured in `dev-docs\cli-migration-plan.md`, with P0 focus on removing internal PowerShell runtime dependencies and adding CLI-first replacements for scan/assets/edmx workflows. + +### 2026-05-06T10:08:56-04:00: Native CLI replacements for assets and EDMX helpers (Bishop) + +- Added native C# services at `src\BlazorWebFormsComponents.Cli\Services\NuGetStaticAssetExtractor.cs` and `src\BlazorWebFormsComponents.Cli\Services\EdmxToEfCoreConverter.cs`, and rewired `Program.cs` plus `MigrationPipeline` to use them instead of the PowerShell bridge classes. +- The asset extractor now supports `packages.config` and top-level `PackageReference`, searches legacy `packages\` folders plus the global NuGet cache, writes `asset-manifest.json` and `AssetReferences.html`, and honors a manifest-only mode without copying files. +- The EDMX converter now parses conceptual/mapping metadata with `System.Xml.Linq`, generates POCO entities plus a DbContext with relationship configuration, and returns graceful failures for missing or invalid EDMX input. +- Added first-class CLI entrypoints for `scan`, `assets extract`, and `edmx convert`, then updated all four migration-toolkit PowerShell scripts with deprecation banners that point to the CLI command to use instead. +- Regression coverage lives in `tests\BlazorWebFormsComponents.Cli.Tests\Services\NuGetStaticAssetExtractorTests.cs` and `tests\BlazorWebFormsComponents.Cli.Tests\Services\EdmxToEfCoreConverterTests.cs`; verified with `dotnet build src\BlazorWebFormsComponents.Cli\BlazorWebFormsComponents.Cli.csproj` and `dotnet test tests\BlazorWebFormsComponents.Cli.Tests --nologo`. diff --git a/.squad/skills/cli-compile-surface-hardening/SKILL.md b/.squad/skills/cli-compile-surface-hardening/SKILL.md new file mode 100644 index 000000000..9a32944b7 --- /dev/null +++ b/.squad/skills/cli-compile-surface-hardening/SKILL.md @@ -0,0 +1,33 @@ +--- +name: "cli-compile-surface-hardening" +description: "Keep migrated Blazor output compiling when more Web Forms artifacts stay on the generated compile surface" +domain: "migration-tooling" +confidence: "high" +source: "earned" +--- + +## Context +Use this when the migration CLI starts emitting more generated `.razor.cs` files or copying more legacy source into the output project. The goal is to preserve deterministic L1 behavior while preventing compile failures from style analyzers, generic Razor component inference, or unresolved markup references. + +## Patterns +- In generated migration projects, prefer `false` over broad warning suppression when copied legacy files would otherwise fail repo-level IDE analyzers. +- Add validator generic arguments with the BWFC component's actual generic parameter name, not an assumed `TValue` alias: + - `RequiredFieldValidator` → `Type="string"` + - `CompareValidator` / `RangeValidator` → `InputType="string"` +- Run markup-driven member stub generation after the main code-behind transforms, using transformed Razor markup (`metadata.MarkupContent`) as the source of truth. +- Stub only the deterministic missing-member shapes that are safe to infer mechanically: + - `@MethodName()` → render-method stub returning `object?` + - `@_fieldName` → private `object?` field + - `OnClick="@HandlerName"` and similar → `void HandlerName(object? sender, EventArgs e)` +- Register new transforms in both production DI (`src\BlazorWebFormsComponents.Cli\Program.cs`) and `tests\BlazorWebFormsComponents.Cli.Tests\TestHelpers.cs` so the lightweight and full pipelines stay aligned. + +## Examples +- `src\BlazorWebFormsComponents.Cli\Transforms\Markup\ValidatorGenericTypeTransform.cs` +- `src\BlazorWebFormsComponents.Cli\Transforms\CodeBehind\MarkupReferencedMemberStubTransform.cs` +- `tests\BlazorWebFormsComponents.Cli.Tests\TransformUnit\CompiledCodeBehindStubPipelineTests.cs` + +## Anti-Patterns +- Suppressing all warnings/errors in the generated project when only code-style analyzers are the problem. +- Injecting `TValue="string"` into BWFC validators that actually expose `Type` or `InputType` generic parameters. +- Generating markup-reference stubs before markup transforms run, or reading original Web Forms markup instead of the transformed Razor output. +- Emitting placeholder stubs for every `@Identifier` token; restrict deterministic generation to the specific reference shapes you can classify safely. diff --git a/dev-docs/cli-migration-plan.md b/dev-docs/cli-migration-plan.md new file mode 100644 index 000000000..51ba7a4bb --- /dev/null +++ b/dev-docs/cli-migration-plan.md @@ -0,0 +1,203 @@ +# CLI Migration Plan + +## Executive Summary + +`migration-toolkit\scripts\bwfc-migrate.ps1` is already effectively deprecated in code: its live path now resolves the CLI project, forwards to `webforms-to-blazor migrate` or `webforms-to-blazor prescan`, and returns before the legacy PowerShell implementation runs. The real remaining gap is not the main orchestrator entrypoint; it is that the CLI still depends on PowerShell for two helper workflows (`Migrate-NugetStaticAssets.ps1` and `Convert-EdmxToEfCore.ps1`) and has no CLI replacement for `bwfc-scan.ps1`. + +## Section 1: What `bwfc-migrate.ps1` does + +### 1.1 Actual live behavior today + +1. Resolves `src\BlazorWebFormsComponents.Cli\BlazorWebFormsComponents.Cli.csproj` relative to the repository. +2. Builds `dotnet run --project -- ...` arguments. +3. If `-Prescan` is supplied, forwards to `webforms-to-blazor prescan --input `. +4. Otherwise forwards to `webforms-to-blazor migrate --input --output --overwrite`. +5. Maps `-SkipProjectScaffold` to `--skip-scaffold`, `-WhatIf` to `--dry-run`, and `-Verbose` to `--verbose`. +6. Exits with the CLI exit code. +7. Returns immediately; the large PowerShell implementation that follows is dead code for current callers. + +### 1.2 Legacy embedded migration pipeline still present below `return` + +The unreachable legacy body shows the original end-to-end orchestration the CLI has been porting: + +1. Resolve and validate source/output paths; derive and sanitize a project name. +2. Print migration banner and mode summary. +3. Create the output directory. +4. Generate project scaffold unless `-SkipProjectScaffold` is set: + - `.csproj` + - `Program.cs` + - `_Imports.razor` + - `Components\App.razor` + - `Components\Routes.razor` +5. Extract `Web.config` `appSettings` and `connectionStrings` into `appsettings.json`. +6. Discover `.aspx`, `.ascx`, and `.master` files. +7. For each Web Forms markup file: + 1. Determine the `.razor` output path. + 2. Detect redirect-handler pages and record manual follow-up. + 3. Add TODO headers for checkout/auth-heavy pages. + 4. Apply directive transforms (`Page`, `Master`, `Control`, `Import`, `Register`). + 5. Apply structural markup transforms (`Content`, `form`, master-page conversion). + 6. Convert expressions, login views, route helpers, and select methods. + 7. Convert AJAX Toolkit and `asp:` prefixes. + 8. Remove Web Forms-only attributes. + 9. Rewrite `~/` URL references. + 10. Fix template placeholders. + 11. Normalize booleans, enums, and unit values. + 12. Flag/remove `DataSourceID` and data-source controls. + 13. Write the `.razor` file. + 14. Copy/transform code-behind into `.razor.cs` (or VB equivalent). +8. Copy static files into `wwwroot\`. +9. Auto-detect CSS and inject `` tags into `Components\App.razor`. +10. Run `Migrate-NugetStaticAssets.ps1` to extract package static assets into `wwwroot\lib\` and emit `asset-manifest.json` plus `AssetReferences.html`. +11. Auto-detect JavaScript and inject `")); + } + + return string.Join(Environment.NewLine, lines) + Environment.NewLine; + } + + private static List SelectPreferredAssets(IEnumerable files) + { + return files + .GroupBy(GetAssetPreferenceKey, StringComparer.OrdinalIgnoreCase) + .Select(group => group + .OrderByDescending(file => file.Contains(".min.", StringComparison.OrdinalIgnoreCase)) + .ThenBy(file => file, StringComparer.OrdinalIgnoreCase) + .First()) + .OrderBy(file => file, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static string GetAssetPreferenceKey(string file) + { + return file.Replace(".min.", ".", StringComparison.OrdinalIgnoreCase); + } + + private sealed record PackageReferenceInfo(string Id, string Version); + + private sealed record DiscoveredAsset(string SourceFile, string RelativePath); + + private sealed class PackageReferenceInfoComparer : IEqualityComparer + { + public static PackageReferenceInfoComparer Instance { get; } = new(); + + public bool Equals(PackageReferenceInfo? x, PackageReferenceInfo? y) + { + if (x is null && y is null) + return true; + if (x is null || y is null) + return false; + + return string.Equals(x.Id, y.Id, StringComparison.OrdinalIgnoreCase) + && string.Equals(x.Version, y.Version, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(PackageReferenceInfo obj) + { + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Id), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Version)); + } + } +} + +public sealed record NuGetAssetExtractionOptions(string SourcePath, string OutputPath, string? PackagesPath = null, bool ManifestOnly = false); + +public sealed record NuGetAssetExtractionOutcome( + bool Success, + bool Skipped, + string? ErrorMessage, + string? ManifestPath, + string? AssetReferencesPath, + int PackagesWithAssets, + int TotalFilesExtracted) +{ + public static NuGetAssetExtractionOutcome SkippedResult(string message) => new(true, true, message, null, null, 0, 0); +} + +public sealed record NuGetAssetExtractionResult(int PackagesWithAssets, int TotalFilesExtracted); + +public sealed record NuGetAssetManifest( + DateTimeOffset Timestamp, + string SourceProject, + string PackagesFolder, + string OutputProject, + int TotalPackages, + int PackagesWithAssets, + int TotalFilesExtracted, + bool ManifestOnly, + IReadOnlyList Packages); + +public sealed record NuGetAssetManifestEntry( + string PackageId, + string Version, + string Status, + string Reason, + IReadOnlyList Files); diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/MarkupReferencedMemberStubTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/MarkupReferencedMemberStubTransform.cs new file mode 100644 index 000000000..4a6c13e5e --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/MarkupReferencedMemberStubTransform.cs @@ -0,0 +1,171 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind; + +/// +/// Adds compile-safe fallback members for identifiers that the converted markup +/// still references after the deterministic code-behind transform pass. +/// +public class MarkupReferencedMemberStubTransform : ICodeBehindTransform +{ + public string Name => "MarkupReferencedMemberStub"; + public int Order => 900; + + private static readonly Regex ClassOpenRegex = new( + @"partial\s+class\s+\w+[^\{]*\{", + RegexOptions.Compiled); + + private static readonly Regex MethodCallRegex = new( + @"(?[A-Za-z_]\w*)\s*\(", + RegexOptions.Compiled); + + private static readonly Regex FieldReferenceRegex = new( + @"(?_[A-Za-z]\w*)\b", + RegexOptions.Compiled); + + private static readonly Regex ParenthesizedFieldReferenceRegex = new( + @"@\(\s*(?_[A-Za-z]\w*)\s*\)", + RegexOptions.Compiled); + + private static readonly Regex EventHandlerRegex = new( + @"\b(?: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)\s*=\s*""@(?[A-Za-z_]\w*)""", + RegexOptions.Compiled); + + private static readonly HashSet RazorKeywords = new(StringComparer.OrdinalIgnoreCase) + { + "await", + "else", + "for", + "foreach", + "if", + "inherits", + "inject", + "layout", + "namespace", + "page", + "section", + "switch", + "using", + "while" + }; + + public string Apply(string content, FileMetadata metadata) + { + if (string.IsNullOrWhiteSpace(metadata.MarkupContent)) + { + return content; + } + + var classMatch = ClassOpenRegex.Match(content); + if (!classMatch.Success) + { + return content; + } + + var classOpenBraceIndex = content.IndexOf('{', classMatch.Index); + if (classOpenBraceIndex < 0) + { + return content; + } + + var classCloseBraceIndex = FindMatchingBrace(content, classOpenBraceIndex); + if (classCloseBraceIndex < 0) + { + return content; + } + + var fieldNames = CollectMatches(metadata.MarkupContent, FieldReferenceRegex) + .Concat(CollectMatches(metadata.MarkupContent, ParenthesizedFieldReferenceRegex)) + .Distinct(StringComparer.Ordinal) + .Where(name => !HasDeclaredMember(content, name)) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + var eventHandlerNames = CollectMatches(metadata.MarkupContent, EventHandlerRegex) + .Distinct(StringComparer.Ordinal) + .Where(name => !HasDeclaredMethod(content, name)) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + var renderMethodNames = CollectMatches(metadata.MarkupContent, MethodCallRegex) + .Where(name => !RazorKeywords.Contains(name)) + .Where(name => !eventHandlerNames.Contains(name, StringComparer.Ordinal)) + .Distinct(StringComparer.Ordinal) + .Where(name => !HasDeclaredMethod(content, name)) + .OrderBy(name => name, StringComparer.Ordinal) + .ToList(); + + if (fieldNames.Count == 0 && renderMethodNames.Count == 0 && eventHandlerNames.Count == 0) + { + return content; + } + + var stubs = new List(); + stubs.AddRange(fieldNames.Select(name => $" private object? {name}; // TODO: migrate from Web Forms code-behind")); + stubs.AddRange(renderMethodNames.Select(CreateRenderMethodStub)); + stubs.AddRange(eventHandlerNames.Select(CreateEventHandlerStub)); + + var stubBlock = Environment.NewLine + Environment.NewLine + string.Join(Environment.NewLine + Environment.NewLine, stubs) + Environment.NewLine; + return content.Insert(classCloseBraceIndex, stubBlock); + } + + private static IEnumerable CollectMatches(string markup, Regex regex) + { + foreach (Match match in regex.Matches(markup)) + { + var name = match.Groups["name"].Value; + if (!string.IsNullOrWhiteSpace(name)) + { + yield return name; + } + } + } + + private static string CreateRenderMethodStub(string name) => + $" protected object? {name}()\n {{\n // TODO: migrate from Web Forms code-behind\n return null;\n }}"; + + private static string CreateEventHandlerStub(string name) => + $" protected void {name}(object? sender, EventArgs e)\n {{\n // TODO: migrate from Web Forms code-behind\n }}"; + + private static bool HasDeclaredMember(string content, string name) + { + var escapedName = Regex.Escape(name); + return Regex.IsMatch( + content, + $@"(?:public|protected|private|internal)\s+(?:static\s+|readonly\s+|const\s+|volatile\s+|new\s+)*[\w<>,?.\[\]]+\s+{escapedName}\s*(?:[;={{])", + RegexOptions.Multiline) + || HasDeclaredMethod(content, name); + } + + private static bool HasDeclaredMethod(string content, string name) + { + var escapedName = Regex.Escape(name); + return Regex.IsMatch( + content, + $@"(?:public|protected|private|internal)\s+(?:static\s+|async\s+|virtual\s+|override\s+|sealed\s+|partial\s+|new\s+)*[\w<>,?.\[\]]+\s+{escapedName}\s*\(", + RegexOptions.Multiline); + } + + private static int FindMatchingBrace(string content, int openBraceIndex) + { + var depth = 0; + for (var i = openBraceIndex; i < content.Length; i++) + { + if (content[i] == '{') + { + depth++; + } + else if (content[i] == '}') + { + depth--; + if (depth == 0) + { + return i; + } + } + } + + return -1; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ValidatorGenericTypeTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ValidatorGenericTypeTransform.cs new file mode 100644 index 000000000..98420ee2c --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ValidatorGenericTypeTransform.cs @@ -0,0 +1,32 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.Markup; + +/// +/// Adds explicit generic type arguments to validator components whose BWFC API +/// requires them and defaults migrated form validators to string when the input +/// type cannot be inferred mechanically. +/// +public class ValidatorGenericTypeTransform : IMarkupTransform +{ + public string Name => "ValidatorGenericType"; + public int Order => 615; + + private static readonly (Regex Pattern, string TypeAttribute)[] ValidatorPatterns = + [ + (new Regex(@").)*\bType=)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "Type"), + (new Regex(@").)*\bInputType=)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "InputType"), + (new Regex(@").)*\bInputType=)", RegexOptions.Compiled | RegexOptions.IgnoreCase), "InputType") + ]; + + public string Apply(string content, FileMetadata metadata) + { + foreach (var (pattern, typeAttribute) in ValidatorPatterns) + { + content = pattern.Replace(content, match => $"{match.Value} {typeAttribute}=\"string\""); + } + + return content; + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs index 364858646..5e0392a8a 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs @@ -7,6 +7,8 @@ using BlazorWebFormsComponents.Cli.Pipeline; using BlazorWebFormsComponents.Cli.Scaffolding; using BlazorWebFormsComponents.Cli.SemanticPatterns; +using NativeEdmxToEfCoreConverter = BlazorWebFormsComponents.Cli.Services.EdmxToEfCoreConverter; +using NativeNuGetStaticAssetExtractor = BlazorWebFormsComponents.Cli.Services.NuGetStaticAssetExtractor; namespace BlazorWebFormsComponents.Cli.Tests; @@ -61,8 +63,8 @@ private static MigrationPipeline CreateFullPipeline(OutputWriter? writer = null) new SourceFileCopier(outputWriter, codeBehindTransforms), new AppStartCopier(outputWriter), new AppAssetInjector(outputWriter), - new NuGetStaticAssetExtractor(new PowerShellScriptRunner()), - new EdmxConverterBridge(new PowerShellScriptRunner()), + new NativeNuGetStaticAssetExtractor(), + new NativeEdmxToEfCoreConverter(), new RedirectHandlerAnnotator(outputWriter)); } diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs index 3941b3070..5f8a82c55 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs @@ -45,6 +45,7 @@ public void ProjectScaffolder_GeneratesCsproj() Assert.Contains("Fritz.BlazorWebFormsComponents", csproj); Assert.Contains("net10.0", csproj); Assert.Contains("enable", csproj); + Assert.Contains("false", csproj); Assert.Contains("Microsoft.NET.Sdk.Web", csproj); } diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternCatalogTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternCatalogTests.cs index d4183c9b2..4e29229dd 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternCatalogTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternCatalogTests.cs @@ -4,6 +4,8 @@ using BlazorWebFormsComponents.Cli.Pipeline; using BlazorWebFormsComponents.Cli.Scaffolding; using BlazorWebFormsComponents.Cli.SemanticPatterns; +using NativeEdmxToEfCoreConverter = BlazorWebFormsComponents.Cli.Services.EdmxToEfCoreConverter; +using NativeNuGetStaticAssetExtractor = BlazorWebFormsComponents.Cli.Services.NuGetStaticAssetExtractor; using BlazorWebFormsComponents.Cli.Transforms; namespace BlazorWebFormsComponents.Cli.Tests; @@ -111,8 +113,8 @@ [new AppendCodeBehindTransform()], new SourceFileCopier(outputWriter, []), new AppStartCopier(outputWriter), new AppAssetInjector(outputWriter), - new NuGetStaticAssetExtractor(new PowerShellScriptRunner()), - new EdmxConverterBridge(new PowerShellScriptRunner()), + new NativeNuGetStaticAssetExtractor(), + new NativeEdmxToEfCoreConverter(), new RedirectHandlerAnnotator(outputWriter)); var context = new MigrationContext diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/Services/EdmxToEfCoreConverterTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/Services/EdmxToEfCoreConverterTests.cs new file mode 100644 index 000000000..6c59e31ba --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/Services/EdmxToEfCoreConverterTests.cs @@ -0,0 +1,186 @@ +using BlazorWebFormsComponents.Cli.Services; + +namespace BlazorWebFormsComponents.Cli.Tests.Services; + +public sealed class EdmxToEfCoreConverterTests : IDisposable +{ + private readonly string _testRoot = Path.Combine(AppContext.BaseDirectory, "TestOutput", nameof(EdmxToEfCoreConverterTests), Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + Directory.Delete(_testRoot, recursive: true); + } + + [Fact] + public async Task ConvertAsync_SimpleEdmx_GeneratesEntitiesAndDbContext() + { + var sourceFile = CreateFile("simple", "Store.edmx", SampleEdmx); + var outputPath = CreateDirectory("simple", "generated"); + + var converter = new EdmxToEfCoreConverter(); + var result = await converter.ConvertAsync(new EdmxConversionOptions(sourceFile, outputPath, "Contoso.Models")); + + Assert.True(result.Success); + Assert.Equal(3, result.EntitiesGenerated); + Assert.True(result.DbContextGenerated); + Assert.True(File.Exists(Path.Combine(outputPath, "Customer.cs"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Order.cs"))); + Assert.True(File.Exists(Path.Combine(outputPath, "Product.cs"))); + Assert.True(File.Exists(Path.Combine(outputPath, "StoreContext.cs"))); + + var customerCode = await File.ReadAllTextAsync(Path.Combine(outputPath, "Customer.cs")); + Assert.Contains("namespace Contoso.Models;", customerCode); + Assert.Contains("[Table(\"tblCustomers\")]", customerCode); + Assert.Contains("[Column(\"CustomerId\")]", customerCode); + Assert.Contains("public string Name { get; set; }", customerCode); + + var dbContextCode = await File.ReadAllTextAsync(Path.Combine(outputPath, "StoreContext.cs")); + Assert.Contains("public DbSet Customers", dbContextCode); + Assert.Contains("public DbSet Orders", dbContextCode); + Assert.Contains("public DbSet Products", dbContextCode); + } + + [Fact] + public async Task ConvertAsync_NavigationProperties_GeneratesRelationshipConfiguration() + { + var sourceFile = CreateFile("relationships", "Store.edmx", SampleEdmx); + var outputPath = CreateDirectory("relationships", "generated"); + + var converter = new EdmxToEfCoreConverter(); + var result = await converter.ConvertAsync(new EdmxConversionOptions(sourceFile, outputPath, "Contoso.Models")); + + Assert.True(result.Success); + Assert.Equal(1, result.RelationshipsConfigured); + + var customerCode = await File.ReadAllTextAsync(Path.Combine(outputPath, "Customer.cs")); + var orderCode = await File.ReadAllTextAsync(Path.Combine(outputPath, "Order.cs")); + var dbContextCode = await File.ReadAllTextAsync(Path.Combine(outputPath, "StoreContext.cs")); + + Assert.Contains("public virtual ICollection Orders { get; set; } = [];", customerCode); + Assert.Contains("public virtual Customer? Customer { get; set; }", orderCode); + Assert.Contains("entity.HasOne(d => d.Customer)", dbContextCode); + Assert.Contains(".WithMany(p => p.Orders)", dbContextCode); + Assert.Contains(".HasForeignKey(d => d.CustomerId)", dbContextCode); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConvertAsync_MissingOrInvalidEdmx_ReturnsGracefulError(bool createInvalidFile) + { + var outputPath = CreateDirectory("invalid", createInvalidFile ? "bad" : "missing"); + var sourceFile = Path.Combine(outputPath, "Broken.edmx"); + if (createInvalidFile) + await File.WriteAllTextAsync(sourceFile, ""); + + var converter = new EdmxToEfCoreConverter(); + var result = await converter.ConvertAsync(new EdmxConversionOptions(sourceFile, outputPath, "Contoso.Models")); + + Assert.False(result.Success); + Assert.NotNull(result.ErrorMessage); + Assert.Empty(result.GeneratedFiles); + Assert.Contains(createInvalidFile ? "Unable to read EDMX XML" : "EDMX file not found", result.ErrorMessage!, StringComparison.OrdinalIgnoreCase); + } + + private string CreateDirectory(params string[] segments) + { + var path = Path.Combine([_testRoot, .. segments]); + Directory.CreateDirectory(path); + return path; + } + + private string CreateFile(string folder, string fileName, string content) + { + var directory = CreateDirectory(folder); + var filePath = Path.Combine(directory, fileName); + File.WriteAllText(filePath, content); + return filePath; + } + + private const string SampleEdmx = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + """; +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/Services/NuGetStaticAssetExtractorTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/Services/NuGetStaticAssetExtractorTests.cs new file mode 100644 index 000000000..e2d5d195f --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/Services/NuGetStaticAssetExtractorTests.cs @@ -0,0 +1,124 @@ +using System.Text.Json; +using BlazorWebFormsComponents.Cli.Services; + +namespace BlazorWebFormsComponents.Cli.Tests.Services; + +public sealed class NuGetStaticAssetExtractorTests : IDisposable +{ + private readonly string _testRoot = Path.Combine(AppContext.BaseDirectory, "TestOutput", nameof(NuGetStaticAssetExtractorTests), Guid.NewGuid().ToString("N")); + + public void Dispose() + { + if (Directory.Exists(_testRoot)) + Directory.Delete(_testRoot, recursive: true); + } + + [Fact] + public async Task ExtractAsync_PackagesDirectory_CopiesAssetsAndWritesManifest() + { + var sourcePath = CreateDirectory("basic", "source"); + var outputPath = CreateDirectory("basic", "output"); + var packagesPath = Path.Combine(sourcePath, "packages"); + Directory.CreateDirectory(packagesPath); + + await File.WriteAllTextAsync(Path.Combine(sourcePath, "packages.config"), """ + + + + + + """); + + await WriteFileAsync(Path.Combine(packagesPath, "bootstrap.5.3.3", "Content", "Content", "bootstrap.css"), "body { color: black; }"); + await WriteFileAsync(Path.Combine(packagesPath, "jQuery.3.7.1", "Scripts", "jquery-3.7.1.js"), "console.log('jquery');"); + + var extractor = new NuGetStaticAssetExtractor(); + var result = await extractor.ExtractAsync(new NuGetAssetExtractionOptions(sourcePath, outputPath, packagesPath)); + + Assert.True(result.Success); + Assert.False(result.Skipped); + Assert.Equal(2, result.PackagesWithAssets); + Assert.Equal(2, result.TotalFilesExtracted); + Assert.True(File.Exists(Path.Combine(outputPath, "wwwroot", "lib", "bootstrap", "bootstrap.css"))); + Assert.True(File.Exists(Path.Combine(outputPath, "wwwroot", "lib", "jQuery", "jquery-3.7.1.js"))); + + var manifestJson = await File.ReadAllTextAsync(Path.Combine(outputPath, "asset-manifest.json")); + Assert.Contains("\"PackageId\": \"bootstrap\"", manifestJson); + Assert.Contains("\"PackageId\": \"jQuery\"", manifestJson); + + var assetReferences = await File.ReadAllTextAsync(Path.Combine(outputPath, "AssetReferences.html")); + Assert.Contains("/lib/bootstrap/bootstrap.css", assetReferences); + Assert.Contains("/lib/jQuery/jquery-3.7.1.js", assetReferences); + } + + [Fact] + public async Task ExtractAsync_ManifestOnly_WritesManifestWithoutCopyingFiles() + { + var sourcePath = CreateDirectory("manifest-only", "source"); + var outputPath = CreateDirectory("manifest-only", "output"); + var packagesPath = Path.Combine(sourcePath, "packages"); + Directory.CreateDirectory(packagesPath); + + await File.WriteAllTextAsync(Path.Combine(sourcePath, "packages.config"), """ + + + + + """); + + await WriteFileAsync(Path.Combine(packagesPath, "bootstrap.5.3.3", "Content", "bootstrap.min.css"), "body { color: black; }"); + + var extractor = new NuGetStaticAssetExtractor(); + var result = await extractor.ExtractAsync(new NuGetAssetExtractionOptions(sourcePath, outputPath, packagesPath, ManifestOnly: true)); + + Assert.True(result.Success); + Assert.False(result.Skipped); + Assert.Equal(1, result.PackagesWithAssets); + Assert.False(File.Exists(Path.Combine(outputPath, "wwwroot", "lib", "bootstrap", "bootstrap.min.css"))); + + using var manifestDocument = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(outputPath, "asset-manifest.json"))); + Assert.True(manifestDocument.RootElement.GetProperty("ManifestOnly").GetBoolean()); + Assert.Equal("analyzed", manifestDocument.RootElement.GetProperty("Packages")[0].GetProperty("Status").GetString()); + Assert.True(File.Exists(Path.Combine(outputPath, "AssetReferences.html"))); + } + + [Fact] + public async Task ExtractAsync_MissingPackagesDirectory_CompletesWithoutCopyingFiles() + { + var sourcePath = CreateDirectory("missing-packages", "source"); + var outputPath = CreateDirectory("missing-packages", "output"); + var missingPackagesPath = Path.Combine(sourcePath, "packages"); + + await File.WriteAllTextAsync(Path.Combine(sourcePath, "packages.config"), """ + + + + + """); + + var extractor = new NuGetStaticAssetExtractor(); + var result = await extractor.ExtractAsync(new NuGetAssetExtractionOptions(sourcePath, outputPath, missingPackagesPath)); + + Assert.True(result.Success); + Assert.Equal(0, result.PackagesWithAssets); + Assert.Equal(0, result.TotalFilesExtracted); + Assert.False(Directory.Exists(Path.Combine(outputPath, "wwwroot", "lib"))); + Assert.True(File.Exists(Path.Combine(outputPath, "asset-manifest.json"))); + } + + private string CreateDirectory(params string[] segments) + { + var path = Path.Combine([_testRoot, .. segments]); + Directory.CreateDirectory(path); + return path; + } + + private static async Task WriteFileAsync(string path, string content) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + await File.WriteAllTextAsync(path, content); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/TestHelpers.cs b/tests/BlazorWebFormsComponents.Cli.Tests/TestHelpers.cs index 0fce30c53..1b6579dcf 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/TestHelpers.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/TestHelpers.cs @@ -184,9 +184,10 @@ public static MigrationPipeline CreateDefaultPipeline() // Order 510-520: Semantic controls new LoginViewTransform(), new SelectMethodTransform(), - // Order 600-610: Prefix stripping (Ajax before Asp) + // Order 600-620: Prefix stripping and validator typing new AjaxToolkitPrefixTransform(), new AspPrefixTransform(), + new ValidatorGenericTypeTransform(), // Order 700-750: Attributes & refs new AttributeStripTransform(), new EventWiringTransform(), @@ -222,6 +223,7 @@ public static MigrationPipeline CreateDefaultPipeline() new DataBindTransform(), new ClientScriptTransform(), new UrlCleanupTransform(), + new MarkupReferencedMemberStubTransform(), }; return new MigrationPipeline(markupTransforms, codeBehindTransforms, CreateDefaultSemanticPatterns()); diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/CompiledCodeBehindStubPipelineTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/CompiledCodeBehindStubPipelineTests.cs new file mode 100644 index 000000000..7154bd7be --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/CompiledCodeBehindStubPipelineTests.cs @@ -0,0 +1,30 @@ +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Tests.TransformUnit; + +public class CompiledCodeBehindStubPipelineTests +{ + [Fact] + public void Pipeline_UsesTransformedMarkup_WhenGeneratingMissingMemberStubs() + { + var pipeline = TestHelpers.CreateDefaultPipeline(); + var markup = "\n

@FormatTotal()

\n
@_orderTotal
"; + var codeBehind = "namespace TestApp;\n\npublic partial class CheckoutReview\n{\n}"; + var metadata = new FileMetadata + { + SourceFilePath = "CheckoutReview.aspx", + OutputFilePath = "CheckoutReview.razor.cs", + FileType = FileType.Page, + OriginalContent = markup, + CodeBehindContent = codeBehind + }; + + var transformedMarkup = pipeline.TransformMarkup(markup, metadata); + var transformedCodeBehind = pipeline.TransformCodeBehind(codeBehind, metadata); + + Assert.Contains("OnClick=\"@SubmitOrder\"", transformedMarkup); + Assert.Contains("protected void SubmitOrder(object? sender, EventArgs e)", transformedCodeBehind); + Assert.Contains("protected object? FormatTotal()", transformedCodeBehind); + Assert.Contains("private object? _orderTotal;", transformedCodeBehind); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/MarkupReferencedMemberStubTransformTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/MarkupReferencedMemberStubTransformTests.cs new file mode 100644 index 000000000..947ca29a1 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/MarkupReferencedMemberStubTransformTests.cs @@ -0,0 +1,50 @@ +using BlazorWebFormsComponents.Cli.Pipeline; +using BlazorWebFormsComponents.Cli.Transforms.CodeBehind; + +namespace BlazorWebFormsComponents.Cli.Tests.TransformUnit; + +public class MarkupReferencedMemberStubTransformTests +{ + private readonly MarkupReferencedMemberStubTransform _transform = new(); + + [Fact] + public void AddsFieldRenderMethodAndEventHandlerStubs_WhenMarkupReferencesAreMissing() + { + var metadata = new FileMetadata + { + SourceFilePath = "CheckoutReview.aspx", + OutputFilePath = "CheckoutReview.razor.cs", + FileType = FileType.Page, + OriginalContent = string.Empty, + MarkupContent = " - +

Login

+ @if (Registered.GetValueOrDefault() != 0) + { +

Registration succeeded. Please log in.

+ } + @if (!string.IsNullOrWhiteSpace(LoggedIn)) + { +

Hello, @LoggedIn!

+

Manage your account

+

Log out

+ } + @if (!string.IsNullOrWhiteSpace(Error)) + { +

@Error

+ } + + @if (!string.IsNullOrWhiteSpace(ReturnUrl)) + { + + } +
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ +
- -

Register as a new user

- +
+
+
+
+ +
+
+ +

Register as a new user

+

Forgot your password?

+ @code { - private string? Error => GetQueryValue("error"); + [Parameter, SupplyParameterFromQuery(Name = "error")] public string? Error { get; set; } - private int? Registered => int.TryParse(GetQueryValue("registered"), out var registered) ? registered : null; + [Parameter, SupplyParameterFromQuery(Name = "registered")] public int? Registered { get; set; } - private string? GetQueryValue(string key) - { - var uri = Navigation.ToAbsoluteUri(Navigation.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - return query.TryGetValue(key, out var value) ? value.ToString() : null; - } -} + [Parameter, SupplyParameterFromQuery(Name = "returnUrl")] public string? ReturnUrl { get; set; } + + [Parameter, SupplyParameterFromQuery(Name = "loggedIn")] public string? LoggedIn { get; set; } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/Account/Manage.razor b/samples/AfterWingtipToys/Account/Manage.razor index ed913ca37..d21cb78d3 100644 --- a/samples/AfterWingtipToys/Account/Manage.razor +++ b/samples/AfterWingtipToys/Account/Manage.razor @@ -1,22 +1,70 @@ -@page "/Account/Manage" -@inject UserStoreService UserStore - -Manage your account +@page "/Manage" +Manage Account - -

Manage your account

- @if (string.IsNullOrWhiteSpace(CurrentUser)) - { -

Please log in to manage your account.

- } - else - { -

You are signed in as @CurrentUser.

-

Log out

- } -
-
+ + +

@(Title).

+ +
+ +

@(SuccessMessage)

+
+
+ +
+
+
+

Change your account settings

+
+
+
Password:
+
+ + +
+
External Logins:
+
@(LoginsCount) + -@code { - private string? CurrentUser => UserStore.GetCurrentUserEmail(); -} +
+ @* + Phone Numbers can used as a second factor of verification in a two-factor authentication system. + See this article + for details on setting up this ASP.NET application to support two-factor authentication using SMS. + Uncomment the following block after you have set up two-factor authentication + *@ + +
Phone Number:
+ @* + <% if (HasPhoneNumber) + { %> +
+ +
+ <% } + else + { %> +
+
+ <% } %> + *@ + +
Two-Factor Authentication:
+
+

+ There are no two-factor authentication providers configured. See this article + for details on setting up this ASP.NET application to support two-factor authentication. +

+ @* Two-factor management actions require manual ASP.NET Core Identity migration. *@ +
+
+
+
+
+ +
+
+ diff --git a/samples/AfterWingtipToys/Account/ManageLogins.razor b/samples/AfterWingtipToys/Account/ManageLogins.razor index 6eaf3399e..b2c0de39d 100644 --- a/samples/AfterWingtipToys/Account/ManageLogins.razor +++ b/samples/AfterWingtipToys/Account/ManageLogins.razor @@ -1,9 +1,46 @@ -@page "/Account/ManageLogins" - -Manage your external logins +@page "/ManageLogins" + - -

Manage external logins

-

External login providers are not configured in the migrated benchmark sample.

-
+ + +

Manage your external logins.

+ +

@(SuccessMessage)

+
+
+
+ + + @* TODO(bwfc-select-method): Review DeleteMethod="RemoveLogin" migration for BWFC event/CRUD handling *@ + + +

Registered Logins

+ + + @context + +
+ +
+ + + @context.LoginProvider + +
+
+
+ +
+
+
diff --git a/samples/AfterWingtipToys/Account/ManagePassword.razor b/samples/AfterWingtipToys/Account/ManagePassword.razor index 9de34d0be..31904c956 100644 --- a/samples/AfterWingtipToys/Account/ManagePassword.razor +++ b/samples/AfterWingtipToys/Account/ManagePassword.razor @@ -1,9 +1,56 @@ -@page "/Account/ManagePassword" - -Change password +@page "/ManagePassword" +Manage Password - -

Change password

-

Password management is not implemented in the migrated benchmark sample.

-
+ + +

Manage Password

+ @* TODO(bwfc-identity): Wire this account page to ASP.NET Core Identity or your app's authentication service. *@ + @* TODO(bwfc-identity): Recreate validation and submit handling with EditForm, minimal APIs, or an equivalent SSR-safe endpoint. *@ + @if (!string.IsNullOrWhiteSpace(Error)) + { +

@Error

+ } +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ +@code { + [Parameter, SupplyParameterFromQuery(Name = "error")] public string? Error { get; set; } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/Account/OpenAuthProviders.razor b/samples/AfterWingtipToys/Account/OpenAuthProviders.razor index d40351c01..e300fe201 100644 --- a/samples/AfterWingtipToys/Account/OpenAuthProviders.razor +++ b/samples/AfterWingtipToys/Account/OpenAuthProviders.razor @@ -1,5 +1,20 @@

Use another service to log in.


-

External login providers are not configured in this migrated benchmark sample.

+ + +

+ +

+
+ +
+

There are no external authentication services configured. See this article for details on setting up this ASP.NET application to support logging in via external services.

+
+
+
diff --git a/samples/AfterWingtipToys/Account/Register.razor b/samples/AfterWingtipToys/Account/Register.razor index 8ac0e148b..102399c44 100644 --- a/samples/AfterWingtipToys/Account/Register.razor +++ b/samples/AfterWingtipToys/Account/Register.razor @@ -1,51 +1,46 @@ +@page "/Register" @page "/Account/Register" -@inject NavigationManager Navigation - Register -

Register

- @if (!string.IsNullOrWhiteSpace(Error)) - { -

@Error

- } -
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
- -
-
-
-
+

Register

+ @* TODO(bwfc-identity): Wire this account page to ASP.NET Core Identity or your app's authentication service. *@ + @* TODO(bwfc-identity): Recreate validation and submit handling with EditForm, minimal APIs, or an equivalent SSR-safe endpoint. *@ + @if (!string.IsNullOrWhiteSpace(Error)) + { +

@Error

+ } +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+

Already have an account? Sign in

+
@code { - private string? Error => GetQueryValue("error"); - - private string? GetQueryValue(string key) - { - var uri = Navigation.ToAbsoluteUri(Navigation.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - return query.TryGetValue(key, out var value) ? value.ToString() : null; - } -} + [Parameter, SupplyParameterFromQuery(Name = "error")] public string? Error { get; set; } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/Account/RegisterExternalLogin.razor b/samples/AfterWingtipToys/Account/RegisterExternalLogin.razor index 51d1fc3a4..1730a6699 100644 --- a/samples/AfterWingtipToys/Account/RegisterExternalLogin.razor +++ b/samples/AfterWingtipToys/Account/RegisterExternalLogin.razor @@ -1,9 +1,37 @@ -@page "/Account/RegisterExternalLogin" - -Register external login +@page "/RegisterExternalLogin" +Register an external login - -

Register external login

-

External login providers are not configured in the migrated benchmark sample.

-
+ + +

Register with your @(ProviderName) account

+ + +
+

Association Form

+
+ +

+ You've authenticated with @(ProviderName). Please enter an email below for the current site + and click the Log in button. +

+ +
+ +
+ + + +
+
+ +
+
+
+
+
+
+
+
diff --git a/samples/AfterWingtipToys/Account/ResetPassword.razor b/samples/AfterWingtipToys/Account/ResetPassword.razor index 74b76bd3b..870ffc995 100644 --- a/samples/AfterWingtipToys/Account/ResetPassword.razor +++ b/samples/AfterWingtipToys/Account/ResetPassword.razor @@ -1,9 +1,44 @@ -@page "/Account/ResetPassword" - -Reset password +@page "/ResetPassword" +Reset Password - -

Reset password

-

Password reset is not implemented in the migrated benchmark sample.

-
+ + +

Reset Password

+ @* TODO(bwfc-identity): Wire this account page to ASP.NET Core Identity or your app's authentication service. *@ + @* TODO(bwfc-identity): Recreate validation and submit handling with EditForm, minimal APIs, or an equivalent SSR-safe endpoint. *@ + @if (!string.IsNullOrWhiteSpace(Error)) + { +

@Error

+ } +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+
+ +@code { + [Parameter, SupplyParameterFromQuery(Name = "error")] public string? Error { get; set; } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/Account/ResetPasswordConfirmation.razor b/samples/AfterWingtipToys/Account/ResetPasswordConfirmation.razor index 1cfe5867c..d55360ae8 100644 --- a/samples/AfterWingtipToys/Account/ResetPasswordConfirmation.razor +++ b/samples/AfterWingtipToys/Account/ResetPasswordConfirmation.razor @@ -1,9 +1,12 @@ -@page "/Account/ResetPasswordConfirmation" - -Reset password confirmation +@page "/ResetPasswordConfirmation" +Password Changed - -

Reset password confirmation

-

Your password has been reset. Please click here to log in.

-
+ + +

@(Title).

+
+

Your password has been changed. Click here to login

+
+
+
diff --git a/samples/AfterWingtipToys/Account/TwoFactorAuthenticationSignIn.razor b/samples/AfterWingtipToys/Account/TwoFactorAuthenticationSignIn.razor index 35833a7a5..66431fbc5 100644 --- a/samples/AfterWingtipToys/Account/TwoFactorAuthenticationSignIn.razor +++ b/samples/AfterWingtipToys/Account/TwoFactorAuthenticationSignIn.razor @@ -1,9 +1,54 @@ -@page "/Account/TwoFactorAuthenticationSignIn" - -Two-factor authentication sign in +@page "/TwoFactorAuthenticationSignIn" +Two-Factor Authentication - -

Two-factor authentication sign in

-

Two-factor authentication is not implemented in the migrated benchmark sample.

-
+ + +

@(Title).

+ +
+

Send verification code

+
+
+
+ Select Two-Factor Authentication Provider: + + +
+
+
+
+ +
+

Enter verification code

+
+ + +

+ +

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/AfterWingtipToys/Account/VerifyPhoneNumber.razor b/samples/AfterWingtipToys/Account/VerifyPhoneNumber.razor index 27e06ba6c..b881a6167 100644 --- a/samples/AfterWingtipToys/Account/VerifyPhoneNumber.razor +++ b/samples/AfterWingtipToys/Account/VerifyPhoneNumber.razor @@ -1,9 +1,32 @@ -@page "/Account/VerifyPhoneNumber" - -Verify phone number +@page "/VerifyPhoneNumber" +Verify Phone Number - -

Verify phone number

-

Phone verification is not implemented in the migrated benchmark sample.

-
+ + +

@(Title).

+

+ +

+
+

Enter verification code

+
+ + +
+ +
+ + +
+
+
+
+
+
+
+
+
diff --git a/samples/AfterWingtipToys/AddToCart.razor b/samples/AfterWingtipToys/AddToCart.razor index 6d3e2b027..9dadd3636 100644 --- a/samples/AfterWingtipToys/AddToCart.razor +++ b/samples/AfterWingtipToys/AddToCart.razor @@ -1,9 +1,15 @@ @page "/AddToCart" + -Add To Cart - - -

Add To Cart

-

If you are seeing this page directly, view your cart.

-
-
+ + + + + +
+
+ +
+
+ + diff --git a/samples/AfterWingtipToys/AddToCart.razor.cs b/samples/AfterWingtipToys/AddToCart.razor.cs index 125a9f483..4fcc68f3b 100644 --- a/samples/AfterWingtipToys/AddToCart.razor.cs +++ b/samples/AfterWingtipToys/AddToCart.razor.cs @@ -49,22 +49,27 @@ protected override async Task OnInitializedAsync() // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior await base.OnInitializedAsync(); - string rawId = Request.QueryString["ProductID"]; + string rawId = Request.QueryString["ProductID"].ToString(); + if (string.IsNullOrWhiteSpace(rawId)) + { + rawId = Request.QueryString["productID"].ToString(); + } + int productId; if (!String.IsNullOrEmpty(rawId) && int.TryParse(rawId, out productId)) { using (ShoppingCartActions usersShoppingCart = new ShoppingCartActions()) { - usersShoppingCart.AddToCart(Convert.ToInt16(rawId)); + usersShoppingCart.AddToCart(productId); } - } else { Debug.Fail("ERROR : We should never get to AddToCart.aspx without a ProductId."); throw new Exception("ERROR : It is illegal to load AddToCart.aspx without setting a ProductId."); } - Response.Redirect("ShoppingCart.aspx"); + + Response.Redirect("/ShoppingCart"); } } } \ No newline at end of file diff --git a/samples/AfterWingtipToys/Admin/AdminPage.razor b/samples/AfterWingtipToys/Admin/AdminPage.razor index 37bfefa54..5f9df48f9 100644 --- a/samples/AfterWingtipToys/Admin/AdminPage.razor +++ b/samples/AfterWingtipToys/Admin/AdminPage.razor @@ -1,9 +1,71 @@ -@page "/Admin/AdminPage" - -Admin Page +@page "/AdminPage" + - -

Admin Page

-

Administration features are not implemented in the migrated benchmark sample.

-
-
+ + +

Administration

+
+

Add Product:

+ + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + +
+ + +
+ + + +
+ + +
+

+

+ - - Back to Products - - +
+

@Product.ProductName

+
+ + + + + + +
+ @Product.ProductName +   + Description:
@Product.Description +
+ Price: @($"{Product.UnitPrice:c}") +
+ Product Number: @Product.ProductID +
+
+ + + Add To Cart + + +
}
- -@code { - private int? ProductId => TryGetIntQueryValue("id"); - - private Product? Product => ProductId.HasValue ? Catalog.GetProduct(ProductId.Value) : null; - - private int? TryGetIntQueryValue(string key) - { - var uri = Navigation.ToAbsoluteUri(Navigation.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - return query.TryGetValue(key, out var value) && int.TryParse(value, out var parsed) - ? parsed - : null; - } -} diff --git a/samples/AfterWingtipToys/ProductDetails.razor.cs b/samples/AfterWingtipToys/ProductDetails.razor.cs index 25d4e5be0..6e86677af 100644 --- a/samples/AfterWingtipToys/ProductDetails.razor.cs +++ b/samples/AfterWingtipToys/ProductDetails.razor.cs @@ -1,72 +1,42 @@ -// ============================================================================= -// TODO(bwfc-general): This code-behind was copied from Web Forms and needs manual migration. -// -// Common transforms needed (use the BWFC Copilot skill for assistance): -// TODO(bwfc-lifecycle): Page_Load / Page_Init → OnInitializedAsync / OnParametersSetAsync -// TODO(bwfc-lifecycle): Page_PreRender → OnAfterRenderAsync -// TODO(bwfc-ispostback): IsPostBack checks → remove or convert to state logic -// TODO(bwfc-viewstate): ViewState usage → component [Parameter] or private fields -// TODO(bwfc-session-state): Session/Cache access → auto-wired on WebFormsPageBase via SessionShim/CacheShim -// TODO(bwfc-navigation): Response.Redirect → auto-wired on WebFormsPageBase via ResponseShim -// TODO(bwfc-form): Request.Form["key"] → auto-wired on WebFormsPageBase via FormShim (use for interactive mode) -// TODO(bwfc-server): Server.MapPath/HtmlEncode → auto-wired on WebFormsPageBase via ServerShim -// TODO(bwfc-config): ConfigurationManager.AppSettings → BWFC shim (call app.UseConfigurationManagerShim() in Program.cs) -// TODO(bwfc-general): ClientScript.RegisterStartupScript → auto-wired on WebFormsPageBase via ClientScriptShim -// TODO(bwfc-general): Event handlers (Button_Click, etc.) → convert to Blazor event callbacks -// TODO(bwfc-datasource): Data binding (DataBind, DataSource) → component parameters or OnInitialized -// TODO(bwfc-general): ScriptManager code-behind references → use ScriptManagerShim via ScriptManager.GetCurrent(this) -// TODO(bwfc-general): UpdatePanel markup preserved by BWFC (ContentTemplate supported) — remove only code-behind API calls -// TODO(bwfc-general): User controls → Blazor component references -// ============================================================================= -using System; -using System.Collections.Generic; -using System.Linq; +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; using WingtipToys.Models; + namespace WingtipToys { public partial class ProductDetails { - // TODO(bwfc-general): ClientScript calls preserved — works via WebFormsPageBase (no injection needed). ScriptManagerShim may need @inject ScriptManagerShim ScriptManager for non-page classes. + [Inject] private ProductContext Db { get; set; } = default!; - // --- Request.Form Migration --- - // TODO(bwfc-form): Request.Form calls work automatically via RequestShim on WebFormsPageBase. - // For interactive mode, wrap your form in . - // Form keys found: key - // For non-page classes, inject RequestShim via DI. + [Parameter, SupplyParameterFromQuery(Name = "ProductID")] + public int? ProductId { get; set; } - private FormView productDetail = default!; - // --- ConfigurationManager Migration --- - // TODO(bwfc-config): ConfigurationManager calls work via BWFC shim. - // Ensure app.UseConfigurationManagerShim() is called in Program.cs. + [Parameter] + public string? ProductName { get; set; } - protected override async Task OnInitializedAsync() - { - // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior - await base.OnInitializedAsync(); + protected Product? Product { get; private set; } + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); - } + IQueryable query = Db.Products.AsNoTracking(); - public IQueryable GetProduct( - [QueryString("ProductID")] int? productId, - [RouteData] string productName) - { - var _db = new WingtipToys.Models.ProductContext(); - IQueryable query = _db.Products; - if (productId.HasValue && productId > 0) + if (ProductId.HasValue && ProductId.Value > 0) { - query = query.Where(p => p.ProductID == productId); + query = query.Where(p => p.ProductID == ProductId.Value); } - else if (!String.IsNullOrEmpty(productName)) + else if (!string.IsNullOrWhiteSpace(ProductName)) { - query = query.Where(p => - String.Compare(p.ProductName, productName) == 0); + query = query.Where(p => p.ProductName == ProductName); } else { - query = null; + Product = null; + return; } - return query; + + Product = await query.FirstOrDefaultAsync(); } } -} \ No newline at end of file +} diff --git a/samples/AfterWingtipToys/ProductList.razor b/samples/AfterWingtipToys/ProductList.razor index fd8afe78f..731df66de 100644 --- a/samples/AfterWingtipToys/ProductList.razor +++ b/samples/AfterWingtipToys/ProductList.razor @@ -1,63 +1,85 @@ @page "/ProductList" -@inject CatalogService Catalog -@inject NavigationManager Navigation - +@page "/Category/{CategoryName}" Products - - -

@Heading

-
- @foreach (var product in Products) - { -
-
- - @product.ProductName - -
-

- @product.ProductName -

-

@product.Description

-

@($"{product.UnitPrice:c}")

-

- View Details -

-
-
-
- } -
-
-
-
- -@code { - private int? CategoryId => TryGetIntQueryValue("id"); - - private IReadOnlyList Products => Catalog.GetProducts(CategoryId); + +
+
+
+

@(Page.Title)

+
- private string Heading - { - get - { - if (!CategoryId.HasValue) - { - return "All Products"; - } - - var category = Catalog.GetCategories().FirstOrDefault(c => c.CategoryID == CategoryId.Value); - return category is null ? "Products" : category.CategoryName; - } - } - - private int? TryGetIntQueryValue(string key) - { - var uri = Navigation.ToAbsoluteUri(Navigation.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - return query.TryGetValue(key, out var value) && int.TryParse(value, out var parsed) - ? parsed - : null; - } -} + + + + + + +
No data was returned.
+
+ + + + + + @context + + + + + + + + + + + + + + +
+ + @Item.ProductName + +
+ + @Item.ProductName + +
+ + Price: @($"{Item.UnitPrice:c}") + +
+ + + Add To Cart + + +
 
+ +
+ + + + + + + + + + + +
+ + @context +
+
+
+
+
+
+
+ diff --git a/samples/AfterWingtipToys/ProductList.razor.cs b/samples/AfterWingtipToys/ProductList.razor.cs index db07f7b56..6ea1be3bb 100644 --- a/samples/AfterWingtipToys/ProductList.razor.cs +++ b/samples/AfterWingtipToys/ProductList.razor.cs @@ -1,71 +1,42 @@ -// ============================================================================= -// TODO(bwfc-general): This code-behind was copied from Web Forms and needs manual migration. -// -// Common transforms needed (use the BWFC Copilot skill for assistance): -// TODO(bwfc-lifecycle): Page_Load / Page_Init → OnInitializedAsync / OnParametersSetAsync -// TODO(bwfc-lifecycle): Page_PreRender → OnAfterRenderAsync -// TODO(bwfc-ispostback): IsPostBack checks → remove or convert to state logic -// TODO(bwfc-viewstate): ViewState usage → component [Parameter] or private fields -// TODO(bwfc-session-state): Session/Cache access → auto-wired on WebFormsPageBase via SessionShim/CacheShim -// TODO(bwfc-navigation): Response.Redirect → auto-wired on WebFormsPageBase via ResponseShim -// TODO(bwfc-form): Request.Form["key"] → auto-wired on WebFormsPageBase via FormShim (use for interactive mode) -// TODO(bwfc-server): Server.MapPath/HtmlEncode → auto-wired on WebFormsPageBase via ServerShim -// TODO(bwfc-config): ConfigurationManager.AppSettings → BWFC shim (call app.UseConfigurationManagerShim() in Program.cs) -// TODO(bwfc-general): ClientScript.RegisterStartupScript → auto-wired on WebFormsPageBase via ClientScriptShim -// TODO(bwfc-general): Event handlers (Button_Click, etc.) → convert to Blazor event callbacks -// TODO(bwfc-datasource): Data binding (DataBind, DataSource) → component parameters or OnInitialized -// TODO(bwfc-general): ScriptManager code-behind references → use ScriptManagerShim via ScriptManager.GetCurrent(this) -// TODO(bwfc-general): UpdatePanel markup preserved by BWFC (ContentTemplate supported) — remove only code-behind API calls -// TODO(bwfc-general): User controls → Blazor component references -// ============================================================================= -using System; -using System.Collections.Generic; -using System.Linq; +using Microsoft.AspNetCore.Components; +using Microsoft.EntityFrameworkCore; using WingtipToys.Models; + namespace WingtipToys { public partial class ProductList { - // TODO(bwfc-general): ClientScript calls preserved — works via WebFormsPageBase (no injection needed). ScriptManagerShim may need @inject ScriptManagerShim ScriptManager for non-page classes. - - // --- Request.Form Migration --- - // TODO(bwfc-form): Request.Form calls work automatically via RequestShim on WebFormsPageBase. - // For interactive mode, wrap your form in . - // Form keys found: key - // For non-page classes, inject RequestShim via DI. + [Inject] private ProductContext Db { get; set; } = default!; private ListView productList = default!; - // --- ConfigurationManager Migration --- - // TODO(bwfc-config): ConfigurationManager calls work via BWFC shim. - // Ensure app.UseConfigurationManagerShim() is called in Program.cs. - protected override async Task OnInitializedAsync() - { - // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior - await base.OnInitializedAsync(); + [Parameter, SupplyParameterFromQuery(Name = "id")] + public int? CategoryId { get; set; } + [Parameter] + public string? CategoryName { get; set; } - } + protected IReadOnlyList Products { get; private set; } = []; - public IQueryable GetProducts( - [QueryString("id")] int? categoryId, - [RouteData] string categoryName) + protected override async Task OnParametersSetAsync() { - var _db = new WingtipToys.Models.ProductContext(); - IQueryable query = _db.Products; + await base.OnParametersSetAsync(); + + IQueryable query = Db.Products + .Include(p => p.Category) + .OrderBy(p => p.ProductID); - if (categoryId.HasValue && categoryId > 0) + if (CategoryId.HasValue && CategoryId.Value > 0) { - query = query.Where(p => p.CategoryID == categoryId); + query = query.Where(p => p.CategoryID == CategoryId.Value); } - if (!String.IsNullOrEmpty(categoryName)) + if (!string.IsNullOrWhiteSpace(CategoryName)) { - query = query.Where(p => - String.Compare(p.Category.CategoryName, - categoryName) == 0); + query = query.Where(p => p.Category != null && p.Category.CategoryName == CategoryName); } - return query; + + Products = await query.ToListAsync(); } } -} \ No newline at end of file +} diff --git a/samples/AfterWingtipToys/Program.cs b/samples/AfterWingtipToys/Program.cs index b34e265ba..6f90058bc 100644 --- a/samples/AfterWingtipToys/Program.cs +++ b/samples/AfterWingtipToys/Program.cs @@ -1,55 +1,66 @@ +using System.Collections.Concurrent; using BlazorWebFormsComponents; -using WingtipToys.Services; +using Microsoft.EntityFrameworkCore; +using WingtipToys.Logic; +using WingtipToys.Models; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents(); builder.Services.AddBlazorWebFormsComponents(); -builder.Services.AddDistributedMemoryCache(); -builder.Services.AddSession(options => -{ - options.Cookie.HttpOnly = true; - options.Cookie.IsEssential = true; - options.IdleTimeout = TimeSpan.FromMinutes(30); -}); -builder.Services.AddHttpContextAccessor(); +builder.Services.AddDbContext(options => + options.UseInMemoryDatabase("WingtipToys")); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); +var registeredUsers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var app = builder.Build(); if (!app.Environment.IsDevelopment()) { - app.UseExceptionHandler("/ErrorPage"); + app.UseExceptionHandler("/Error"); app.UseHsts(); } +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); + + if (!db.Categories.Any()) + { + db.Categories.AddRange(GetCategories()); + db.Products.AddRange(GetProducts()); + db.SaveChanges(); + } +} + app.UseHttpsRedirection(); app.MapStaticAssets(); -app.UseSession(); app.UseAntiforgery(); -app.MapGet("/AddToCart", (int productID, CartService cartService) => +app.MapGet("/Account/PerformLogin", (string? email, string? password, string? returnUrl) => { - cartService.AddToCart(productID); - return Results.Redirect("/ShoppingCart"); -}); + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) + { + return Results.Redirect("/Account/Login?error=Email%20and%20password%20are%20required"); + } -app.MapGet("/Cart/Update", (int productId, int quantity, CartService cartService) => -{ - cartService.UpdateQuantity(productId, quantity); - return Results.Redirect("/ShoppingCart"); -}); + if (registeredUsers.TryGetValue(email, out var storedPassword) && + !string.Equals(storedPassword, password, StringComparison.Ordinal)) + { + return Results.Redirect("/Account/Login?error=Invalid%20email%20or%20password"); + } -app.MapGet("/Cart/Remove", (int productId, CartService cartService) => -{ - cartService.Remove(productId); - return Results.Redirect("/ShoppingCart"); + var target = $"/Account/Login?loggedIn={Uri.EscapeDataString(email)}"; + if (!string.IsNullOrWhiteSpace(returnUrl)) + { + target += $"&returnUrl={Uri.EscapeDataString(returnUrl)}"; + } + + return Results.Redirect(target); }); -app.MapGet("/Account/PerformRegister", (string? email, string? password, string? confirmPassword, UserStoreService userStore) => +app.MapGet("/Account/PerformRegister", (string? email, string? password, string? confirmPassword) => { if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) { @@ -61,29 +72,181 @@ return Results.Redirect("/Account/Register?error=Passwords%20do%20not%20match"); } - return userStore.Register(email, password, out var registerError) - ? Results.Redirect("/Account/Login?registered=1") - : Results.Redirect($"/Account/Register?error={Uri.EscapeDataString(registerError ?? "Registration failed")}"); + registeredUsers[email] = password; + return Results.Redirect("/Account/Login?registered=1"); }); -app.MapGet("/Account/PerformLogin", (string? email, string? password, UserStoreService userStore) => +app.MapGet("/ShoppingCart/Update", (int productId, int quantity) => { - if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) - { - return Results.Redirect("/Account/Login?error=Email%20and%20password%20are%20required"); - } - - return userStore.Login(email, password, out var loginError) - ? Results.Redirect("/") - : Results.Redirect($"/Account/Login?error={Uri.EscapeDataString(loginError ?? "Invalid login")}"); + using var shoppingCart = new ShoppingCartActions(); + shoppingCart.UpdateItem(shoppingCart.GetCartId(), productId, Math.Max(quantity, 1)); + return Results.Redirect("/ShoppingCart"); }); -app.MapGet("/Account/Logout", (UserStoreService userStore) => +app.MapGet("/ShoppingCart/Remove", (int productId) => { - userStore.Logout(); - return Results.Redirect("/"); + using var shoppingCart = new ShoppingCartActions(); + shoppingCart.RemoveItem(shoppingCart.GetCartId(), productId); + return Results.Redirect("/ShoppingCart"); }); app.MapRazorComponents(); app.Run(); + +static List GetCategories() => +[ + new Category { CategoryID = 1, CategoryName = "Cars", Description = "Toy cars" }, + new Category { CategoryID = 2, CategoryName = "Planes", Description = "Toy planes" }, + new Category { CategoryID = 3, CategoryName = "Trucks", Description = "Toy trucks" }, + new Category { CategoryID = 4, CategoryName = "Boats", Description = "Toy boats" }, + new Category { CategoryID = 5, CategoryName = "Rockets", Description = "Toy rockets" } +]; + +static List GetProducts() => +[ + new Product + { + ProductID = 1, + ProductName = "Convertible Car", + Description = "This convertible car is fast! The engine is powered by a neutrino based battery (not included).Power it up and let it go!", + ImagePath = "carconvert.png", + UnitPrice = 22.50, + CategoryID = 1 + }, + new Product + { + ProductID = 2, + ProductName = "Old-time Car", + Description = "There's nothing old about this toy car, except it's looks. Compatible with other old toy cars.", + ImagePath = "carearly.png", + UnitPrice = 15.95, + CategoryID = 1 + }, + new Product + { + ProductID = 3, + ProductName = "Fast Car", + Description = "Yes this car is fast, but it also floats in water.", + ImagePath = "carfast.png", + UnitPrice = 32.99, + CategoryID = 1 + }, + new Product + { + ProductID = 4, + ProductName = "Super Fast Car", + Description = "Use this super fast car to entertain guests. Lights and doors work!", + ImagePath = "carfaster.png", + UnitPrice = 8.95, + CategoryID = 1 + }, + new Product + { + ProductID = 5, + ProductName = "Old Style Racer", + Description = "This old style racer can fly (with user assistance). Gravity controls flight duration.No batteries required.", + ImagePath = "carracer.png", + UnitPrice = 34.95, + CategoryID = 1 + }, + new Product + { + ProductID = 6, + ProductName = "Ace Plane", + Description = "Authentic airplane toy. Features realistic color and details.", + ImagePath = "planeace.png", + UnitPrice = 95.00, + CategoryID = 2 + }, + new Product + { + ProductID = 7, + ProductName = "Glider", + Description = "This fun glider is made from real balsa wood. Some assembly required.", + ImagePath = "planeglider.png", + UnitPrice = 4.95, + CategoryID = 2 + }, + new Product + { + ProductID = 8, + ProductName = "Paper Plane", + Description = "This paper plane is like no other paper plane. Some folding required.", + ImagePath = "planepaper.png", + UnitPrice = 2.95, + CategoryID = 2 + }, + new Product + { + ProductID = 9, + ProductName = "Propeller Plane", + Description = "Rubber band powered plane features two wheels.", + ImagePath = "planeprop.png", + UnitPrice = 32.95, + CategoryID = 2 + }, + new Product + { + ProductID = 10, + ProductName = "Early Truck", + Description = "This toy truck has a real gas powered engine. Requires regular tune ups.", + ImagePath = "truckearly.png", + UnitPrice = 15.00, + CategoryID = 3 + }, + new Product + { + ProductID = 11, + ProductName = "Fire Truck", + Description = "You will have endless fun with this one quarter sized fire truck.", + ImagePath = "truckfire.png", + UnitPrice = 26.00, + CategoryID = 3 + }, + new Product + { + ProductID = 12, + ProductName = "Big Truck", + Description = "This fun toy truck can be used to tow other trucks that are not as big.", + ImagePath = "truckbig.png", + UnitPrice = 29.00, + CategoryID = 3 + }, + new Product + { + ProductID = 13, + ProductName = "Big Ship", + Description = "Is it a boat or a ship. Let this floating vehicle decide by using its artifically intelligent computer brain!", + ImagePath = "boatbig.png", + UnitPrice = 95.00, + CategoryID = 4 + }, + new Product + { + ProductID = 14, + ProductName = "Paper Boat", + Description = "Floating fun for all! This toy boat can be assembled in seconds. Floats for minutes!Some folding required.", + ImagePath = "boatpaper.png", + UnitPrice = 4.95, + CategoryID = 4 + }, + new Product + { + ProductID = 15, + ProductName = "Sail Boat", + Description = "Put this fun toy sail boat in the water and let it go!", + ImagePath = "boatsail.png", + UnitPrice = 42.95, + CategoryID = 4 + }, + new Product + { + ProductID = 16, + ProductName = "Rocket", + Description = "This fun rocket will travel up to a height of 200 feet.", + ImagePath = "rocket.png", + UnitPrice = 122.95, + CategoryID = 5 + } +]; diff --git a/samples/AfterWingtipToys/ShoppingCart.razor b/samples/AfterWingtipToys/ShoppingCart.razor index 4187a11fb..d028c4b5d 100644 --- a/samples/AfterWingtipToys/ShoppingCart.razor +++ b/samples/AfterWingtipToys/ShoppingCart.razor @@ -1,16 +1,11 @@ @page "/ShoppingCart" -@inject CartService Cart -@inject NavigationManager Navigation - Shopping Cart -
-

Shopping Cart

-
+

@ShoppingCartTitleText

- @if (Items.Count == 0) + @if (ShoppingCartItems.Count == 0) {

Your shopping cart is empty.

} @@ -28,23 +23,23 @@ - @foreach (var item in Items) + @foreach (var item in ShoppingCartItems) { - @item.Product.ProductID - @item.Product.ProductName - @($"{item.Product.UnitPrice:c}") + @item.ProductId + @item.Product?.ProductName + @($"{item.Product?.UnitPrice:c}") -
- - + + +
- @($"{item.Product.UnitPrice * item.Quantity:c}") + @($"{(Convert.ToDouble(item.Quantity) * Convert.ToDouble(item.Product?.UnitPrice ?? 0)):c}") -
- + +
@@ -53,32 +48,10 @@ -

- Order Total: @($"{Cart.GetTotal():c}") -

+
+ Order Total: @OrderTotalText +
}
- -@code { - private IReadOnlyList Items => Cart.GetItems(); - - protected override void OnParametersSet() - { - var addProductId = TryGetIntQueryValue("addProductId"); - if (addProductId.HasValue) - { - Cart.AddToCart(addProductId.Value); - } - } - - private int? TryGetIntQueryValue(string key) - { - var uri = Navigation.ToAbsoluteUri(Navigation.Uri); - var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); - return query.TryGetValue(key, out var value) && int.TryParse(value, out var parsed) - ? parsed - : null; - } -} diff --git a/samples/AfterWingtipToys/ShoppingCart.razor.cs b/samples/AfterWingtipToys/ShoppingCart.razor.cs index 918e3043e..c32d601ce 100644 --- a/samples/AfterWingtipToys/ShoppingCart.razor.cs +++ b/samples/AfterWingtipToys/ShoppingCart.razor.cs @@ -1,151 +1,27 @@ -// ============================================================================= -// TODO(bwfc-general): This code-behind was copied from Web Forms and needs manual migration. -// -// Common transforms needed (use the BWFC Copilot skill for assistance): -// TODO(bwfc-lifecycle): Page_Load / Page_Init → OnInitializedAsync / OnParametersSetAsync -// TODO(bwfc-lifecycle): Page_PreRender → OnAfterRenderAsync -// TODO(bwfc-ispostback): IsPostBack checks → remove or convert to state logic -// TODO(bwfc-viewstate): ViewState usage → component [Parameter] or private fields -// TODO(bwfc-session-state): Session/Cache access → auto-wired on WebFormsPageBase via SessionShim/CacheShim -// TODO(bwfc-navigation): Response.Redirect → auto-wired on WebFormsPageBase via ResponseShim -// TODO(bwfc-form): Request.Form["key"] → auto-wired on WebFormsPageBase via FormShim (use for interactive mode) -// TODO(bwfc-server): Server.MapPath/HtmlEncode → auto-wired on WebFormsPageBase via ServerShim -// TODO(bwfc-config): ConfigurationManager.AppSettings → BWFC shim (call app.UseConfigurationManagerShim() in Program.cs) -// TODO(bwfc-general): ClientScript.RegisterStartupScript → auto-wired on WebFormsPageBase via ClientScriptShim -// TODO(bwfc-general): Event handlers (Button_Click, etc.) → convert to Blazor event callbacks -// TODO(bwfc-datasource): Data binding (DataBind, DataSource) → component parameters or OnInitialized -// TODO(bwfc-general): ScriptManager code-behind references → use ScriptManagerShim via ScriptManager.GetCurrent(this) -// TODO(bwfc-general): UpdatePanel markup preserved by BWFC (ContentTemplate supported) — remove only code-behind API calls -// TODO(bwfc-general): User controls → Blazor component references -// ============================================================================= - -// --- Session State Migration --- -// TODO(bwfc-session-state): Session["key"] calls work automatically via SessionShim on WebFormsPageBase. -// Session keys found: payment_amt -// Options for long-term replacement: -// (1) ProtectedSessionStorage (Blazor Server) — persists across circuits -// (2) Scoped service via DI — lifetime matches user circuit -// (3) Cascading parameter from a root-level state provider -// See: https://learn.microsoft.com/aspnet/core/blazor/state-management - -using System; -using System.Collections.Generic; -using System.Linq; -using WingtipToys.Models; using WingtipToys.Logic; -using System.Collections.Specialized; -using System.Collections; +using WingtipToys.Models; + namespace WingtipToys { public partial class ShoppingCart { - // TODO(bwfc-general): ClientScript calls preserved — works via WebFormsPageBase (no injection needed). ScriptManagerShim may need @inject ScriptManagerShim ScriptManager for non-page classes. - - // --- Request.Form Migration --- - // TODO(bwfc-form): Request.Form calls work automatically via RequestShim on WebFormsPageBase. - // For interactive mode, wrap your form in . - // Form keys found: key - // For non-page classes, inject RequestShim via DI. - - // --- Response.Redirect Migration --- - // TODO(bwfc-navigation): Response.Redirect() works via ResponseShim on WebFormsPageBase. Handles ~/ and .aspx automatically. - // For non-page classes, inject ResponseShim via DI. - - private GridView CartList = default!; - private ImageButton CheckoutImageBtn = default!; - private Label LabelTotalText = default!; - private Label lblTotal = default!; - private TextBox PurchaseQuantity = default!; - private CheckBox Remove = default!; - private Button UpdateBtn = default!; - // --- ConfigurationManager Migration --- - // TODO(bwfc-config): ConfigurationManager calls work via BWFC shim. - // Ensure app.UseConfigurationManagerShim() is called in Program.cs. - - protected override async Task OnInitializedAsync() - { - // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior - await base.OnInitializedAsync(); - - using (ShoppingCartActions usersShoppingCart = new ShoppingCartActions()) - { - decimal cartTotal = 0; - cartTotal = usersShoppingCart.GetTotal(); - if (cartTotal > 0) - { - // Display Total. - lblTotal.Text = String.Format("{0:c}", cartTotal); - } - else - { - LabelTotalText.Text = ""; - lblTotal.Text = ""; - ShoppingCartTitle.InnerText = "Shopping Cart is Empty"; - UpdateBtn.Visible = false; - CheckoutImageBtn.Visible = false; - } - } - } - - public List GetShoppingCartItems() - { - ShoppingCartActions actions = new ShoppingCartActions(); - return actions.GetCartItems(); - } - - public List UpdateCartItems() - { - using (ShoppingCartActions usersShoppingCart = new ShoppingCartActions()) - { - String cartId = usersShoppingCart.GetCartId(); - - ShoppingCartActions.ShoppingCartUpdates[] cartUpdates = new ShoppingCartActions.ShoppingCartUpdates[CartList.Rows.Count]; - for (int i = 0; i < CartList.Rows.Count; i++) - { - IOrderedDictionary rowValues = new OrderedDictionary(); - rowValues = GetValues(CartList.Rows[i]); - cartUpdates[i].ProductId = Convert.ToInt32(rowValues["ProductID"]); - - CheckBox cbRemove = new CheckBox(); - cbRemove = (CheckBox)CartList.Rows[i].FindControl("Remove"); - cartUpdates[i].RemoveItem = cbRemove.Checked; - - TextBox quantityTextBox = new TextBox(); - quantityTextBox = (TextBox)CartList.Rows[i].FindControl("PurchaseQuantity"); - cartUpdates[i].PurchaseQuantity = Convert.ToInt16(quantityTextBox.Text.ToString()); - } - usersShoppingCart.UpdateShoppingCartDatabase(cartId, cartUpdates); - lblTotal.Text = String.Format("{0:c}", usersShoppingCart.GetTotal()); - return usersShoppingCart.GetCartItems(); - } - } - - public static IOrderedDictionary GetValues(GridViewRow row) - { - IOrderedDictionary values = new OrderedDictionary(); - foreach (DataControlFieldCell cell in row.Cells) - { - if (cell.Visible) - { - // Extract values from the cell. - cell.ContainingField.ExtractValuesFromCell(values, cell, row.RowState, true); - } - } - return values; - } + protected string ShoppingCartTitleText { get; private set; } = "Shopping Cart"; + protected string OrderTotalText { get; private set; } = string.Empty; + protected List ShoppingCartItems { get; private set; } = []; - protected void UpdateBtn_Click() + protected override async Task OnParametersSetAsync() { - UpdateCartItems(); + await base.OnParametersSetAsync(); + RefreshTotals(); } - protected void CheckoutBtn_Click(ImageClickEventArgs e) + private void RefreshTotals() { - using (ShoppingCartActions usersShoppingCart = new ShoppingCartActions()) - { - Session["payment_amt"] = usersShoppingCart.GetTotal(); - } - Response.Redirect("Checkout/CheckoutStart.aspx"); + using var usersShoppingCart = new ShoppingCartActions(); + ShoppingCartItems = usersShoppingCart.GetCartItems(); + var cartTotal = usersShoppingCart.GetTotal(); + OrderTotalText = cartTotal > 0 ? $"{cartTotal:c}" : string.Empty; + ShoppingCartTitleText = cartTotal > 0 ? "Shopping Cart" : "Shopping Cart is Empty"; } } -} \ No newline at end of file +} diff --git a/samples/AfterWingtipToys/Site.Mobile.razor b/samples/AfterWingtipToys/Site.Mobile.razor index be0d2c3a2..bc4a95934 100644 --- a/samples/AfterWingtipToys/Site.Mobile.razor +++ b/samples/AfterWingtipToys/Site.Mobile.razor @@ -1,6 +1,30 @@ - - -

Mobile View

-

The mobile-specific master page is not implemented in this migrated benchmark sample.

-
-
+@* TODO(bwfc-master-page): Review shell scripts, bundle references, and auth/cart chrome for SSR-safe migration. *@ + + + + + + + + + @ChildComponents +
+

Mobile Master Page

+ +
+ +
+ +
+ +@ChildContent +
+
+ +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public RenderFragment? ChildComponents { get; set; } +} diff --git a/samples/AfterWingtipToys/Site.razor b/samples/AfterWingtipToys/Site.razor index cd85fe61d..8a832f580 100644 --- a/samples/AfterWingtipToys/Site.razor +++ b/samples/AfterWingtipToys/Site.razor @@ -1,14 +1,23 @@ -@inject CatalogService Catalog -@inject CartService Cart -@inject UserStoreService UserStore +@* TODO(bwfc-master-page): Review shell scripts, bundle references, and auth/cart chrome for SSR-safe migration. *@ - - - - + + + + @(Page.Title) - Wingtip Toys + + + @* Legacy Web Forms bundling removed for Blazor SSR. *@ + + @* Legacy CSS bundling removed for Blazor SSR. *@ + + + + @ChildComponents + +