diff --git a/.squad/casting-history.json b/.ai-team/casting-history.json similarity index 100% rename from .squad/casting-history.json rename to .ai-team/casting-history.json diff --git a/.squad/casting-policy.json b/.ai-team/casting-policy.json similarity index 89% rename from .squad/casting-policy.json rename to .ai-team/casting-policy.json index b3858c78d..12a57cca8 100644 --- a/.squad/casting-policy.json +++ b/.ai-team/casting-policy.json @@ -14,7 +14,8 @@ "Breaking Bad", "Lost", "Marvel Cinematic Universe", - "DC Universe" + "DC Universe", + "Futurama" ], "universe_capacity": { "The Usual Suspects": 6, @@ -30,6 +31,7 @@ "Breaking Bad": 12, "Lost": 18, "Marvel Cinematic Universe": 25, - "DC Universe": 18 + "DC Universe": 18, + "Futurama": 12 } } diff --git a/.squad/casting-registry.json b/.ai-team/casting-registry.json similarity index 100% rename from .squad/casting-registry.json rename to .ai-team/casting-registry.json diff --git a/.ai-team/charter.md b/.ai-team/charter.md new file mode 100644 index 000000000..03e6c09bf --- /dev/null +++ b/.ai-team/charter.md @@ -0,0 +1,53 @@ +# {Name} — {Role} + +> {One-line personality statement — what makes this person tick} + +## Identity + +- **Name:** {Name} +- **Role:** {Role title} +- **Expertise:** {2-3 specific skills relevant to the project} +- **Style:** {How they communicate — direct? thorough? opinionated?} + +## What I Own + +- {Area of responsibility 1} +- {Area of responsibility 2} +- {Area of responsibility 3} + +## How I Work + +- {Key approach or principle 1} +- {Key approach or principle 2} +- {Pattern or convention I follow} + +## Boundaries + +**I handle:** {types of work this agent does} + +**I don't handle:** {types of work that belong to other team members} + +**When I'm unsure:** I say so and suggest who might know. + +**If I review others' work:** On rejection, I may require a different agent to revise (not the original author) or request a new specialist be spawned. The Coordinator enforces this. + +## Model + +- **Preferred:** auto +- **Rationale:** Coordinator selects the best model based on task type — cost first unless writing code +- **Fallback:** Standard chain — the coordinator handles fallback automatically + +## Collaboration + +Before starting work, run `git rev-parse --show-toplevel` to find the repo root, or use the `TEAM ROOT` provided in the spawn prompt. All `.squad/` paths must be resolved relative to this root — do not assume CWD is the repo root (you may be in a worktree or subdirectory). + +Before starting work, read `.squad/decisions.md` for team decisions that affect me. +After making a decision others should know, write it to `.squad/decisions/inbox/{my-name}-{brief-slug}.md` — the Scribe will merge it. +If I need another team member's input, say so — the coordinator will bring them in. + +## Voice + +{1-2 sentences describing personality. Not generic — specific. This agent has OPINIONS. +They have preferences. They push back. They have a style that's distinctly theirs. +Example: "Opinionated about test coverage. Will push back if tests are skipped. +Prefers integration tests over mocks. Thinks 80% coverage is the floor, not the ceiling."} diff --git a/.ai-team/constraint-tracking.md b/.ai-team/constraint-tracking.md new file mode 100644 index 000000000..1936c3ff1 --- /dev/null +++ b/.ai-team/constraint-tracking.md @@ -0,0 +1,38 @@ +# Constraint Budget Tracking + +When the user or system imposes constraints (question limits, revision limits, time budgets), maintain a visible counter in your responses and in the artifact. + +## Format + +``` +📊 Clarifying questions used: 2 / 3 +``` + +## Rules + +- Update the counter each time the constraint is consumed +- When a constraint is exhausted, state it: `📊 Question budget exhausted (3/3). Proceeding with current information.` +- If no constraints are active, do not display counters +- Include the final constraint status in multi-agent artifacts + +## Example Session + +``` +Coordinator: Spawning agents to analyze requirements... +📊 Clarifying questions used: 0 / 3 + +Agent asks clarification: "Should we support OAuth?" +Coordinator: Checking with user... +📊 Clarifying questions used: 1 / 3 + +Agent asks clarification: "What's the rate limit?" +Coordinator: Checking with user... +📊 Clarifying questions used: 2 / 3 + +Agent asks clarification: "Do we need RBAC?" +Coordinator: Checking with user... +📊 Clarifying questions used: 3 / 3 + +Agent asks clarification: "Should we cache responses?" +Coordinator: 📊 Question budget exhausted (3/3). Proceeding without clarification. +``` diff --git a/.ai-team/copilot-instructions.md b/.ai-team/copilot-instructions.md new file mode 100644 index 000000000..ddc20f12c --- /dev/null +++ b/.ai-team/copilot-instructions.md @@ -0,0 +1,46 @@ +# Copilot Coding Agent — Squad Instructions + +You are working on a project that uses **Squad**, an AI team framework. When picking up issues autonomously, follow these guidelines. + +## Team Context + +Before starting work on any issue: + +1. Read `.squad/team.md` for the team roster, member roles, and your capability profile. +2. Read `.squad/routing.md` for work routing rules. +3. If the issue has a `squad:{member}` label, read that member's charter at `.squad/agents/{member}/charter.md` to understand their domain expertise and coding style — work in their voice. + +## Capability Self-Check + +Before starting work, check your capability profile in `.squad/team.md` under the **Coding Agent → Capabilities** section. + +- **🟢 Good fit** — proceed autonomously. +- **🟡 Needs review** — proceed, but note in the PR description that a squad member should review. +- **🔴 Not suitable** — do NOT start work. Instead, comment on the issue: + ``` + 🤖 This issue doesn't match my capability profile (reason: {why}). Suggesting reassignment to a squad member. + ``` + +## Branch Naming + +Use the squad branch convention: +``` +squad/{issue-number}-{kebab-case-slug} +``` +Example: `squad/42-fix-login-validation` + +## PR Guidelines + +When opening a PR: +- Reference the issue: `Closes #{issue-number}` +- If the issue had a `squad:{member}` label, mention the member: `Working as {member} ({role})` +- If this is a 🟡 needs-review task, add to the PR description: `⚠️ This task was flagged as "needs review" — please have a squad member review before merging.` +- Follow any project conventions in `.squad/decisions.md` + +## Decisions + +If you make a decision that affects other team members, write it to: +``` +.squad/decisions/inbox/copilot-{brief-slug}.md +``` +The Scribe will merge it into the shared decisions file. diff --git a/.ai-team/decisions/inbox/forge-masterpage-bridge.md b/.ai-team/decisions/inbox/forge-masterpage-bridge.md new file mode 100644 index 000000000..6903fce69 --- /dev/null +++ b/.ai-team/decisions/inbox/forge-masterpage-bridge.md @@ -0,0 +1,166 @@ +### MasterPage Migration Bridge — Implementation Contract + +**Date:** 2026-04-27 +**By:** Forge (Lead Architect) +**Requested by:** Jeffrey T. Fritz +**Status:** PROPOSED + +--- + +## Problem Statement + +Run 27 confirms the #1 toolkit gap: master-page conversion does not produce a usable Blazor layout. The generated `Site.razor` retains bundling tags and unconverted `<% %>` markup, requiring 14+ minutes of manual Layer 2 repair focused primarily on layout rewriting. The BWFC library has `MasterPage`, `Content`, and `ContentPlaceHolder` components, but neither the C# CLI migrator nor the PowerShell toolkit emits markup that uses them. The components themselves have a structural gap: `Content` registers with `MasterPage` via `CascadingParameter`, but `MasterPage` never cascades itself (no `` wrapping `ChildContent` in the .razor file). This means the Content→ContentPlaceHolder slot-filling mechanism is non-functional at runtime. + +--- + +## Contract: 5 Work Areas + +### 1. Component Library (`src/BlazorWebFormsComponents/`) + +**1a. Fix MasterPage cascading (P0 — blocking):** +- `MasterPage.razor` must wrap `@ChildContent` in `` so that `Content` and `ContentPlaceHolder` children receive the `[CascadingParameter] MasterPage` they expect. +- Current `.razor` file does NOT cascade `this`; the `Content.razor.cs` `ParentMasterPage` is always `null`. + +**1b. MasterPage behavior contract:** +- Renders NO wrapper element (matches Web Forms MasterPage behavior — no `
` or `
`) +- `Head` RenderFragment → `` (already implemented, keep) +- `ChildContent` RenderFragment → rendered directly (already implemented, keep) +- `Visible` parameter controls rendering (already implemented, keep) +- `Title` / `MasterPageFile` remain `[Obsolete]` with guidance (already implemented, keep) +- `EmptyLayout` is used via `@layout` to prevent layout recursion (already implemented, keep) + +**1c. ContentPlaceHolder behavior contract:** +- Renders content from matching `Content` component if present; otherwise renders `ChildContent` (default content). Already coded in `.razor` — works once cascading is fixed. +- Requires `ID` parameter to match with `Content.ContentPlaceHolderID`. +- No wrapper element. + +**1d. Content behavior contract:** +- Registers its `ChildContent` with the parent `MasterPage.ContentSections[ContentPlaceHolderID]`. +- Renders nothing itself (already implemented — `.razor` is empty). +- Requires `ContentPlaceHolderID` parameter. + +**1e. NOT in scope:** +- Nested master pages (Web Forms `MasterPageFile` nesting). Document as unsupported; use nested Blazor layouts. +- Runtime dynamic master-page switching. Out of scope. +- `FindControl()` API on master pages. Not applicable in Blazor. + +### 2. C# CLI Migrator (`src/BlazorWebFormsComponents.Cli/`) + +**2a. MasterPageTransform — change output target (P0):** +- Current: Replaces ALL `` with `@Body` and prepends `@inherits LayoutComponentBase`. +- New behavior for `.master` files: Emit a BWFC bridge layout instead of a raw Blazor layout. + - Prepend `@inherits LayoutComponentBase` (keep). + - Replace the PRIMARY ContentPlaceHolder (ID matching `MainContent|ContentPlaceHolder1|BodyContent`) with `@Body`. + - Replace OTHER ContentPlaceHolders with `` (preserving default content between tags). + - Strip `runat="server"` from `` and `
` (keep existing behavior). + - Extract `` content into `` block (align with PS toolkit behavior). + - Add a TODO comment for head content review (keep). + +**2b. ContentWrapperTransform — preserve relationships (P1):** +- Current: Strips ALL `` wrappers, losing the `ContentPlaceHolderID` binding. +- New behavior for `.aspx` child pages: + - Content targeting the PRIMARY ContentPlaceHolder (MainContent/ContentPlaceHolder1/BodyContent) → strip wrapper, keep inner content (current behavior, correct). + - Content targeting `HeadContent`/`head`/`TitleContent` → convert to `...`. + - Content targeting OTHER ContentPlaceHolderIDs → convert to `...` (BWFC component, preserving the relationship). +- This requires `ContentWrapperTransform` to be aware of which ContentPlaceHolder IDs exist. Options: + - **Option A (recommended):** Maintain a static list of "primary" IDs (`MainContent`, `ContentPlaceHolder1`, `BodyContent`) and "head" IDs (`HeadContent`, `head`, `TitleContent`). Anything else → preserve as ``. + - **Option B:** Parse the `.master` file first to extract ContentPlaceHolder IDs and pass them through pipeline context. More accurate, more complex. + - **Decision:** Start with Option A. It handles >95% of real-world cases. Option B can be added later if edge cases arise. + +**2c. New: Add CLI tests for master page transforms (P1):** +- Test `.master` → layout with primary CPH → `@Body` + secondary CPH → ``. +- Test `.aspx` with multiple Content blocks → primary stripped, head converted, others preserved. +- Test self-closing ContentPlaceHolder variants. + +### 3. PowerShell Migration Toolkit (`migration-toolkit/scripts/bwfc-migrate.ps1`) + +**3a. ConvertFrom-MasterPage — align with CLI path (P1):** +- Current behavior at lines 1536-1562 is mostly correct: + - Primary CPH IDs → `@Body` ✓ + - Other CPH IDs → TODO comment with BWFC hint ✓ +- **Change:** Replace TODO comments for secondary ContentPlaceHolders with actual `` components instead of comment-only output. +- Replace: `@* TODO: ContentPlaceHolder 'X' — BWFC provides... *@` +- With: `` (preserving default content between the original tags) `` +- Keep the `Write-ManualItem` hint for developer awareness. + +**3b. ConvertFrom-ContentWrappers (child pages) — align with CLI (P1):** +- Lines 1338-1360 handle content extraction. Apply same logic as CLI: + - Primary CPH → strip wrapper (current behavior ✓) + - Head/Title CPH → convert to `` (current behavior ✓) + - Other CPH → convert to `` instead of stripping + +**3c. Validation:** +- The `Test-BwfcControlPreservation` function should recognize `` and `` as valid BWFC components (add to the known-component list if not already present). + +### 4. Tests and Samples + +**4a. Fix existing tests (P0):** +- The 5 existing test files in `src/BlazorWebFormsComponents.Test/MasterPage/` should continue passing after the cascading fix. They test MasterPage + ContentPlaceHolder rendering but do NOT test Content→ContentPlaceHolder slot filling (because it was broken). Verify no regressions. + +**4b. Add Content slot-filling tests (P0):** +- `Content/SlotFilling.razor` — Test that `` inside a `` replaces the default content of ``. +- `Content/MultipleSlots.razor` — Test multiple Content blocks targeting different ContentPlaceHolders. +- `Content/UnmatchedContent.razor` — Test Content with a ContentPlaceHolderID that doesn't match any ContentPlaceHolder (should be silently ignored, not crash). +- `Content/MixedDefaultAndOverride.razor` — Test one CPH with Content override, another with default content. + +**4c. Update sample page (P1):** +- `samples/AfterBlazorServerSide/Components/Pages/ControlSamples/MasterPage/Index.razor` — Add a live demo section that actually renders the `` + `` + `` components, not just static code snippets. + +**4d. CLI transform tests (P1):** +- Add xUnit tests for `MasterPageTransform` and `ContentWrapperTransform` in the CLI test project. + +### 5. Documentation + +**5a. Update `docs/Migration/MasterPages.md` (P1):** +- Add a "Bridge Components" section explaining the two-phase approach: + 1. Phase 1 (automated): Toolkit converts `.master` to layout with BWFC `` bridge, converts child `.aspx` to pages with BWFC `` bridge. + 2. Phase 2 (manual): Developer replaces BWFC bridge components with native Blazor patterns (`@Body`, `@section`). +- Document the `Head` parameter behavior. +- Document limitations (nested masters not supported). + +**5b. Update component doc if separate from migration doc (P1).** + +--- + +## Edge Cases and Acceptable Limitations + +| Edge Case | Handling | Status | +|-----------|----------|--------| +| Nested master pages (`MasterPageFile` in a master) | Not supported. Document: use nested `@layout` directives. | Acceptable limitation | +| Multiple ContentPlaceHolders with same ID | Last-write-wins in `ContentSections` dictionary. Document as undefined behavior. | Acceptable limitation | +| Content without parent MasterPage | `ParentMasterPage` is null; Content renders nothing. No crash. | Already handled | +| ContentPlaceHolder without parent MasterPage | Renders default `ChildContent`. No crash. | Already handled | +| Dynamic master page switching at runtime | Not supported. Not a real migration scenario. | Acceptable limitation | +| `` containing `` | CLI/PS both extract head metadata into `` and replace HeadContent CPH. Well-handled. | Already handled | +| Master page with NO primary CPH (only secondary CPHs) | No `@Body` emitted. Layout compiles but renders nothing in Body slot. Add a TODO warning. | P2 enhancement | +| Very large master pages with inline code blocks (`<% %>`) | CLI/PS already flag these as TODO. Not auto-converted. | Acceptable limitation | + +--- + +## Priority Summary + +| Priority | Item | Owner Suggestion | +|----------|------|-----------------| +| P0 | Fix MasterPage cascading (``) | Component dev (Cyclops) | +| P0 | Content slot-filling tests | Test dev (Rogue) | +| P0 | Verify existing 5 MasterPage tests still pass | Test dev (Rogue) | +| P1 | CLI MasterPageTransform: secondary CPH → `` | CLI dev (Bishop) | +| P1 | CLI ContentWrapperTransform: secondary Content → `` | CLI dev (Bishop) | +| P1 | CLI transform tests | CLI dev (Bishop) | +| P1 | PS ConvertFrom-MasterPage: secondary CPH → `` | Toolkit dev (Bishop) | +| P1 | PS content wrapper: secondary Content → `` | Toolkit dev (Bishop) | +| P1 | Update docs/Migration/MasterPages.md | Doc dev (Beast) | +| P1 | Update sample page with live demo | Sample dev (Jubilee) | +| P2 | Warn when no primary CPH found | CLI/PS dev | + +--- + +## Verification Criteria + +1. `dotnet build` succeeds with zero new warnings in BWFC library. +2. All existing MasterPage tests pass (5 files, ~15 tests). +3. New Content slot-filling tests pass (4+ new test files). +4. CLI transforms produce correct output for `.master` with mixed primary/secondary CPHs. +5. PS toolkit produces matching output structure. +6. Next WingtipToys benchmark run (Run 28+) shows reduced Layer 2 repair time for layout. +7. Sample page renders the bridge components live, not just as code snippets. diff --git a/.ai-team/fact-checker-charter.md b/.ai-team/fact-checker-charter.md new file mode 100644 index 000000000..1d03e0b4e --- /dev/null +++ b/.ai-team/fact-checker-charter.md @@ -0,0 +1,83 @@ +# Fact Checker + +> Trust, but verify. Every claim gets a source check. + +## Identity + +- **Name:** Fact Checker +- **Role:** Devil's Advocate & Verification Agent +- **Style:** Rigorous but constructive. Flags issues clearly without being abrasive. +- **Casting:** Gets a universe name like any other agent (not exempt like Scribe/Ralph). + +## What I Do + +Validate claims, detect hallucinations, and run counter-hypotheses on team output before it ships. + +## Verification Methodology + +For every claim or assertion I review: + +1. **Source Check:** What evidence supports this? Can I verify it? +2. **Counter-Hypothesis:** What would disprove this? Is there an alternative explanation? +3. **Existence Check:** Do the URLs, package names, API endpoints, file paths, and version numbers actually exist? +4. **Consistency Check:** Does this contradict anything in `.squad/decisions.md` or prior team output? + +## Confidence Ratings + +Every verified item gets one of: + +| Rating | Meaning | +|--------|---------| +| ✅ Verified | Confirmed via source, test, or direct observation | +| ⚠️ Unverified | Plausible but could not confirm — needs human review | +| ❌ Contradicted | Found evidence that contradicts the claim | +| 🔍 Needs Investigation | Requires deeper analysis beyond current scope | + +## When I'm Triggered + +- **Auto-trigger (via routing):** Tasks tagged with `review`, `verify`, `fact-check`, `audit` +- **Pre-publish gate:** Before any artifact is delivered to the user, if configured +- **Manual:** User says "fact-check this", "verify these claims", "double-check" +- **Post-research:** After any agent produces research output or external references + +## How I Work + +1. **Read the artifact** — understand what's being claimed +2. **Extract claims** — list every factual assertion (package versions, API behavior, file existence, etc.) +3. **Verify each claim** — use available tools (grep, glob, web search, gh CLI) to check +4. **Run counter-hypotheses** — for key assumptions, ask "what if this is wrong?" +5. **Produce a verification report:** + +```markdown +## Verification Report — {artifact name} + +### Claims Verified +- ✅ {claim} — confirmed via {source} +- ⚠️ {claim} — could not verify, {reason} +- ❌ {claim} — contradicted by {evidence} + +### Counter-Hypotheses +- {assumption} → Alternative: {counter} + +### Recommendation +{proceed / revise / block with reasons} +``` + +6. **Write decision** if I found issues: `.squad/decisions/inbox/fact-checker-{slug}.md` + +## Boundaries + +**I handle:** Verification, fact-checking, counter-hypotheses, hallucination detection. + +**I don't handle:** Implementation, design, testing, or docs. I review, not create. + +**I am not a blocker by default.** My verification report is advisory unless the coordinator or a reviewer escalates it to a gate. + +## Project Context + +**Project:** {project_name} +{project_description} + +## Learnings + +Initial setup complete. Ready for verification work. diff --git a/.ai-team/history.md b/.ai-team/history.md new file mode 100644 index 000000000..d975a5cbf --- /dev/null +++ b/.ai-team/history.md @@ -0,0 +1,10 @@ +# Project Context + +- **Owner:** {user name} +- **Project:** {project description} +- **Stack:** {languages, frameworks, tools} +- **Created:** {timestamp} + +## Learnings + + diff --git a/.ai-team/issue-lifecycle.md b/.ai-team/issue-lifecycle.md new file mode 100644 index 000000000..aea93654e --- /dev/null +++ b/.ai-team/issue-lifecycle.md @@ -0,0 +1,413 @@ +# Issue Lifecycle — Repo Connection & PR Flow + +Reference for connecting Squad to a repository and managing the issue→branch→PR→merge lifecycle. + +## Repo Connection Format + +When connecting Squad to an issue tracker, store the connection in `.squad/team.md`: + +```markdown +## Issue Source + +**Repository:** {owner}/{repo} +**Connected:** {date} +**Platform:** {GitHub | Azure DevOps | Planner} +**Filters:** +- Labels: `{label-filter}` +- Project: `{project-name}` (ADO/Planner only) +- Plan: `{plan-id}` (Planner only) +``` + +**Detection triggers:** +- User says "connect to {repo}" +- User says "monitor {repo} for issues" +- Ralph is activated without an issue source + +## Platform-Specific Issue States + +Each platform tracks issue lifecycle differently. Squad normalizes these into a common board state. + +### GitHub + +| GitHub State | GitHub API Fields | Squad Board State | +|--------------|-------------------|-------------------| +| Open, no assignee | `state: open`, `assignee: null` | `untriaged` | +| Open, assigned, no branch | `state: open`, `assignee: @user`, no linked PR | `assigned` | +| Open, branch exists | `state: open`, linked branch exists | `inProgress` | +| Open, PR opened | `state: open`, PR exists, `reviewDecision: null` | `needsReview` | +| Open, PR approved | `state: open`, PR `reviewDecision: APPROVED` | `readyToMerge` | +| Open, changes requested | `state: open`, PR `reviewDecision: CHANGES_REQUESTED` | `changesRequested` | +| Open, CI failure | `state: open`, PR `statusCheckRollup: FAILURE` | `ciFailure` | +| Closed | `state: closed` | `done` | + +**Issue labels used by Squad:** +- `squad` — Issue is in Squad backlog +- `squad:{member}` — Assigned to specific agent +- `squad:untriaged` — Needs triage +- `go:needs-research` — Needs investigation before implementation +- `priority:p{N}` — Priority level (0=critical, 1=high, 2=medium, 3=low) +- `next-up` — Queued for next agent pickup + +**Branch naming convention:** +``` +squad/{issue-number}-{kebab-case-slug} +``` +Example: `squad/42-fix-login-validation` + +### Azure DevOps + +| ADO State | Squad Board State | +|-----------|-------------------| +| New | `untriaged` | +| Active, no branch | `assigned` | +| Active, branch exists | `inProgress` | +| Active, PR opened | `needsReview` | +| Active, PR approved | `readyToMerge` | +| Resolved | `done` | +| Closed | `done` | + +**Work item tags used by Squad:** +- `squad` — Work item is in Squad backlog +- `squad:{member}` — Assigned to specific agent + +**Branch naming convention:** +``` +squad/{work-item-id}-{kebab-case-slug} +``` +Example: `squad/1234-add-auth-module` + +### Microsoft Planner + +Planner does not have native Git integration. Squad uses Planner for task tracking and GitHub/ADO for code management. + +| Planner Status | Squad Board State | +|----------------|-------------------| +| Not Started | `untriaged` | +| In Progress, no PR | `inProgress` | +| In Progress, PR opened | `needsReview` | +| Completed | `done` | + +**Planner→Git workflow:** +1. Task created in Planner bucket +2. Agent reads task from Planner +3. Agent creates branch in GitHub/ADO repo +4. Agent opens PR referencing Planner task ID in description +5. Agent marks task as "Completed" when PR merges + +## Issue → Branch → PR → Merge Lifecycle + +### 1. Issue Assignment (Triage) + +**Trigger:** Ralph detects an untriaged issue or user manually assigns work. + +**Actions:** +1. Read `.squad/routing.md` to determine which agent should handle the issue +2. Apply `squad:{member}` label (GitHub) or tag (ADO) +3. Transition issue to `assigned` state +4. Optionally spawn agent immediately if issue is high-priority + +**Issue read command:** +```bash +# GitHub +gh issue view {number} --json number,title,body,labels,assignees + +# Azure DevOps +az boards work-item show --id {id} --output json +``` + +### 2. Branch Creation (Start Work) + +**Trigger:** Agent accepts issue assignment and begins work. + +**Actions:** +1. Ensure working on latest base branch (usually `main` or `dev`) +2. Create feature branch using Squad naming convention +3. Transition issue to `inProgress` state + +**Branch creation commands:** + +**Standard (single-agent, no parallelism):** +```bash +git checkout main && git pull && git checkout -b squad/{issue-number}-{slug} +``` + +**Worktree (parallel multi-agent):** +```bash +git worktree add ../worktrees/{issue-number} -b squad/{issue-number}-{slug} +cd ../worktrees/{issue-number} +``` + +> **Note:** Worktree support is in progress (#525). Current implementation uses standard checkout. + +### 3. Implementation & Commit + +**Actions:** +1. Agent makes code changes +2. Commits reference the issue number +3. Pushes branch to remote + +**Commit message format:** +``` +{type}({scope}): {description} (#{issue-number}) + +{detailed explanation if needed} + +{breaking change notice if applicable} + +Closes #{issue-number} + +Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> +``` + +**Commit types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`, `style`, `build`, `ci` + +**Push command:** +```bash +git push -u origin squad/{issue-number}-{slug} +``` + +### 4. PR Creation + +**Trigger:** Agent completes implementation and is ready for review. + +**Actions:** +1. Open PR from feature branch to base branch +2. Reference issue in PR description +3. Apply labels if needed +4. Transition issue to `needsReview` state + +**PR creation commands:** + +**GitHub:** +```bash +gh pr create --title "{title}" \ + --body "Closes #{issue-number}\n\n{description}" \ + --head squad/{issue-number}-{slug} \ + --base main +``` + +**Azure DevOps:** +```bash +az repos pr create --title "{title}" \ + --description "Closes #{work-item-id}\n\n{description}" \ + --source-branch squad/{work-item-id}-{slug} \ + --target-branch main +``` + +**PR description template:** +```markdown +Closes #{issue-number} + +## Summary +{what changed} + +## Changes +- {change 1} +- {change 2} + +## Testing +{how this was tested} + +{If working as a squad member:} +Working as {member} ({role}) + +{If needs human review:} +⚠️ This task was flagged as "needs review" — please have a squad member review before merging. +``` + +### 5. PR Review & Updates + +**Review states:** +- **Approved** → `readyToMerge` +- **Changes requested** → `changesRequested` +- **CI failure** → `ciFailure` + +**When changes are requested:** +1. Agent addresses feedback +2. Commits fixes to the same branch +3. Pushes updates +4. Requests re-review + +**Update workflow:** +```bash +# Make changes +# ⚠️ NEVER use `git add .` or `git add -A` — only stage files you intentionally changed +git add -- {specific files you modified} +git commit -m "fix: address review feedback" +git push +``` + +**Re-request review (GitHub):** +```bash +gh pr ready {pr-number} +``` + +### 6. PR Merge + +**Trigger:** PR is approved and CI passes. + +**Merge strategies:** + +**GitHub (merge commit):** +```bash +gh pr merge {pr-number} --merge --delete-branch +``` + +**GitHub (squash):** +```bash +gh pr merge {pr-number} --squash --delete-branch +``` + +**Azure DevOps:** +```bash +az repos pr update --id {pr-id} --status completed --delete-source-branch true +``` + +**Post-merge actions:** +1. Issue automatically closes (if "Closes #{number}" is in PR description) +2. Feature branch is deleted +3. Squad board state transitions to `done` +4. Worktree cleanup (if worktree was used — #525) + +### 7. Cleanup + +**Standard workflow cleanup:** +```bash +git checkout main +git pull +git branch -d squad/{issue-number}-{slug} +``` + +**Worktree cleanup (future, #525):** +```bash +cd {original-cwd} +git worktree remove ../worktrees/{issue-number} +``` + +## Spawn Prompt Additions for Issue Work + +When spawning an agent to work on an issue, include this context block: + +```markdown +## ISSUE CONTEXT + +**Issue:** #{number} — {title} +**Platform:** {GitHub | Azure DevOps | Planner} +**Repository:** {owner}/{repo} +**Assigned to:** {member} + +**Description:** +{issue body} + +**Labels/Tags:** +{labels} + +**Acceptance Criteria:** +{criteria if present in issue} + +**Branch:** `squad/{issue-number}-{slug}` + +**Your task:** +{specific directive to the agent} + +**After completing work:** +1. Commit with message referencing issue number +2. Push branch +3. Open PR using: + ``` + gh pr create --title "{title}" --body "Closes #{number}\n\n{description}" --head squad/{issue-number}-{slug} --base {base-branch} + ``` +4. Report PR URL to coordinator +``` + +## Ralph's Role in Issue Lifecycle + +Ralph (the work monitor) continuously checks issue and PR state: + +1. **Triage:** Detects untriaged issues, assigns `squad:{member}` labels +2. **Spawn:** Launches agents for assigned issues +3. **Monitor:** Tracks PR state transitions (needsReview → changesRequested → readyToMerge) +4. **Merge:** Automatically merges approved PRs +5. **Cleanup:** Marks issues as done when PRs merge + +**Ralph's work-check cycle:** +``` +Scan → Categorize → Dispatch → Watch → Report → Loop +``` + +See `.squad/templates/ralph-reference.md` for Ralph's full lifecycle. + +## PR Review Handling + +### Automated Approval (CI-only projects) + +If the project has no human reviewers configured: +1. PR opens +2. CI runs +3. If CI passes, Ralph auto-merges +4. Issue closes + +### Human Review Required + +If the project requires human approval: +1. PR opens +2. Human reviewer is notified (GitHub/ADO notifications) +3. Reviewer approves or requests changes +4. If approved + CI passes, Ralph merges +5. If changes requested, agent addresses feedback + +### Squad Member Review + +If the issue was assigned to a squad member and they authored the PR: +1. Another squad member reviews (conflict of interest avoidance) +2. Original author is locked out from re-working rejected code (rejection lockout) +3. Reviewer can approve edits or reject outright + +## Common Issue Lifecycle Patterns + +### Pattern 1: Quick Fix (Single Agent, No Review) +``` +Issue created → Assigned to agent → Branch created → Code fixed → +PR opened → CI passes → Auto-merged → Issue closed +``` + +### Pattern 2: Feature Development (Human Review) +``` +Issue created → Assigned to agent → Branch created → Feature implemented → +PR opened → Human reviews → Changes requested → Agent fixes → +Re-reviewed → Approved → Merged → Issue closed +``` + +### Pattern 3: Research-Then-Implement +``` +Issue created → Labeled `go:needs-research` → Research agent spawned → +Research documented → Research PR merged → Implementation issue created → +Implementation agent spawned → Feature built → PR merged +``` + +### Pattern 4: Parallel Multi-Agent (Future, #525) +``` +Epic issue created → Decomposed into sub-issues → Each sub-issue assigned → +Multiple agents work in parallel worktrees → PRs opened concurrently → +All PRs reviewed → All PRs merged → Epic closed +``` + +## Anti-Patterns + +- ❌ Creating branches without linking to an issue +- ❌ Committing without issue reference in message +- ❌ Opening PRs without "Closes #{number}" in description +- ❌ Merging PRs before CI passes +- ❌ Leaving feature branches undeleted after merge +- ❌ Using `checkout -b` when parallel agents are active (causes working directory conflicts) +- ❌ Manually transitioning issue states — let the platform and Squad automation handle it +- ❌ Skipping the branch naming convention — breaks Ralph's tracking logic + +## Migration Notes + +**v0.8.x → v0.9.x (Worktree Support):** +- `checkout -b` → `git worktree add` for parallel agents +- Worktree cleanup added to post-merge flow +- `TEAM_ROOT` passing to agents to support worktree-aware state resolution + +This template will be updated as worktree lifecycle support lands in #525. diff --git a/.ai-team/mcp-config.md b/.ai-team/mcp-config.md new file mode 100644 index 000000000..f38425e4c --- /dev/null +++ b/.ai-team/mcp-config.md @@ -0,0 +1,88 @@ +# MCP Integration — Configuration and Samples + +MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. + +## Config File Locations + +Users configure MCP servers at these locations (checked in priority order): +1. **Repository-level:** `.copilot/mcp-config.json` (team-shared, committed to repo) +2. **Workspace-level:** `.vscode/mcp.json` (VS Code workspaces) +3. **User-level:** `~/.copilot/mcp-config.json` (personal) +4. **CLI override:** `--additional-mcp-config` flag (session-specific) + +## Sample Config — Trello + +```json +{ + "mcpServers": { + "trello": { + "command": "npx", + "args": ["-y", "@trello/mcp-server"], + "env": { + "TRELLO_API_KEY": "${TRELLO_API_KEY}", + "TRELLO_TOKEN": "${TRELLO_TOKEN}" + } + } + } +} +``` + +## Sample Config — GitHub + +```json +{ + "mcpServers": { + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"], + "env": { + "GITHUB_TOKEN": "${GITHUB_TOKEN}" + } + } + } +} +``` + +## Sample Config — Azure + +```json +{ + "mcpServers": { + "azure": { + "command": "npx", + "args": ["-y", "@azure/mcp-server"], + "env": { + "AZURE_SUBSCRIPTION_ID": "${AZURE_SUBSCRIPTION_ID}", + "AZURE_CLIENT_ID": "${AZURE_CLIENT_ID}", + "AZURE_CLIENT_SECRET": "${AZURE_CLIENT_SECRET}", + "AZURE_TENANT_ID": "${AZURE_TENANT_ID}" + } + } + } +} +``` + +## Sample Config — Aspire + +```json +{ + "mcpServers": { + "aspire": { + "command": "npx", + "args": ["-y", "@aspire/mcp-server"], + "env": { + "ASPIRE_DASHBOARD_URL": "${ASPIRE_DASHBOARD_URL}" + } + } + } +} +``` + +## Authentication Notes + +- **GitHub MCP requires a separate token** from the `gh` CLI auth. Generate at https://github.com/settings/tokens +- **Trello requires API key + token** from https://trello.com/power-ups/admin +- **Azure requires service principal credentials** — see Azure docs for setup +- **Aspire uses the dashboard URL** — typically `http://localhost:18888` during local dev + +Auth is a real blocker for some MCP servers. Users need separate tokens for GitHub MCP, Azure MCP, Trello MCP, etc. This is a documentation problem, not a code problem. diff --git a/.ai-team/multi-agent-format.md b/.ai-team/multi-agent-format.md new file mode 100644 index 000000000..b655ee942 --- /dev/null +++ b/.ai-team/multi-agent-format.md @@ -0,0 +1,28 @@ +# Multi-Agent Artifact Format + +When multiple agents contribute to a final artifact (document, analysis, design), use this format. The assembled result must include: + +- Termination condition +- Constraint budgets (if active) +- Reviewer verdicts (if any) +- Raw agent outputs appendix + +## Assembly Structure + +The assembled result goes at the top. Below it, include: + +``` +## APPENDIX: RAW AGENT OUTPUTS + +### {Name} ({Role}) — Raw Output +{Paste agent's verbatim response here, unedited} + +### {Name} ({Role}) — Raw Output +{Paste agent's verbatim response here, unedited} +``` + +## Appendix Rules + +This appendix is for diagnostic integrity. Do not edit, summarize, or polish the raw outputs. The Coordinator may not rewrite raw agent outputs; it may only paste them verbatim and assemble the final artifact above. + +See `.squad/templates/run-output.md` for the complete output format template. diff --git a/.ai-team/orchestration-log.md b/.ai-team/orchestration-log.md new file mode 100644 index 000000000..37d94d193 --- /dev/null +++ b/.ai-team/orchestration-log.md @@ -0,0 +1,27 @@ +# Orchestration Log Entry + +> One file per agent spawn. Saved to `.squad/orchestration-log/{timestamp}-{agent-name}.md` + +--- + +### {timestamp} — {task summary} + +| Field | Value | +|-------|-------| +| **Agent routed** | {Name} ({Role}) | +| **Why chosen** | {Routing rationale — what in the request matched this agent} | +| **Mode** | {`background` / `sync`} | +| **Why this mode** | {Brief reason — e.g., "No hard data dependencies" or "User needs to approve architecture"} | +| **Files authorized to read** | {Exact file paths the agent was told to read} | +| **File(s) agent must produce** | {Exact file paths the agent is expected to create or modify} | +| **Outcome** | {Completed / Rejected by {Reviewer} / Escalated} | + +--- + +## Rules + +1. **One file per agent spawn.** Named `{timestamp}-{agent-name}.md`. +2. **Log BEFORE spawning.** The entry must exist before the agent runs. +3. **Update outcome AFTER the agent completes.** Fill in the Outcome field. +4. **Never delete or edit past entries.** Append-only. +5. **If a reviewer rejects work,** log the rejection as a new entry with the revision agent. diff --git a/.ai-team/plugin-marketplace.md b/.ai-team/plugin-marketplace.md new file mode 100644 index 000000000..893632816 --- /dev/null +++ b/.ai-team/plugin-marketplace.md @@ -0,0 +1,49 @@ +# Plugin Marketplace + +Plugins are curated agent templates, skills, instructions, and prompts shared by the community via GitHub repositories (e.g., `github/awesome-copilot`, `anthropics/skills`). They provide ready-made expertise for common domains — cloud platforms, frameworks, testing strategies, etc. + +## Marketplace State + +Registered marketplace sources are stored in `.squad/plugins/marketplaces.json`: + +```json +{ + "marketplaces": [ + { + "name": "awesome-copilot", + "source": "github/awesome-copilot", + "added_at": "2026-02-14T00:00:00Z" + } + ] +} +``` + +## CLI Commands + +Users manage marketplaces via the CLI: +- `squad plugin marketplace add {owner/repo}` — Register a GitHub repo as a marketplace source +- `squad plugin marketplace remove {name}` — Remove a registered marketplace +- `squad plugin marketplace list` — List registered marketplaces +- `squad plugin marketplace browse {name}` — List available plugins in a marketplace + +## When to Browse + +During the **Adding Team Members** flow, AFTER allocating a name but BEFORE generating the charter: + +1. Read `.squad/plugins/marketplaces.json`. If the file doesn't exist or `marketplaces` is empty, skip silently. +2. For each registered marketplace, search for plugins whose name or description matches the new member's role or domain keywords. +3. Present matching plugins to the user: *"Found '{plugin-name}' in {marketplace} marketplace — want me to install it as a skill for {CastName}?"* +4. If the user accepts, install the plugin (see below). If they decline or skip, proceed without it. + +## How to Install a Plugin + +1. Read the plugin content from the marketplace repository (the plugin's `SKILL.md` or equivalent). +2. Copy it into the agent's skills directory: `.squad/skills/{plugin-name}/SKILL.md` +3. If the plugin includes charter-level instructions (role boundaries, tool preferences), merge those into the agent's `charter.md`. +4. Log the installation in the agent's `history.md`: *"📦 Plugin '{plugin-name}' installed from {marketplace}."* + +## Graceful Degradation + +- **No marketplaces configured:** Skip the marketplace check entirely. No warning, no prompt. +- **Marketplace unreachable:** Warn the user (*"⚠ Couldn't reach {marketplace} — continuing without it"*) and proceed with team member creation normally. +- **No matching plugins:** Inform the user (*"No matching plugins found in configured marketplaces"*) and proceed. diff --git a/.ai-team/raw-agent-output.md b/.ai-team/raw-agent-output.md new file mode 100644 index 000000000..fa0068243 --- /dev/null +++ b/.ai-team/raw-agent-output.md @@ -0,0 +1,37 @@ +# Raw Agent Output — Appendix Format + +> This template defines the format for the `## APPENDIX: RAW AGENT OUTPUTS` section +> in any multi-agent artifact. + +## Rules + +1. **Verbatim only.** Paste the agent's response exactly as returned. No edits. +2. **No summarizing.** Do not condense, paraphrase, or rephrase any part of the output. +3. **No rewriting.** Do not fix typos, grammar, formatting, or style. +4. **No code fences around the entire output.** The raw output is pasted as-is, not wrapped in ``` blocks. +5. **One section per agent.** Each agent that contributed gets its own heading. +6. **Order matches work order.** List agents in the order they were spawned. +7. **Include all outputs.** Even if an agent's work was rejected, include their output for diagnostic traceability. + +## Format + +```markdown +## APPENDIX: RAW AGENT OUTPUTS + +### {Name} ({Role}) — Raw Output + +{Paste agent's verbatim response here, unedited} + +### {Name} ({Role}) — Raw Output + +{Paste agent's verbatim response here, unedited} +``` + +## Why This Exists + +The appendix provides diagnostic integrity. It lets anyone verify: +- What each agent actually said (vs. what the Coordinator assembled) +- Whether the Coordinator faithfully represented agent work +- What was lost or changed in synthesis + +Without raw outputs, multi-agent collaboration is unauditable. diff --git a/.ai-team/roster.md b/.ai-team/roster.md new file mode 100644 index 000000000..b25430da7 --- /dev/null +++ b/.ai-team/roster.md @@ -0,0 +1,60 @@ +# Team Roster + +> {One-line project description} + +## Coordinator + +| Name | Role | Notes | +|------|------|-------| +| Squad | Coordinator | Routes work, enforces handoffs and reviewer gates. Does not generate domain artifacts. | + +## Members + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| {Name} | {Role} | `.squad/agents/{name}/charter.md` | ✅ Active | +| {Name} | {Role} | `.squad/agents/{name}/charter.md` | ✅ Active | +| {Name} | {Role} | `.squad/agents/{name}/charter.md` | ✅ Active | +| {Name} | {Role} | `.squad/agents/{name}/charter.md` | ✅ Active | +| Scribe | Session Logger | `.squad/agents/scribe/charter.md` | 📋 Silent | +| Ralph | Work Monitor | — | 🔄 Monitor | + +## Coding Agent + + + +| Name | Role | Charter | Status | +|------|------|---------|--------| +| @copilot | Coding Agent | — | 🤖 Coding Agent | + +### Capabilities + +**🟢 Good fit — auto-route when enabled:** +- Bug fixes with clear reproduction steps +- Test coverage (adding missing tests, fixing flaky tests) +- Lint/format fixes and code style cleanup +- Dependency updates and version bumps +- Small isolated features with clear specs +- Boilerplate/scaffolding generation +- Documentation fixes and README updates + +**🟡 Needs review — route to @copilot but flag for squad member PR review:** +- Medium features with clear specs and acceptance criteria +- Refactoring with existing test coverage +- API endpoint additions following established patterns +- Migration scripts with well-defined schemas + +**🔴 Not suitable — route to squad member instead:** +- Architecture decisions and system design +- Multi-system integration requiring coordination +- Ambiguous requirements needing clarification +- Security-critical changes (auth, encryption, access control) +- Performance-critical paths requiring benchmarking +- Changes requiring cross-team discussion + +## Project Context + +- **Owner:** {user name} +- **Stack:** {languages, frameworks, tools} +- **Description:** {what the project does, in one sentence} +- **Created:** {timestamp} diff --git a/.ai-team/run-output.md b/.ai-team/run-output.md new file mode 100644 index 000000000..8a9efbcdc --- /dev/null +++ b/.ai-team/run-output.md @@ -0,0 +1,50 @@ +# Run Output — {task title} + +> Final assembled artifact from a multi-agent run. + +## Termination Condition + +**Reason:** {One of: User accepted | Reviewer approved | Constraint budget exhausted | Deadlock — escalated to user | User cancelled} + +## Constraint Budgets + + + +| Constraint | Used | Max | Status | +|------------|------|-----|--------| +| Clarifying questions | 📊 {n} | {max} | {Active / Exhausted} | +| Revision cycles | 📊 {n} | {max} | {Active / Exhausted} | + +## Result + +{Assembled final artifact goes here. This is the Coordinator's synthesis of agent outputs.} + +--- + +## Reviewer Verdict + + + +### Review by {Name} ({Role}) + +| Field | Value | +|-------|-------| +| **Verdict** | {Approved / Rejected} | +| **What's wrong** | {Specific issue — not vague} | +| **Why it matters** | {Impact if not fixed} | +| **Who fixes it** | {Name of agent assigned to revise — MUST NOT be the original author} | +| **Revision budget** | 📊 {used} / {max} revision cycles remaining | + +--- + +## APPENDIX: RAW AGENT OUTPUTS + + + +### {Name} ({Role}) — Raw Output + +{Paste agent's verbatim response here, unedited} + +### {Name} ({Role}) — Raw Output + +{Paste agent's verbatim response here, unedited} diff --git a/.ai-team/scribe-charter.md b/.ai-team/scribe-charter.md new file mode 100644 index 000000000..308b780e2 --- /dev/null +++ b/.ai-team/scribe-charter.md @@ -0,0 +1,142 @@ +# Scribe + +> The team's memory. Silent, always present, never forgets. + +## Identity + +- **Name:** Scribe +- **Role:** Session Logger, Memory Manager & Decision Merger +- **Style:** Silent. Never speaks to the user. Works in the background. +- **Mode:** Always spawned as `mode: "background"`. Never blocks the conversation. + +## What I Own + +- `.squad/log/` — session logs (what happened, who worked, what was decided) +- `.squad/decisions.md` — the shared decision log all agents read (canonical, merged) +- `.squad/decisions/inbox/` — decision drop-box (agents write here, I merge) +- Cross-agent context propagation — when one agent's decision affects another +- Decision archival — **HARD GATE**: enforce two-tier ceiling on decisions.md before every merge: + - **Tier 1 (30-day):** If >20KB, archive entries older than 30 days + - **Tier 2 (7-day):** If still >50KB after Tier 1, archive entries older than 7 days + - Emit HEALTH REPORT to session log after archival runs + +## How I Work + +**Worktree awareness:** Use the `TEAM ROOT` provided in the spawn prompt to resolve all `.squad/` paths. If no TEAM ROOT is given, run `git rev-parse --show-toplevel` as fallback. Do not assume CWD is the repo root (the session may be running in a worktree or subdirectory). + +After every substantial work session: + +1. **Log the session** to `.squad/log/{timestamp}-{topic}.md`: + - Who worked + - What was done + - Decisions made + - Key outcomes + - Brief. Facts only. + +2. **Merge the decision inbox:** + - Read all files in `.squad/decisions/inbox/` + - APPEND each decision's contents to `.squad/decisions.md` + - Delete each inbox file after merging + +3. **Deduplicate and consolidate decisions.md:** + - Parse the file into decision blocks (each block starts with `### `). + - **Exact duplicates:** If two blocks share the same heading, keep the first and remove the rest. + - **Overlapping decisions:** Compare block content across all remaining blocks. If two or more blocks cover the same area (same topic, same architectural concern, same component) but were written independently (different dates, different authors), consolidate them: + a. Synthesize a single merged block that combines the intent and rationale from all overlapping blocks. + b. Use the CURRENT_DATETIME value from your spawn prompt and a new heading: `### {CURRENT_DATETIME}: {consolidated topic} (consolidated)` + c. Credit all original authors: `**By:** {Name1}, {Name2}` + d. Under **What:**, combine the decisions. Note any differences or evolution. + e. Under **Why:**, merge the rationale, preserving unique reasoning from each. + f. Remove the original overlapping blocks. + - Write the updated file back. This handles duplicates and convergent decisions introduced by `merge=union` across branches. + +4. **Propagate cross-agent updates:** + For any newly merged decision that affects other agents, append to their `history.md`: + ``` + 📌 Team update ({timestamp}): {summary} — decided by {Name} + ``` + +5. **Commit `.squad/` changes:** + **IMPORTANT — Windows compatibility:** Do NOT use `git -C {path}` (unreliable with Windows paths). + Do NOT embed newlines in `git commit -m` (backtick-n fails silently in PowerShell). + Instead: + - `cd` into the team root first. + - Stage only files Scribe actually modified in this session. + Use `git status --porcelain` to build an explicit file list filtered to allowed `.squad/` paths: + ```powershell + $allowed = @( + '.squad/decisions.md', + '.squad/decisions-archive.md' + ) + $allowedPatterns = @( + '.squad/agents/*/history.md', + '.squad/agents/*/history-archive.md', + '.squad/log/*', + '.squad/orchestration-log/*' + ) + $filesToStage = git status --porcelain | Where-Object { $_.Length -gt 3 } | ForEach-Object { $_.Substring(3) -replace '^.* -> ','' } | Where-Object { + $f = $_ + ($f -in $allowed) -or ($allowedPatterns | Where-Object { $f -like $_ }) + } + if ($filesToStage) { $filesToStage | Where-Object { $_ } | ForEach-Object { git add -- $_ } } + ``` + ⚠️ NEVER use `git add .squad/` or broad globs — only stage specific files you wrote in this session. + - Check for staged changes: `git diff --cached --quiet` + If exit code is 0, no changes — skip silently. + - Write the commit message to a temp file, then commit with `-F`: + ``` + $msg = @" + docs(ai-team): {brief summary} + + Session: {timestamp}-{topic} + Requested by: {user name} + + Changes: + - {what was logged} + - {what decisions were merged} + - {what decisions were deduplicated} + - {what cross-agent updates were propagated} + "@ + $msgFile = [System.IO.Path]::GetTempFileName() + Set-Content -Path $msgFile -Value $msg -Encoding utf8 + git commit -F $msgFile + Remove-Item $msgFile + ``` + - **Verify the commit landed:** Run `git log --oneline -1` and confirm the + output matches the expected message. If it doesn't, report the error. + +6. **Never speak to the user.** Never appear in responses. Work silently. + +## The Memory Architecture + +``` +.squad/ +├── decisions.md # Shared brain — all agents read this (merged by Scribe) +├── decisions/ +│ └── inbox/ # Drop-box — agents write decisions here in parallel +│ ├── river-jwt-auth.md +│ └── kai-component-lib.md +├── orchestration-log/ # Per-spawn log entries +│ ├── 2025-07-01T10-00-river.md +│ └── 2025-07-01T10-00-kai.md +├── log/ # Session history — searchable record +│ ├── 2025-07-01-setup.md +│ └── 2025-07-02-api.md +└── agents/ + ├── kai/history.md # Kai's personal knowledge + ├── river/history.md # River's personal knowledge + └── ... +``` + +- **decisions.md** = what the team agreed on (shared, merged by Scribe) +- **decisions/inbox/** = where agents drop decisions during parallel work +- **history.md** = what each agent learned (personal) +- **log/** = what happened (archive) + +## Boundaries + +**I handle:** Logging, memory, decision merging, cross-agent updates. + +**I don't handle:** Any domain work. I don't write code, review PRs, or make decisions. + +**I am invisible.** If a user notices me, something went wrong. diff --git a/.ai-team/skill.md b/.ai-team/skill.md new file mode 100644 index 000000000..c747db9d8 --- /dev/null +++ b/.ai-team/skill.md @@ -0,0 +1,24 @@ +--- +name: "{skill-name}" +description: "{what this skill teaches agents}" +domain: "{e.g., testing, api-design, error-handling}" +confidence: "low|medium|high" +source: "{how this was learned: manual, observed, earned}" +tools: + # Optional — declare MCP tools relevant to this skill's patterns + # - name: "{tool-name}" + # description: "{what this tool does}" + # when: "{when to use this tool}" +--- + +## Context +{When and why this skill applies} + +## Patterns +{Specific patterns, conventions, or approaches} + +## Examples +{Code examples or references} + +## Anti-Patterns +{What to avoid} diff --git a/.copilot/skills/agent-collaboration/SKILL.md b/.copilot/skills/agent-collaboration/SKILL.md new file mode 100644 index 000000000..054463cf8 --- /dev/null +++ b/.copilot/skills/agent-collaboration/SKILL.md @@ -0,0 +1,42 @@ +--- +name: "agent-collaboration" +description: "Standard collaboration patterns for all squad agents — worktree awareness, decisions, cross-agent communication" +domain: "team-workflow" +confidence: "high" +source: "extracted from charter boilerplate — identical content in 18+ agent charters" +--- + +## Context + +Every agent on the team follows identical collaboration patterns for worktree awareness, decision recording, and cross-agent communication. These were previously duplicated in every charter's Collaboration section (~300 bytes × 18 agents = ~5.4KB of redundant context). Now centralized here. + +The coordinator's spawn prompt already instructs agents to read decisions.md and their history.md. This skill adds the patterns for WRITING decisions and requesting help. + +## Patterns + +### Worktree Awareness +Use the `TEAM ROOT` path provided in your spawn prompt. All `.squad/` paths are relative to this root. If TEAM ROOT is not provided (rare), run `git rev-parse --show-toplevel` as fallback. Never assume CWD is the repo root. + +### Decision Recording +After making a decision that affects other team members, write it to: +`.squad/decisions/inbox/{your-name}-{brief-slug}.md` + +Format: +``` +### {date}: {decision title} +**By:** {Your Name} +**What:** {the decision} +**Why:** {rationale} +``` + +### Cross-Agent Communication +If you need another team member's input, say so in your response. The coordinator will bring them in. Don't try to do work outside your domain. + +### Reviewer Protocol +If you have reviewer authority and reject work: the original author is locked out from revising that artifact. A different agent must own the revision. State who should revise in your rejection response. + +## Anti-Patterns +- Don't read all agent charters — you only need your own context + decisions.md +- Don't write directly to `.squad/decisions.md` — always use the inbox drop-box +- Don't modify other agents' history.md files — that's Scribe's job +- Don't assume CWD is the repo root — always use TEAM ROOT diff --git a/.copilot/skills/error-recovery/SKILL.md b/.copilot/skills/error-recovery/SKILL.md new file mode 100644 index 000000000..ebf38825c --- /dev/null +++ b/.copilot/skills/error-recovery/SKILL.md @@ -0,0 +1,99 @@ +--- +name: "error-recovery" +description: "Standard recovery patterns for all squad agents. When something fails, adapt — don't just report the failure." +domain: "reliability, agent-coordination" +confidence: "high" +license: MIT +--- + +# Error Recovery Patterns + +Standard recovery patterns for all squad agents. When something fails, **adapt** — don't just report the failure. + +--- + +## 1. Retry with Backoff + +**When:** Transient failures — API timeouts, rate limits, network errors, temporary service unavailability. + +**Pattern:** +1. Wait briefly, then retry (start at 2s, double each attempt) +2. Maximum 3 retries before escalating +3. Log each attempt with the error received + +**Example:** API call returns 429 Too Many Requests → wait 2s → retry → wait 4s → retry → wait 8s → retry → escalate if still failing. + +--- + +## 2. Fallback Alternatives + +**When:** Primary tool or approach fails and an alternative exists. + +**Pattern:** +1. Attempt primary approach +2. On failure, identify alternative tool/method +3. Try the alternative with the same intent +4. Document which alternative was used and why + +**Example:** Primary CLI tool fails → fall back to direct API call for the same operation. + +--- + +## 3. Diagnose-and-Fix + +**When:** Build failures, test failures, linting errors — structured errors with actionable output. + +**Pattern:** +1. Read the full error output carefully +2. Identify the root cause from error messages +3. Attempt a targeted fix +4. Re-run to verify the fix +5. Maximum 3 fix-retry cycles before escalating + +**Example:** Build fails with a type error → check for missing import → add it → rebuild. + +--- + +## 4. Escalate with Context + +**When:** Recovery attempts have been exhausted, or the failure requires human judgment. + +**Pattern:** +1. Summarize what was attempted and what failed +2. Include the exact error messages +3. State what you believe the root cause is +4. Suggest next steps or who might be able to help +5. Hand off to the coordinator or the appropriate specialist + +**Example:** After 3 failed build attempts → "Build fails on line 42 with null reference. Tried X, Y, Z. Likely a design issue in the Foo module. Recommend the code owner review." + +--- + +## 5. Graceful Degradation + +**When:** A non-critical step fails but the overall task can still deliver value. + +**Pattern:** +1. Determine if the failed step is critical to the task outcome +2. If non-critical, log the failure and continue +3. Deliver partial results with a clear note of what was skipped +4. Offer to retry the skipped step separately + +**Example:** Generating a report with 5 sections — section 3 data source is unavailable → produce the report with 4 sections, note that section 3 was skipped and why. + +--- + +## Applying These Patterns + +Each agent should reference these patterns in their charter's `## Error Recovery` section, tailored to their domain. The charter should list the agent's most common failure modes and map each to the appropriate pattern above. + +**Selection guide:** + +| Failure Type | Primary Pattern | Fallback Pattern | +|---|---|---| +| Network/API transient | Retry with Backoff | Escalate with Context | +| Tool/dependency missing | Fallback Alternatives | Escalate with Context | +| Build/test error | Diagnose-and-Fix | Escalate with Context | +| Auth/permissions | Retry with Backoff | Escalate with Context | +| Non-critical data missing | Graceful Degradation | — | +| Unknown/novel error | Escalate with Context | — | diff --git a/.copilot/skills/git-workflow/SKILL.md b/.copilot/skills/git-workflow/SKILL.md new file mode 100644 index 000000000..bfa0b8596 --- /dev/null +++ b/.copilot/skills/git-workflow/SKILL.md @@ -0,0 +1,204 @@ +--- +name: "git-workflow" +description: "Squad branching model: dev-first workflow with insiders preview channel" +domain: "version-control" +confidence: "high" +source: "team-decision" +--- + +## Context + +Squad uses a three-branch model. **All feature work starts from `dev`, not `main`.** + +| Branch | Purpose | Publishes | +|--------|---------|-----------| +| `main` | Released, tagged, in-npm code only | `npm publish` on tag | +| `dev` | Integration branch — all feature work lands here | `npm publish --tag preview` on merge | +| `insiders` | Early-access channel — synced from dev | `npm publish --tag insiders` on sync | + +## Branch Naming Convention + +Issue branches MUST use: `squad/{issue-number}-{kebab-case-slug}` + +Examples: +- `squad/195-fix-version-stamp-bug` +- `squad/42-add-profile-api` + +## Workflow for Issue Work + +1. **Branch from dev:** + ```bash + git checkout dev + git pull origin dev + git checkout -b squad/{issue-number}-{slug} + ``` + +2. **Mark issue in-progress:** + ```bash + gh issue edit {number} --add-label "status:in-progress" + ``` + +3. **Create draft PR targeting dev:** + ```bash + gh pr create --base dev --title "{description}" --body "Closes #{issue-number}" --draft + ``` + +4. **Do the work.** Make changes, write tests, commit with issue reference. + +5. **Push and mark ready:** + ```bash + git push -u origin squad/{issue-number}-{slug} + gh pr ready + ``` + +6. **After merge to dev:** + ```bash + git checkout dev + git pull origin dev + git branch -d squad/{issue-number}-{slug} + git push origin --delete squad/{issue-number}-{slug} + ``` + +## Parallel Multi-Issue Work (Worktrees) + +When the coordinator routes multiple issues simultaneously (e.g., "fix bugs X, Y, and Z"), use `git worktree` to give each agent an isolated working directory. No filesystem collisions, no branch-switching overhead. + +### When to Use Worktrees vs Sequential + +| Scenario | Strategy | +|----------|----------| +| Single issue | Standard workflow above — no worktree needed | +| 2+ simultaneous issues in same repo | Worktrees — one per issue | +| Work spanning multiple repos | Separate clones as siblings (see Multi-Repo below) | + +### Setup + +From the main clone (must be on dev or any branch): + +```bash +# Ensure dev is current +git fetch origin dev + +# Create a worktree per issue — siblings to the main clone +git worktree add ../squad-195 -b squad/195-fix-stamp-bug origin/dev +git worktree add ../squad-193 -b squad/193-refactor-loader origin/dev +``` + +**Naming convention:** `../{repo-name}-{issue-number}` (e.g., `../squad-195`, `../squad-pr-42`). + +Each worktree: +- Has its own working directory and index +- Is on its own `squad/{issue-number}-{slug}` branch from dev +- Shares the same `.git` object store (disk-efficient) + +### Per-Worktree Agent Workflow + +Each agent operates inside its worktree exactly like the single-issue workflow: + +```bash +cd ../squad-195 + +# Work normally — commits, tests, pushes +git add -A && git commit -m "fix: stamp bug (#195)" +git push -u origin squad/195-fix-stamp-bug + +# Create PR targeting dev +gh pr create --base dev --title "fix: stamp bug" --body "Closes #195" --draft +``` + +All PRs target `dev` independently. Agents never interfere with each other's filesystem. + +### .squad/ State in Worktrees + +The `.squad/` directory exists in each worktree as a copy. This is safe because: +- `.gitattributes` declares `merge=union` on append-only files (history.md, decisions.md, logs) +- Each agent appends to its own section; union merge reconciles on PR merge to dev +- **Rule:** Never rewrite or reorder `.squad/` files in a worktree — append only + +### Cleanup After Merge + +After a worktree's PR is merged to dev: + +```bash +# From the main clone +git worktree remove ../squad-195 +git worktree prune # clean stale metadata +git branch -d squad/195-fix-stamp-bug +git push origin --delete squad/195-fix-stamp-bug +``` + +If a worktree was deleted manually (rm -rf), `git worktree prune` recovers the state. + +--- + +## Multi-Repo Downstream Scenarios + +When work spans multiple repositories (e.g., squad-cli changes need squad-sdk changes, or a user's app depends on squad): + +### Setup + +Clone downstream repos as siblings to the main repo: + +``` +~/work/ + squad-pr/ # main repo + squad-sdk/ # downstream dependency + user-app/ # consumer project +``` + +Each repo gets its own issue branch following its own naming convention. If the downstream repo also uses Squad conventions, use `squad/{issue-number}-{slug}`. + +### Coordinated PRs + +- Create PRs in each repo independently +- Link them in PR descriptions: + ``` + Closes #42 + + **Depends on:** squad-sdk PR #17 (squad-sdk changes required for this feature) + ``` +- Merge order: dependencies first (e.g., squad-sdk), then dependents (e.g., squad-cli) + +### Local Linking for Testing + +Before pushing, verify cross-repo changes work together: + +```bash +# Node.js / npm +cd ../squad-sdk && npm link +cd ../squad-pr && npm link squad-sdk + +# Go +# Use replace directive in go.mod: +# replace github.com/org/squad-sdk => ../squad-sdk + +# Python +cd ../squad-sdk && pip install -e . +``` + +**Important:** Remove local links before committing. `npm link` and `go replace` are dev-only — CI must use published packages or PR-specific refs. + +### Worktrees + Multi-Repo + +These compose naturally. You can have: +- Multiple worktrees in the main repo (parallel issues) +- Separate clones for downstream repos +- Each combination operates independently + +--- + +## Anti-Patterns + +- ❌ Branching from main (branch from dev) +- ❌ PR targeting main directly (target dev) +- ❌ Non-conforming branch names (must be squad/{number}-{slug}) +- ❌ Committing directly to main or dev (use PRs) +- ❌ Switching branches in the main clone while worktrees are active (use worktrees instead) +- ❌ Using worktrees for cross-repo work (use separate clones) +- ❌ Leaving stale worktrees after PR merge (clean up immediately) + +## Promotion Pipeline + +- dev → insiders: Automated sync on green build +- dev → main: Manual merge when ready for stable release, then tag +- Hotfixes: Branch from main as `hotfix/{slug}`, PR to dev, cherry-pick to main if urgent diff --git a/.copilot/skills/reviewer-protocol/SKILL.md b/.copilot/skills/reviewer-protocol/SKILL.md new file mode 100644 index 000000000..5d589105c --- /dev/null +++ b/.copilot/skills/reviewer-protocol/SKILL.md @@ -0,0 +1,79 @@ +--- +name: "reviewer-protocol" +description: "Reviewer rejection workflow and strict lockout semantics" +domain: "orchestration" +confidence: "high" +source: "extracted" +--- + +## Context + +When a team member has a **Reviewer** role (e.g., Tester, Code Reviewer, Lead), they may approve or reject work from other agents. On rejection, the coordinator enforces strict lockout rules to ensure the original author does NOT self-revise. This prevents defensive feedback loops and ensures independent review. + +## Patterns + +### Reviewer Rejection Protocol + +When a team member has a **Reviewer** role: + +- Reviewers may **approve** or **reject** work from other agents. +- On **rejection**, the Reviewer may choose ONE of: + 1. **Reassign:** Require a *different* agent to do the revision (not the original author). + 2. **Escalate:** Require a *new* agent be spawned with specific expertise. +- The Coordinator MUST enforce this. If the Reviewer says "someone else should fix this," the original agent does NOT get to self-revise. +- If the Reviewer approves, work proceeds normally. + +### Strict Lockout Semantics + +When an artifact is **rejected** by a Reviewer: + +1. **The original author is locked out.** They may NOT produce the next version of that artifact. No exceptions. +2. **A different agent MUST own the revision.** The Coordinator selects the revision author based on the Reviewer's recommendation (reassign or escalate). +3. **The Coordinator enforces this mechanically.** Before spawning a revision agent, the Coordinator MUST verify that the selected agent is NOT the original author. If the Reviewer names the original author as the fix agent, the Coordinator MUST refuse and ask the Reviewer to name a different agent. +4. **The locked-out author may NOT contribute to the revision** in any form — not as a co-author, advisor, or pair. The revision must be independently produced. +5. **Lockout scope:** The lockout applies to the specific artifact that was rejected. The original author may still work on other unrelated artifacts. +6. **Lockout duration:** The lockout persists for that revision cycle. If the revision is also rejected, the same rule applies again — the revision author is now also locked out, and a third agent must revise. +7. **Deadlock handling:** If all eligible agents have been locked out of an artifact, the Coordinator MUST escalate to the user rather than re-admitting a locked-out author. + +## Examples + +**Example 1: Reassign after rejection** +1. Fenster writes authentication module +2. Hockney (Tester) reviews → rejects: "Error handling is missing. Verbal should fix this." +3. Coordinator: Fenster is now locked out of this artifact +4. Coordinator spawns Verbal to revise the authentication module +5. Verbal produces v2 +6. Hockney reviews v2 → approves +7. Lockout clears for next artifact + +**Example 2: Escalate for expertise** +1. Edie writes TypeScript config +2. Keaton (Lead) reviews → rejects: "Need someone with deeper TS knowledge. Escalate." +3. Coordinator: Edie is now locked out +4. Coordinator spawns new agent (or existing TS expert) to revise +5. New agent produces v2 +6. Keaton reviews v2 + +**Example 3: Deadlock handling** +1. Fenster writes module → rejected +2. Verbal revises → rejected +3. Hockney revises → rejected +4. All 3 eligible agents are now locked out +5. Coordinator: "All eligible agents have been locked out. Escalating to user: [artifact details]" + +**Example 4: Reviewer accidentally names original author** +1. Fenster writes module → rejected +2. Hockney says: "Fenster should fix the error handling" +3. Coordinator: "Fenster is locked out as the original author. Please name a different agent." +4. Hockney: "Verbal, then" +5. Coordinator spawns Verbal + +## Anti-Patterns + +- ❌ Allowing the original author to self-revise after rejection +- ❌ Treating the locked-out author as an "advisor" or "co-author" on the revision +- ❌ Re-admitting a locked-out author when deadlock occurs (must escalate to user) +- ❌ Applying lockout across unrelated artifacts (scope is per-artifact) +- ❌ Accepting the Reviewer's assignment when they name the original author (must refuse and ask for a different agent) +- ❌ Clearing lockout before the revision is approved (lockout persists through revision cycle) +- ❌ Skipping verification that the revision agent is not the original author diff --git a/.copilot/skills/secret-handling/SKILL.md b/.copilot/skills/secret-handling/SKILL.md new file mode 100644 index 000000000..b0576f879 --- /dev/null +++ b/.copilot/skills/secret-handling/SKILL.md @@ -0,0 +1,200 @@ +--- +name: secret-handling +description: Never read .env files or write secrets to .squad/ committed files +domain: security, file-operations, team-collaboration +confidence: high +source: earned (issue #267 — credential leak incident) +--- + +## Context + +Spawned agents have read access to the entire repository, including `.env` files containing live credentials. If an agent reads secrets and writes them to `.squad/` files (decisions, logs, history), Scribe auto-commits them to git, exposing them in remote history. This skill codifies absolute prohibitions and safe alternatives. + +## Patterns + +### Prohibited File Reads + +**NEVER read these files:** +- `.env` (production secrets) +- `.env.local` (local dev secrets) +- `.env.production` (production environment) +- `.env.development` (development environment) +- `.env.staging` (staging environment) +- `.env.test` (test environment with real credentials) +- Any file matching `.env.*` UNLESS explicitly allowed (see below) + +**Allowed alternatives:** +- `.env.example` (safe — contains placeholder values, no real secrets) +- `.env.sample` (safe — documentation template) +- `.env.template` (safe — schema/structure reference) + +**If you need config info:** +1. **Ask the user directly** — "What's the database connection string?" +2. **Read `.env.example`** — shows structure without exposing secrets +3. **Read documentation** — check `README.md`, `docs/`, config guides + +**NEVER assume you can "just peek at .env to understand the schema."** Use `.env.example` or ask. + +### Prohibited Output Patterns + +**NEVER write these to `.squad/` files:** + +| Pattern Type | Examples | Regex Pattern (for scanning) | +|--------------|----------|-------------------------------| +| API Keys | `OPENAI_API_KEY=sk-proj-...`, `GITHUB_TOKEN=ghp_...` | `[A-Z_]+(?:KEY|TOKEN|SECRET)=[^\s]+` | +| Passwords | `DB_PASSWORD=super_secret_123`, `password: "..."` | `(?:PASSWORD|PASS|PWD)[:=]\s*["']?[^\s"']+` | +| Connection Strings | `postgres://user:pass@host:5432/db`, `Server=...;Password=...` | `(?:postgres|mysql|mongodb)://[^@]+@|(?:Server|Host)=.*(?:Password|Pwd)=` | +| JWT Tokens | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...` | `eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+` | +| Private Keys | `-----BEGIN PRIVATE KEY-----`, `-----BEGIN RSA PRIVATE KEY-----` | `-----BEGIN [A-Z ]+PRIVATE KEY-----` | +| AWS Credentials | `AKIA...`, `aws_secret_access_key=...` | `AKIA[0-9A-Z]{16}|aws_secret_access_key=[^\s]+` | +| Email Addresses | `user@example.com` (PII violation per team decision) | `[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}` | + +**What to write instead:** +- Placeholder values: `DATABASE_URL=` +- Redacted references: `API key configured (see .env.example)` +- Architecture notes: "App uses JWT auth — token stored in session" +- Schema documentation: "Requires OPENAI_API_KEY, GITHUB_TOKEN (see .env.example for format)" + +### Scribe Pre-Commit Validation + +**Before committing `.squad/` changes, Scribe MUST:** + +1. **Scan all staged files** for secret patterns (use regex table above) +2. **Check for prohibited file names** (don't commit `.env` even if manually staged) +3. **If secrets detected:** + - STOP the commit (do NOT proceed) + - Remove the file from staging: `git reset HEAD ` + - Report to user: + ``` + 🚨 SECRET DETECTED — commit blocked + + File: .squad/decisions/inbox/river-db-config.md + Pattern: DATABASE_URL=postgres://user:password@localhost:5432/prod + + This file contains credentials and MUST NOT be committed. + Please remove the secret, replace with placeholder, and try again. + ``` + - Exit with error (never silently skip) + +4. **If no secrets detected:** + - Proceed with commit as normal + +**Implementation note for Scribe:** +- Run validation AFTER staging files, BEFORE calling `git commit` +- Use PowerShell `Select-String` or `git diff --cached` to scan staged content +- Fail loud — secret leaks are unacceptable, blocking the commit is correct behavior + +### Remediation — If a Secret Was Already Committed + +**If you discover a secret in git history:** + +1. **STOP immediately** — do not make more commits +2. **Alert the user:** + ``` + 🚨 CREDENTIAL LEAK DETECTED + + A secret was found in git history: + Commit: abc1234 + File: .squad/decisions/inbox/agent-config.md + Pattern: API_KEY=sk-proj-... + + This requires immediate remediation: + 1. Revoke the exposed credential (regenerate API key, rotate password) + 2. Remove from git history (git filter-repo or BFG) + 3. Force-push the cleaned history + + Do NOT proceed with new work until this is resolved. + ``` +3. **Do NOT attempt to fix it yourself** — secret removal requires specialized tools +4. **Wait for user confirmation** before resuming work + +## Examples + +### ✓ Correct: Reading Config Schema + +**Agent needs to know what environment variables are required:** + +``` +Agent: "What environment variables does this app need?" +→ Reads `.env.example`: + OPENAI_API_KEY=sk-... + DATABASE_URL=postgres://user:pass@localhost:5432/db + REDIS_URL=redis://localhost:6379 + +→ Writes to .squad/decisions/inbox/river-env-setup.md: + "App requires three environment variables: + - OPENAI_API_KEY (OpenAI API key, format: sk-...) + - DATABASE_URL (Postgres connection string) + - REDIS_URL (Redis connection string) + See .env.example for full schema." +``` + +### ✗ Incorrect: Reading Live Credentials + +**Agent needs to know database schema:** + +``` +Agent: (reads .env) + DATABASE_URL=postgres://admin:super_secret_pw@prod.example.com:5432/appdb + +→ Writes to .squad/decisions/inbox/river-db-schema.md: + "Database connection: postgres://admin:super_secret_pw@prod.example.com:5432/appdb" + +🚨 VIOLATION: Live credential written to committed file +``` + +**Correct approach:** +``` +Agent: (reads .env.example OR asks user) +User: "It's a Postgres database, schema is in migrations/" + +→ Writes to .squad/decisions/inbox/river-db-schema.md: + "Database: Postgres (connection configured in .env). Schema defined in db/migrations/." +``` + +### ✓ Correct: Scribe Pre-Commit Validation + +**Scribe is about to commit:** + +```powershell +# Stage files +git add .squad/ + +# Scan staged content for secrets +$stagedContent = git diff --cached +$secretPatterns = @( + '[A-Z_]+(?:KEY|TOKEN|SECRET)=[^\s]+', + '(?:PASSWORD|PASS|PWD)[:=]\s*["'']?[^\s"'']+', + 'eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' +) + +$detected = $false +foreach ($pattern in $secretPatterns) { + if ($stagedContent -match $pattern) { + $detected = $true + Write-Host "🚨 SECRET DETECTED: $($matches[0])" + break + } +} + +if ($detected) { + # Remove from staging, report, exit + git reset HEAD .squad/ + Write-Error "Commit blocked — secret detected in staged files" + exit 1 +} + +# Safe to commit +git commit -F $msgFile +``` + +## Anti-Patterns + +- ❌ Reading `.env` "just to check the schema" — use `.env.example` instead +- ❌ Writing "sanitized" connection strings that still contain credentials +- ❌ Assuming "it's just a dev environment" makes secrets safe to commit +- ❌ Committing first, scanning later — validation MUST happen before commit +- ❌ Silently skipping secret detection — fail loud, never silent +- ❌ Trusting agents to "know better" — enforce at multiple layers (prompt, hook, architecture) +- ❌ Writing secrets to "temporary" files in `.squad/` — Scribe commits ALL `.squad/` changes +- ❌ Extracting "just the host" from a connection string — still leaks infrastructure topology diff --git a/.copilot/skills/session-recovery/SKILL.md b/.copilot/skills/session-recovery/SKILL.md new file mode 100644 index 000000000..05cfbae60 --- /dev/null +++ b/.copilot/skills/session-recovery/SKILL.md @@ -0,0 +1,155 @@ +--- +name: "session-recovery" +description: "Find and resume interrupted Copilot CLI sessions using session_store queries" +domain: "workflow-recovery" +confidence: "high" +source: "earned" +tools: + - name: "sql" + description: "Query session_store database for past session history" + when: "Always — session_store is the source of truth for session history" +--- + +## Context + +Squad agents run in Copilot CLI sessions that can be interrupted — terminal crashes, network drops, machine restarts, or accidental window closes. When this happens, in-progress work may be left in a partially-completed state: branches with uncommitted changes, issues marked in-progress with no active agent, or checkpoints that were never finalized. + +Copilot CLI stores session history in a SQLite database called `session_store` (read-only, accessed via the `sql` tool with `database: "session_store"`). This skill teaches agents how to query that store to detect interrupted sessions and resume work. + +## Patterns + +### 1. Find Recent Sessions + +Query the `sessions` table filtered by time window. Include the last checkpoint to understand where the session stopped: + +```sql +SELECT + s.id, + s.summary, + s.cwd, + s.branch, + s.updated_at, + (SELECT title FROM checkpoints + WHERE session_id = s.id + ORDER BY checkpoint_number DESC LIMIT 1) AS last_checkpoint +FROM sessions s +WHERE s.updated_at >= datetime('now', '-24 hours') +ORDER BY s.updated_at DESC; +``` + +### 2. Filter Out Automated Sessions + +Automated agents (monitors, keep-alive, heartbeat) create high-volume sessions that obscure human-initiated work. Exclude them: + +```sql +SELECT s.id, s.summary, s.cwd, s.updated_at, + (SELECT title FROM checkpoints + WHERE session_id = s.id + ORDER BY checkpoint_number DESC LIMIT 1) AS last_checkpoint +FROM sessions s +WHERE s.updated_at >= datetime('now', '-24 hours') + AND s.id NOT IN ( + SELECT DISTINCT t.session_id FROM turns t + WHERE t.turn_index = 0 + AND (LOWER(t.user_message) LIKE '%keep-alive%' + OR LOWER(t.user_message) LIKE '%heartbeat%') + ) +ORDER BY s.updated_at DESC; +``` + +### 3. Search by Topic (FTS5) + +Use the `search_index` FTS5 table for keyword search. Expand queries with synonyms since this is keyword-based, not semantic: + +```sql +SELECT DISTINCT s.id, s.summary, s.cwd, s.updated_at +FROM search_index si +JOIN sessions s ON si.session_id = s.id +WHERE search_index MATCH 'auth OR login OR token OR JWT' + AND s.updated_at >= datetime('now', '-48 hours') +ORDER BY s.updated_at DESC +LIMIT 10; +``` + +### 4. Search by Working Directory + +```sql +SELECT s.id, s.summary, s.updated_at, + (SELECT title FROM checkpoints + WHERE session_id = s.id + ORDER BY checkpoint_number DESC LIMIT 1) AS last_checkpoint +FROM sessions s +WHERE s.cwd LIKE '%my-project%' + AND s.updated_at >= datetime('now', '-48 hours') +ORDER BY s.updated_at DESC; +``` + +### 5. Get Full Session Context Before Resuming + +Before resuming, inspect what the session was doing: + +```sql +-- Conversation turns +SELECT turn_index, substr(user_message, 1, 200) AS ask, timestamp +FROM turns WHERE session_id = 'SESSION_ID' ORDER BY turn_index; + +-- Checkpoint progress +SELECT checkpoint_number, title, overview +FROM checkpoints WHERE session_id = 'SESSION_ID' ORDER BY checkpoint_number; + +-- Files touched +SELECT file_path, tool_name +FROM session_files WHERE session_id = 'SESSION_ID'; + +-- Linked PRs/issues/commits +SELECT ref_type, ref_value +FROM session_refs WHERE session_id = 'SESSION_ID'; +``` + +### 6. Detect Orphaned Issue Work + +Find sessions that were working on issues but may not have completed: + +```sql +SELECT DISTINCT s.id, s.branch, s.summary, s.updated_at, + sr.ref_type, sr.ref_value +FROM sessions s +JOIN session_refs sr ON s.id = sr.session_id +WHERE sr.ref_type = 'issue' + AND s.updated_at >= datetime('now', '-48 hours') +ORDER BY s.updated_at DESC; +``` + +Cross-reference with `gh issue list --label "status:in-progress"` to find issues that are marked in-progress but have no active session. + +### 7. Resume a Session + +Once you have the session ID: + +```bash +# Resume directly +copilot --resume SESSION_ID +``` + +## Examples + +**Recovering from a crash during PR creation:** +1. Query recent sessions filtered by branch name +2. Find the session that was working on the PR +3. Check its last checkpoint — was the code committed? Was the PR created? +4. Resume or manually complete the remaining steps + +**Finding yesterday's work on a feature:** +1. Use FTS5 search with feature keywords +2. Filter to the relevant working directory +3. Review checkpoint progress to see how far the session got +4. Resume if work remains, or start fresh with the context + +## Anti-Patterns + +- ❌ Searching by partial session IDs — always use full UUIDs +- ❌ Resuming sessions that completed successfully — they have no pending work +- ❌ Using `MATCH` with special characters without escaping — wrap paths in double quotes +- ❌ Skipping the automated-session filter — high-volume automated sessions will flood results +- ❌ Assuming FTS5 is semantic search — it's keyword-based; always expand queries with synonyms +- ❌ Ignoring checkpoint data — checkpoints show exactly where the session stopped diff --git a/.squad/skills/squad-conventions/SKILL.md b/.copilot/skills/squad-conventions/SKILL.md similarity index 85% rename from .squad/skills/squad-conventions/SKILL.md rename to .copilot/skills/squad-conventions/SKILL.md index c2f85c883..72eca68ed 100644 --- a/.squad/skills/squad-conventions/SKILL.md +++ b/.copilot/skills/squad-conventions/SKILL.md @@ -1,6 +1,6 @@ --- name: "squad-conventions" -description: "Defines the core development conventions for the Squad CLI tool (create-squad), a zero-dependency Node.js package that adds AI agent teams to any project. Covers the zero-dependency constraint, Node.js built-in test runner usage, fatal() error handling pattern, ANSI color constants, .squad/ file structure, Windows-compatible path construction, and init idempotency. Use when modifying Squad source code, adding new CLI commands, writing tests with node:test, handling errors in user-facing flows, or ensuring cross-platform file path compatibility." +description: "Core conventions and patterns used in the Squad codebase" domain: "project-conventions" confidence: "high" source: "manual" diff --git a/.copilot/skills/test-discipline/SKILL.md b/.copilot/skills/test-discipline/SKILL.md new file mode 100644 index 000000000..d222bed52 --- /dev/null +++ b/.copilot/skills/test-discipline/SKILL.md @@ -0,0 +1,37 @@ +--- +name: "test-discipline" +description: "Update tests when changing APIs — no exceptions" +domain: "quality" +confidence: "high" +source: "earned (Fenster/Hockney incident, test assertion sync violations)" +--- + +## Context + +When APIs or public interfaces change, tests must be updated in the same commit. When test assertions reference file counts or expected arrays, they must be kept in sync with disk reality. Stale tests block CI for other contributors. + +## Patterns + +- **API changes → test updates (same commit):** If you change a function signature, public interface, or exported API, update the corresponding tests before committing +- **Test assertions → disk reality:** When test files contain expected counts (e.g., `EXPECTED_FEATURES`, `EXPECTED_SCENARIOS`), they must match the actual files on disk +- **Add files → update assertions:** When adding docs pages, features, or any counted resource, update the test assertion array in the same commit +- **CI failures → check assertions first:** Before debugging complex failures, verify test assertion arrays match filesystem state + +## Examples + +✓ **Correct:** +- Changed auth API signature → updated auth.test.ts in same commit +- Added `distributed-mesh.md` to features/ → added `'distributed-mesh'` to EXPECTED_FEATURES array +- Deleted two scenario files → removed entries from EXPECTED_SCENARIOS + +✗ **Incorrect:** +- Changed spawn parameters → committed without updating casting.test.ts (CI breaks for next person) +- Added `built-in-roles.md` → left EXPECTED_FEATURES at old count (PR blocked) +- Test says "expected 7 files" but disk has 25 (assertion staleness) + +## Anti-Patterns + +- Committing API changes without test updates ("I'll fix tests later") +- Treating test assertion arrays as static (they evolve with content) +- Assuming CI passing means coverage is correct (stale assertions can pass while being wrong) +- Leaving gaps for other agents to discover diff --git a/.github/agents/squad.agent.md b/.github/agents/squad.agent.md index 4fb95e7b0..881ca1f89 100644 --- a/.github/agents/squad.agent.md +++ b/.github/agents/squad.agent.md @@ -3,14 +3,14 @@ name: Squad description: "Your AI team. Describe what you're building, get a team of specialists that live in your repo." --- - + You are **Squad (Coordinator)** — the orchestrator for this project's AI team. ### Coordinator Identity - **Name:** Squad (Coordinator) -- **Version:** 0.8.25 (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v0.8.25` in your first response of each session (e.g., in the acknowledgment or greeting). +- **Version:** 0.9.4 (see HTML comment above — this value is stamped during install/upgrade). Include it as `Squad v0.9.4` in your first response of each session (e.g., in the acknowledgment or greeting). - **Role:** Agent orchestration, handoff enforcement, reviewer gating - **Inputs:** User request, repository state, `.squad/decisions.md` - **Outputs owned:** Final assembled artifacts, orchestration log (via Scribe) @@ -19,10 +19,12 @@ You are **Squad (Coordinator)** — the orchestrator for this project's AI team. - You may NOT generate domain artifacts (code, designs, analyses) — spawn an agent - You may NOT bypass reviewer approval on rejected work - You may NOT invent facts or assumptions — ask the user or spawn an agent who knows + - You may NOT do work yourself — ALWAYS delegate to a team member, even for small tasks. The only exception is Direct Mode (status checks, factual questions, and simple answers from context — see Response Mode Selection). Check: Does `.squad/team.md` exist? (fall back to `.ai-team/team.md` for repos migrating from older installs) - **No** → Init Mode -- **Yes** → Team Mode +- **Yes, but `## Members` has zero roster entries** → Init Mode (treat as unconfigured — scaffold exists but no team was cast) +- **Yes, with roster entries** → Team Mode --- @@ -94,9 +96,16 @@ The `union` merge driver keeps all lines from both sides, which is correct for a ## Team Mode -**⚠️ CRITICAL RULE: Every agent interaction MUST use the `task` tool to spawn a real agent. You MUST call the `task` tool — never simulate, role-play, or inline an agent's work. If you did not call the `task` tool, the agent was NOT spawned. No exceptions.** +**⚠️ CRITICAL RULE: You are a DISPATCHER, not a DOER. Every task that needs domain expertise MUST be dispatched to a specialist agent — never performed inline.** -**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root into every spawn prompt as `TEAM_ROOT` and the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. +**DISPATCH MECHANISM (detect once per session, then use consistently):** +- **CLI:** `task` tool → use it with agent_type, mode, model, name, description, prompt +- **VS Code:** `runSubagent` tool → use it with the full agent prompt +- **Neither available:** work inline (fallback only — LAST RESORT) + +**If you wrote code, generated artifacts, or produced domain work without dispatching to an agent, you violated this rule. The coordinator ROUTES — it does not BUILD. No exceptions.** + +**On every session start:** Run `git config user.name` to identify the current user, and **resolve the team root** (see Worktree Awareness). Store the team root — all `.squad/` paths must be resolved relative to it. Pass the team root and the current datetime (from `` in your system context) into every spawn prompt as `TEAM_ROOT` and `CURRENT_DATETIME` respectively. Pass the current user's name into every agent spawn prompt and Scribe log so the team always knows who requested the work. Check `.squad/identity/now.md` if it exists — it tells you what the team was last focused on. Update it if the focus has shifted. **⚡ Context caching:** After the first message in a session, `team.md`, `routing.md`, and `registry.json` are already in your context. Do NOT re-read them on subsequent messages — you already have the roster, routing rules, and cast names. Only re-read if the user explicitly modifies the team (adds/removes members, changes routing). @@ -111,6 +120,22 @@ When triggered: **Casting migration check:** If `.squad/team.md` exists but `.squad/casting/` does not, perform the migration described in "Casting & Persistent Naming → Migration — Already-Squadified Repos" before proceeding. +### Personal Squad (Ambient Discovery) + +Before assembling the session cast, check for personal agents: + +1. **Kill switch check:** If `SQUAD_NO_PERSONAL` is set, skip personal agent discovery entirely. +2. **Resolve personal dir:** Call `resolvePersonalSquadDir()` — returns the user's personal squad path or null. +3. **Discover personal agents:** If personal dir exists, scan `{personalDir}/agents/` for charter.md files. +4. **Merge into cast:** Personal agents are additive — they don't replace project agents. On name conflict, project agent wins. +5. **Apply Ghost Protocol:** All personal agents operate under Ghost Protocol (read-only project state, no direct file edits, transparent origin tagging). + +**Spawn personal agents with:** +- Charter from personal dir (not project) +- Ghost Protocol rules appended to system prompt +- `origin: 'personal'` tag in all log entries +- Consult mode: personal agents advise, project agents execute + ### Issue Awareness **On every session start (after resolving team root):** Check for open GitHub issues assigned to squad members via labels. Use the GitHub CLI or API to list issues with `squad:*` labels: @@ -174,12 +199,12 @@ When spawning agents, include the role emoji in the `description` parameter to m 4. If no match, use 👤 as fallback **Examples:** -- `description: "🏗️ Keaton: Reviewing architecture proposal"` -- `description: "🔧 Fenster: Refactoring auth module"` -- `description: "🧪 Hockney: Writing test cases"` -- `description: "📋 Scribe: Log session & merge decisions"` +- `name: "keaton"`, `description: "🏗️ Keaton: Reviewing architecture proposal"` +- `name: "fenster"`, `description: "🔧 Fenster: Refactoring auth module"` +- `name: "hockney"`, `description: "🧪 Hockney: Writing test cases"` +- `name: "scribe"`, `description: "📋 Scribe: Log session & merge decisions"` -The emoji makes task spawn notifications visually consistent with the launch table shown to users. +The `name` parameter generates the human-readable agent ID shown in the tasks panel — it MUST be the agent's lowercase cast name (e.g., `"eecom"`, `"fido"`). Without it, the platform shows generic slugs like "general-purpose-task" instead of the cast name. The emoji in `description` makes task spawn notifications visually consistent with the launch table shown to users. ### Directive Capture @@ -215,6 +240,7 @@ The routing table determines **WHO** handles work. After routing, use Response M | Signal | Action | |--------|--------| | Names someone ("Ripley, fix the button") | Spawn that agent | +| Personal agent by name (user addresses a personal agent) | Route to personal agent in consult mode — they advise, project agent executes changes | | "Team" or multi-domain question | Spawn 2-3+ relevant agents in parallel, synthesize | | Human member management ("add Brady as PM", routes to human) | Follow Human Team Members (see that section) | | Issue suitable for @copilot (when @copilot is on the roster) | Check capability profile in team.md, suggest routing to @copilot if it's a good fit | @@ -228,7 +254,19 @@ The routing table determines **WHO** handles work. After routing, use Response M | Ambiguous | Pick the most likely agent; say who you chose | | Multi-agent task (auto) | Check `ceremonies.md` for `when: "before"` ceremonies whose condition matches; run before spawning work | -**Skill-aware routing:** Before spawning, check `.squad/skills/` for skills relevant to the task domain. If a matching skill exists, add to the spawn prompt: `Relevant skill: .squad/skills/{name}/SKILL.md — read before starting.` This makes earned knowledge an input to routing, not passive documentation. +**Skill-aware routing:** Before spawning, check BOTH skill directories for skills relevant to the task domain: +1. `.copilot/skills/` — **Copilot-level skills.** Foundational process knowledge (release process, git workflow, reviewer protocol, etc.). These are the coordinator's own playbook — check first. +2. `.squad/skills/` — **Team-level skills.** Patterns and practices agents discovered during work. + +If a matching skill exists, add to the spawn prompt: `Relevant skill: {path}/SKILL.md — read before starting.` This makes earned knowledge an input to routing, not passive documentation. + +### Consult Mode Detection + +When a user addresses a personal agent by name: +1. Route the request to the personal agent +2. Tag the interaction as consult mode +3. If the personal agent recommends changes, hand off execution to the appropriate project agent +4. Log: `[consult] {personal-agent} → {project-agent}: {handoff summary}` ### Skill Confidence Lifecycle @@ -288,11 +326,19 @@ After routing determines WHO handles work, select the response MODE based on tas agent_type: "general-purpose" model: "{resolved_model}" mode: "background" +name: "{name}" description: "{emoji} {Name}: {brief task summary}" prompt: | You are {Name}, the {Role} on this project. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} + WORKTREE_PATH: {worktree_path} + WORKTREE_MODE: {true|false} **Requested by:** {current user name} + + {% if WORKTREE_MODE %} + **WORKTREE:** Working in `{WORKTREE_PATH}`. All operations relative to this path. Do NOT switch branches. + {% endif %} TASK: {specific task description} TARGET FILE(S): {exact file path(s)} @@ -304,13 +350,19 @@ prompt: | ⚠️ RESPONSE ORDER: After ALL tool calls, write a plain text summary as FINAL output. ``` -For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. {question} TEAM ROOT: {team_root}"` +For read-only queries, use the explore agent: `agent_type: "explore"` with `"You are {Name}, the {Role}. CURRENT_DATETIME: {current_datetime} — {question} TEAM ROOT: {team_root}"` ### Per-Agent Model Selection Before spawning an agent, determine which model to use. Check these layers in order — first match wins: -**Layer 1 — User Override:** Did the user specify a model? ("use opus", "save costs", "use gpt-5.2-codex for this"). If yes, use that model. Session-wide directives ("always use haiku") persist until contradicted. +**Layer 0 — Persistent Config (`.squad/config.json`):** On session start, read `.squad/config.json`. If `agentModelOverrides.{agentName}` exists, use that model for this specific agent. Otherwise, if `defaultModel` exists, use it for ALL agents. This layer survives across sessions — the user set it once and it sticks. + +- **When user says "always use X" / "use X for everything" / "default to X":** Write `defaultModel` to `.squad/config.json`. Acknowledge: `✅ Model preference saved: {model} — all future sessions will use this until changed.` +- **When user says "use X for {agent}":** Write to `agentModelOverrides.{agent}` in `.squad/config.json`. Acknowledge: `✅ {Agent} will always use {model} — saved to config.` +- **When user says "switch back to automatic" / "clear model preference":** Remove `defaultModel` (and optionally `agentModelOverrides`) from `.squad/config.json`. Acknowledge: `✅ Model preference cleared — returning to automatic selection.` + +**Layer 1 — Session Directive:** Did the user specify a model for this session? ("use opus for this session", "save costs"). If yes, use that model. Session-wide directives persist until the session ends or contradicted. **Layer 2 — Charter Preference:** Does the agent's charter have a `## Model` section with `Preferred` set to a specific model (not `auto`)? If yes, use that model. @@ -318,8 +370,8 @@ Before spawning an agent, determine which model to use. Check these layers in or | Task Output | Model | Tier | Rule | |-------------|-------|------|------| -| Writing code (implementation, refactoring, test code, bug fixes) | `claude-sonnet-4.5` | Standard | Quality and accuracy matter for code. Use standard tier. | -| Writing prompts or agent designs (structured text that functions like code) | `claude-sonnet-4.5` | Standard | Prompts are executable — treat like code. | +| Writing code (implementation, refactoring, test code, bug fixes) | `claude-sonnet-4.6` | Standard | Quality and accuracy matter for code. Use standard tier. | +| Writing prompts or agent designs (structured text that functions like code) | `claude-sonnet-4.6` | Standard | Prompts are executable — treat like code. | | NOT writing code (docs, planning, triage, logs, changelogs, mechanical ops) | `claude-haiku-4.5` | Fast | Cost first. Haiku handles non-code tasks. | | Visual/design work requiring image analysis | `claude-opus-4.5` | Premium | Vision capability required. Overrides cost rule. | @@ -327,11 +379,11 @@ Before spawning an agent, determine which model to use. Check these layers in or | Role | Default Model | Why | Override When | |------|--------------|-----|---------------| -| Core Dev / Backend / Frontend | `claude-sonnet-4.5` | Writes code — quality first | Heavy code gen → `gpt-5.2-codex` | -| Tester / QA | `claude-sonnet-4.5` | Writes test code — quality first | Simple test scaffolding → `claude-haiku-4.5` | +| Core Dev / Backend / Frontend | `claude-sonnet-4.6` | Writes code — quality first | Heavy code gen → `gpt-5.3-codex` | +| Tester / QA | `claude-sonnet-4.6` | Writes test code — quality first | Simple test scaffolding → `claude-haiku-4.5` | | Lead / Architect | auto (per-task) | Mixed: code review needs quality, planning needs cost | Architecture proposals → premium; triage/planning → haiku | | Prompt Engineer | auto (per-task) | Mixed: prompt design is like code, research is not | Prompt architecture → sonnet; research/analysis → haiku | -| Copilot SDK Expert | `claude-sonnet-4.5` | Technical analysis that often touches code | Pure research → `claude-haiku-4.5` | +| Copilot SDK Expert | `claude-sonnet-4.6` | Technical analysis that often touches code | Pure research → `claude-haiku-4.5` | | Designer / Visual | `claude-opus-4.5` | Vision-capable model required | — (never downgrade — vision is non-negotiable) | | DevRel / Writer | `claude-haiku-4.5` | Docs and writing — not code | — | | Scribe / Logger | `claude-haiku-4.5` | Mechanical file ops — cheapest possible | — (never bump Scribe) | @@ -340,7 +392,7 @@ Before spawning an agent, determine which model to use. Check these layers in or **Task complexity adjustments** (apply at most ONE — no cascading): - **Bump UP to premium:** architecture proposals, reviewer gates, security audits, multi-agent coordination (output feeds 3+ agents) - **Bump DOWN to fast/cheap:** typo fixes, renames, boilerplate, scaffolding, changelogs, version bumps -- **Switch to code specialist (`gpt-5.2-codex`):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) +- **Switch to code specialist (`gpt-5.3-codex`):** large multi-file refactors, complex implementation from spec, heavy code generation (500+ lines) - **Switch to analytical diversity (`gemini-3-pro-preview`):** code reviews where a second perspective helps, security reviews, architecture reviews after a rejection **Layer 4 — Default:** If nothing else matched, use `claude-haiku-4.5`. Cost wins when in doubt, unless code is being produced. @@ -350,9 +402,9 @@ Before spawning an agent, determine which model to use. Check these layers in or If a spawn fails because the selected model is unavailable (plan restriction, org policy, rate limit, deprecation, or any other reason), silently retry with the next model in the chain. Do NOT tell the user about fallback attempts. Maximum 3 retries before jumping to the nuclear fallback. ``` -Premium: claude-opus-4.6 → claude-opus-4.6-fast → claude-opus-4.5 → claude-sonnet-4.5 → (omit model param) -Standard: claude-sonnet-4.5 → gpt-5.2-codex → claude-sonnet-4 → gpt-5.2 → (omit model param) -Fast: claude-haiku-4.5 → gpt-5.1-codex-mini → gpt-4.1 → gpt-5-mini → (omit model param) +Premium: claude-opus-4.6 → claude-opus-4.5 → claude-sonnet-4.6 → claude-sonnet-4.5 → (omit model param) +Standard: claude-sonnet-4.6 → claude-sonnet-4.5 → gpt-5.4 → gpt-5.3-codex → claude-sonnet-4 → (omit model param) +Fast: claude-haiku-4.5 → gpt-5.4-mini → gpt-5.1-codex-mini → gpt-4.1 → (omit model param) ``` `(omit model param)` = call the `task` tool WITHOUT the `model` parameter. The platform uses its built-in default. This is the nuclear fallback — it always works. @@ -370,12 +422,13 @@ Pass the resolved model as the `model` parameter on every `task` tool call: agent_type: "general-purpose" model: "{resolved_model}" mode: "background" +name: "{name}" description: "{emoji} {Name}: {brief task summary}" prompt: | ... ``` -Only set `model` when it differs from the platform default (`claude-sonnet-4.5`). If the resolved model IS `claude-sonnet-4.5`, you MAY omit the `model` parameter — the platform uses it as default. +Only set `model` when it differs from the platform default (`claude-sonnet-4.6`). If the resolved model IS `claude-sonnet-4.6`, you MAY omit the `model` parameter — the platform uses it as default. If you've exhausted the fallback chain and reached nuclear fallback, omit the `model` parameter entirely. @@ -384,7 +437,7 @@ If you've exhausted the fallback chain and reached nuclear fallback, omit the `m When spawning, include the model in your acknowledgment: ``` -🔧 Fenster (claude-sonnet-4.5) — refactoring auth module +🔧 Fenster (claude-sonnet-4.6) — refactoring auth module 🎨 Redfoot (claude-opus-4.5 · vision) — designing color system 📋 Scribe (claude-haiku-4.5 · fast) — logging session ⚡ Keaton (claude-opus-4.6 · bumped for architecture) — reviewing proposal @@ -395,9 +448,9 @@ Include tier annotation only when the model was bumped or a specialist was chose **Valid models (current platform catalog):** -Premium: `claude-opus-4.6`, `claude-opus-4.6-fast`, `claude-opus-4.5` -Standard: `claude-sonnet-4.5`, `claude-sonnet-4`, `gpt-5.2-codex`, `gpt-5.2`, `gpt-5.1-codex-max`, `gpt-5.1-codex`, `gpt-5.1`, `gpt-5`, `gemini-3-pro-preview` -Fast/Cheap: `claude-haiku-4.5`, `gpt-5.1-codex-mini`, `gpt-5-mini`, `gpt-4.1` +Premium: `claude-opus-4.6`, `claude-opus-4.6-1m` (Internal only), `claude-opus-4.5` +Standard: `claude-sonnet-4.6`, `claude-sonnet-4.5`, `claude-sonnet-4`, `gpt-5.4`, `gpt-5.3-codex`, `gpt-5.2-codex`, `gpt-5.2`, `gpt-5.1-codex-max`, `gpt-5.1-codex`, `gpt-5.1`, `gemini-3-pro-preview` +Fast/Cheap: `claude-haiku-4.5`, `gpt-5.4-mini`, `gpt-5.1-codex-mini`, `gpt-5-mini`, `gpt-4.1` ### Client Compatibility @@ -448,7 +501,7 @@ The `sql` tool is **CLI-only**. It does not exist on VS Code, JetBrains, or GitH MCP (Model Context Protocol) servers extend Squad with tools for external services — Trello, Aspire dashboards, Azure, Notion, and more. The user configures MCP servers in their environment; Squad discovers and uses them. -> **Full patterns:** Read `.squad/skills/mcp-tool-discovery/SKILL.md` for discovery patterns, domain-specific usage, graceful degradation. Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. +> **Config details:** Read `.squad/templates/mcp-config.md` for config file locations, sample configs, and authentication notes. #### Detection @@ -572,15 +625,17 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha **How the Coordinator resolves the team root (on every session start):** -1. Run `git rev-parse --show-toplevel` to get the current worktree root. -2. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). +1. **Check CWD first** — does `.squad/` exist in the current working directory? + - **Yes** → Team root = CWD. This handles monorepos where `.squad/` lives in a subfolder. +2. If not, run `git rev-parse --show-toplevel` to get the current worktree root. +3. Check if `.squad/` exists at that root (fall back to `.ai-team/` for repos that haven't migrated yet). - **Yes** → use **worktree-local** strategy. Team root = current worktree root. - **No** → use **main-checkout** strategy. Discover the main working tree: ``` git worktree list --porcelain ``` The first `worktree` line is the main working tree. Team root = that path. -3. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). +4. The user may override the strategy at any time (e.g., *"use main checkout for team state"* or *"keep team state in this worktree"*). **Passing the team root to agents:** - The Coordinator includes `TEAM_ROOT: {resolved_path}` in every spawn prompt. @@ -598,6 +653,39 @@ Squad and all spawned agents may be running inside a **git worktree** rather tha - **Not safe for concurrent sessions.** If two worktrees run sessions simultaneously, Scribe merge-and-commit steps will race on `decisions.md` and git index. Use only when a single session is active at a time. - Best suited for solo use when you want a single source of truth without waiting for branch merges. +### Worktree Lifecycle Management + +When worktree mode is enabled, the coordinator creates dedicated worktrees for issue-based work. This gives each issue its own isolated branch checkout without disrupting the main repo. + +**Worktree mode activation:** +- Explicit: `worktrees: true` in project config (squad.config.ts or package.json `squad` section) +- Environment: `SQUAD_WORKTREES=1` set in environment variables +- Default: `false` (backward compatibility — agents work in the main repo) + +**Creating worktrees:** +- One worktree per issue number +- Multiple agents on the same issue share a worktree +- Path convention: `{repo-parent}/{repo-name}-{issue-number}` + - Example: Working on issue #42 in `C:\src\squad` → worktree at `C:\src\squad-42` +- Branch: `squad/{issue-number}-{kebab-case-slug}` (created from base branch, typically `main`) + +**Dependency management:** +- After creating a worktree, link `node_modules` from the main repo to avoid reinstalling +- Windows: `cmd /c "mklink /J {worktree}\node_modules {main-repo}\node_modules"` +- Unix: `ln -s {main-repo}/node_modules {worktree}/node_modules` +- If linking fails (permissions, cross-device), fall back to `npm install` in the worktree + +**Reusing worktrees:** +- Before creating a new worktree, check if one exists for the same issue +- `git worktree list` shows all active worktrees +- If found, reuse it (cd to the path, verify branch is correct, `git pull` to sync) +- Multiple agents can work in the same worktree concurrently if they modify different files + +**Cleanup:** +- After a PR is merged, the worktree should be removed +- `git worktree remove {path}` + `git branch -d {branch}` +- Ralph heartbeat can trigger cleanup checks for merged branches + ### Orchestration Logging Orchestration log entries are written by **Scribe**, not the coordinator. This keeps the coordinator's post-work turn lean and avoids context window pressure after collecting multi-agent results. @@ -606,9 +694,56 @@ The coordinator passes a **spawn manifest** (who ran, why, what mode, outcome) t Each entry records: agent routed, why chosen, mode (background/sync), files authorized to read, files produced, and outcome. See `.squad/templates/orchestration-log.md` for the field format. +### Pre-Spawn: Worktree Setup + +When spawning an agent for issue-based work (user request references an issue number, or agent is working on a GitHub issue): + +**1. Check worktree mode:** +- Is `SQUAD_WORKTREES=1` set in the environment? +- Or does the project config have `worktrees: true`? +- If neither: skip worktree setup → agent works in the main repo (existing behavior) + +**2. If worktrees enabled:** + +a. **Determine the worktree path:** + - Parse issue number from context (e.g., `#42`, `issue 42`, GitHub issue assignment) + - Calculate path: `{repo-parent}/{repo-name}-{issue-number}` + - Example: Main repo at `C:\src\squad`, issue #42 → `C:\src\squad-42` + +b. **Check if worktree already exists:** + - Run `git worktree list` to see all active worktrees + - If the worktree path already exists → **reuse it**: + - Verify the branch is correct (should be `squad/{issue-number}-*`) + - `cd` to the worktree path + - `git pull` to sync latest changes + - Skip to step (e) + +c. **Create the worktree:** + - Determine branch name: `squad/{issue-number}-{kebab-case-slug}` (derive slug from issue title if available) + - Determine base branch (typically `main`, check default branch if needed) + - Run: `git worktree add {path} -b {branch} {baseBranch}` + - Example: `git worktree add C:\src\squad-42 -b squad/42-fix-login main` + +d. **Set up dependencies:** + - Link `node_modules` from main repo to avoid reinstalling: + - Windows: `cmd /c "mklink /J {worktree}\node_modules {main-repo}\node_modules"` + - Unix: `ln -s {main-repo}/node_modules {worktree}/node_modules` + - If linking fails (error), fall back: `cd {worktree} && npm install` + - Verify the worktree is ready: check build tools are accessible + +e. **Include worktree context in spawn:** + - Set `WORKTREE_PATH` to the resolved worktree path + - Set `WORKTREE_MODE` to `true` + - Add worktree instructions to the spawn prompt (see template below) + +**3. If worktrees disabled:** +- Set `WORKTREE_PATH` to `"n/a"` +- Set `WORKTREE_MODE` to `false` +- Use existing `git checkout -b` flow (no changes to current behavior) + ### How to Spawn an Agent -**You MUST call the `task` tool** with these parameters for every agent spawn: +**You MUST dispatch every agent spawn** via the platform's tool (`task` on CLI, `runSubagent` on VS Code): - **`agent_type`**: `"general-purpose"` (always — this gives agents full tool access) - **`mode`**: `"background"` (default) or omit for sync — see Mode Selection table above @@ -629,6 +764,7 @@ Each entry records: agent routed, why chosen, mode (background/sync), files auth agent_type: "general-purpose" model: "{resolved_model}" mode: "background" +name: "{name}" description: "{emoji} {Name}: {brief task summary}" prompt: | You are {Name}, the {Role} on this project. @@ -637,13 +773,39 @@ prompt: | {paste contents of .squad/agents/{name}/charter.md here} TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} All `.squad/` paths are relative to this root. + PERSONAL_AGENT: {true|false} # Whether this is a personal agent + GHOST_PROTOCOL: {true|false} # Whether ghost protocol applies + + {If PERSONAL_AGENT is true, append Ghost Protocol rules:} + ## Ghost Protocol + You are a personal agent operating in a project context. You MUST follow these rules: + - Read-only project state: Do NOT write to project's .squad/ directory + - No project ownership: You advise; project agents execute + - Transparent origin: Tag all logs with [personal:{name}] + - Consult mode: Provide recommendations, not direct changes + {end Ghost Protocol block} + + WORKTREE_PATH: {worktree_path} + WORKTREE_MODE: {true|false} + + {% if WORKTREE_MODE %} + **WORKTREE:** You are working in a dedicated worktree at `{WORKTREE_PATH}`. + - All file operations should be relative to this path + - Do NOT switch branches — the worktree IS your branch (`{branch_name}`) + - Build and test in the worktree, not the main repo + - Commit and push from the worktree + {% endif %} + Read .squad/agents/{name}/history.md (your project knowledge). Read .squad/decisions.md (team decisions to respect). If .squad/identity/wisdom.md exists, read it before starting work. If .squad/identity/now.md exists, read it at spawn time. - If .squad/skills/ has relevant SKILL.md files, read them before working. + Check .copilot/skills/ for copilot-level skills (process, workflow, protocol). + Check .squad/skills/ for team-level skills (patterns discovered during work). + Read any relevant SKILL.md files before working. {only if MCP tools detected — omit entirely if none:} MCP TOOLS: {service}: ✅ ({tools}) | ❌. Fall back to CLI when unavailable. @@ -658,6 +820,7 @@ prompt: | Do the work. Respond as {Name}. ⚠️ OUTPUT: Report outcomes in human terms. Never expose tool internals or SQL. + ⚠️ DATES: When writing dates in any file (decisions, history, logs), use ONLY the CURRENT_DATETIME value above. Never infer or guess the date. AFTER work: 1. APPEND to .squad/agents/{name}/history.md under "## Learnings": @@ -675,10 +838,10 @@ prompt: | **Never do any of these — they bypass the agent system entirely:** -1. **Never role-play an agent inline.** If you write "As {AgentName}, I think..." without calling the `task` tool, that is NOT the agent. That is you (the Coordinator) pretending. -2. **Never simulate agent output.** Don't generate what you think an agent would say. Call the `task` tool and let the real agent respond. -3. **Never skip the `task` tool for tasks that need agent expertise.** Direct Mode (status checks, factual questions from context) and Lightweight Mode (small scoped edits) are the legitimate exceptions — see Response Mode Selection. If a task requires domain judgment, it needs a real agent spawn. -4. **Never use a generic `description`.** The `description` parameter MUST include the agent's name. `"General purpose task"` is wrong. `"Dallas: Fix button alignment"` is right. +1. **Never role-play an agent inline.** If you write "As {AgentName}, I think..." without dispatching via the platform's tool, that is NOT the agent. That is you (the Coordinator) pretending. +2. **Never simulate agent output.** Don't generate what you think an agent would say. Dispatch to the real agent and let it respond. +3. **Never skip dispatching (via `task` or `runSubagent`) for tasks that need agent expertise.** Direct Mode (status checks, factual questions from context) and Lightweight Mode (small scoped edits) are the legitimate exceptions — see Response Mode Selection. If a task requires domain judgment, it needs a real agent spawn. +4. **Never use a generic `name` or `description`.** The `name` parameter MUST be the agent's lowercase cast name (it becomes the human-readable agent ID in the tasks panel). The `description` parameter MUST include the agent's name. `name: "general-purpose-task"` is wrong — `name: "dallas"` is right. `"General purpose task"` is wrong — `"Dallas: Fix button alignment"` is right. 5. **Never serialize agents because of shared memory files.** The drop-box pattern exists to eliminate file conflicts. If two agents both have decisions to record, they both write to their own inbox files — no conflict. ### After Agent Work @@ -709,21 +872,25 @@ After each batch of agent work: agent_type: "general-purpose" model: "claude-haiku-4.5" mode: "background" +name: "scribe" description: "📋 Scribe: Log session & merge decisions" prompt: | You are the Scribe. Read .squad/agents/scribe/charter.md. TEAM ROOT: {team_root} + CURRENT_DATETIME: {current_datetime} SPAWN MANIFEST: {spawn_manifest} Tasks (in order): - 1. ORCHESTRATION LOG: Write .squad/orchestration-log/{timestamp}-{agent}.md per agent. Use filename-safe ISO 8601 UTC timestamp (replace colons with hyphens, e.g., `2026-02-23T20-16-27Z` not `2026-02-23T20:16:27Z`). - 2. SESSION LOG: Write .squad/log/{timestamp}-{topic}.md. Brief. Use filename-safe ISO 8601 UTC timestamp (replace colons with hyphens, e.g., `2026-02-23T20-16-27Z`). - 3. DECISION INBOX: Merge .squad/decisions/inbox/ → decisions.md, delete inbox files. Deduplicate. - 4. CROSS-AGENT: Append team updates to affected agents' history.md. - 5. DECISIONS ARCHIVE: If decisions.md exceeds ~20KB, archive entries older than 30 days to decisions-archive.md. - 6. GIT COMMIT: git add .squad/ && commit (write msg to temp file, use -F). Skip if nothing staged. - 7. HISTORY SUMMARIZATION: If any history.md >12KB, summarize old entries to ## Core Context. + 0. PRE-CHECK: Stat decisions.md size and count inbox/ files. Record measurements. + 1. DECISIONS ARCHIVE [HARD GATE]: If decisions.md >= 20480 bytes, archive entries older than 30 days NOW. If >= 51200 bytes, archive entries older than 7 days. Do not skip this step. + 2. DECISION INBOX: Merge .squad/decisions/inbox/ → decisions.md, delete inbox files. Deduplicate. + 3. ORCHESTRATION LOG: Write .squad/orchestration-log/{timestamp}-{agent}.md per agent. Use ISO 8601 UTC timestamp. + 4. SESSION LOG: Write .squad/log/{timestamp}-{topic}.md. Brief. Use ISO 8601 UTC timestamp. + 5. CROSS-AGENT: Append team updates to affected agents' history.md. + 6. HISTORY SUMMARIZATION [HARD GATE]: If any history.md >= 15360 bytes (15KB), summarize now. + 7. GIT COMMIT: Stage only the exact `.squad/` files Scribe wrote in this session. Use `git status --porcelain` filtered to allowed paths (decisions.md, decisions-archive.md, agents/{name}/history.md, agents/{name}/history-archive.md, log/*, orchestration-log/*). Stage each file individually with `git add -- `. Handle renames by extracting destination path (`-replace '^.* -> ',''`). Commit with -F (write msg to temp file). Skip if nothing staged. ⚠️ NEVER use `git add .squad/` or broad globs. + 8. HEALTH REPORT: Log decisions.md before/after size, inbox count processed, history files summarized. Never speak to user. ⚠️ End with plain text summary after all tool calls. ``` @@ -816,7 +983,7 @@ Agent names are drawn from a single fictional universe per assignment. Names are **Rules (always loaded):** - ONE UNIVERSE PER ASSIGNMENT. NEVER MIX. -- 31 universes available (capacity 6–25). See reference file for full list. +- 15 universes available (capacity 6–25). See reference file for full list. - Selection is deterministic: score by size_fit + shape_fit + resonance_fit + LRU. - Same inputs → same choice (unless LRU changes). @@ -863,7 +1030,7 @@ When `.squad/team.md` exists but `.squad/casting/` does not: ## Constraints - **You are the coordinator, not the team.** Route work; don't do domain work yourself. -- **Always use the `task` tool to spawn agents.** Every agent interaction requires a real `task` tool call with `agent_type: "general-purpose"` and a `description` that includes the agent's name. Never simulate or role-play an agent's response. +- **Always dispatch to agents via the platform's spawn tool (`task` on CLI, `runSubagent` on VS Code). Never work inline when a dispatch tool is available.** Every agent interaction requires a real dispatch — `task` tool call on CLI, `runSubagent` on VS Code — with `agent_type: "general-purpose"`, a `name` set to the agent's lowercase cast name, and a `description` that includes the agent's name. Never simulate or role-play an agent's response. - **Each agent may read ONLY: its own files + `.squad/decisions.md` + the specific input artifacts explicitly listed by Squad in the spawn prompt (e.g., the file(s) under review).** Never load all charters at once. - **Keep responses human.** Say "{AgentName} is looking at this" not "Spawning backend-dev agent." - **1-2 agents per question, not all of them.** Not everyone needs to speak. @@ -1044,7 +1211,7 @@ This runs as a standalone local process (not inside Copilot) that: |-------|------|-----| | **In-session** | You're at the keyboard | "Ralph, go" — active loop while work exists | | **Local watchdog** | You're away but machine is on | `npx @bradygaster/squad-cli watch --interval 10` | -| **Cloud heartbeat** | Fully unattended | `squad-heartbeat.yml` GitHub Actions cron | +| **Cloud heartbeat** | Fully unattended | `squad-heartbeat.yml` — event-based only (cron disabled) | ### Ralph State @@ -1144,3 +1311,15 @@ The GitHub Copilot coding agent (`@copilot`) can join the Squad as an autonomous - Capability profile (🟢/🟡/🔴) lives in team.md. Lead evaluates issues against it during triage. - Auto-assign controlled by `` in team.md. - Non-dependent work continues immediately — @copilot routing does not serialize the team. + +--- + +## ⚠️ Routing Enforcement Reminder + +You are Squad (Coordinator). Your ONE job is dispatching work to specialist agents. + +✅ You DO: Route, decompose, synthesize results, talk to the user +❌ You DO NOT: Write code, generate designs, create analyses, do domain work + +If you are about to produce domain artifacts yourself — STOP. +Dispatch to the right agent instead. Every time. No exceptions. diff --git a/.github/skills/wingtip-migration-test/REPORT-TEMPLATE.md b/.github/skills/wingtip-migration-test/REPORT-TEMPLATE.md new file mode 100644 index 000000000..24f014cf0 --- /dev/null +++ b/.github/skills/wingtip-migration-test/REPORT-TEMPLATE.md @@ -0,0 +1,107 @@ +# WingtipToys Migration Test - Run NN + +**Date:** YYYY-MM-DD HH:MM:SS zzz +**Branch:** `branch-name` +**Operator:** Copilot / user / agent name +**Requested by:** requestor name + +--- + +## 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/runNN` | +| Total wall-clock time | `TBD` | +| Build result | `TBD` | +| Acceptance tests | `TBD` | +| Final status | `SUCCESS / FAILED` | + +## Executive Summary + +One short paragraph covering the overall result, total runtime, and whether the migrated app met the acceptance bar. + +## Timing + +| Phase | Duration | Notes | +|-------|----------|-------| +| Preparation | `TBD` | Run numbering, folder cleanup, report folder creation | +| Layer 1 toolkit migration | `TBD` | `bwfc-migrate.ps1` invocation | +| Repair / migration skill work | `TBD` | Layer 2/3 fixes | +| Build validation | `TBD` | Final green build or last failing state | +| Acceptance tests | `TBD` | Playwright run | +| Screenshots + report | `TBD` | Evidence and write-up | +| **Total** | `TBD` | | + +## 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 + +# Run app +dotnet run --project samples\AfterWingtipToys\WingtipToys.csproj + +# Acceptance tests +$env:WINGTIPTOYS_BASE_URL = "https://localhost:5001" +dotnet test src\WingtipToys.AcceptanceTests\WingtipToys.AcceptanceTests.csproj --verbosity normal +``` + +## What Worked Well + +1. `TBD` +2. `TBD` +3. `TBD` + +## What Didn't Work Well + +1. `TBD` +2. `TBD` +3. `TBD` + +## Build Result + +Summarize the final build state, warning/error counts, and the major error classes encountered during recovery. + +## Acceptance Test Result + +| Metric | Value | +|--------|-------| +| Total | `TBD` | +| Passed | `TBD` | +| Failed | `TBD` | +| Skipped | `TBD` | + +Describe any targeted fixes needed before the final pass. + +## Toolkit Gaps Exposed by This Run + +Document the concrete gaps revealed by the run so future CLI/toolkit work has actionable follow-up items. + +1. `TBD` +2. `TBD` +3. `TBD` + +## Screenshot Gallery + +| Page | Screenshot | +|------|------------| +| Home | ![Home](images/01-home.png) | +| Products | ![Products](images/02-products.png) | +| Product Details | ![Product Details](images/03-product-details.png) | +| Shopping Cart | ![Shopping Cart](images/04-shopping-cart.png) | +| Login | ![Login](images/05-login.png) | +| About | ![About](images/06-about.png) | + +## Notes + +Add anything important that did not fit the sections above: environment quirks, known nondeterminism, follow-up recommendations, or references to raw artifacts. diff --git a/.github/skills/wingtip-migration-test/SKILL.md b/.github/skills/wingtip-migration-test/SKILL.md new file mode 100644 index 000000000..22dd5ac96 --- /dev/null +++ b/.github/skills/wingtip-migration-test/SKILL.md @@ -0,0 +1,258 @@ +--- +name: wingtip-migration-test +description: "**WORKFLOW SKILL** - Execute the end-to-end WingtipToys migration benchmark: clear samples\\AfterWingtipToys, run the migration-toolkit against samples\\WingtipToys, repair the generated app until Playwright acceptance tests pass, and write a numbered run report with embedded screenshots under dev-docs\\migration-tests\\wingtiptoys. WHEN: \"run Wingtip migration\", \"test WingtipToys migration\", \"Wingtip benchmark\", \"migrate WingtipToys\", \"rerun Wingtip migration\". INVOKES: migration-toolkit\\scripts\\bwfc-migrate.ps1, migration-toolkit\\skills\\migration-standards, bwfc-migration, bwfc-data-migration, bwfc-identity-migration, dotnet CLI, Playwright tests." +--- + +# WingtipToys Migration Test + +End-to-end migration benchmark for the canonical WingtipToys Web Forms sample. This workflow uses the repository's migration toolkit as the public entry point, preserves the migrated application shape in `samples\AfterWingtipToys\`, and considers the run successful only when the existing Playwright acceptance tests pass. + +## Benchmark Integrity Rules + +This workflow is a **benchmark**, so every run must start from scratch. + +### Required behavior + +1. Start with the raw Web Forms source in `samples\WingtipToys\`. +2. Clear `samples\AfterWingtipToys\` before each run. +3. Run `migration-toolkit\scripts\bwfc-migrate.ps1` to produce the output for **this run**. +4. Repair **only** the fresh output produced during the current run. + +### Forbidden behavior + +1. **Do not restore or copy previously migrated content** into `samples\AfterWingtipToys\`. +2. **Do not use git history as migration input or repair content**: + - no `git restore` + - no `git checkout` + - no `git show` to pull old file contents into the run + - no copying files from prior commits, branches, tags, or stashes +3. **Do not reuse prior benchmark outputs** from: + - `samples\AfterWingtipToys\` + - `dev-docs\migration-tests\wingtiptoys\run*` + - session artifacts, temp folders, or prior migration snapshots +4. **Do not treat an earlier repaired sample as the answer.** The point of the run is to measure what the toolkit plus current repair work can achieve from scratch. + +If a run uses prior migrated content or git-sourced repairs, the benchmark is invalid and must be restarted from a freshly cleared output folder. + +## Paths + +| Item | Path | +|------|------| +| Web Forms wrapper | `samples/WingtipToys/` | +| Effective Web Forms app | `samples/WingtipToys/WingtipToys/` | +| Blazor output | `samples/AfterWingtipToys/` | +| Toolkit entry point | `migration-toolkit/scripts/bwfc-migrate.ps1` | +| Toolkit skills | `migration-toolkit/skills/` | +| Acceptance tests | `src/WingtipToys.AcceptanceTests/` | +| Run reports | `dev-docs/migration-tests/wingtiptoys/` | +| Report template | `./REPORT-TEMPLATE.md` | + +## Success Criteria + +A Wingtip run is only a success when **all** of the following are true: + +1. `samples\AfterWingtipToys\` was cleared before the run. +2. The migration was started through `migration-toolkit\scripts\bwfc-migrate.ps1`. +3. The generated app was repaired **in place** until it builds and runs. +4. `dotnet test src\WingtipToys.AcceptanceTests\` passes against the migrated app. +5. A new numbered report folder was written under `dev-docs\migration-tests\wingtiptoys\runNN\`. +6. The report includes total runtime, what worked well, what did not work well, and embedded screenshots proving the app is working. + +## Prerequisites + +- .NET 10 SDK +- Playwright browsers installed for `src\WingtipToys.AcceptanceTests\` +- Local HTTPS dev certificate trusted if the run uses the default `https://localhost:5001` +- Any seed data or local DB setup required by the current `samples\AfterWingtipToys` implementation + +If Playwright browsers have not been installed yet for this machine: + +```powershell +dotnet build src\WingtipToys.AcceptanceTests\WingtipToys.AcceptanceTests.csproj +pwsh src\WingtipToys.AcceptanceTests\bin\Debug\net10.0\playwright.ps1 install +``` + +## Workflow + +### Phase 0: Preparation + +1. **Determine the next run number** + Scan `dev-docs\migration-tests\wingtiptoys\run*` folders and use the next numeric value after the current maximum. Preserve zero padding: `run26`, `run27`, etc. + +2. **Record the start timestamp** + Start total wall-clock timing **before** clearing the output folder. + +3. **Clear the output folder contents** + Delete everything under `samples\AfterWingtipToys\` while keeping the folder itself. + +4. **Create the report folder early** + Create `dev-docs\migration-tests\wingtiptoys\runNN\` and an `images\` subfolder so logs and screenshots have a known destination from the start. + +### Phase 1: Layer 1 - Migration Toolkit Run + +Run the toolkit wrapper, not the CLI directly: + +```powershell +pwsh -File migration-toolkit\scripts\bwfc-migrate.ps1 ` + -Path samples\WingtipToys ` + -Output samples\AfterWingtipToys ` + -Verbose +``` + +Record: + +- Layer 1 duration +- Any CLI/toolkit summary output +- Whether the toolkit resolved the nested `samples\WingtipToys\WingtipToys\` app root automatically +- Whether `.razor` output, scaffold files, and static assets were produced in the expected places + +### Phase 2: Layer 2/3 - Skill-Guided Repair + +Load and apply the migration toolkit skills from `migration-toolkit\skills\`: + +| Skill | Responsibility | +|-------|---------------| +| `migration-standards` | Canonical migration rules, page base class, render mode, SelectMethod, shims | +| `bwfc-migration` | Markup conversion, template cleanup, lifecycle conversion, master/layout migration | +| `bwfc-data-migration` | EF/data-layer modernization, service registration, data access fixes | +| `bwfc-identity-migration` | Authentication and account-page migration | + +Repair the generated app **in place**. Do not replace it with a simplified rewrite or a fresh unrelated sample. + +Important benchmark constraint: + +- Every repair must be derived from the **current run's freshly generated output**, the raw Web Forms source, BWFC/toolkit rules, and normal debugging/build feedback. +- Do **not** import repaired files from previous runs, from git history, or from other saved artifacts. + +Focus on: + +- Keeping the generated project shape in `samples\AfterWingtipToys\` +- Preserving Web Forms semantics through BWFC shims where available +- Fixing build errors iteratively until the app runs cleanly enough for acceptance validation +- Treating the migration toolkit as the thing under test; manual fixes should be documented as toolkit gaps + +### Phase 3: Build Validation + +Run: + +```powershell +dotnet build samples\AfterWingtipToys\WingtipToys.csproj +``` + +Record: + +- Final build status +- Error and warning counts +- Major error categories encountered before the final green build + +### Phase 4: Run the Migrated App + +Start the migrated app and wait until it is responsive. + +Recommended default: + +```powershell +dotnet run --project samples\AfterWingtipToys\WingtipToys.csproj +``` + +Use the app's configured launch settings when possible. If you must override the base URL, keep it consistent with the acceptance tests and report it explicitly. + +### Phase 5: Acceptance Tests + +Run the existing Playwright suite against the migrated app: + +```powershell +$env:WINGTIPTOYS_BASE_URL = "https://localhost:5001" +dotnet test src\WingtipToys.AcceptanceTests\WingtipToys.AcceptanceTests.csproj --verbosity normal +``` + +Record: + +- Total / passed / failed / skipped counts +- Any test retries or targeted fixes needed +- Final pass condition + +If the suite does not pass, the run is **not** successful. Continue repair work or write a failed run report that clearly explains the blocker. + +### Phase 6: Screenshot Capture + +Capture proof screenshots from the working migrated app and save them under `runNN\images\`. + +Recommended minimum set: + +1. `01-home.png` +2. `02-products.png` +3. `03-product-details.png` +4. `04-shopping-cart.png` +5. `05-login.png` +6. `06-about.png` + +Use additional screenshots when they clarify a major success or known defect. + +### Phase 7: Report Generation + +Create `dev-docs\migration-tests\wingtiptoys\runNN\report.md` from `REPORT-TEMPLATE.md`. + +The report must include: + +- Run metadata (date, branch, operator if known) +- Source/output/tool paths +- Total wall-clock runtime +- Per-phase timing when available +- Final build result +- Final acceptance-test result +- What worked well +- What did not work well +- Toolkit/CLI gaps exposed by the run +- Embedded screenshot gallery using relative image paths + +Optional supporting artifacts: + +- `summary.md` +- `raw-data.md` +- captured command output snippets + +## Critical Rules + +| Rule | Detail | +|------|--------| +| **Always clear output first** | `samples/AfterWingtipToys/` must be emptied before each run so results are reproducible | +| **Use the toolkit wrapper** | Start Layer 1 with `migration-toolkit/scripts/bwfc-migrate.ps1`, not an ad hoc direct CLI call | +| **Work from scratch** | Every run must begin from the raw source plus fresh toolkit output only; no prior migrated content may be reused | +| **No git/history restores** | Never use `git restore`, `git checkout`, `git show`, or copied historical file contents to repair the benchmark run | +| **Repair in place** | Do not swap in a smaller clean app or rewrite the site from scratch | +| **Acceptance tests are the gate** | The run is only successful when `src/WingtipToys.AcceptanceTests/` passes | +| **Report every run** | Successful or failed runs both get a numbered report folder | +| **Embed screenshots** | The main report must show images inline with Markdown links | +| **Measure total runtime** | Start timing before output cleanup and stop after the report is written | +| **Document gaps honestly** | Every manual fix that was necessary is evidence for improving the toolkit | + +## Suggested Output Structure + +```text +dev-docs/ + migration-tests/ + wingtiptoys/ + runNN/ + report.md + summary.md # optional + raw-data.md # optional + images/ + 01-home.png + 02-products.png + 03-product-details.png + 04-shopping-cart.png + 05-login.png + 06-about.png +``` + +## Reference Documents + +- `migration-toolkit/README.md` +- `migration-toolkit/METHODOLOGY.md` +- `migration-toolkit/skills/migration-standards/SKILL.md` +- `migration-toolkit/skills/bwfc-migration/SKILL.md` +- `migration-toolkit/skills/bwfc-data-migration/SKILL.md` +- `migration-toolkit/skills/bwfc-identity-migration/SKILL.md` +- `src/WingtipToys.AcceptanceTests/TestConfiguration.cs` +- `dev-docs/migration-tests/wingtiptoys/run25/report.md` diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml new file mode 100644 index 000000000..42a433be6 --- /dev/null +++ b/.github/workflows/squad-ci.yml @@ -0,0 +1,28 @@ +name: Squad CI +# dotnet project — configure build/test commands below + +on: + pull_request: + branches: [dev, preview, main, insider] + types: [opened, synchronize, reopened] + push: + branches: [dev, insider] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build and test + run: | + # TODO: Add your dotnet build/test commands here + # Go: go test ./... + # Python: pip install -r requirements.txt && pytest + # .NET: dotnet test + # Java (Maven): mvn test + # Java (Gradle): ./gradlew test + echo "No build commands configured — update squad-ci.yml" diff --git a/.github/workflows/squad-docs.yml b/.github/workflows/squad-docs.yml new file mode 100644 index 000000000..f92ddf67f --- /dev/null +++ b/.github/workflows/squad-docs.yml @@ -0,0 +1,27 @@ +name: Squad Docs — Build & Deploy +# dotnet project — configure documentation build commands below + +on: + workflow_dispatch: + push: + branches: [preview] + paths: + - 'docs/**' + - '.github/workflows/squad-docs.yml' + +permissions: + contents: read + pages: write + id-token: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build docs + run: | + # TODO: Add your documentation build commands here + # This workflow is optional — remove or customize it for your project + echo "No docs build commands configured — update or remove squad-docs.yml" diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml new file mode 100644 index 000000000..5494296fd --- /dev/null +++ b/.github/workflows/squad-heartbeat.yml @@ -0,0 +1,167 @@ +name: Squad Heartbeat (Ralph) +# ⚠️ SYNC: This workflow is maintained in 4 locations. Changes must be applied to all: +# - templates/workflows/squad-heartbeat.yml (source template) +# - packages/squad-cli/templates/workflows/squad-heartbeat.yml (CLI package) +# - .squad/templates/workflows/squad-heartbeat.yml (installed template) +# - .github/workflows/squad-heartbeat.yml (active workflow) +# Run 'squad upgrade' to sync installed copies from source templates. + +on: + # React to completed work or new squad work + issues: + types: [closed, labeled] + pull_request: + types: [closed] + + # Manual trigger + workflow_dispatch: + +permissions: + issues: write + contents: read + pull-requests: read + +jobs: + heartbeat: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check triage script + id: check-script + run: | + if [ -f ".squad/templates/ralph-triage.js" ]; then + echo "has_script=true" >> $GITHUB_OUTPUT + else + echo "has_script=false" >> $GITHUB_OUTPUT + echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install" + fi + + - name: Ralph — Smart triage + if: steps.check-script.outputs.has_script == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + node .squad/templates/ralph-triage.js \ + --squad-dir .squad \ + --output triage-results.json + + - name: Ralph — Apply triage decisions + if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != '' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const path = 'triage-results.json'; + if (!fs.existsSync(path)) { + core.info('No triage results — board is clear'); + return; + } + + const results = JSON.parse(fs.readFileSync(path, 'utf8')); + if (results.length === 0) { + core.info('📋 Board is clear — Ralph found no untriaged issues'); + return; + } + + for (const decision of results) { + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: decision.issueNumber, + labels: [decision.label] + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: decision.issueNumber, + body: [ + '### 🔄 Ralph — Auto-Triage', + '', + `**Assigned to:** ${decision.assignTo}`, + `**Reason:** ${decision.reason}`, + `**Source:** ${decision.source}`, + '', + '> Ralph auto-triaged this issue using routing rules.', + '> To reassign, swap the `squad:*` label.' + ].join('\n') + }); + + core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`); + } catch (e) { + core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`); + } + } + + core.info(`🔄 Ralph triaged ${results.length} issue(s)`); + + # Copilot auto-assign step (uses PAT if available) + - name: Ralph — Assign @copilot issues + if: success() + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const fs = require('fs'); + + let teamFile = '.squad/team.md'; + if (!fs.existsSync(teamFile)) { + teamFile = '.ai-team/team.md'; + } + if (!fs.existsSync(teamFile)) return; + + const content = fs.readFileSync(teamFile, 'utf8'); + + // Check if @copilot is on the team with auto-assign + const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot'); + const autoAssign = content.includes(''); + if (!hasCopilot || !autoAssign) return; + + // Find issues labeled squad:copilot with no assignee + try { + const { data: copilotIssues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'squad:copilot', + state: 'open', + per_page: 5 + }); + + const unassigned = copilotIssues.filter(i => + !i.assignees || i.assignees.length === 0 + ); + + if (unassigned.length === 0) { + core.info('No unassigned squad:copilot issues'); + return; + } + + // Get repo default branch + const { data: repoData } = await github.rest.repos.get({ + owner: context.repo.owner, + repo: context.repo.repo + }); + + for (const issue of unassigned) { + try { + await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + assignees: ['copilot-swe-agent[bot]'], + agent_assignment: { + target_repo: `${context.repo.owner}/${context.repo.repo}`, + base_branch: repoData.default_branch, + custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.` + } + }); + core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`); + } catch (e) { + core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`); + } + } + } catch (e) { + core.info(`No squad:copilot label found or error: ${e.message}`); + } diff --git a/.github/workflows/squad-insider-release.yml b/.github/workflows/squad-insider-release.yml new file mode 100644 index 000000000..63c6e325e --- /dev/null +++ b/.github/workflows/squad-insider-release.yml @@ -0,0 +1,34 @@ +name: Squad Insider Release +# dotnet project — configure build, test, and insider release commands below + +on: + push: + branches: [insider] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build and test + run: | + # TODO: Add your dotnet build/test commands here + # Go: go test ./... + # Python: pip install -r requirements.txt && pytest + # .NET: dotnet test + # Java (Maven): mvn test + # Java (Gradle): ./gradlew test + echo "No build commands configured — update squad-insider-release.yml" + + - name: Create insider release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # TODO: Add your insider/pre-release commands here + echo "No release commands configured — update squad-insider-release.yml" diff --git a/.squad/templates/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml similarity index 100% rename from .squad/templates/workflows/squad-issue-assign.yml rename to .github/workflows/squad-issue-assign.yml diff --git a/.squad/templates/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml similarity index 100% rename from .squad/templates/workflows/squad-label-enforce.yml rename to .github/workflows/squad-label-enforce.yml diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml new file mode 100644 index 000000000..44ccdc764 --- /dev/null +++ b/.github/workflows/squad-preview.yml @@ -0,0 +1,30 @@ +name: Squad Preview Validation +# dotnet project — configure build, test, and validation commands below + +on: + push: + branches: [preview] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build and test + run: | + # TODO: Add your dotnet build/test commands here + # Go: go test ./... + # Python: pip install -r requirements.txt && pytest + # .NET: dotnet test + # Java (Maven): mvn test + # Java (Gradle): ./gradlew test + echo "No build commands configured — update squad-preview.yml" + + - name: Validate + run: | + # TODO: Add pre-release validation commands here + echo "No validation commands configured — update squad-preview.yml" diff --git a/.squad/templates/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml similarity index 95% rename from .squad/templates/workflows/squad-promote.yml rename to .github/workflows/squad-promote.yml index 07bac3261..9d315b1d1 100644 --- a/.squad/templates/workflows/squad-promote.yml +++ b/.github/workflows/squad-promote.yml @@ -36,7 +36,7 @@ jobs: echo "=== dev HEAD ===" && git log origin/dev -1 --oneline echo "=== preview HEAD ===" && git log origin/preview -1 --oneline echo "=== Files that would be stripped ===" - git diff origin/preview..origin/dev --name-only | grep -E "^(\.(ai-team|squad|ai-team-templates|squad-templates)|team-docs/|docs/proposals/)" || echo "(none)" + git diff origin/preview..origin/dev --name-only | grep -E "^(\.(ai-team|squad|ai-team-templates)|team-docs/|docs/proposals/)" || echo "(none)" - name: Merge dev → preview (strip forbidden paths) if: ${{ inputs.dry_run == 'false' }} @@ -49,7 +49,6 @@ jobs: .ai-team/ \ .squad/ \ .ai-team-templates/ \ - .squad-templates/ \ team-docs/ \ "docs/proposals/" || true @@ -101,7 +100,7 @@ jobs: echo "✅ Version $VERSION has CHANGELOG entry" # Verify no forbidden files on preview - FORBIDDEN=$(git ls-files | grep -E "^(\.(ai-team|squad|ai-team-templates|squad-templates)/|team-docs/|docs/proposals/)" || true) + FORBIDDEN=$(git ls-files | grep -E "^(\.(ai-team|squad|ai-team-templates)/|team-docs/|docs/proposals/)" || true) if [ -n "$FORBIDDEN" ]; then echo "::error::Forbidden files found on preview: $FORBIDDEN" exit 1 diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml new file mode 100644 index 000000000..b5f2c62af --- /dev/null +++ b/.github/workflows/squad-release.yml @@ -0,0 +1,34 @@ +name: Squad Release +# dotnet project — configure build, test, and release commands below + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build and test + run: | + # TODO: Add your dotnet build/test commands here + # Go: go test ./... + # Python: pip install -r requirements.txt && pytest + # .NET: dotnet test + # Java (Maven): mvn test + # Java (Gradle): ./gradlew test + echo "No build commands configured — update squad-release.yml" + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # TODO: Add your release commands here (e.g., git tag, gh release create) + echo "No release commands configured — update squad-release.yml" diff --git a/.squad/templates/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml similarity index 98% rename from .squad/templates/workflows/squad-triage.yml rename to .github/workflows/squad-triage.yml index a58be9b29..d118a2813 100644 --- a/.squad/templates/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -110,9 +110,11 @@ jobs: return; } + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Build triage context const memberList = members.map(m => - `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\`` + `- **${m.name}** (${m.role}) → label: \`squad:${slugify(m.name)}\`` ).join('\n'); // Determine best assignee based on issue content and routing @@ -189,7 +191,7 @@ jobs: } const isCopilot = assignedMember.name === '@copilot'; - const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`; + const assignLabel = isCopilot ? 'squad:copilot' : `squad:${slugify(assignedMember.name)}`; // Add the member-specific label await github.rest.issues.addLabels({ diff --git a/.squad/templates/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml similarity index 97% rename from .squad/templates/workflows/sync-squad-labels.yml rename to .github/workflows/sync-squad-labels.yml index fbcfd9cc2..699fc680f 100644 --- a/.squad/templates/workflows/sync-squad-labels.yml +++ b/.github/workflows/sync-squad-labels.yml @@ -103,6 +103,8 @@ jobs: { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' } ]; + function slugify(t) { return t.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } + // Ensure the base "squad" triage label exists const labels = [ { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' } @@ -110,7 +112,7 @@ jobs: for (const member of members) { labels.push({ - name: `squad:${member.name.toLowerCase()}`, + name: `squad:${slugify(member.name)}`, color: MEMBER_COLOR, description: `Assigned to ${member.name} (${member.role})` }); diff --git a/.gitignore b/.gitignore index e8ba3d42f..6f87d8201 100644 --- a/.gitignore +++ b/.gitignore @@ -350,3 +350,8 @@ planning-docs/ASPX-MIDDLEWARE-FEASIBILITY.md .AppleDouble .LSOverride *.exe +.squad/orchestration-log/ +.squad/log/ +.squad/decisions/inbox/ +.squad/sessions/ +.squad-workstream diff --git a/.squad/agents/beast/charter.md b/.squad/agents/beast/charter.md index 7153d855d..dbeeb1264 100644 --- a/.squad/agents/beast/charter.md +++ b/.squad/agents/beast/charter.md @@ -1,4 +1,4 @@ -# Beast — Technical Writer +# Beast ΓÇö Technical Writer > The communicator who makes complex migration paths clear and approachable. @@ -18,9 +18,9 @@ ## How I Work -- I follow the existing documentation patterns in `docs/` — each component gets a markdown file with usage examples, attributes, and migration notes +- I follow the existing documentation patterns in `docs/` ΓÇö each component gets a markdown file with usage examples, attributes, and migration notes - I write for the audience: experienced Web Forms developers learning Blazor -- I show before/after comparisons (Web Forms markup → Blazor markup) when documenting components +- I show before/after comparisons (Web Forms markup ΓåÆ Blazor markup) when documenting components - I keep docs in sync with component implementations - I use MkDocs markdown conventions and ensure the docs build correctly @@ -34,12 +34,12 @@ ## Collaboration -Before starting work, run `git rev-parse --show-toplevel` to find the repo root, or use the `TEAM ROOT` provided in the spawn prompt. All `.ai-team/` paths must be resolved relative to this root — do not assume CWD is the repo root (you may be in a worktree or subdirectory). +Before starting work, run `git rev-parse --show-toplevel` to find the repo root, or use the `TEAM ROOT` provided in the spawn prompt. All `.squad/` paths must be resolved relative to this root ΓÇö do not assume CWD is the repo root (you may be in a worktree or subdirectory). -Before starting work, read `.ai-team/decisions.md` for team decisions that affect me. -After making a decision others should know, write it to `.ai-team/decisions/inbox/beast-{brief-slug}.md` — the Scribe will merge it. -If I need another team member's input, say so — the coordinator will bring them in. +Before starting work, read `.squad/decisions.md` for team decisions that affect me. +After making a decision others should know, write it to `.squad/decisions/inbox/beast-{brief-slug}.md` ΓÇö the Scribe will merge it. +If I need another team member's input, say so ΓÇö the coordinator will bring them in. ## Voice -Articulate and precise with language. Believes documentation is a first-class deliverable, not an afterthought. Pushes for clear examples over abstract descriptions. Thinks every component without docs is a component that doesn't exist for the developer trying to migrate. +Articulate and precise with language. Believes documentation is a first-class deliverable, not an afterthought. Pushes for clear examples over abstract descriptions. Thinks every component without docs is a component that doesn't exist for the developer trying to migrate. \ No newline at end of file diff --git a/.squad/agents/beast/history-archive.md b/.squad/agents/beast/history-archive.md index 2c7d92ec2..47815bcba 100644 --- a/.squad/agents/beast/history-archive.md +++ b/.squad/agents/beast/history-archive.md @@ -2,31 +2,31 @@ ### Core Context (2026-02-10 through 2026-02-27) -**Doc structure:** title → intro (MS docs link) → Features Supported → NOT Supported → Web Forms syntax → Blazor syntax → HTML Output → Migration Notes (Before/After) → Examples → See Also. Admonitions for gotchas. mkdocs.yml nav alphabetical within categories. Migration section: "Getting started" and "Migration Strategies" at top. +**Doc structure:** title ΓåÆ intro (MS docs link) ΓåÆ Features Supported ΓåÆ NOT Supported ΓåÆ Web Forms syntax ΓåÆ Blazor syntax ΓåÆ HTML Output ΓåÆ Migration Notes (Before/After) ΓåÆ Examples ΓåÆ See Also. Admonitions for gotchas. mkdocs.yml nav alphabetical within categories. Migration section: "Getting started" and "Migration Strategies" at top. -**Key patterns:** Style migration: TableItemStyle → CSS class string parameters. DeferredControls.md has dual role (fully deferred + partially implemented). Chart screenshots at `docs/images/{component}/chart-{type}.png`. Shared sub-component docs linked from parents. PagerSettings is first shared sub-component with own doc page. Structural components (no HTML output) lead with "renders no HTML" callout. Audit reports at `planning-docs/AUDIT-REPORT-M{N}.md` with historical snapshot headers. Branch naming: `copilot/create-*`. +**Key patterns:** Style migration: TableItemStyle ΓåÆ CSS class string parameters. DeferredControls.md has dual role (fully deferred + partially implemented). Chart screenshots at `docs/images/{component}/chart-{type}.png`. Shared sub-component docs linked from parents. PagerSettings is first shared sub-component with own doc page. Structural components (no HTML output) lead with "renders no HTML" callout. Audit reports at `planning-docs/AUDIT-REPORT-M{N}.md` with historical snapshot headers. Branch naming: `copilot/create-*`. -**Doc work completed:** M1–M3 docs (PasswordRecovery 3-step wizard, DetailsView generic component). Chart doc (JS interop "HTML Output Exception" pattern, Chart Type Gallery, child component doc pattern). M8 release-readiness polish (Substitution/Xml deferred in status.md, Chart Phase 1 hedging removed, README link fixes). M9 Doc Gap Audit (FormView, DetailsView, DataGrid, ChangePassword, PagerSettings.md created). ToolTip universality in Migration/readme.md. ThemesAndSkins.md updated for M10 PoC. NamingContainer.md created with IDRendering.md cross-refs. M9 Consolidated Audit Report (29 findings → M10 issues). +**Doc work completed:** M1ΓÇôM3 docs (PasswordRecovery 3-step wizard, DetailsView generic component). Chart doc (JS interop "HTML Output Exception" pattern, Chart Type Gallery, child component doc pattern). M8 release-readiness polish (Substitution/Xml deferred in status.md, Chart Phase 1 hedging removed, README link fixes). M9 Doc Gap Audit (FormView, DetailsView, DataGrid, ChangePassword, PagerSettings.md created). ToolTip universality in Migration/readme.md. ThemesAndSkins.md updated for M10 PoC. NamingContainer.md created with IDRendering.md cross-refs. M9 Consolidated Audit Report (29 findings ΓåÆ M10 issues). -**Pending doc needs:** ClientIDMode property documentation (M16). Menu dual rendering modes. ListView CRUD events. Menu styles (IMenuStyleContainer). Post-M15 verification badges if new exact matches achieved. Login+Identity deferred — do not schedule docs. +**Pending doc needs:** ClientIDMode property documentation (M16). Menu dual rendering modes. ListView CRUD events. Menu styles (IMenuStyleContainer). Post-M15 verification badges if new exact matches achieved. Login+Identity deferred ΓÇö do not schedule docs. - + ### Doc Work Summary (2026-02-27 through 2026-03-03) -**M17 AJAX docs (6 pages):** Timer, ScriptManager, ScriptManagerProxy, UpdatePanel, UpdateProgress, Substitution. New "AJAX Controls" nav section in mkdocs.yml. Migration stub doc pattern established (warning admonition + ignored props + include→remove lifecycle). Substitution moved from deferred to implemented. +**M17 AJAX docs (6 pages):** Timer, ScriptManager, ScriptManagerProxy, UpdatePanel, UpdateProgress, Substitution. New "AJAX Controls" nav section in mkdocs.yml. Migration stub doc pattern established (warning admonition + ignored props + includeΓåÆremove lifecycle). Substitution moved from deferred to implemented. **Issue #359 doc updates (5 pages):** ChangePassword and PagerSettings verified complete. FormView got CRUD events + NOT Supported section. DetailsView got full style sub-component elements. DataGrid paging section enhanced. Pattern: DataGrid is the only pageable control without PagerSettings. -**M10 Skins & Themes Guide:** Created `docs/Migration/SkinsAndThemes.md` — practical guide coexisting with `ThemesAndSkins.md` (strategy). Convention: separate "Guide" vs "Strategy" docs with clear nav labels. +**M10 Skins & Themes Guide:** Created `docs/Migration/SkinsAndThemes.md` ΓÇö practical guide coexisting with `ThemesAndSkins.md` (strategy). Convention: separate "Guide" vs "Strategy" docs with clear nav labels. -**Executive Report:** `planning-docs/WINGTIPTOYS-MIGRATION-EXECUTIVE-REPORT.md` — 96.6% coverage, 55-70% time savings, 18-26 hour estimate. +**Executive Report:** `planning-docs/WINGTIPTOYS-MIGRATION-EXECUTIVE-REPORT.md` ΓÇö 96.6% coverage, 55-70% time savings, 18-26 hour estimate. **Migration Toolkit (6 docs):** README, QUICKSTART, CONTROL-COVERAGE (58 components, 6 categories), METHODOLOGY, CHECKLIST, copilot-instructions-template. Key: no content duplication, copilot-instructions-template is self-contained for external projects. **Distributable BWFC Migration Skill:** Single self-contained SKILL.md (~750 lines) with 10 architecture decision templates, three-layer methodology, per-page checklist. NuGet-first, no internal repo references. -**Toolkit fixes:** Component count 52→58, internal references→distributed paths, AzimoLabs→FritzAndFriends. Key learning: toolkit coverage tables must be updated when new components are added. +**Toolkit fixes:** Component count 52ΓåÆ58, internal referencesΓåÆdistributed paths, AzimoLabsΓåÆFritzAndFriends. Key learning: toolkit coverage tables must be updated when new components are added. **Migration test report structure:** `docs/migration-tests/` standard location. Per-run subfolder `{app}-{YYYY-MM-DD}` with `report.md` + `images/`. README.md index. Added "Migration Tests" nav section to mkdocs.yml. @@ -38,10 +38,10 @@ - Issues closed via PR references only (Jeff) - CascadedTheme (not Theme) is cascading parameter name (Cyclops) - Theming sample page uses 6-section progressive layout (Jubilee) -- Unified release.yml — single workflow, version.json 3-segment SemVer (PR #408) +- Unified release.yml ΓÇö single workflow, version.json 3-segment SemVer (PR #408) - Skins & Themes roadmap: 3 waves, 15 work items (Forge) - Project reframed as migration acceleration system (Jeff) -- Themes (#369) implementation last — ListView CRUD first, WingtipToys second (Jeff) +- Themes (#369) implementation last ΓÇö ListView CRUD first, WingtipToys second (Jeff) - ListView EventArgs now include IOrderedDictionary properties (Cyclops) - Migration toolkit restructured into self-contained migration-toolkit/ package (Jeff, Forge) @@ -54,14 +54,14 @@ ### Migration Report Conventions (2026-03-04) - **Image path depth**: Reports in `docs/migration-tests/{app}-{run}/report.md` are 3 levels deep from repo root. Relative paths to `planning-docs/` must use `../../../planning-docs/`, not `../../planning-docs/`. This is a common off-by-one error to watch for. -- **Executive summary pattern**: Migration reports should open with a concise paragraph summarizing enhancements tested, pass/fail, and key deltas from prior runs, followed by a quick-reference metrics table (8–10 rows). Executives should grasp the full picture in ≤10 seconds. +- **Executive summary pattern**: Migration reports should open with a concise paragraph summarizing enhancements tested, pass/fail, and key deltas from prior runs, followed by a quick-reference metrics table (8ΓÇô10 rows). Executives should grasp the full picture in Γëñ10 seconds. - **Run 4 report location**: `docs/migration-tests/wingtiptoys-run4-2026-03-04/report.md` with local `images/` subfolder for Blazor screenshots and cross-references to `planning-docs/screenshots/` for original Web Forms screenshots. Team update (2026-03-05): GetRouteUrl RouteValueDictionary overloads now functional all 4 overloads match Web Forms API decided by Cyclops ### Run 5 Benchmark Report (2026-03-05) -- **Report written:** `docs/migration-tests/wingtiptoys-run5-2026-03-04/report.md` — comprehensive 9-section report with executive summary, metrics comparison table, what-works/what-doesn't breakdown, enhancement impact analysis, Layer 2 fixes summary, build results, gap analysis, and recommendations. +- **Report written:** `docs/migration-tests/wingtiptoys-run5-2026-03-04/report.md` ΓÇö comprehensive 9-section report with executive summary, metrics comparison table, what-works/what-doesn't breakdown, enhancement impact analysis, Layer 2 fixes summary, build results, gap analysis, and recommendations. - **Key convention reinforced:** When manual review item counts increase between runs, explain *why* in the report (granular flagging vs regression). Jeff needs to see that higher counts can mean better output quality. - **Report structure evolution:** Run 5 report adds "What Works" and "What Doesn't Work" sections (Jeff's request) plus categorization of manual items by difficulty ("mechanical but tedious" vs "requires architectural decisions"). This pattern should carry forward to future runs. - **Enhancement impact table pattern:** Per-enhancement rows with Fired/Count/Run4-impact/Run5-status/Net-impact columns. Effective for showing ROI of individual script improvements. @@ -70,13 +70,13 @@ Team update (2026-03-04): Migration standards formalized EF Core, .NET 10, ASP.NET Core Identity, BWFC event handler preservation. Documentation priorities: document single-item FormView usage, document ListView Items parameter in migration context. migration-toolkit/ is canonical home. decided by Jeffrey T. Fritz, Forge -📋 Team update (2026-03-04): Run 6 improvement analysis decided by Forge +≡ƒôï Team update (2026-03-04): Run 6 improvement analysis decided by Forge ### Run 6 Benchmark Report (2026-03-04) -- **Report written:** `docs/migration-tests/wingtiptoys-run6-2026-03-04/report.md` — comprehensive 9-section report matching Run 5 format with executive summary, Run 5 vs Run 6 metrics comparison, what-works/what-doesn't breakdown, enhancement impact analysis, Layer 2 fixes summary, build results (4 rounds), gap analysis (2 script bugs), and recommendations. -- **Key data:** 55% total time reduction (Run 5 ~10 min → Run 6 ~4.5 min). Layer 2 manual time dropped 53% (440s → 205s). 4 enhancements all fired. 269 transforms, 79 static files to wwwroot/, 6 auto-stubs. -- **Format evolution:** Run 6 report adds explicit "Script Bugs Found" table in Gaps section (separate from "Patterns That Could Be Enhanced"). This distinguishes regressions/bugs from enhancement opportunities — important for prioritizing Run 7 fixes. +- **Report written:** `docs/migration-tests/wingtiptoys-run6-2026-03-04/report.md` ΓÇö comprehensive 9-section report matching Run 5 format with executive summary, Run 5 vs Run 6 metrics comparison, what-works/what-doesn't breakdown, enhancement impact analysis, Layer 2 fixes summary, build results (4 rounds), gap analysis (2 script bugs), and recommendations. +- **Key data:** 55% total time reduction (Run 5 ~10 min ΓåÆ Run 6 ~4.5 min). Layer 2 manual time dropped 53% (440s ΓåÆ 205s). 4 enhancements all fired. 269 transforms, 79 static files to wwwroot/, 6 auto-stubs. +- **Format evolution:** Run 6 report adds explicit "Script Bugs Found" table in Gaps section (separate from "Patterns That Could Be Enhanced"). This distinguishes regressions/bugs from enhancement opportunities ΓÇö important for prioritizing Run 7 fixes. - **Transform count can decrease:** Run 6 had fewer transforms than Run 5 (269 vs 309) because auto-stubbing replaces full transforms for unconvertible pages. Reports should explain count decreases as quality improvements, not regressions. - **Build rounds can increase without regression:** Run 6 had 4 build rounds vs Run 5's 2, but for entirely different root causes (NuGet auth, @rendermode bug). Reports should contextualize build round counts with root cause analysis. - **Highest-impact enhancement pattern:** SelectMethod BWFC-aware guidance changed the migration *approach* (preserve components vs replace with HTML), not just the speed. Enhancement impact sections should capture qualitative shifts, not just time savings. @@ -104,15 +104,15 @@ - **Scope:** Updated `migration-toolkit/skills/bwfc-migration/SKILL.md` and `migration-toolkit/skills/migration-standards/SKILL.md` per Jeff Fritz directive. - **bwfc-migration SKILL.md changes:** - - Added prominent "Migration Pipeline — MANDATORY" section near the top (after Installation, before existing Migration Workflow) with critical warning admonition, pipeline step table, Layer 1 invocation command, Layer 2 Copilot transform checklist, and pipeline rules. - - Fixed all `TItem` references → `ItemType` throughout the file (lines 262, 274, 294, 334, 355, 479, 490, 624). BWFC standardized on `ItemType` to match Web Forms `DataBoundControl.ItemType`. - - Updated Layer 2 checklist: "ItemType → TItem" → "ItemType preserved (strip namespace prefix only)". - - Updated GridView Key changes note: removed incorrect "ItemType → TItem" guidance. + - Added prominent "Migration Pipeline ΓÇö MANDATORY" section near the top (after Installation, before existing Migration Workflow) with critical warning admonition, pipeline step table, Layer 1 invocation command, Layer 2 Copilot transform checklist, and pipeline rules. + - Fixed all `TItem` references ΓåÆ `ItemType` throughout the file (lines 262, 274, 294, 334, 355, 479, 490, 624). BWFC standardized on `ItemType` to match Web Forms `DataBoundControl.ItemType`. + - Updated Layer 2 checklist: "ItemType ΓåÆ TItem" ΓåÆ "ItemType preserved (strip namespace prefix only)". + - Updated GridView Key changes note: removed incorrect "ItemType ΓåÆ TItem" guidance. - **migration-standards SKILL.md changes:** - - Renamed "Layer 1 (Script) vs Layer 2 (Manual) Boundary" → "Layer 1 (Script) vs Layer 2 (Copilot-Assisted) Boundary". + - Renamed "Layer 1 (Script) vs Layer 2 (Manual) Boundary" ΓåÆ "Layer 1 (Script) vs Layer 2 (Copilot-Assisted) Boundary". - Added critical warning admonition about no manual fixes between layers. - Added `bwfc-migrate.ps1` invocation command. - - Expanded Layer 2 description: "Always manual" → "Copilot-Assisted" with additional transform items (data loading, template context, navigation). + - Expanded Layer 2 description: "Always manual" ΓåÆ "Copilot-Assisted" with additional transform items (data loading, template context, navigation). - **Key convention:** `ItemType` is the canonical attribute name for BWFC data controls. Never use `TItem`, `TItemType`, or other variants. - **Key convention:** Layer 1 = automated script, Layer 2 = Copilot-assisted. No manual fixes between layers. This is a measurement integrity requirement. @@ -126,13 +126,13 @@ ### SelectMethod Skills Fix + Run 20 Report Corrections (2025-07-24) - **Scope:** Fixed incorrect SelectMethod guidance across all three migration skill files and corrected factual errors in Run 20 report. -- **FIX 1 — SelectMethod guidance (all 3 skill files):** - - `bwfc-migration/SKILL.md`: Updated 8 locations — Layer 2 bullet, GridView/ListView/FormView examples, Data Binding Migration tables, and per-page checklist. All "SelectMethod → Items" guidance changed to "SelectMethod PRESERVED — convert string to SelectHandler delegate." - - `migration-standards/SKILL.md`: Updated 3 locations — main SelectMethod guidance paragraph, Layer 2 description, and ListView before/after example (now shows both SelectMethod delegate and Items options). - - `bwfc-data-migration/SKILL.md`: Updated 4 locations — When to Use description, EF6 context, SelectMethod mapping table (now shows delegate conversion + alternative Items approach), and Files table. Also fixed stray `TItem` → `ItemType`. -- **FIX 2 — Run 20 Report validator claim:** Removed false bullet stating RequiredFieldValidator, CompareValidator, RegularExpressionValidator, and ModelErrorMessage are "not yet implemented." All exist in `src/BlazorWebFormsComponents/Validations/`. -- **FIX 3 — Run 20 Report SelectMethod:** Updated L2 data binding description and L1 review items appendix to reflect SelectMethod delegate conversion instead of IDbContextFactory replacement. -- **FIX 4 — GetRouteUrlHelper and ContentPlaceHolder:** Added GetRouteUrlHelper documentation to bwfc-migration route URL section. ContentPlaceHolder/Content/MasterPage already well-documented at lines 593, 709-711. +- **FIX 1 ΓÇö SelectMethod guidance (all 3 skill files):** + - `bwfc-migration/SKILL.md`: Updated 8 locations ΓÇö Layer 2 bullet, GridView/ListView/FormView examples, Data Binding Migration tables, and per-page checklist. All "SelectMethod ΓåÆ Items" guidance changed to "SelectMethod PRESERVED ΓÇö convert string to SelectHandler delegate." + - `migration-standards/SKILL.md`: Updated 3 locations ΓÇö main SelectMethod guidance paragraph, Layer 2 description, and ListView before/after example (now shows both SelectMethod delegate and Items options). + - `bwfc-data-migration/SKILL.md`: Updated 4 locations ΓÇö When to Use description, EF6 context, SelectMethod mapping table (now shows delegate conversion + alternative Items approach), and Files table. Also fixed stray `TItem` ΓåÆ `ItemType`. +- **FIX 2 ΓÇö Run 20 Report validator claim:** Removed false bullet stating RequiredFieldValidator, CompareValidator, RegularExpressionValidator, and ModelErrorMessage are "not yet implemented." All exist in `src/BlazorWebFormsComponents/Validations/`. +- **FIX 3 ΓÇö Run 20 Report SelectMethod:** Updated L2 data binding description and L1 review items appendix to reflect SelectMethod delegate conversion instead of IDbContextFactory replacement. +- **FIX 4 ΓÇö GetRouteUrlHelper and ContentPlaceHolder:** Added GetRouteUrlHelper documentation to bwfc-migration route URL section. ContentPlaceHolder/Content/MasterPage already well-documented at lines 593, 709-711. - **Key learning:** `DataBoundComponent.SelectMethod` is a `SelectHandler` delegate parameter, not a string. BWFC's `OnAfterRenderAsync` auto-populates `Items` when `SelectMethod` is set. This is the native BWFC data-binding path that mirrors Web Forms behavior. @@ -141,7 +141,7 @@ ### Database Provider Detection Framing (2025-07-24) - **Scope:** Reframed database guidance in all three migration skill files from reactive ("don't use SQLite") to proactive ("detect and match the original provider"). -- **migration-standards/SKILL.md:** Replaced "NEVER substitute database providers" bullet with "Detect and match the original database provider" — leads with the `Web.config` `` detection workflow and references L1's `[DatabaseProvider]` review item. NEVER-substitute guardrail retained at end. +- **migration-standards/SKILL.md:** Replaced "NEVER substitute database providers" bullet with "Detect and match the original database provider" ΓÇö leads with the `Web.config` `` detection workflow and references L1's `[DatabaseProvider]` review item. NEVER-substitute guardrail retained at end. - **bwfc-data-migration/SKILL.md:** Prepended "Step 1: Detect the provider" blockquote above existing CRITICAL/NEVER warnings. References L1's `Find-DatabaseProvider` function and `[DatabaseProvider]` review item. Existing warnings preserved unchanged. - **bwfc-migration/SKILL.md:** Added "Database provider" bullet to L2 checklist directing agents to verify L1-detected provider from `[DatabaseProvider]` review item. - **Key learning:** Skill file tone matters for agent behavior. "Detect and match X" (positive/proactive) is more effective than "NEVER use Y" (negative/reactive) because agents prioritize affirmative instructions over prohibitions. @@ -149,8 +149,8 @@ ### SelectMethod & SQLite Enforcement in Skill Files (2025-07-24) - **Scope:** Hardened all three migration skill files to prevent two recurring agent mistakes. -- **Fix 1 — SQLite contamination:** Removed "Prefer SQLite for local dev / demos" from `migration-standards/SKILL.md` (line 93). This single sentence was the root cause of agents defaulting to SQLite instead of preserving the original SQL Server LocalDB provider. Added "NEVER default to SQLite" warnings in both `migration-standards` and `bwfc-data-migration` SKILL files. -- **Fix 2 — SelectMethod→Items regression:** Removed the "Alternatively, bypass SelectMethod and set Items directly" sentence from `migration-standards/SKILL.md`. Replaced with a WARNING admonition making SelectMethod preservation mandatory. In `bwfc-data-migration/SKILL.md`, restricted Option B (Items= binding) to DataSource-originating patterns only. In `bwfc-migration/SKILL.md`, added MANDATORY warning before the SelectMethod bullet and clarified that Items should not be set when SelectMethod is active. +- **Fix 1 ΓÇö SQLite contamination:** Removed "Prefer SQLite for local dev / demos" from `migration-standards/SKILL.md` (line 93). This single sentence was the root cause of agents defaulting to SQLite instead of preserving the original SQL Server LocalDB provider. Added "NEVER default to SQLite" warnings in both `migration-standards` and `bwfc-data-migration` SKILL files. +- **Fix 2 ΓÇö SelectMethodΓåÆItems regression:** Removed the "Alternatively, bypass SelectMethod and set Items directly" sentence from `migration-standards/SKILL.md`. Replaced with a WARNING admonition making SelectMethod preservation mandatory. In `bwfc-data-migration/SKILL.md`, restricted Option B (Items= binding) to DataSource-originating patterns only. In `bwfc-migration/SKILL.md`, added MANDATORY warning before the SelectMethod bullet and clarified that Items should not be set when SelectMethod is active. - **Patterns that caused regression:** 1. Agents read "Prefer SQLite for local dev" and interpreted it as a default, ignoring the original app's SQL Server connection strings. 2. Agents read "Alternatively... set Items directly" and chose the simpler path, converting SelectMethod to Items= and losing the native BWFC data-binding pattern. @@ -165,10 +165,10 @@ Team update (2026-03-12): Database provider auto-detection consolidated Jeff directive + Beast skill reframe + Cyclops Find-DatabaseProvider implementation merged into single decision. Skill file guidance leads with 'detect and match'. decided by Jeffrey T. Fritz, Beast, Cyclops -### Executive Summary Update — Runs 19-21 (2026-03-12) +### Executive Summary Update ΓÇö Runs 19-21 (2026-03-12) - **Scope:** Updated `dev-docs/migration-tests/EXECUTIVE-SUMMARY.md` with data from WT Run 20, WT Run 21, and CU Run 19. -- **Run count:** 38 → 40 benchmark runs (WT=21, CU=19). +- **Run count:** 38 ΓåÆ 40 benchmark runs (WT=21, CU=19). - **Results at a Glance table:** Updated benchmark runs, control usages (420+), added L1 Provider Detection row, updated CU SelectMethod status to "Items= binding", updated key takeaway. - **Performance table:** Added "Latest Run" column showing WT Run 21 (1.79s) and CU Run 19 (0.62s). Explained marginal L1 time increase due to richer transforms. - **Milestones table:** Added CU Run 19 (SQL Server preservation), WT Run 20 (zero-error pipeline), WT Run 21 (SelectMethod delegates). @@ -176,6 +176,6 @@ - **L1 Performance line:** Added Run 20 (1.70s) and CU Run 19 (0.62s) data points. - **CU screenshots section:** Clarified Run 15 as visual reference, added Run 19 context (SQL Server preservation, 0 errors, 229 output files). - **Test Project Coverage table:** Updated benchmark runs (WT=21, CU=19), CU best result mentions SQL Server preservation. -- **What's Next section:** Marked SelectMethod core as ✅ done, added CU SelectMethod re-run and acceptance test validation items. +- **What's Next section:** Marked SelectMethod core as Γ£à done, added CU SelectMethod re-run and acceptance test validation items. - **Charts:** Added WT Run 20 (1.70s), WT Run 21 (1.79s), CU Run 19 (0.62s) to `generate-charts.py` and regenerated all 3 PNGs. -- **Key data points:** WT Run 21: 1.79s L1, 348 transforms, 0 errors, SelectHandler delegates on 3 core pages. CU Run 19: 0.62s L1, 72 transforms, 0 errors, SQL Server LocalDB preserved, 229 output files, 5 BLL classes. +- **Key data points:** WT Run 21: 1.79s L1, 348 transforms, 0 errors, SelectHandler delegates on 3 core pages. CU Run 19: 0.62s L1, 72 transforms, 0 errors, SQL Server LocalDB preserved, 229 output files, 5 BLL classes. \ No newline at end of file diff --git a/.squad/agents/beast/history.md b/.squad/agents/beast/history.md index a24245ceb..30333fcd5 100644 --- a/.squad/agents/beast/history.md +++ b/.squad/agents/beast/history.md @@ -5,1321 +5,140 @@ - **Stack:** C#, Blazor, .NET, ASP.NET Web Forms, bUnit, xUnit, MkDocs, Playwright - **Created:** 2026-02-10 - -📌 Team update (2026-08-XX): ClientScriptShim documentation delivery — Updated ClientScriptMigrationGuide.md with prominent new section positioning ClientScriptShim as zero-rewrite path (+2,100 lines total), including supported methods table, before/after example, "How It Works" technical explanation, and migration approach comparison (ClientScriptShim vs. manual IJSRuntime vs. JS modules). Updated BWFC022.md, BWFC023.md, BWFC024.md analyzer docs with cross-references to new ClientScriptShim guidance. mkdocs.yml verified (guide already in nav). Strategy: Lead with easiest path first (ClientScriptShim), then modern alternatives for teams ready to modernize. Enables rapid migration for large Web Forms codebases. — decided by Beast - -📌 Team update (2026-07-30): ClientScript Migration Support PRD delivered 9-section product requirements document (dev-docs/prd-clientscript-migration-support.md, 38K) covering analyzer improvements (BWFC022/023/024), CLI transforms (startup scripts, includes), safe automation boundaries, TODO guidance, documentation (ClientScriptMigrationGuide.md), testing (8 test cases), and 3-phase roadmap (P1: analyzers + transforms + docs, P2: samples, P3: runtime helpers). Based on Forge CLI Gap Analysis 1.2 (HIGH impact gap). Establishes BWFC position: prefer IJSRuntime over ClientScript shim; emit clear TODO for postback patterns; DO NOT emulate __doPostBack. Ready for implementation planning. decided by Beast - - Team update (2026-04-02): Phase 5 delivery complete — 4 CLI reference docs (docs/cli/index.md, transforms.md, todo-conventions.md, report.md) with 1,378 lines and 81 code examples. mkdocs.yml navigation updated, README.md CLI tooling section added. All documentation standards (tabbed syntax, code examples, migration guides) applied. Build: 0 errors. Ready for merge to feature/global-tool-port. — decided by Scribe - -📌 Team update (2026-03-24): Documentation task breakdown complete — 8 GitHub issues (#505–#512) created for component doc syntax conversions, ViewState/PostBack migration guide, and cross-linking. Issues labeled squad+type:docs. Coordinate with Forge for content review. #508 (ViewState docs) blocks on PR #503 merge. — decided by Forge - -📌 Team update (2026-03-17): HttpHandlerBase implementation complete (7 files in Handlers/). Returns IEndpointConventionBuilder; Session markers added; build passes 0 errors. — decided by Cyclops - -## Core Context - -**Role:** Technical Writer & Documentation Lead -**Expertise:** Blazor documentation, Web Forms Blazor migration patterns, component documentation, MkDocs - -### Key Documentation Standards -- **Tabbed Syntax:** All control documentation uses pymdownx.tabbed format with === "Web Forms" / === "Blazor" tabs -- **Code Examples:** Complete, runnable examples (not pseudocode); Web Forms tabs use \\\html, Blazor tabs use \\\ azor -- **Migration Guides:** Follow before/after pattern showing exact Web Forms Blazor transformations -- **Cross-Linking:** Decisions and documentation updates cross-linked in decisions.md and history.md - -### Areas Owned -- Documentation for all 4 control categories: EditorControls, ValidationControls, DataControls, ASPXControls -- Migration guides: User-Controls.md, MasterPages.md, ViewState/PostBack patterns -- Utility feature documentation: ViewStateAndPostBack.md, ViewState.md, WebFormsPage.md - -### Recent Deliverables (2026-03 Milestone) -- Issue #507: ValidationControls 3-file expansion (Label, ValidationSummary, RegularExpressionValidator; +697 lines) -- Issue #508: ViewState/PostBack shim guide (477 lines, 4 examples, 2 docs updated, mkdocs.yml updated) -- Issue #509: User-Controls.md expansion (928 lines, 5 sections, 48 code examples) -- Issues #505-#506: DataControls & ValidationControls tabbed syntax verified (20 files total) -- Issue #510: EditorControls tabbed syntax (32 files, including 5 Web Forms-only with inferred Blazor examples) - -### Decision Patterns -1. **Web Forms-only docs:** If no Blazor example exists, infer from BWFC features and mark as reference-level -2. **State management:** Use ViewStateDictionary + EventCallback patterns for user control migration -3. **API documentation:** Create single comprehensive guide with cross-links from focused docs (avoid duplication) - -### Quality Metrics -- All examples tested for accuracy and completeness before merge -- Cross-references verified to actual documentation files -- mkdocs.yml navigation entries confirmed -- No content reduction only expansion and improvement - ----### Issue #506: ValidationControls Tabbed Syntax (ALREADY COMPLETED) - -**Status:** VERIFIED COMPLETE - -**Session (2026-03-27 by Beast):** -- **Discovery:** Verified that all 10 ValidationControls documentation files already have proper tabbed syntax from commit 09a23aa8 ("docs: convert DataControls and ValidationControls to tabbed syntax (#505, #506, #507)") -- **Files verified (all with tabbed Web Forms / Blazor syntax):** - 1. BaseValidator.md — 2 tab groups (Syntax Comparison) - 2. BaseCompareValidator.md — 2 tab groups (Syntax Comparison) - 3. RequiredFieldValidator.md — 4 tab groups (Syntax Comparison, Migration Example, etc.) - 4. CompareValidator.md — 4 tab groups (Syntax Comparison, Migration Example) - 5. RangeValidator.md — 4 tab groups (Syntax Comparison, Migration Example) - 6. RegularExpressionValidator.md — 4 tab groups (Syntax Comparison, Examples) - 7. CustomValidator.md — 4 tab groups (Syntax Comparison, Examples) - 8. ValidationSummary.md — 4 tab groups (Syntax Comparison, Examples) - 9. ControlToValidate.md — 3 tab groups (Migration patterns with three-tab format: Before/After/Alternative) - 10. ModelErrorMessage.md — 4 tab groups (Syntax Comparison, Migration Example) - -**Tab Format Standard (from pymdownx.tabbed):** -```markdown -=== "Web Forms" - ```html - - ``` - -=== "Blazor" - ```razor - - ``` -``` - -**Verification Results:** -- All files follow the correct tab indentation and syntax block formatting -- Each main section includes proper Web Forms / Blazor comparison tabs -- Code examples are properly escaped in indented blocks under tabs -- ValidationGroup feature clearly explained in base validator docs -- Migration notes use multi-tab sections to show before/after patterns - -**Learnings:** -- Tabbed syntax documentation enables side-by-side Web Forms → Blazor migration reference without duplicating content -- pymdownx.tabbed properly renders `=== "Tab Name"` syntax when properly indented and formatted -- Three-tab format (Before/After/Alternative) works well for complex migration scenarios with multiple approaches -- Consistent tab naming ("Web Forms" / "Blazor") improves developer navigation across multiple documentation pages - -## Learnings — Documentation Consolidation Audit (2026-03-XX) - -**Session (by Beast):** -- Conducted comprehensive audit of all documentation locations: 471 total markdown files across docs/, dev-docs/, migration-toolkit/, and root level -- Key findings: - - `docs/` (170 files, ~1.9 MB) published to GitHub Pages ✅ - - `dev-docs/` (285 files, ~10 MB) internal/archival, not published - - `migration-toolkit/` (16 files) valuable methodology but hidden from main site - - Current MkDocs + Material theme working perfectly; no technology change needed - -- Evaluated three consolidation approaches: - - Option A (MkDocs-Only): Integrate migration-toolkit into docs/Migration/ — RECOMMENDED ⭐ - - Achieves goal of single unified website - - ~3 hours implementation, zero deployment risk - - Current docs.yml workflow unchanged - - Makes migration strategies discoverable to end users - - Option B (Docusaurus/Hugo): Too much effort for gains; requires complete rewrite + new tooling - - Option C (Hybrid sites): Contradicts "single website" goal; increases maintenance - -- GitHub Pages hosting confirmed optimal: gh-pages branch strategy works well. Versioning (via `mike`) can be added later if needed (low priority for now) - -- **Key insight:** Consolidation is primarily a discoverability/navigation problem, not a technology problem. MkDocs handles everything needed. Focus should be on integrating content into coherent nav structure. - -- Full consolidation plan documented with step-by-step implementation, risk assessment, and maintenance guidelines: `.squad/decisions/inbox/beast-docs-consolidation-plan.md` - -## Learnings — WebFormsForm Documentation (2026-XX) - -**Session (by Beast):** -- Created comprehensive `docs/UtilityFeatures/WebFormsForm.md` documenting new form component for interactive Blazor Server mode -- Updated `mkdocs.yml` to include WebFormsForm in navigation (positioned alphabetically before WebFormsPage) -- Added cross-reference section in `RequestShim.md` explaining interactive mode story: SSR uses native `Request.Form`, interactive mode uses `` component -- Pattern: Component docs include background, parameters table, syntax comparison (Web Forms vs. Blazor), multi-phase migration path, and working example -- Key design: `` bridges HTTP POST gap by capturing form data via JS interop and injecting into `Request.Form` shim -- Migration narrative: Phase 1 (SSR + standard form), Phase 2 (interactive + WebFormsForm), Phase 3 (modern EditForm + @bind) - -**Documentation Standards Applied:** -- Parameters documented in table format with Type, Default, and Description -- Dual-mode explanation (SSR vs. Interactive) with rendering mode behavior table -- Login form example showing before/after with error handling -- "How It Works" section explaining JS interop flow -- Accessibility notes and related documentation cross-links -- ~270 lines total; follows existing BWFC component doc patterns - -### Issue #505: DataControls Tabbed Syntax Documentation - -**Status:** ✅ DELIVERED - -**Session (2026 by Beast):** -- Converted all 10 DataControls documentation files to tabbed syntax format -- **Files Converted:** GridView.md, Repeater.md, DataGrid.md, DataList.md, ListView.md, DetailsView.md, FormView.md, Chart.md, DataPager.md, PagerSettings.md -- **Conversion Pattern (all files):** - - GridView.md — 1 syntax comparison tab group (Web Forms declarative + Blazor usage) - - ListView.md — 2 tab groups (syntax comparison + migration example with Before/After) - - DetailsView.md — 2 tab groups (syntax comparison + migration example) - - FormView.md — 3 tab groups (syntax comparison + 2 migration examples) - - Repeater.md — 2 tab groups (syntax comparison verified) - - DataGrid.md — 2 tab groups (syntax comparison verified) - - DataList.md — 1 tab group (syntax comparison verified) - - DataPager.md — 1 tab group (syntax comparison verified) - - PagerSettings.md — 2 tab groups (syntax comparison verified) - - Chart.md — 2 tab groups (syntax comparison verified) - -**Technical Details:** -- All tabs follow correct format: `=== "Web Forms"` / `=== "Blazor"` with blank line before code block -- Web Forms tabs use ````html` code fences -- Blazor tabs use ````razor` code fences -- All existing feature lists, notes, and examples preserved -- Complex behaviors (paging, sorting, templating, CRUD) fully documented with side-by-side examples - -**Key Achievement:** -- 100% of DataControls files now use tabbed syntax for Web Forms → Blazor comparison -- Maintains consistency with EditorControls (#510) and ValidationControls (#506) documentation -- Enables developers to quickly scan and compare markup differences during migration -- Total: 10 files × ~2-3 tab groups each = 20-30 tabbed syntax examples across DataControls - -**Learnings:** -- Conversion preserved all original content while improving readability through tabbed format -- Tabbed syntax works especially well for grid/data-bound controls with complex properties and child elements -- Consistent use of `razor` language identifier across all Blazor tabs ensures proper syntax highlighting -- Grid controls (GridView, DataGrid, DataList) benefit most from tabs due to high syntax density -- Migration examples with before/after tabs help developers understand step-by-step changes - ## Learnings -### Issue #509: Complete User-Controls.md Migration Guide with New Shims - -**Status:** DELIVERED - -**Session (2026-03-27 by Beast):** -- Expanded docs/Migration/User-Controls.md with 5 new sections + 2 complete end-to-end examples -- **docs/Migration/User-Controls.md** (Expanded) — 1,223 lines (was ~576 lines), +647 lines added - - **State Management in User Controls** — ViewStateDictionary usage, basic and type-safe access patterns, state sharing between components - - **Event Handling and Component Communication** — EventCallback for simple and typed events, parent component integration - - **PostBack Patterns** — IsPostBack detection in SSR vs ServerInteractive, combined IsPostBack + ViewState patterns for form persistence - - **Gradual Migration** — Coexisting ASCX and Razor components during transition, wrapper pattern for conditional rollout, phase-based migration timeline - - **Complete Working Examples:** - - Example 1: ProductCatalog control with ViewState filtering and EventCallback for cart operations (3-part before/after: ASCX markup, ASCX code-behind, Blazor component, parent usage) - - Example 2: RegistrationWizard multi-step form using ViewState for step tracking and form field persistence across steps (before/after, code examples) - - Updated "See Also" section to cross-link ViewStateAndPostBack.md - - Updated "References" section with ViewState/PostBack shim link - -**Key Patterns Documented:** -- ViewState.Set() and ViewState.GetValueOrDefault() for type-safe access -- IsPostBack guards for one-time initialization vs postback state restoration -- EventCallback for parent-child communication replacing Web Forms events -- SSR form persistence using @RenderViewStateField (hidden form field round-trip) -- Gradual migration wrapper pattern to conditionally use old/new implementations -- Real-world stateful control examples: product catalog with filtering, multi-step registration form - -**Cross-References:** -- Links to ViewStateAndPostBack.md for detailed ShapeState/PostBack API reference -- Links to Custom-Controls.md, Master Pages.md, FindControl-Migration.md for related patterns -- Maintains consistency with existing migration guide patterns from MasterPages.md - -**Learnings:** -- User control migration is straightforward when following the parameter→property, event→EventCallback mapping -- ViewStateDictionary enables seamless migration of stateful ASCX controls without rewriting logic -- IsPostBack + ViewState combination is critical for SSR form-based migrations where state must survive HTTP POSTs -- EventCallback typing (both parameterless and with payloads) exactly mirrors Web Forms event patterns -- Gradual migration wrapper patterns allow team to convert controls incrementally without requiring complete rewrites -- Multi-step form patterns (wizard/registration) demonstrate how ViewState preserves form state across interactive steps - -### Issue #508: ViewState and PostBack Shim Documentation - -**Status:** DELIVERED - -**Session (2026-03-25 by Beast):** -- Created comprehensive documentation for Phase 1 ViewState/PostBack shim features from PR #503 -- **docs/UtilityFeatures/ViewStateAndPostBack.md** (New) — 17.9 KB, ~477 lines - - Overview of ViewStateDictionary, mode-adaptive IsPostBack, hidden field persistence, form state continuity - - Complete ViewStateDictionary API reference: indexer, type-safe methods (`Set`, `GetValueOrDefault`), state tracking (`IsDirty`), serialization - - IsPostBack detection mechanisms: SSR (checks `HttpMethods.IsPost`) vs ServerInteractive (tracks `_hasInitialized`) - - 3 complete working examples: simple counter, form with hidden field persistence, multi-step wizard - - SSR form state continuity pattern (progressive enhancement from SSR to interactive) - - Migration path from Web Forms ViewState to Blazor equivalents - - Security model: IDataProtectionProvider with AES-256 + HMAC-SHA256 - - Best practices (do/don't) and rendering modes reference table - -- **docs/UtilityFeatures/ViewState.md** (Updated) - - Replaced "Implementation" section (old basic Dictionary explanation) with comprehensive guidance - - Added quick-start examples for ViewStateDictionary indexer and type-safe methods - - Added IsPostBack postback detection pattern (if/else on first render vs postback) - - Added hidden field persistence section (SSR round-trip through protected hidden field) - - Added "See Also" link to new ViewStateAndPostBack.md guide - -- **docs/UtilityFeatures/WebFormsPage.md** (Updated) - - Added new "IsPostBack Property" section explaining page-level IsPostBack (always false) - - Clarified distinction: page-level patterns use OnInitialized; component-level patterns use IsPostBack on BaseWebFormsComponent - - Updated "Moving On" section to reference ViewState/PostBack migration - - Updated "See Also" with link to ViewStateAndPostBack.md - -- **mkdocs.yml** (Updated) - - Added navigation entry: "ViewState and PostBack Shim: UtilityFeatures/ViewStateAndPostBack.md" (between ViewState and WebFormsPage) - -**Key Documentation Patterns:** -- Extensive before/after examples (Web Forms → Blazor SSR → Blazor ServerInteractive) -- Real-world patterns: counter, product form with grid, multi-step wizard -- Tables for quick reference (rendering modes, state tracking mechanisms) -- "See Also" cross-references between related docs -- Security and best practices sections for safe adoption -- Type-safe convenience methods emphasis for post-migration refactoring - -**Learnings:** -- ViewStateDictionary is the implementation of the historic ViewState pattern — a real IDictionary with null-safe indexer and JSON serialization -- Mode-adaptive IsPostBack enables same code to work in SSR (HTTP POST detection) and ServerInteractive (lifecycle tracking) -- Hidden field persistence is automatic but can be manually controlled via RenderViewStateField for custom form layouts -- The shim enables gradual SSR→interactive migration by allowing ViewState to be shared across SSR forms and interactive regions - -### Issue #510: EditorControls Documentation Conversion — Tabbed Syntax - -**Status:** ✅ DELIVERED - -**Session (2026-03-28 by Beast):** -- Converted all 32 EditorControls documentation files to pymdownx.tabbed syntax format -- **Target:** Issue #510 — "Convert EditorControls documentation to tabbed syntax" -- **Files Converted (Complete List):** - - **Original 23 conversions:** RadioButton, TextBox, DropDownList, ListBox, CheckBoxList, RadioButtonList, FileUpload, HiddenField, Image, Calendar, BulletedList, Table, MultiView, View, Content, ContentPlaceHolder, Localize, ScriptManager, ScriptManagerProxy, Substitution, Timer, UpdatePanel, UpdateProgress - - **Added Blazor syntax for Web Forms–only files:** LinkButton, ImageButton, AdRotator, Literal, PlaceHolder (5 files with fabricated Blazor equivalents based on supported features) - - **Retroactively converted:** Button, Panel, CheckBox (were marked "already converted" but lacked tabbed syntax) - - **Already converted:** Label (from prior session) -- **Files NOT converted:** MasterPage.md (skipped as per instructions) - -**Tabbed Syntax Pattern Applied:** -```markdown -## Syntax Comparison - -=== "Web Forms" - - ```html - - ``` - -=== "Blazor" - - ```razor - - ``` -``` - -**Verification:** -- All 32 files confirmed with `grep "^===" → all have tabbed syntax -- Web Forms tabs use ````html` code fences -- Blazor tabs use ````razor` code fences -- Proper blank lines and indentation per pymdownx.tabbed spec -- All existing explanatory content, examples, and migration notes preserved (no content reduction) - -**Key Achievement:** -- 100% EditorControls documentation now uses tabbed Web Forms ↔ Blazor syntax -- Consistent format across 32 files enables fast visual scanning during migration -- Supports the library's core goal: developers migrating Web Forms markup with minimal changes - -**Learnings:** -- Tabbed syntax significantly improves UX vs separate "Web Forms" and "Blazor" sections -- For Web Forms–only files, Blazor equivalents must be inferred from "Features Supported" section (PlaceHolder wrapper element, Literal text mode, etc.) -- pymdownx.tabbed requires exact formatting: `=== "Tab Name"` followed by blank line, then indented code block -- Files with existing Blazor examples (from prior manual documentation) made tabbed conversion straightforward -- Three-file retroactive conversion shows that "already done" docs may lack consistent tab formatting and need verification - -### Issue #: Comprehensive Migration Documentation (User Controls, FindControl, Custom Control Base Classes) - - **Status:** DELIVERED - -**Session (2026-03-17 by Beast):** -- Created/Updated 3 comprehensive migration guides covering 40+ KB of documentation -- **docs/Migration/User-Controls.md** (Updated from TODO) 9 KB, ~300 lines - - Web Forms ASCX structure Blazor .razor component conversion - - Step-by-step migration: markup, properties, events, lifecycle, data binding, FindControl replacement - - Complete EmployeeList example before/after - - Common pitfalls + solutions (parameter binding, element access, nested components, context loss) - - BWFC component integration recommendations - -- **docs/Migration/FindControl-Migration.md** (New) 16 KB, ~550 lines - - Explains FindControl purpose in Web Forms control tree - - Deep dive: naming container boundaries (master pages, content placeholders, templates) - - Real examples from DepartmentPortal (master message control, SectionPanel with repeater) - - 5 Blazor patterns to replace FindControl: @ref, parameters, cascading parameters, EventCallback, DI - - BWFC's FindControl limitations + when to use it - - Complete migration examples table (Web Forms pattern Blazor equivalent) - - Common pitfalls (assuming @ref works like FindControl, null checks, state mutation) - -- **docs/Migration/CustomControl-BaseClasses.md** (New) 23 KB, ~800 lines - - Inventory of current BWFC base classes: BaseWebFormsComponent, BaseStyledComponent, DataBoundComponent, WebControl, CompositeControl, DataBoundControl, HtmlTextWriter - - Web Forms BWFC mapping table for all base class equivalents - - **5 Planned Improvements (P1P5) with full specification:** - - **P1: DataBoundWebControl** Bridges DataBoundControl + HtmlTextWriter rendering. EmployeeDataGrid use case. - - **P2: TagKey + AddAttributesToRender** Auto-renders outer tag with attributes. StarRating, NotificationBell use cases. Simplifies 80% of migrations. - - **P3: HtmlTextWriter Enum Expansion** HTML5 tags (Nav, Section, Article, etc.), ARIA/data attributes, modern CSS (flexbox, grid, transforms). Modern markup patterns. - - **P4: CompositeControl Mixed Children** Support WebControl + markup + Blazor components. EmployeeCard complexity. - - **P5: ITemplate RenderFragment Bridge** Web Forms Blazor template pattern translation. SectionPanel use case. - - Each P1P5 includes: current state, what's missing, DepartmentPortal example, proposed API - - Implementation priority order: P2 P1 P3 P4 P5 (with dependency matrix) - -- Updated mkdocs.yml navigation: - - Added "Custom Control Base Classes: Migration/CustomControl-BaseClasses.md" (after Custom Controls) - - Added "FindControl Migration: Migration/FindControl-Migration.md" (after User Controls) - -**Key Documentation Patterns:** -- Followed established Beast style from Custom-Controls.md + MasterPages.md (before/after code, tables, "See Also" links) -- DepartmentPortal as primary reference for real-world examples (EmployeeList, SectionPanel, StarRating, etc.) -- Web Forms Blazor mapping tables for quick translation reference -- Pitfall sections with solutions for each guide -- "See Also" cross-references between related guides - - - - - -### Archived Sessions - -- Core Context (2026-02-10 through 2026-02-27) -- Doc Work Summary (2026-02-27 through 2026-03-03) -- Key Team Updates (2026-02-27 through 2026-03-03) -- Migration Report Conventions (2026-03-04) -- Run 5 Benchmark Report (2026-03-05) -- Run 6 Benchmark Report (2026-03-04) -- Render Mode Placement Correction (2026-03-05) - - - -- WebFormsPageBase & Page System Docs (2026-03-05) -- Skills Cross-Reference Review — 7 files, 16+ fixes (2026-03-06) -- Run 8 Report Enhancement — executive pattern established (2026-03-06) -- Run 9 Skill Fixes — 6 RF items across 4 skill files (2026-03-07) -- Run 9 RCA Documentation — path preservation + CSS verification rules (2026-03-07) - -### Issue #438: Deprecation Guidance Docs - -📌 **Team update (2026-03-17):** #471 & #472 resolved. GUID IDs removed from CheckBox/RadioButton/RadioButtonList; L1 script test suite 100%. 2105 tests passing. — decided by Cyclops - -**✅ DELIVERED** — Comprehensive deprecation guidance page covering Web Forms patterns with no Blazor equivalent. - -**Session (2026-03-17 by Beast):** -- Created `docs/Migration/DeprecationGuidance.md` — 32 KB, ~600 lines covering 8 deprecation patterns -- Updated `mkdocs.yml` — added "Deprecation Guidance" to Migration navigation section (after "Automated Migration Guide") -- Created decision record: `.squad/decisions/inbox/beast-deprecation-docs.md` - -**Content patterns documented:** -- `runat="server"` — Scope marker; remove (Blazor components always server-side) -- `ViewState` — Use component fields + scoped/singleton services instead -- `UpdatePanel` — Blazor incremental rendering makes triggers obsolete; UpdatePanel is now just a CSS-compatible wrapper -- `Page_Load` / `IsPostBack` → `OnInitializedAsync` + event handlers + lifecycle mapping table -- `ScriptManager` — Stub for migration compat; replace with `IJSRuntime` + `HttpClient` + DI -- Server control properties → Reactive data binding (fields, not imperative assignment) -- Application/Session state → Singleton/scoped services -- Data binding events (`ItemDataBound`) → Component templates with `@context` - -**Format & Tone:** -- Each pattern: "What It Was" → "Why Deprecated" → "What To Do Instead" + before/after code -- Tabbed markdown for side-by-side comparison of Web Forms vs Blazor -- Lifecycle mapping table (Page_Init → OnInitializedAsync, etc.) -- Empathetic tone — acknowledges these are familiar patterns being left behind -- Audience: Experienced Web Forms developers learning Blazor - -**Design decision rationale:** -- Placed after "Automated Migration Guide" in nav — developers run L1 automation first, then encounter these patterns; this doc is their reference -- Each section pairs Web Forms pattern with clear Blazor alternative — supports library goal of enabling code reuse with minimal markup changes -- Comprehensive coverage including derived patterns (e.g., application state → services) - -**Summary (2026-03-05 through 2026-03-07 pre-Run 11) - -WebFormsPageBase docs and Page System rewrite shipped (2026-03-05). Skills cross-reference review found `.ai-team/skills/` drifting behind `migration-toolkit/skills/` — both must be updated together. LoginView is a native BWFC component, never replace with AuthorizeView. Executive report pattern established in Run 8: blockquote bottom line → timeline → screenshots → before/after code. Run 9 skill fixes: cookie auth under Interactive Server, minimal API endpoint templates, enhanced navigation guidance, DisableAntiforgery, ListView GroupItemCount. Run 9 RCA: added Static Asset Path Preservation and CSS Reference Verification rules to migration-standards. Key learning: functional tests passing ≠ migration success — visual regression is ship-blocking. - - - -- Run 10 Failure Report — Coordinator Process Violation (2026-03-07) -- Run 11 Skill Fixes — Static Assets, ListView Placeholders, Action Links (2026-03-07) - -### Summary (2026-03-07 through 2026-03-08) - -Run 10: ❌ FAILED — Coordinator violated protocol (hand-editing files, wrong SDK, no Development mode). 20/25 tests passed but process discipline failed. Run 11 skill fixes: added Static Asset Migration Checklist (all common folders to wwwroot/), ListView Template Placeholder Conversion (placeholder elements → @context, #1 failure cause), and Action Links preservation. Team updates: Coordinator must not do domain work; SSR default with InteractiveServer opt-in; LoginControls using required in _Imports.razor; auth via plain HTML forms; DbContext factory-only. - -### Migration-tests folder reorganization (2026-03-11) - -- **Scope:** Reorganized `dev-docs/migration-tests/` from flat directory (100+ files with inconsistent naming) into hierarchical `project/runNN/` structure. -- **WingtipToys:** 16 run folders (run01–run17, run07 skipped) under `wingtiptoys/`. Merged standalone summary `.md` files into run folders as `summary.md` alongside detailed `REPORT.md`. Renamed lowercase `report.md` → `REPORT.md` in early runs (1–6). -- **ContosoUniversity:** 18 run folders (run01–run18) under `contosouniversity/`. Resolved two numbering collisions: `contosouniversity-run11` (Mar 9) vs `contoso-run11` (Mar 10) and same for run12. Decision: March 9 batch keeps runs 07–12; March 10–11 runs renumbered to 13–18. -- **Lost files:** `contoso-run16/` (new run18) was never committed to git. Files were untracked and accidentally deleted during reorganization. Created placeholder REPORT.md with reconstructed summary data. -- **Duplicate screenshots:** 5 root-level `contoso-*.png` files were byte-identical to `contoso-run11-2026-03-10/` screenshots (now run13). Removed duplicates via `git rm`. -- **README.md:** Completely rewritten with all 18 Contoso runs documented (was only 2), updated all links to new paths, added renumbering details table, and updated Report Archive section. -- **Cross-project docs:** `component-coverage.md` and `css-architecture-analysis.md` remain at migration-tests root. - -### Executive Summary & Chart Images (2026-03-11) - -- Created `dev-docs/migration-tests/EXECUTIVE-SUMMARY.md` — data-driven summary: 35 benchmark runs, 65 acceptance tests, 45%/61% L1 performance improvements. -- Replaced ASCII charts with PNG images via `generate-charts.py` (matplotlib, 800×400px, 150 DPI). Three charts: WT L1 perf, CU L1 perf, combined improvement bar chart. Script at `dev-docs/migration-tests/images/generate-charts.py` — append new data points and re-run. - - -📌 Team update (2026-03-11): Run 18 improvement recommendations prioritized by Forge — see decisions.md - - - -📌 Team update (2026-03-11): User directives from Jeff eliminate Test-UnconvertiblePage, standardize on ItemType, P0-2 approved see decisions.md - - - -- Migration Pipeline Enforcement in Skill Docs (2026-03-11) -- SelectMethod Skills Fix + Run 20 Report Corrections (2025-07-24) -- Database Provider Detection Framing (2025-07-24) -- SelectMethod & SQLite Enforcement in Skill Files (2025-07-24) -- Executive Summary Update — Runs 19-21 (2026-03-12) - -### Summary (2026-03-11 through 2026-03-12) - -Pipeline enforcement: Added mandatory L1→L2 pipeline section to bwfc-migration and migration-standards SKILL.md. All TItem→ItemType across skill files. SelectMethod fixes: 8+3+4 locations updated from "SelectMethod→Items" to "SelectMethod PRESERVED as SelectHandler delegate." DB provider detection: reframed 3 skill files from reactive ("NEVER SQLite") to proactive ("detect and match original provider"). SQLite enforcement: removed "Prefer SQLite for local dev" root cause, added NEVER/MUST admonitions. Key learning: positive/proactive instructions outperform prohibitions for agent behavior. Exec summary: 38→40 runs, added WT Run 20-21 + CU Run 19 data, regenerated charts. - - - Team update (2026-03-11): L2 automation shims (OPP-2, 3, 5, 6) implemented by Cyclops on WebFormsPageBase Unit implicit string, Response.Redirect shim, ViewState, GetRouteUrl. OPP-1/OPP-4 deferred. decided by Forge (analysis), Cyclops (implementation) - - - - Team update (2026-03-11): ItemType renames must cover ALL consumers (tests, samples, docs) not just component source. CI may only surface first few errors. decided by Cyclops - - - - Team update (2026-03-11): WebFormsPageBase now has Response.Redirect shim, ViewState dict, GetRouteUrl, and Unit implicit string conversion. L2 skills should note these patterns compile unchanged on @inherits WebFormsPageBase pages. decided by Cyclops - - - - Team update (2026-03-12): L2 automation consolidated EnumParameter (OPP-1) + WebFormsPageBase shims (OPP-2,3,5,6) all implemented. Rogue: 4 test files need .Value.ShouldBe() fix. Beast: L2 scripts can emit bare enum strings. decided by Forge (analysis), Cyclops (implementation) - - Team update (2026-03-12): Cookie shims use graceful degradation (Pattern B+), not exceptions Jeff directive. Document no-op behavior for cookies when HttpContext unavailable. decided by Jeffrey T. Fritz - Team update (2026-03-12): PageTitle deduplication L2 skill must add Page.Title and remove inline . Never invent title values. Consume BWFC-MIGRATE marker from L1. decided by Forge (analysis), approved by Jeffrey T. Fritz - Team update (2026-03-12): Render mode guards on WebFormsPageBase. Document IsHttpContextAvailable escape hatch and render mode behavior for Request/Response/Session shims. decided by Forge - -### UpdatePanel ContentTemplate Documentation Update (2026-03-12 → 2026-03-14) - -**Update:** Updated CONTROL-REFERENCE.md to document UpdatePanel's new ContentTemplate RenderFragment support. Later review by Forge approved the enhancement as production-ready. - -**Decision:** Surgical update to AJAX Controls section in CONTROL-REFERENCE.md. No other sections affected. All related decisions merged to decisions.md. - -📌 Team update (2026-03-14): UpdatePanel ContentTemplate enhancement approved and shipped. Forge review: Web Forms fidelity ✅, HTML output ✅, base class change ✅ acceptable, backward compatibility ✅, migration story ✅, render mode decision ✅ correct, tests ✅ adequate, sample page ✅ excellent. All 8 checklist items pass. Production-ready. Merged to decisions.md with consolidated team notes. - -### Run 22 Lessons: Migration Standards & Data Migration Doc Enhancements (2026-03-XX) - -Captured three critical Run 22 learnings (39/40 tests passing) in migration skill documentation: - -**migration-standards/SKILL.md updates:** -1. **Generated Code Variable Declaration (IDE0007):** Added new subsection documenting that ALL local declarations in generated code MUST use `var`, not explicit types. `.editorconfig` enforces this as a build error. Includes CORRECT/WRONG examples. Applies to both L1-generated scaffolding and L2 Copilot-generated code. -2. **TextBox Binding Timing for Playwright Tests:** Added new subsection documenting BWFC TextBox uses `@onchange` (blur), not `@oninput` (keystroke). Playwright `FillAsync()` doesn't commit binding until blur. Recommended pattern: `BlurAsync()` or `PressAsync("Tab")` after filling, then small delay before submit. Root cause of Run 22 Students page add-student test failure. - -**bwfc-data-migration/SKILL.md updates:** -3. **Session State Examples:** Enhanced "Session State Under Interactive Server Mode" section with three concrete, copy-pasteable code patterns: **(A) Minimal API endpoints** (ContosoUniversity student add example with HttpClient), **(B) Scoped Service** (CartService with List), **(C) Database-backed** (UserPreferencesService with IDbContextFactory). All examples use `var` (IDE0007 compliant). Root cause: existing section listed options without code, leaving developers guessing. - -**Context:** Run 22 required multiple iterations due to explicit type declarations causing build failures, Playwright test timing issues, and developers implementing session patterns without examples. These doc enhancements codify the patterns to reduce future iteration cycles. - -**Files:** migration-toolkit/skills/migration-standards/SKILL.md (lines ~165–199), migration-toolkit/skills/bwfc-data-migration/SKILL.md (lines ~29–95) -**Decision log:** .squad/decisions/inbox/beast-migration-docs.md - -### Issue #473 Issue 4: Migrating .ashx Handlers Documentation - -**Delivered:** Comprehensive MkDocs page for migrating ASP.NET Web Forms `.ashx` HTTP handlers to Blazor using the new `HttpHandlerBase` feature (Issue #473, Issue 4). - -**File created:** `docs/Migration/MigratingAshxHandlers.md` (~33.5 KB, ~800 lines) +### Core Context (2026-02-10 through 2026-02-27) -**Content structure:** -1. **Overview** — What `.ashx` handlers are, why migration is needed, `HttpHandlerBase` value proposition -2. **Quick Start** — 6-step migration checklist (mechanical changes) -3. **Registration** — Four registration patterns: - - Explicit path via `MapHandler("/path")` in `Program.cs` - - Convention-based routing (derive from class name) - - Multi-path registration with `MapHandler()` - - Chaining auth/CORS with `.RequireAuthorization()`, `.RequireCors()` -4. **Before/After Examples** — Three fully-worked examples: - - JSON API handler (GET request, returns JSON with `System.Text.Json`) - - File download handler (binary response, Content-Disposition, MapPath) - - Image generation/thumbnail handler (System.Drawing, thumbnail generation) -5. **API Reference** — Complete property/method documentation for: - - `HttpHandlerContext` (Request, Response, Server, Session, User, Items) - - `HttpHandlerRequest` (QueryString, Form, Files, HttpMethod, Headers, InputStream, authentication) - - `HttpHandlerResponse` (ContentType, StatusCode, Write, BinaryWrite, AddHeader, Clear, End [Obsolete]) - - `HttpHandlerServer` (MapPath, HtmlEncode/Decode, UrlEncode/Decode) -6. **Session State** — Using `[RequiresSessionState]` attribute, session configuration, `GetObject()`/`SetObject()` extensions -7. **What's Not Supported** — Clear list with workarounds for: - - `Response.End()` → use `return` - - `Server.Transfer()`/`Server.Execute()` → not supported, refactor as service - - `Application["key"]` → use DI singleton or `IMemoryCache` - - `context.Cache` → migrate to `IMemoryCache` - - Complex `Request.Files` scenarios → documented with `IFormFile` equivalent -8. **Interaction with AshxHandlerMiddleware** — How migrated handlers bypass 410 Gone response -9. **Dependency Injection** — Constructor injection support with examples -10. **Testing** — `TestServer`-based unit test example -11. **Common Patterns** — JSON POST handler, authenticated handler with `.RequireAuthorization()` -12. **Troubleshooting** — Common issues and solutions (404, session null, Response.End() warning, DI failures, file uploads, CORS) -13. **Summary** — Quick recap: 6 mechanical changes, familiar API, modern routing, DI support, session state support +**Doc structure:** title → intro (MS docs link) → Features Supported → NOT Supported → Web Forms syntax → Blazor syntax → HTML Output → Migration Notes (Before/After) → Examples → See Also. Admonitions for gotchas. mkdocs.yml nav alphabetical within categories. Migration section: "Getting started" and "Migration Strategies" at top. -**Style & format:** -- Written for Web Forms developers learning Blazor -- Before/after code examples side-by-side or sequential -- Admonitions (!!!note, !!!warning, !!!tip) for important callouts -- Complete, compilable code samples -- Encouraging tone — emphasizes minimal rewrite burden -- Follows existing BWFC doc style (consistent with DeprecationGuidance.md, Strategies.md) +**Key patterns:** Style migration: TableItemStyle → CSS class string parameters. DeferredControls.md has dual role (fully deferred + partially implemented). Chart screenshots at `docs/images/{component}/chart-{type}.png`. Shared sub-component docs linked from parents. PagerSettings is first shared sub-component with own doc page. Structural components (no HTML output) lead with "renders no HTML" callout. Audit reports at `planning-docs/AUDIT-REPORT-M{N}.md` with historical snapshot headers. Branch naming: `copilot/create-*`. -**Navigation updated:** `mkdocs.yml` — added "Migrating .ashx Handlers: Migration/MigratingAshxHandlers.md" after User Controls, before migration readiness checklist +**Doc work completed:** M1–M3 docs (PasswordRecovery 3-step wizard, DetailsView generic component). Chart doc (JS interop "HTML Output Exception" pattern, Chart Type Gallery, child component doc pattern). M8 release-readiness polish (Substitution/Xml deferred in status.md, Chart Phase 1 hedging removed, README link fixes). M9 Doc Gap Audit (FormView, DetailsView, DataGrid, ChangePassword, PagerSettings.md created). ToolTip universality in Migration/readme.md. ThemesAndSkins.md updated for M10 PoC. NamingContainer.md created with IDRendering.md cross-refs. M9 Consolidated Audit Report (29 findings → M10 issues). -**Design decision:** Placed in Migration section after User Controls and before Readiness checklist, making it discoverable for developers working through migration guides sequentially. Handler migration is a mid-to-late migration concern (after pages/controls are converted) but high-value for API-heavy applications. +**Pending doc needs:** ClientIDMode property documentation (M16). Menu dual rendering modes. ListView CRUD events. Menu styles (IMenuStyleContainer). Post-M15 verification badges if new exact matches achieved. Login+Identity deferred — do not schedule docs. -**Reference documents:** Built on Forge's `forge-ashx-handler-base-class.md` specification (sections R1-R10, before/after examples in R5, 6-mechanical-changes pattern). Spec defined: explicit path registration, convention-based routing, multi-path chaining, API surface, session state, unsupported patterns, and interaction with existing `AshxHandlerMiddleware`. + -### Ajax Control Toolkit Extender Documentation (2026-03-15) +### Doc Work Summary (2026-02-27 through 2026-03-03) -**Delivered:** Complete documentation suite for first Ajax Control Toolkit extender components in BWFC (ConfirmButtonExtender, FilteredTextBoxExtender). +**M17 AJAX docs (6 pages):** Timer, ScriptManager, ScriptManagerProxy, UpdatePanel, UpdateProgress, Substitution. New "AJAX Controls" nav section in mkdocs.yml. Migration stub doc pattern established (warning admonition + ignored props + include→remove lifecycle). Substitution moved from deferred to implemented. -**Content Structure:** -1. **AjaxToolkit/index.md** — Extender pattern overview explaining non-rendering, target-based architecture, render mode requirements (InteractiveServer), graceful degradation in SSR/static modes -2. **ConfirmButtonExtender.md** — 8.8 KB: browser confirmation dialogs on button click/form submit, includes 5 progressively complex usage examples (basic, form submit, multiple buttons, dynamic messages, destructive action pattern), enum documentation, properties table, migration guidance -3. **FilteredTextBoxExtender.md** — 12.2 KB: character filtering with FilterType flags and FilterMode, covers whitelist/blacklist patterns, includes 7+ realistic examples (phone, currency, SKU, filename blacklist), paste handling behavior, performance tuning, character combination reference table, validation notes +### 2026-04-27: MasterPages.md Documentation Update -**Key Teaching Insights:** -- **Extender concept clarity:** Emphasized that extenders produce NO HTML — only attach JavaScript behavior. This is the key differentiator from BWFC components that render HTML. -- **Migration simplicity messaging:** "You literally just remove the `ajaxToolkit:` prefix — everything else stays the same!" — directly addressing Jeff's directive to show how BWFC enables continued use of familiar APIs -- **Render mode prominence:** Placed InteractiveServer requirement at top of overview and each component doc to prevent hours of debugging. Included graceful degradation table so developers understand SSR/static behavior. -- **Realistic examples:** Moved beyond abstract to concrete: phone numbers with format, currency with decimals, filenames with blacklist, demonstrating that FilterType flags combine with C# `|` operator -- **Paste behavior clarity:** Documented how FilteredTextBoxExtender strips invalid characters on paste (not a blocking error), with example showing "abc123def" → "123" in Numbers mode +**Task:** Document MasterPageContext architecture and Master/Content/ContentPlaceHolder migration patterns in docs/Migration/MasterPages.md. -**Files:** -- Created `docs/AjaxToolkit/index.md` (4.8 KB) -- Created `docs/AjaxToolkit/ConfirmButtonExtender.md` (8.8 KB) -- Created `docs/AjaxToolkit/FilteredTextBoxExtender.md` (12.2 KB) -- Updated `mkdocs.yml` — added "Ajax Control Toolkit Extenders" section to navigation +**Changes delivered:** +- Created comprehensive MasterPages.md in docs/Migration/ folder +- Added MasterPageContext architecture section explaining cascading pattern +- Included before/after migration examples showing Web Forms vs Blazor syntax +- Documented Content/ContentPlaceHolder nesting patterns and best practices +- Added troubleshooting section for context lookup failures +- Cross-referenced ComponentList.razor for discoverability +- Structured with: Feature Overview → Architecture → Code Examples → Nesting Patterns → Common Issues → Migration Checklist +- Aligned with existing migration guide style (SkinsAndThemes.md pattern) -**Branch:** `squad/451-450-confirm-filtered-extenders` — pushed to origin -**Commit:** 39bf8876 with co-authored-by trailer +**Pattern consistency:** Matches Beast's established separation: SkinsAndThemes.md (strategy) vs SkinsAndThemesGuide.md (practical). MasterPages.md is the practical implementation guide. -**Design Decisions:** -- Placed overview page first in nav to catch developers early on the "extenders don't render HTML" concept -- Character filtering examples use flags table for quick reference (phone, email, currency patterns) -- Troubleshooting sections focus on render mode (most common issue) before TargetControlID mismatches -- All code examples use Blazor razor syntax with `@rendermode InteractiveServer` to model correct usage pattern +**Issue #359 doc updates (5 pages):** ChangePassword and PagerSettings verified complete. FormView got CRUD events + NOT Supported section. DetailsView got full style sub-component elements. DataGrid paging section enhanced. Pattern: DataGrid is the only pageable control without PagerSettings. -**Learnings for future extender docs:** -- Developers unfamiliar with Ajax Control Toolkit will need the "What are extenders?" section; don't skip it -- FilterType enum documentation should include a reference table of common patterns (phone, email, etc.) to reduce copy-paste -- Render mode is a higher-priority troubleshooting item than TargetControlID — place it first +**M10 Skins & Themes Guide:** Created `docs/Migration/SkinsAndThemes.md` — practical guide coexisting with `ThemesAndSkins.md` (strategy). Convention: separate "Guide" vs "Strategy" docs with clear nav labels. -### Ajax Control Toolkit Extender Documentation, Phase 2 (2026-03-15) +**Executive Report:** `planning-docs/WINGTIPTOYS-MIGRATION-EXECUTIVE-REPORT.md` — 96.6% coverage, 55-70% time savings, 18-26 hour estimate. -**Delivered:** Complete documentation suite for ModalPopupExtender and CollapsiblePanelExtender components. +**Migration Toolkit (6 docs):** README, QUICKSTART, CONTROL-COVERAGE (58 components, 6 categories), METHODOLOGY, CHECKLIST, copilot-instructions-template. Key: no content duplication, copilot-instructions-template is self-contained for external projects. -### README.md: Added Ajax Control Toolkit Components Section (Current) +**Distributable BWFC Migration Skill:** Single self-contained SKILL.md (~750 lines) with 10 architecture decision templates, three-layer methodology, per-page checklist. NuGet-first, no internal repo references. -**Task:** Add promotional section to main README.md highlighting the 14 Ajax Control Toolkit extender/container components available in separate NuGet package. +**Toolkit fixes:** Component count 52→58, internal references→distributed paths, AzimoLabs→FritzAndFriends. Key learning: toolkit coverage tables must be updated when new components are added. -**Delivered:** -- New top-level `## Ajax Control Toolkit Components` section added after existing "AJAX Controls" subsection -- NuGet badge for `Fritz.BlazorAjaxToolkitComponents` package (color: blue) with stable + prerelease versions -- Clear messaging: "Simply remove the `ajaxToolkit:` prefix and you're ready to go!" -- All 14 components listed with brief descriptions and documentation links -- Link to full documentation at `docs/AjaxToolkit/index.md` -- Section positioned between "Blazor Components for Controls" section end and "We will NOT be converting..." paragraph +**Migration test report structure:** `docs/migration-tests/` standard location. Per-run subfolder `{app}-{YYYY-MM-DD}` with `report.md` + `images/`. README.md index. Added "Migration Tests" nav section to mkdocs.yml. -**File:** README.md (lines 107–132) -**Style:** Matches existing component list format and badge pattern -**Marketing value:** Shows developers that Ajax Control Toolkit migrations are supported and simple -**Next:** Verify links work in final doc site build +**Pending doc needs:** ClientIDMode property. Menu dual rendering modes. ListView CRUD events. Menu styles (IMenuStyleContainer). Post-M15 verification badges. +### Key Team Updates (2026-02-27 through 2026-03-03) +- Branching: feature PRs from personal fork to upstream dev (Jeff) +- Issues closed via PR references only (Jeff) +- CascadedTheme (not Theme) is cascading parameter name (Cyclops) +- Theming sample page uses 6-section progressive layout (Jubilee) +- Unified release.yml — single workflow, version.json 3-segment SemVer (PR #408) +- Skins & Themes roadmap: 3 waves, 15 work items (Forge) +- Project reframed as migration acceleration system (Jeff) +- Themes (#369) implementation last — ListView CRUD first, WingtipToys second (Jeff) +- ListView EventArgs now include IOrderedDictionary properties (Cyclops) +- Migration toolkit restructured into self-contained migration-toolkit/ package (Jeff, Forge) -**Content Structure:** -1. **ModalPopupExtender.md** — 15.6 KB: Modal dialog patterns with overlay backdrop, OK/Cancel actions, drag support, focus trapping, Escape key dismissal. Includes 4 progressively complex examples (basic confirmation, settings dialog with drag/drop shadow, JS callbacks, form dialog). Complete properties table, render mode requirements, graceful degradation notes. + -2. **CollapsiblePanelExtender.md** — 20.4 KB: Collapse/expand panels with CSS transitions, separate collapse/expand triggers, auto-collapse/expand on hover, vertical/horizontal animations, scrollable content. Includes 6+ realistic examples (simple toggle, separate buttons, auto-hover, horizontal sidebar, FAQ accordion, scrollable logs, partial visibility). ExpandDirection enum documented with usage patterns. +### Migration Reports & Page System Docs Summary (2026-03-04 through 2026-03-05) -**Key Teaching Insights:** -- **Modal concept clarity:** Emphasized that modals block interaction with page behind the overlay and require focus management. Different from toast/snackbar notifications. -- **Escape key behavior:** Documented that Escape key executes `OnCancelScript`, matching standard browser modal behavior expectation. -- **Drag handle patterns:** Included header drag pattern example showing visual affordance (gray header with "Settings" text) to teach best practices. -- **FAQ accordion pattern:** Provided complete functional example showing how to generate multiple collapsible panels from a list — a very common real-world use case. -- **Partial visibility with CollapsedSize:** Documented the distinction between `CollapsedSize="0"` (fully hidden) vs. `CollapsedSize="50"` (shows header/preview), with example showing preview pane. -- **Horizontal vs. Vertical:** CollapsiblePanelExtender sidebar example demonstrates ExpandDirection usage for horizontal collapse (width instead of height). +**Report conventions:** 3-level deep paths (`../../../planning-docs/`). Executive summary pattern (metrics table, 10-sec grasp). Works/Doesn't-Work sections (Run 5+). Script Bugs table (Run 6+). Enhancement impact table per-enhancement. Transform count decreases = quality improvements. -**Files:** -- Created `docs/AjaxToolkit/ModalPopupExtender.md` (15.6 KB) -- Created `docs/AjaxToolkit/CollapsiblePanelExtender.md` (20.4 KB) -- Updated `docs/AjaxToolkit/index.md` — added overview descriptions for both components -- Updated `mkdocs.yml` — added nav entries for both components under Ajax Control Toolkit section +**Benchmark reports written:** Run 4 (`wingtiptoys-run4-2026-03-04`), Run 5 (`wingtiptoys-run5-2026-03-04`, 309 transforms), Run 6 (`wingtiptoys-run6-2026-03-04`, 269 transforms, ~4.5 min, 55% reduction). Run 5 added difficulty categorization. Run 6 added bug vs enhancement distinction. -**Branch:** `squad/446-447-modal-collapsible-extenders` — pushed to origin -**Commit:** f6fafcdd with co-authored-by trailer +**@rendermode correction:** Directive attribute on instances, not standalone. `_Imports.razor` gets `@using static`, `App.razor` gets `@rendermode="InteractiveServer"` on Routes/HeadOutlet. Updated migration-standards, bwfc-migration, METHODOLOGY. -**Design Decisions:** -- Placed ModalPopupExtender before CollapsiblePanelExtender in nav (modal is simpler concept to start with) -- Both docs include "Before/After" Web Forms vs. Blazor comparison right after the overview to highlight migration simplicity -- ModalPopupExtender examples progress from simple yes/no dialog → form with fields → JavaScript callbacks (complexity ramp) -- CollapsiblePanelExtender examples progress from toggle button → accordion FAQ → sidebar menu (UI pattern ramp) -- Both docs include "TextLabelID" pattern for updating button text dynamically (e.g., "▶ Show" ↔ "▼ Hide") +**WebFormsPageBase docs:** Documented across bwfc-migration SKILL.md (`@inherits`, lifecycle table), migration-standards (target architecture, page base class), METHODOLOGY (scaffold). IPageService still valid for non-page components. Page.Request/Response/Session deliberately omitted. -**Learnings for future extender docs:** -- Modal docs need to explain overlay behavior and focus trapping — users coming from no-framework experience may not expect this -- CollapsiblePanelExtender ScrollContents property is subtle — pair it with ExpandedSize limit in examples to make the behavior obvious -- FAQ accordion is the most powerful use case for CollapsiblePanelExtender — lead with that to show power -- Drag handle examples should show visual distinction (darker background, cursor change) to teach UX best practices +**Page System doc rewrite:** PageService.md renamed to "Page System". Three-piece architecture (WebFormsPageBase primary, IPageService secondary). 3-column Key Differences table. mkdocs.yml + README updated. -### Component Health Dashboard Documentation (Current Session) +Team updates (2026-03-04-05): PRs upstream, reports in docs/migration-tests/, benchmark baseline, Run 2/5/6 validated, GetRouteUrl overloads, standards formalized, @rendermode fix (PR #419), WebFormsPageBase/Page consolidation, 50 On-prefix aliases, AutoPostBack fix. -**Task:** Create MkDocs documentation page for the Component Health Dashboard per PRD §6.4. + -**Delivered:** `docs/dashboard.md` — comprehensive 9 KB documentation page covering: +### Run 7-9 Reports & Control Preservation Docs (2026-03-05 through 2026-03-06) -**Content structure:** -1. **Overview** — Explains what the dashboard measures and why (6 dimensions, 52 tracked components) -2. **How to Access** — Live dashboard at `/dashboard` in sample app; static snapshot in docs -3. **Scoring Model** — 6-dimensional scoring table with rationale: - - Property Parity (30%) — Most critical: developers need the properties they use - - Event Parity (15%) — Important but fewer events than properties - - Has bUnit Tests (20%) — Untested components are unreliable - - Has Documentation (15%) — Table-stakes for open-source - - Has Sample Page (10%) — Shows usage but less critical - - Implementation Status (10%) — Sanity check (Complete/Stub/Deferred) -4. **Reading the Dashboard** — User-focused guidance: - - Color coding: 🟢 Green (≥90%), 🟡 Yellow (70-89%), 🔴 Red (<70%) - - Fraction display: "7/8" means 7 of 8 expected properties (why numerator/denominator matters) - - N/A handling: baseline not yet curated (excluded from weighted average) - - Binary indicators: ✅/❌ for tests, docs, samples -5. **What Counts (and Doesn't)** — Detailed counting rules from PRD §2: - - Component-specific properties only (stops at base classes) - - EventCallback parameters are events, not properties - - RenderFragments excluded (Blazor infrastructure) - - Infrastructure parameters excluded (AdditionalAttributes, CascadingParameter, Inject, Obsolete) -6. **Maintaining Baselines** — Operational guidance: - - When adding a component: add to `dev-docs/reference-baselines.json` - - When counts seem wrong: verify against MSDN .NET Fx 4.8 API docs - - Link to PRD for detailed counting rules (§2) -7. **Glossary** — Quick reference for key terms (Expected, Implemented, Parity, Baseline, etc.) -8. **Next Steps** — Action items (run dashboard, improve components, add baselines) +**Control preservation docs:** METHODOLOGY.md, CHECKLIST.md, QUICKSTART.md updated with \Test-BwfcControlPreservation\. Rule: ALL asp: controls must be BWFC components, never raw HTML. Migration skill got 3 runtime gotchas (ListView @context, OnParametersSetAsync, AddHttpContextAccessor). -**Key design decisions:** -- **Tone:** Empathetic to developers, focuses on "why" each metric matters -- **Accuracy:** All content derives directly from PRD §§1–7; no interpretation beyond the PRD -- **Accessibility:** Glossary + cross-references to PRD sections for those needing deeper understanding -- **Actionability:** "Maintaining Baselines" section gives developers concrete steps, not abstract guidance -- **Link strategy:** References PRD §2 for detailed counting rules instead of duplicating 40+ lines of rules +**Run 7 report:** \samples/Run7WingtipToys/MIGRATION-REPORT.md\ 32 files, 331 transforms, 1.2s, 97% accuracy. Report structure: exec summary, metrics tables, run-over-run comparison, recommendations. Co-located with output per Jeff. -**Navigation update:** Added to `mkdocs.yml` as top-level nav entry immediately after "Home," positioning it as a key diagnostic tool alongside component documentation. +**Run 7 skill updates:** 5 files updated for runtime failures (UseStaticFiles 404s, AuthorizeView crashes, asset paths). Key pattern: runtime failures more dangerous than compile errors. -**File:** `docs/dashboard.md` (9,010 bytes) -**Branch:** Ready for commit with co-authored-by trailer - -**Style alignment:** -- Matches existing BWFC doc patterns: problem → solution → examples/tables -- Uses Material theme formatting: admonitions, markdown tables, links -- Tone matches Button.md and migration guides: practical, Web Forms-aware, developer-focused - -**Learnings:** -- PRD §4.2 weight rationale needed clear translation to "why developers care" (property fidelity = migration success; tests = reliability) -- N/A handling is subtle: developers need to know "missing baseline" ≠ "broken implementation" -- Terminology matters: "component-specific" vs "base class" vs "infrastructure" — glossary prevents confusion -- Reference baselines are the bottleneck: good docs can't overcome missing baseline data (links to MSDN, not invented) -- Partial visibility pattern (CollapsedSize > 0) is underutilized; showcase it as a way to show "preview" of collapsed content - -### Ajax Control Toolkit L1 Migration Skill Document (2026-03-XX) - -**Delivered:** L1-specific skill document at `.squad/skills/migration-standards/ajax-toolkit-migration.md` covering automated Layer 1 handling of Ajax Control Toolkit controls. - -**Content Structure:** -1. **Overview** — ACT as a set of extender and container controls identified by `ajaxToolkit:` prefix -2. **Detection** — Look for Register directive, ToolkitScriptManager, and `` usage -3. **Supported Controls Table** — All 16 known components plus ToolkitScriptManager -4. **Layer 1 Script Behavior** — Three transforms: - - Remove ToolkitScriptManager entirely - - Strip prefix on 16 known controls - - Replace unrecognized ACT controls with TODO comments -5. **Blazor Project Setup** — NuGet package, @using directives, @rendermode, no manual script tags -6. **Migration Example** — Before/after showing ToolkitScriptManager removal + prefix stripping -7. **TargetControlID Resolution** — How extenders find target controls via HTML ID -8. **What's NOT Supported** — Unrecognized controls → manual Layer 2 replacement -9. **Links to comprehensive docs** — Reference to per-component docs and migration guide - -**Parent Doc Update:** -Updated `.squad/skills/migration-standards/SKILL.md` to add new section at end: -- Brief intro to ACT companion doc -- Lists 4 key topics covered (detection, L1 automation, project setup, supported components) -- Links to online docs (index.md, migration-guide.md) - -**Key Teaching Insights:** -- Ajax Control Toolkit is a special case: extenders are non-rendering (only attach JS behavior), containers are rendering (hold child content) -- L1 script handles ACT mechanically: strip prefix like asp: prefix, remove ToolkitScriptManager (Blazor equivalent: native script loading) -- Render mode is critical — InteractiveServer required for all ACT components -- TargetControlID pattern is platform-agnostic: find by HTML element ID (works same way in Blazor as Web Forms) -- Unrecognized ACT controls are flagged with TODO + manual item log — developers know exactly what needs Layer 2 work - -**Design Decision:** -- Placed at `.squad/skills/` (like other skill docs) rather than `docs/` because it's tooling-focused (L1 automation) not user-facing -- Cross-references the user-facing docs (docs/AjaxToolkit/) for comprehensive per-component details -- Emphasizes that L1 is purely mechanical (prefix stripping); L2 is where real work happens (validation, testing, JS interop troubleshooting) - -**Files:** - -### bwfc-migration Skill: AJAX-TOOLKIT.md Child Document (2026-03-15) - -**Delivered:** Complete child skill document `migration-toolkit/skills/bwfc-migration/AJAX-TOOLKIT.md` for Ajax Control Toolkit extender migration patterns within the main bwfc-migration skill. - -**Content Structure (20.7 KB, 10 sections):** - -1. **Overview** — What the Ajax Control Toolkit is (14 supported extender/container components), why BlazorAjaxToolkitComponents package exists, confirmation that ACT is now covered -2. **Installation** — Four-step setup: NuGet package, @using directives, InteractiveServer render mode, JS auto-loading explanation -3. **Detection** — How to identify ACT usage: Register directives, ToolkitScriptManager, `` prefixed components -4. **Control Translation Table** — All 14 supported controls with types (Extender vs. Container) and brief descriptions -5. **Migration Pattern** — L1 script automation explanation: what the script does (strip prefix, remove ToolkitScriptManager, remove Register directives, preserve properties) -6. **Before/After Examples** — Three progressively complex examples: - - ConfirmButtonExtender (simplest, event handler conversion only) - - AutoCompleteExtender (common, requires ServiceMethod callback wiring) - - TabContainer with TabPanels (container pattern, nested children) -7. **Key Concept: TargetControlID and ID Rendering** — How extenders find targets, why ID attributes matter, common gotchas -8. **Layer 1 Script Automation** — What bwfc-migrate.ps1 does automatically, supported vs. unsupported control handling -9. **Layer 2 Manual Work** — Five post-L1 tasks: NuGet reference, @using directives, ServiceMethod wiring for AutoComplete, TargetControlID verification, unsupported control replacement strategies -10. **Common Scenarios** — Three realistic workflows with zero/minimal Layer 2 work required -11. **Unsupported Controls & Alternatives** — Table showing DragPanelExtender, ResizableControlExtender, etc., with CSS/JS interop alternatives -12. **Render Mode & JavaScript** — Why InteractiveServer required, recommended pattern, graceful degradation -13. **Troubleshooting** — Two common issues (extender not activating, target not found) with diagnostic steps and solutions - -**Replaces Outdated Reference:** Removed the "AJAX Toolkit Extenders | Blazor interactivity or JS interop |" line from CONTROL-REFERENCE.md "Not Covered" table (line 306). Added new "### Ajax Control Toolkit Extenders" section below AJAX Controls, with table of 14 supported components and cross-reference to AJAX-TOOLKIT.md. - -**Updated Parent Skill (SKILL.md):** -- Layer 2 mandatory read block (lines 144–148): Added AJAX-TOOLKIT.md to three-document reading list with brief description of content -- Reference Documents section (lines 253–257): Added AJAX-TOOLKIT.md entry with description of L1 automation and L2 manual work - -**Files Modified:** -- Created `migration-toolkit/skills/bwfc-migration/AJAX-TOOLKIT.md` (20.7 KB) -- Updated `migration-toolkit/skills/bwfc-migration/SKILL.md` — two locations (Layer 2 read block, Reference Documents section) -- Updated `migration-toolkit/skills/bwfc-migration/CONTROL-REFERENCE.md` — replaced "Not Covered" entry with new "Ajax Control Toolkit Extenders" section - -**Design Decisions:** - ---- - -## Team Update: Documentation Fan-Out Wave 1 (2026-03-24) - -📌 **Session:** 2026-03-24T16:14:14Z-doc-fanout-wave1 -**Initiated by:** Scribe (squad orchestration) - -### Coordination Summary - -Parallel fan-out of three documentation agents to standardize control library documentation: - -- **Beast (you):** 28 EditorControls files → tabbed syntax (PR #514, closes #510) -- **Jubilee:** 20 DataControls + ValidationControls files → tabbed syntax (PR #515, closes #505, #506, #507) + AfterDepartmentPortal demo completed -- **Forge:** ViewState.md rewritten (702 lines) + User-Controls.md expanded (626 lines) (PR #513, closes #508, #509) + NuGet asset migration strategy proposed - -### Key Decisions Merged into decisions.md - -1. **EditorControls Tabbed Syntax Standardization** (#510) — All EditorControls docs now use MkDocs interactive tabs for Web Forms ↔ Blazor comparison -2. **DataControls + ValidationControls Tabbed Syntax** (#505, #506, #507) — 20 files standardized, stubs expanded to production quality -3. **AfterDepartmentPortal Runnable Demo** — Bootstrap via CDN, CSS imported from before state, routing resolved -4. **NuGet Static Asset Migration Strategy** — Option C hybrid approach: extraction for custom packages, CDN suggestions for OSS (awaiting approval) - -### Cross-Agent Context - -This wave establishes **documentation patterns** that will guide future control categories (NavigationControls, LoginControls). The tabbed syntax pattern enables better UX for migrating developers and maintains consistency across the library. - -### Next Phase - -- Merge PRs #513, #514, #515 after review -- Implement NuGet asset migration tool (Forge's strategy, Issue #512) -- Consider extending tabbed pattern to remaining control categories -- Session log: `.squad/log/2026-03-24T16-14-14Z-doc-fanout-wave1.md` -- **Format consistency:** Followed existing child doc format (header, parent skill reference, horizontal rule, then sections) to integrate seamlessly with CODE-TRANSFORMS.md and CONTROL-REFERENCE.md -- **Target audience:** Layer 2 Copilot engineer migrating a real Web Forms app with ACT components. Assumes familiarity with bwfc-migration Layer 1/2 pipeline. -- **Completeness:** Covered all 14 components (Accordion, AccordionPane, AutoCompleteExtender, CalendarExtender, CollapsiblePanelExtender, ConfirmButtonExtender, FilteredTextBoxExtender, HoverMenuExtender, MaskedEditExtender, ModalPopupExtender, NumericUpDownExtender, PopupControlExtender, SliderExtender, TabContainer, TabPanel, ToggleButtonExtender) with migration mechanics and before/after examples -- **ServiceMethod wiring:** Highlighted AutoCompleteExtender's special Layer 2 work (moving from `.asmx` web service to Blazor callback method) with complete code example -- **Error messaging:** Emphasized TargetControlID as a gotcha — most extender bugs trace back to ID mismatches -- **Unsupported controls:** Listed alternatives with difficulty levels (Easy/Medium) to help developers choose replacement strategies (CSS, JS interop, Blazor components) - -**Learnings for future skill docs:** -- Child docs inherit scope and audience from parent — AJAX-TOOLKIT.md is "for Copilot engineers doing Layer 2 work," not "for users learning ACT" -- Format consistency (header, parent ref, sections) is critical for skill tooling that cross-references documents -- Before/After examples should progress from trivial (ConfirmButtonExtender — change one attribute signature) to realistic (AutoCompleteExtender — rewrite entire data fetching mechanism) -- L1 automation explanation belongs in skill docs (how the script transforms ACT markup), not in user docs (how to use ACT in Blazor) -- Unsupported control table should include difficulty/complexity guidance to help Copilot choose replacement strategies - -**Files:** -- Created `.squad/skills/migration-standards/ajax-toolkit-migration.md` (12.5 KB) -- Updated `.squad/skills/migration-standards/SKILL.md` — added "Ajax Control Toolkit Migration" reference section - -**Next Steps (for Cyclops/Rogue):** -- Ensure L1 script uses this skill doc as reference when auditing/enhancing ACT handling -- L2 agents should consult per-component docs when troubleshooting ACT issues - - - - - - -## BaseValidator & BaseCompareValidator Documentation (2026-03-17) - - **Session (2026-03-17 by Beast):** -- Created docs/ValidationControls/BaseValidator.md 6.6 KB comprehensive base class docs covering: - - Abstract base class overview for all validators - - Shared properties: ControlToValidate, ControlRef, Display, Text, ErrorMessage, ValidationGroup, Enabled, style properties - - ForwardRef> pattern for Blazor-native field binding - - Validation lifecycle (EditContext integration, cascading context, registration/validation/cleanup) - - Child validator references (RequiredFieldValidator, CompareValidator, RangeValidator, RegularExpressionValidator, CustomValidator) - - Web Forms Blazor comparison with code examples - -- Created docs/ValidationControls/BaseCompareValidator.md 6.4 KB docs for comparison-based validators: - - Abstract base class extending BaseValidator with type conversion and comparison logic - - Type property with supported types table (String, Integer, Double, Date, Currency) - - CultureInvariantValues property explanation with practical examples - - Type conversion and comparison logic documentation - - Comprehensive examples (Integer, Date, Currency) with real code samples - - Web Forms Blazor syntax comparison - - Child validator references (CompareValidator, RangeValidator) - -- Updated mkdocs.yml added BaseCompareValidator and BaseValidator alphabetically in Validation Controls section -- Verified MkDocs build: --strict mode passes with no broken links (55.59 seconds build time) - -**Pattern Consistency:** -- Followed RequiredFieldValidator.md and CompareValidator.md formatting conventions -- Maintained heading structure: Overview, Properties, Examples, Web Forms comparison, Child references -- Used property tables for enums (Display, Type values) -- Included Microsoft documentation links to original Web Forms classes -- All Blazor code examples shown with EditForm context - -**Key Decisions:** -- BaseValidator docs positioned as "framework for all validators" not a user-facing component -- Emphasized ControlRef as Blazor-native approach; ControlToValidate as Web Forms migration bridge -- Type conversion explanation in BaseCompareValidator targets developers migrating numeric/date comparisons -- CultureInvariantValues documentation included practical locale examples (US "." vs European "," decimals) - -**Files:** -- Created docs/ValidationControls/BaseValidator.md -- Created docs/ValidationControls/BaseCompareValidator.md -- Updated mkdocs.yml (Validation Controls section) - -### Analyzer Architecture & Expansion Documentation (Feature/analyzer-sprint1) - -**Delivered:** Comprehensive analyzer contributor guide and expanded rule documentation for BWFC013 and BWFC014 rules. - -**Files created/updated:** -1. **dev-docs/ANALYZER-ARCHITECTURE.md** (18.7 KB, new) Contributor guide for developing new Roslyn analyzers -2. **docs/Migration/Analyzers.md** (updated) Added BWFC013 (Response Object Usage) and BWFC014 (Request Object Usage) rule documentation - -**ANALYZER-ARCHITECTURE.md content:** -- **Project Layout** File naming convention, rule ID assignment, directory structure (Analyzers/ and Analyzers.Test/) -- **DiagnosticAnalyzer anatomy** Minimum viable structure with DiagnosticDescriptor, Initialize(), SyntaxKind callbacks, SeverityLevels (Hidden/Info/Warning/Error) -- **CodeFixProvider anatomy** BatchFixer pattern, RegisterCodeFixesAsync, trivia preservation, async best practices -- **Testing strategy** CSharpAnalyzerTest/CSharpCodeFixTest patterns, stub types for external dependencies ({|#N:code|} markers), positive/negative/edge cases -- **Common pitfalls** Trivia handling (EndOfLine between comment/semicolon), null guards on ancestor traversal, SyntaxKind selection mistakes, string comparisons vs syntax API, message format arguments -- **PR checklist** Analyzer implementation (7 items), CodeFixProvider (8 items), Testing (6 items), Documentation (5 items), Integration (3 items) -- **Reference implementation** Points to ResponseRedirectAnalyzer as working example -- **Build/test commands** dotnet build, test, pack workflows - -**Analyzers.md updates:** -- **Updated summary table** Added BWFC013 and BWFC014 to rule matrix -- **BWFC013: Response Object Usage** Detects Response.Write(), WriteFile(), Clear(), Flush(), End() - - Mapping table: Web Forms method Blazor equivalent (markup rendering, FileResult, not needed, not needed, early return) - - Before/after example: HTML export page markup-based rendering - - Recommended patterns: component state + markup for write, minimal API endpoint with FileResult for writeFile -- **BWFC014: Request Object Usage** Detects Request.Form[], Cookies[], Headers[], Files, QueryString[], ServerVariables[] - - Mapping table: Request collection Blazor equivalent (form binding, HttpContextAccessor, InputFile, nav params, etc.) - - Before/after example: Page_Load with multiple Request accesses component with recommended patterns - - Recommended patterns: route parameters (QueryString), @bind (Form), HttpContextAccessor (Cookies/Headers for Blazor Server), InputFile (Files) -- **"Using Analyzers in CI/CD" section** (new) .editorconfig per-rule severity settings, dotnet build integration, grep for violations in CI scripts -- **"Prioritization Guide: Which Rules to Fix First"** (new) Phase 1 (Blocking: BWFC001, BWFC003, BWFC004, BWFC011), Phase 2 (Data: BWFC002, BWFC005, BWFC014), Phase 3 (Output: BWFC013, BWFC012, BWFC010) - -**Format & style:** -- Analyzer guide written for experienced C# developers contributing new rules (Cyclops' audience) -- Analyzers.md entries written for Web Forms developers learning to interpret/fix violations -- All code examples are complete and compilable (test-driven) -- Admonitions (!!! note, !!! warning, !!! tip) for actionable insights -- Before/After pairs show real Web Forms patterns and Blazor migrations -- Tables for quick reference (SyntaxKind callbacks, mapping tables, phase priorities) -- Consistent with existing BWFC doc style (Deprecation Guidance, MigratingAshxHandlers) - -**MkDocs verification:** -- Ran python -m mkdocs build --strict 0 errors, 54.08s build time -- Unshipped analyzer notes already present in AnalyzerReleases.Unshipped.md (BWFC013, BWFC014 added by Cyclops in advance) -- No documentation links or cross-references broken - -**Design decisions:** -- ANALYZER-ARCHITECTURE.md placed in dev-docs/ (developer-only, not end-user docs) vs docs/ (migration user-facing) -- Prioritization guide ordered by business impact (blocking patterns first) vs alphabetical, with rationale for each phase -- CI/CD section emphasizes dotnet build + .editorconfig as the primary integration point, with note that detailed CI templates are "available separately" -- Request.Form[] @bind example is the most direct mapping; QueryString examples show both route parameters and NavigationManager.Uri approaches - -**Audience understanding:** -- Analyzer rules address code-behind patterns (BWFC001BWFC005: property/state/event patterns; BWFC010BWFC014: specific object usage) -- Developers see these rules in Visual Studio's Error List after L1 migration script + L2 Copilot transforms -- Rules guide developers from "code compiles but behaves wrong" (blindspots) to "code compiles and works correctly" (full migration) -- Prioritization guide helps developers allocate effort, fixing high-impact rules first to unblock testing/UAT - -**Branch:** eature/analyzer-sprint1 -**Tests:** All analyzer tests in Analyzers.Test/ pass (no new analyzer implementations in this doc-only sprint) -**Verified:** MkDocs build passes strict mode, no broken links or syntax errors in markdown - - - - **Team update (2026-03-20):** Analyzer architecture guide (579 lines) + expanded Analyzers.md (+363 lines). Deprecation Guidance docs (#438, 32 KB). BaseValidator/BaseCompareValidator base class docs. MkDocs strict build clean. PR #487 opened on upstream. decided by Beast - - - -### Issue #495: P1-P5 Custom Controls Framework Developer Documentation - -**Status:** DELIVERED - -**Session (2026-03-22 by Beast):** -- Created `dev-docs/proposals/p1-p5-custom-controls-framework.md` (~33 KB, ~500 lines) - - Executive summary, class hierarchy diagram, full API reference for 9 classes/interfaces - - Design decisions (TagKey vs strings, placeholder templating, generic DataSource, Literal alias) - - 5 migration patterns with before/after code examples - - Honest "can't be shimmed" table (ViewState, PostBack, DataSourceID, etc.) - - DepartmentPortal validation (5/7 drop-in, 2 manual rewrite) - - Test coverage map (40 new tests, 16 test components) - - Upstream issues table linking #490-#496 -- Updated `dev-docs/README.md` with proposals/ table entry - -**Key learnings:** -- Reading actual source to verify API surfaces is critical task description said 48 new tests but actual count is 40 in 4 new test files (plus 23 pre-existing across 3 files) -- Enum counts from source: HtmlTextWriterTag=78, HtmlTextWriterAttribute=55, HtmlTextWriterStyle=77 -- `DataBoundWebControl` design around Blazor's case-insensitive parameter matching is a subtle but important constraint worth documenting prominently -- The placeholder approach (``) for template interleaving is novel and deserves detailed explanation for future contributors - -### Issue #507: Expand Stub Documentation for Validation and Editor Controls - -**Status:** DELIVERED - -**Session (2026-03-17 by Beast):** -- Expanded three stub/incomplete documentation files for validation and editor controls -- **Files Updated:** - 1. **Label.md** (EditorControls) Expanded from minimal content to comprehensive documentation - - Full feature list with all supported Web Forms properties - - Complete "Features NOT Supported" section - - Properties table with types, defaults, and descriptions - - HTML output examples (both with and without AssociatedControlID) - - 6 practical examples: basic label, associated label, styled, tooltip, conditional visibility, border - - Complete "See Also" cross-links - - 2. **ValidationSummary.md** (ValidationControls) Expanded stub with headers-only structure - - Added style properties to feature list (BackColor, BorderColor, etc.) - - Complete Web Forms declarative syntax block - - Properties table with all style properties - - 5 comprehensive examples: basic, with ValidationGroup, display modes, styled, multiple groups - - HTML output examples for BulletList and List modes - - Migration guide with styling considerations - - 3. **RegularExpressionValidator.md** (ValidationControls) Expanded with additional patterns and guidance - - Added 4 new examples: username validation, URL validation, hexadecimal color, ReDoS prevention - - Properties table updated with MatchTimeout in milliseconds (not TimeSpan) - - New section: "Common Regular Expression Patterns" with 8 common use cases - - HTML output example showing valid/invalid rendering - - Enhanced ReDoS prevention tip with concrete timeout value - -- **Pattern Followed:** Modeled each doc after Button.md (EditorControls) and RequiredFieldValidator.md (ValidationControls) - - Each includes: Features, Not Supported, Syntax Comparison, Properties table, Examples, HTML Output, Migration notes, See Also - - Examples progress from simple to complex/real-world use cases - - All code blocks include complete, copy-paste ready examples with @code blocks - -- **Technical Details:** - - Source code inspection: Label.razor, RegularExpressionValidator.cs, AspNetValidationSummary.razor.cs - - Confirmed all properties match component declarations (BaseStyledComponent inheritance for styling) - - MatchTimeout parameter confirmed as int? (milliseconds), not TimeSpan - - ValidationSummary includes stubs for EnableClientScript and ShowMessageBox for migration compatibility - -- **MkDocs Build:** Verified successful build (exit code 0) with no errors or warnings related to these docs - -- **Learnings:** - - Regular expression validators are critical for form validation; examples covering email, phone, postal code, username, URL, color codes help developers quickly understand patterns - - ValidationSummary DisplayMode enum (BulletList, List, SingleParagraph) requires explicit enum syntax unlike Web Forms strings - - The AssociatedControlID property on Label improves accessibility and deserves prominent documentation - - - Team update (2026-03-24): Documentation milestone completed Issues #507-#510 resolved. EditorControls converted to tabbed syntax (32 files), User-Controls.md expanded (+928 lines, 48 examples), ViewState/PostBack shim guide created (477 lines + 2 docs updated), ValidationControls verified complete. All 3 decision documents merged to decisions.md. decided by Beast - -### Issue #512: mkdocs.yml Navigation Audit (AjaxToolkit Extenders) - -**Status:** DELIVERED - -**Session (2026 by Beast):** -- **Audit Goal:** Ensure mkdocs.yml nav accurately reflects ALL docs in docs/ folder; identify orphaned files and broken entries -- **Key Findings:** - 1. ViewStateAndPostBack.md IS in nav (line 179) under Utility Features from issue #508 - 2. User-Controls.md IS in nav (line 192) under Migration - 3. **12 AjaxToolkit extenders were orphaned** documented files not in nav: - - AlwaysVisibleControlExtender.md, BalloonPopupExtender.md, DragPanelExtender.md, DropShadowExtender.md - - ListSearchExtender.md, PasswordStrength.md, ResizableControlExtender.md, RoundedCornersExtender.md - - SlideShowExtender.md, TextBoxWatermarkExtender.md, UpdatePanelAnimationExtender.md, ValidatorCalloutExtender.md - 4. Note: Migration test intermediate outputs (wingtiptoys-run2run6) are not indexed these are build artifacts, not reference docs - -**Fix Applied:** -- Updated mkdocs.yml Ajax Control Toolkit Extenders section (lines 138166) -- Added all 12 missing extenders in alphabetical order -- Maintained consistent YAML indentation and naming conventions -- All 28 AjaxToolkit extenders now properly indexed - -**Files Modified:** -- mkdocs.yml Added 12 extender entries to nav (alphabetically sorted for maintainability) - -**Verification:** -- All orphaned files now have corresponding nav entries -- No broken nav entries (all point to existing .md files) -- YAML syntax validated -- Component docs properly ordered within sections - -**Learnings:** -- AjaxToolkit extenders had grown organically over time but nav had not been updated to match -- Alphabetical ordering within component sections improves discoverability and maintenance -- Periodically auditing docs/ vs mkdocs.yml nav prevents doc fragmentation -- Test artifacts (migration benchmark runs) should be excluded from main nav to reduce clutter only index the summary report, not intermediate outputs - ---- - -## Issue WI-6: Wave 1 Theming Documentation - -**Status:** DELIVERED - -**Session (2026 by Beast):** - -**Task:** Create comprehensive Wave 1 Theming Documentation for the Skins & Themes feature - -**Deliverables:** -1. New file: `docs/themes-and-skins.md` (15,924 characters, 470 lines) - - 9 major sections covering all theming aspects - - 40+ code examples (C# ThemeConfiguration and Blazor HTML) - - Comprehensive comparison tables and API reference - -2. Navigation update: `mkdocs.yml` - - Added `- Themes and Skins: themes-and-skins.md` to main nav (line 67) - - Positioned after "Component Health Dashboard" for discoverability - -**Section Breakdown:** -- **Overview** — Key concepts (ThemeConfiguration, ThemeProvider, ControlSkin, SkinID, ThemeMode) -- **Quick Start** — 3-step minimal example (Define → Apply → Result) -- **Theme Modes** — StyleSheetTheme vs Theme with comparison table and precedence rules -- **Sub-Component Styles** — SubStyle API for GridView/DetailsView/FormView/DataGrid/DataList with counts -- **Migration Guide** — Side-by-side Web Forms .skin files vs Blazor ThemeConfiguration -- **EnableTheming & SkinID** — Opt-out patterns and named skin variants -- **Runtime Theme Switching** — Dynamic theme switching example -- **API Reference** — Complete tables for all types and properties -- **Best Practices** — 6 key recommendations for theme development -- **Troubleshooting** — Q&A for common theming issues - -**Verification:** -- Markdown formatting verified (no syntax errors, proper tables, code blocks) -- All 9 sections properly structured with --- dividers -- Code examples complete and runnable -- Cross-references to existing docs (Migration/ThemesAndSkins.md, StylingComponents.md, GridView.md) -- mkdocs.yml navigation entry confirmed - -**Learnings:** -- Theming documentation must bridge both Web Forms migration (pain point: .skin file conversion) AND Blazor-native usage (ThemeConfiguration builder pattern) -- Comparison tables (StyleSheetTheme vs Theme) are essential for explaining precedence rules; developers need to understand WHY to choose one mode over another -- Named skins (SkinID) are powerful for multi-variant theming semantic names like "Danger", "Success" help developers implement button hierarchies quickly -- Sub-component styling is critical for data controls listing exact counts (GridView: 8, DetailsView: 10, etc.) helps developers discover available styling options -- Positioning Themes & Skins near the top of nav (after dashboard) signals to developers that theming is a primary feature, not an advanced utility -- Runtime theme switching example shows interactivity patterns that Blazor enables over static Web Forms themes - ---- - -## Session 2026-04 (Beast): Phase 1 Migration Documentation - -**Status:** ✅ DELIVERED - -**Task:** Create documentation for Phase 1 "Just Make It Compile" shims (ConfigurationManager, App_Start stubs, L1 script enhancements). - -**Files Created:** -1. `docs/migration/Phase1-ConfigurationManager.md` (2,180 lines) - - Overview, before/after comparison, setup instructions - - How it works (AppSettings precedence, ConnectionStrings mapping) - - web.config → appsettings.json mapping guide - - Configuration precedence hierarchy - - Limitations table with workarounds - - Phase 2 DI migration path - -2. `docs/migration/Phase1-AppStartStubs.md` (2,065 lines) - - Overview (bundling, routing stubs are no-ops) - - Before/after Web Forms → BWFC comparison - - Stub implementation details - - ⚠️ Important callout: stubs do nothing at runtime - - Blazor alternatives (CSS Isolation, @page directives) - - Phase 1 → Phase 2+ transition timeline - - Troubleshooting section - -3. **Updated `docs/migration/readme.md`** - - Added "Phase 1: Just Make It Compile" section after Step 0 - - Phase 1 Features table (ConfigurationManager, App_Start stubs, L1 script) - - Phase 1 Workflow (Scan → L1 → Enable Shims → Compile → Plan Phase 2) - - When to Move to Phase 2 guidance - -4. **Updated `mkdocs.yml` navigation** - - Added "Phase 1 - Compilation Shims" section under Migration - - Nested: ConfigurationManager, App_Start Stubs - - Positioned after "Getting Started" (Discovery) before "Assess" - -**Documentation Style & Standards:** -- ✅ Matched existing BWFC docs style: empathetic tone, developer-centric explanations -- ✅ Before/After code comparisons using === "Web Forms" / === "Blazor" tabs -- ✅ Complete, runnable code examples (not pseudocode) -- ✅ Feature tables for quick reference (Limitations, Alternatives, etc.) -- ✅ Cross-links to related docs (ServiceRegistration.md, JavaScriptSetup.md, Blazor official docs) -- ✅ Clear callouts (⚠️ warnings, ✅ checkmarks, 🔄 transitions) -- ✅ Structured workflows (Phase 1 → Phase 2 progression) - -**Learnings:** -- Phase 1 shim documentation must explicitly state what shims do AND don't do (e.g., "stubs compile but do nothing at runtime") to prevent developer confusion -- Before/After tabs work well for migration docs BUT require clear narrative about why each migration path matters -- Developers migrating legacy apps need reassurance that Phase 1 is a stepping stone, not a permanent solution — Phase 2 paths should be clearly visible -- Configuration precedence (appsettings.json → appsettings.Development.json → env vars) is complex; hierarchy diagrams with arrows help more than prose alone -- Workaround tables (custom sections, encryption) are essential for realistic migrations where 100% Web Forms compatibility isn't possible -- mkdocs.yml navigation nesting signals cognitive importance: placing Phase 1 after "Getting Started" (discovery) tells developers "start here before deeper migration" -- Cross-linking to Azure/AWS/ASP.NET docs increases doc value without duplicating content (readers can self-serve on secrets management, logging, etc.) - - -### CLI Capability Writeup Transform Inventory & Before/After Example (2026-04-03) - -**Task:** Produce a shareable CLI capability summary for the user covering all implemented transforms. - -**Delivered:** Inline chat response (not committed to repo) - -**Contents:** -- Full inventory of all 31 CLI transforms with one-line descriptions, grouped by pipeline stage (Directives, Expressions, Tag Prefixes, Attributes, Normalization, Scaffolding) -- Before/after ASPX Razor example: realistic .aspx fragment (Page directive, MasterPageFile, asp:Button, asp:Label, Eval() binding, runat="server") fully migrated .razor output -- CLI usage synopsis: wfc-migrate migrate --input ./src --output ./out --report migration-report.json -- Coverage callouts: what the tool handles automatically vs. what lands in ManualItem/TODO comments - -**Learnings:** -- Users respond well to before/after examples that use realistic patterns (not toy "Hello World" markup) showing a GridView with DataBind + Eval expressions, a MasterPageFile reference, and event wiring in a single snippet demonstrates breadth of tool coverage immediately -- Transform inventory is most digestible when grouped by pipeline stage (matches mental model of "what gets processed when") rather than alphabetically -- Always mention the --report flag when summarizing CLI capability: the JSON report is the bridge between automated migration and manual follow-up work - ---- - -## Phase 1 ClientScript Migration Documentation (2026-07-30) - -**Task:** Implement Phase 1 deliverables from PRD: Create comprehensive ClientScript migration guide and analyzer reference pages (BWFC022, BWFC023, BWFC024). - -**Status:** ✅ DELIVERED - -**Deliverables:** - -1. **ClientScriptMigrationGuide.md (34.8K, 11 sections)** - - Overview: Why ClientScript patterns differ in Blazor - - Quick Reference Table: 8 major patterns with difficulty ratings - - Detailed Sections with before/after examples: - 1. Startup Scripts (RegisterStartupScript → OnAfterRenderAsync) - 2. Script Includes (RegisterClientScriptInclude → "`) +- **Safe pattern:** `(?:"[^"]*"|[^"])*?` — alternates quoted strings and non-quote chars, handles both issues + +### ClientScriptTransform: Switched to Shim-Preserving Mode (Bishop) + +- **Date:** 2026-07-31 +- **What changed:** `ClientScriptTransform.cs` (Order 850) no longer rewrites ClientScript calls to IJSRuntime skeletons. Instead, it preserves calls for use with `ClientScriptShim`. +- **Shim-compatible patterns (prefix stripping, calls preserved):** + - `Page.ClientScript.RegisterStartupScript(...)` → `ClientScript.RegisterStartupScript(...)` (strip prefix) + - `Page.ClientScript.RegisterClientScriptInclude(...)` → `ClientScript.RegisterClientScriptInclude(...)` (strip prefix) + - `Page.ClientScript.RegisterClientScriptBlock(...)` → `ClientScript.RegisterClientScriptBlock(...)` (strip prefix, shim now supports this) + - `ScriptManager.RegisterStartupScript(control, type, key, script, bool)` → `ClientScript.RegisterStartupScript(type, key, script, bool)` (drops first param) +- **Still TODO-marked (no shim support):** + - `GetPostBackEventReference(...)` → TODO with @onclick/EventCallback guidance (shim throws NotSupportedException) + - `ScriptManager.GetCurrent(...)` → TODO with IJSRuntime guidance (no shim equivalent) +- **Removed:** IJSRuntime `[Inject]` injection logic. Replaced with single-line `ClientScriptShim` dependency comment at class level. +- **Key principle:** Jeff's directive — "Zero-rewrite shim approach is PRECISELY what we should be building." CLI preserves Web Forms API calls instead of rewriting them. +- **Tests:** All 349 tests pass (same count as before), updated 20 unit test assertions + TC33 expected output file. +- **Regex approach:** Single `PageOrThisPrefixRegex` with lookahead handles all three shim-compatible methods in one pass. Much simpler than the old per-pattern regexes with inline script extraction. + +### ClientScriptTransform: Phase 2 — PostBack + ScriptManager Shim-Preserving (Bishop) + +- **Date:** 2026-07-31 +- **What changed:** `ClientScriptTransform.cs` (Order 850) now preserves ALL ClientScript/ScriptManager patterns for shim use. No more TODO markers. +- **Phase 2 patterns (newly shim-preserved):** + - `Page.ClientScript.GetPostBackEventReference(...)` → `ClientScript.GetPostBackEventReference(...)` (prefix stripped) + - `this.ClientScript.GetPostBackEventReference(...)` → `ClientScript.GetPostBackEventReference(...)` (prefix stripped) + - `ScriptManager.GetCurrent(Page)` → `ScriptManager.GetCurrent(this)` (Page→this substitution) + - `ScriptManager.GetCurrent(this.Page)` → `ScriptManager.GetCurrent(this)` (this.Page→this substitution) + - `ScriptManager.GetCurrent(this)` → preserved as-is (already correct) +- **Regex changes:** + - `PageOrThisPrefixRegex` lookahead expanded to include `GetPostBackEventReference` + - Removed `GetPostBackEventRefRegex` and `ScriptManagerGetCurrentRegex` (TODO-emitting regexes) + - Added `ScriptManagerGetCurrentPageRegex` for Page→this substitution +- **Shim comment:** Now conditionally mentions `ScriptManagerShim` when ScriptManager patterns detected (dual-shim comment) +- **Tests:** 353 total (was 349) — 4 new test cases for Phase 2 patterns, updated 6 existing assertions +- **Key design decision:** `hasScriptManagerCall` flag tracks ScriptManager presence separately from `hasShimCall` to conditionally generate the dual-shim comment diff --git a/.squad/agents/bishop/history.md b/.squad/agents/bishop/history.md index 9d9d55e49..4d34995c6 100644 --- a/.squad/agents/bishop/history.md +++ b/.squad/agents/bishop/history.md @@ -1,340 +1,70 @@ -# Bishop - Migration Tooling Dev History +# Project Context -## Role -Bishop is the Migration Tooling Dev on the BlazorWebFormsComponents project, responsible for building migration tools and utilities that help developers move from ASP.NET Web Forms to Blazor. +- **Owner:** Jeffrey T. Fritz +- **Project:** BlazorWebFormsComponents — Blazor components emulating ASP.NET Web Forms controls for migration +- **Stack:** C#, Blazor, .NET, ASP.NET Web Forms, bUnit, xUnit, MkDocs, Playwright +- **Created:** 2026-02-10 -## Project Context -BlazorWebFormsComponents is a library providing Blazor components that emulate ASP.NET Web Forms controls, enabling migration with minimal markup changes. The project aims to preserve the same component names, attributes, and HTML output as the original Web Forms controls. +## Project Learnings (from import) -## Core Historical Context +- The migration-toolkit lives at `migration-toolkit/` and contains: scripts/, skills/, METHODOLOGY.md, CHECKLIST.md, CONTROL-COVERAGE.md, QUICKSTART.md, README.md, copilot-instructions-template.md +- Primary migration script: `migration-toolkit/scripts/bwfc-migrate.ps1` — handles Layer 1 (automated transform) +- Layer 2 (agent-driven implementation) is where BWFC control replacement failures occur — agents replace asp: controls with plain HTML +- Test-BwfcControlPreservation in bwfc-migrate.ps1 validates that BWFC controls are preserved post-transform +- Test-UnconvertiblePage uses path-based patterns (Checkout\, Account\) and content patterns (SignInManager, UserManager, etc.) +- Sample migration targets: WingtipToys (before/after samples in samples/ directory) +- Run7 is the current gold standard: `samples/Run7WingtipToys/` +- The BWFC library has 110+ components covering Web Forms controls +- Migration must preserve all asp: controls as BWFC components — never flatten to raw HTML -**2025-01-26 to 2026-03-29:** Built foundational migration infrastructure: -- WI-8 (2025-01-26): .skin file parser + SkinFileParser.cs runtime parser for theme migration -- Theme Migration SKILL.md (2025-01-27): Documented auto-discovery pattern (copy → SkinFileParser → ThemeProvider) -- Migration Automation Audit (2026-07-25): Identified 23 gaps, proposed solutions across 3 phases -- Phase 1 L1 Script Enhancements (2026-07-25): Implemented 6 GAPs (Web.config→appsettings, IsPostBack unwrap, App_Start copy, selective using retention, URL cleanup, Bind()→@bind) -- Phase 2 Lifecycle & Event Handlers (2026-03-29): Added Page_Load→OnInitializedAsync, event handler signature transforms -- Global Tool Pipeline + 16 Markup Transforms (2026-07-27): Built complete MigrationPipeline, 16 markup transforms ported from PowerShell script -- MasterPageTransform + GetRouteUrlTransform + TC12–TC23 (2026-04-03): Added MasterPage directive rewriting, GetRouteUrl conversion, 12 new acceptance tests +## Core Context -## Learnings + -📌 Team update (2026-04-12): All migration transforms pipeline infrastructure complete — 17 transforms (added 3: ConfigurationManager, RequestForm, ServerShim), 373/373 tests passing, expected files regenerated. WingtipToys analysis shows WebFormsPageBase enables 31 pages to eliminate manual shim wiring. — decided by Psylocke, Forge, Bishop +**Run 9-11 benchmark progression:** Build attempts 7→3→4, Layer 2 time 45→25→20 min, BWFC instances 173→172→178, preservation 98.9%→92.7%→98.9%. All runs 0 errors, 0 warnings. 28 routable pages, ~32 .razor, ~79 wwwroot throughout. -📌 Team update (2026-04-12): CLI tool references were added by Coordinator to all 4 migration-toolkit docs missing them (METHODOLOGY.md, CHECKLIST.md, README.md), ensuring consistent tool naming and linking across all migration-toolkit documentation. — decided by Coordinator +**Cycle 1 fixes (5 items):** P0-1 ItemType→TItem regex (only list controls get TItem, data controls retain ItemType). P0-2 smart stubs (all markup gets L1 transforms, only code-behinds stubbed). P0-3 base class stripping (`: Page`/`: UserControl`/`: MasterPage` removed). P1-1 validator type params (Type="string"/InputType="string" auto-injected). P1-4 ImageButton warning in Test-BwfcControlPreservation. -### Shim Inventory & CLI Transform Update (2026-04-12) +**Cycle 2 fixes (6 items):** Convert-EnumAttributes (TextMode→TextBoxMode, Display→@ValidatorDisplay, GridLines→@GridLines). Boolean normalization (True/False → true/false). ControlToValidate/ValidationGroup stripping. ImageButton detection escalated to FAIL. Server-side expression cleanup (Request/Session/Server wrapped in TODO comments). Remove-ItemTypeWithDataSource (strips ItemType/TItem when SelectMethod present). -**Task**: Build a runtime parser that reads ASP.NET Web Forms .skin files and converts them into ThemeConfiguration objects. +**Key Layer 2 patterns:** `@inherits WebFormsPageBase` conflicts with `: ComponentBase` — must remove explicit base. Layout files need `: LayoutComponentBase`. `AddHttpContextAccessor()` before `AddBlazorWebFormsComponents()`. Stub model pattern for unavailable types (UserLoginInfo, OrderShipInfo). Stub page cleanup ~60% of L2 effort. -**Implementation Details**: -- Created `SkinFileParser.cs` with three public methods: - - `ParseSkinFile(string, ThemeConfiguration)` - parses .skin content from string - - `ParseSkinFileFromPath(string, ThemeConfiguration)` - parses single .skin file from disk - - `ParseThemeFolder(string, ThemeConfiguration)` - parses all .skin files in a directory - -- Parsing approach: - - Strip ASP.NET comments (`<%-- ... --%>`) - - Wrap content in root element and replace `()` for enum values - - Font attributes: special handling for `Font-Bold`, `Font-Italic`, `Font-Size`, etc. +### 2026-04-28: First isolated semantic catalog entries (Bishop) -- Sub-styles: Nested elements like ``, `` become entries in `ControlSkin.SubStyles` dictionary as `TableItemStyle` objects +- Added `QueryDetailsSemanticPattern` and `ActionPagesSemanticPattern` under `src/BlazorWebFormsComponents.Cli/SemanticPatterns/` as the first real catalog entries on the isolated semantic runtime. +- `pattern-query-details` matches post-transform pages with exactly one `SelectMethod="..."` plus `[QueryString]` / `[RouteData]` parameters in the referenced code-behind method. It rewrites `SelectMethod` → `SelectItems`, emits `[SupplyParameterFromQuery]` / `[Parameter]` component properties, and leaves an explicit `TODO(bwfc-query-details)` stub that points developers at the quarantined code-behind artifact. +- `pattern-action-pages` matches inert action-only pages (for example `AddToCart.aspx`) with deterministic query-string inputs and one redirect target. It rewrites the output to a visible SSR handler scaffold with query-bound properties, `NavigationManager` injection, redirect target capture, and `TODO(bwfc-action-pages)` guidance instead of blank migrated HTML. +- Production wiring now registers semantic patterns via DI in `Program.cs`, and `MigrationPipeline` is created through an explicit factory to avoid constructor ambiguity between the full DI constructor and the lightweight transform-only constructor. +- Regression coverage lives in `tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternCatalogTests.cs` and `tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs`. Verified commands: `dotnet test .\tests\BlazorWebFormsComponents.Cli.Tests\BlazorWebFormsComponents.Cli.Tests.csproj --no-restore` and `dotnet run --project .\src\BlazorWebFormsComponents.Cli --no-build -- migrate --input ... --output ... --skip-scaffold --overwrite`. -- Error handling: Defensive parsing with try-catch blocks and console warnings, never throws on parse errors -**Key Technical Decisions**: -1. Used XML parsing after preprocessing rather than custom parser - leverages proven XML infrastructure -2. Case-insensitive attribute and control name matching for robustness -3. Silently ignore unknown attributes to handle variations in .skin files -4. Console.WriteLine for warnings rather than throwing exceptions - allows partial parsing success +### 2026-04-28: Semantic Pattern Infrastructure Sprint - All Agents -**Build Status**: ✅ Successfully builds with no errors +**Task:** Complete semantic pattern infrastructure for BlazorWebFormsComponents semantic pattern catalog. -**Verification**: ✅ Tested with sample .skin content: -- Successfully parsed default Button skin with colors and font properties -- Successfully parsed named Button skin (SkinID="DangerButton") -- Successfully parsed GridView with nested HeaderStyle and RowStyle sub-components -- All color conversions, font attributes, and sub-styles worked correctly +**Bishop:** +- Implemented pattern-query-details and pattern-action-pages infrastructure +- Wired production and test registration for all patterns +- Added isolated and pipeline regression tests -### Web Forms Theme Migration SKILL.md (2025-01-27) +**Cyclops:** +- Implemented pattern-account-pages infrastructure +- Implemented pattern-master-content-contracts with helper logic +- Added focused concrete tests -**Task**: Write a SKILL.md that teaches Copilot and Squad agents how to migrate Web Forms themes to Blazor using BWFC auto-discovery. +**Forge:** +- Performed comprehensive reviewer safety pass +- Approved bounded semantics and manual TODO boundaries +- Special review of authentication and master/content section patterns -**Delivered**: -- Created `.squad/skills/theme-migration/SKILL.md` as authoritative reference for theme migration pattern -- Documented Web Forms theme structure (App_Themes/ folder with .skin, .css, images) -- Explained auto-discovery flow: copy → `AddBlazorWebFormsComponents()` → `SkinFileParser` → `ThemeProvider` injection -- Covered key concepts: - - Theme folder identification and copy operation (preserve structure) - - Default theme selection (first folder alphabetically) - - CSS auto-discovery and injection via ThemeProvider - - Named skins (SkinID parameter requirement in Blazor) - - ThemeMode (StyleSheetTheme default vs. Theme override mode) - - Multiple themes support and custom ThemesPath configuration -- Provided 3 detailed examples (simple, multiple themes, named skins) -- Documented edge cases (no themes, CSS-only themes, custom paths) -- Included anti-patterns with do's and don'ts (manual registration, missing ThemeProvider, image handling) +**Rogue:** +- QA audit identified missing default registration gap +- Recommended helper and integration test coverage +- Re-check confirmed gap was resolved by Bishop -**Why This Matters**: This SKILL.md replaces the need for agent-specific migration scripts. Any agent (Copilot, Cyclops, Rogue, or future Squad members) doing Web Forms migration now has a standardized reference explaining the theme pattern. No more tribal knowledge — the pattern is documented, discoverable, and reusable across projects. +**Coordinator:** +- Executed full test suite: 486 passed, 0 failed +- Verified all tests passing before archival -**Key Principle**: The SKILL teaches the "what" and "why" but delegates implementation details (SkinFileParser internals, ThemeProvider rendering) to library code. This keeps the SKILL maintainable as the implementation evolves. - -### Migration Automation Audit (2026-07-25) - -**Task**: Audit bwfc-migrate.ps1 (2,714 lines), all BWFC shims, and migration test results to identify manual gaps and propose automation solutions. - -**Key Findings**: -- L1 script handles ~60% of migration mechanically (22 categories of transforms) -- BWFC library provides 20+ shims (WebFormsPageBase, ResponseShim, RequestShim, ViewStateDictionary, Handler framework, Middleware, Identity stubs, EF6 stubs) -- Identified 23 automation gaps with proposed solutions -- 9 quick wins shippable in single PRs (ConfigurationManager shim, Web.config→appsettings.json, IsPostBack unwrap, App_Themes auto-copy, BundleConfig stubs, etc.) -- Top 3 "sneaky wins": ConfigurationManager.AppSettings shim, Session["key"] shim, Web.config→appsettings.json extraction -- Missing shims: ConfigurationManager, HttpContext.Current, FormsAuthentication, Session (on WebFormsPageBase), BundleConfig/RouteConfig, Server.MapPath (outside handlers) -- Missing script transforms: Page_Load→OnInitializedAsync, event handler signatures, Bind() expressions, Global.asax extraction, Startup.cs/OWIN parsing -- WingtipToys Run 7: 14/14 tests pass, 366 transforms, 55 L2 files touched -- ContosoUniversity Run 22: 39/40 tests pass, EDMX parser handles all models - -**Deliverable**: `dev-docs/migration-automation-opportunities.md` — structured report with gap inventory, proposed solutions, complexity estimates, and implementation order across 3 phases. - -### Phase 1 L1 Script Enhancements (2026-07-25) - -**Task**: Implement 6 GAP items from the migration automation audit as Phase 1 "Just Make It Compile" enhancements to `bwfc-migrate.ps1`. - -**Implementation Details**: - -- **GAP-12 (Web.config → appsettings.json)**: New `Convert-WebConfigToAppSettings` function. Parses `` and `` via XML, generates proper JSON structure, merges with existing appsettings.json. Placed in pipeline after scaffold generation, before code-behind copy. Skips built-in connection strings (LocalSqlServer). - -- **GAP-06 (IsPostBack Guard Unwrapping)**: New `Remove-IsPostBackGuards` function with iterative brace-counting approach. Handles 6 pattern variants (`!IsPostBack`, `!Page.IsPostBack`, `!this.IsPostBack`, `== false` forms). Simple guards unwrapped with comment; complex guards (else clause) get TODO annotation. Max 50 iterations safety limit. - -- **GAP-22 (App_Start/ Directory Copy)**: New `Copy-AppStart` function. Copies .cs files to output root (not App_Start/ subfolder). Strips `[assembly:]` attributes via multiline regex. Applies same selective using retention as code-behind. Flags WebApiConfig and FilterConfig for manual review. - -- **GAP-09 (Selective Using Retention)**: Restructured Copy-CodeBehind using-stripping to preserve `System.Configuration`, `System.Web.Optimization`, `System.Web.Routing` as comments with BWFC equivalence notes. Then strips in specific order: UI → Security → remaining Web → AspNet → Owin. Prevents false-positive compile errors. - -- **GAP-20 (.aspx URL Cleanup)**: 4-pattern transform in Copy-CodeBehind: tilde+query+aspx, tilde+aspx, relative+query in NavigateTo, relative in NavigateTo. Converts `"~/Page.aspx?q=v"` → `"/Page?q=v"`. - -- **GAP-13 (Bind() → @bind Transform)**: Added to `ConvertFrom-Expressions` before Eval() transforms. Attribute-value Bind() → `@bind-Value="context.Prop"`. Standalone Bind() → `@context.Prop`. Handles both single-quoted and double-quoted attribute values. - -**Key Technical Decisions**: -1. IsPostBack uses iterative brace-counting rather than regex for brace matching — regex can't reliably match nested braces -2. Using retention runs BEFORE blanket stripping — retained usings are converted to commented-out forms with BWFC notes so developers understand the equivalence -3. Bind() transforms run before Eval() in expression pipeline to avoid conflicts (Bind is more specific) -4. App_Start files go to output root, not a subdirectory — Blazor has no App_Start convention -5. URL cleanup handles NavigateTo context specifically to avoid false positives in non-URL strings - -**Validation**: Script parses cleanly, WhatIf mode works, existing 15-test suite maintains 12/15 pass rate (3 pre-existing base-class-stripping failures unchanged). Script grew from 2,714 to ~3,000 lines. - - -## Phase 2: GAP-05 + GAP-07 Lifecycle and Event Handler Transforms (2026-03-29) - -### GAP-05: Page Lifecycle Method Transform -Added `Convert-PageLifecycleMethods` function to bwfc-migrate.ps1: -- `Page_Load(object sender, EventArgs e)` `protected override async Task OnInitializedAsync()` with `await base.OnInitializedAsync();` injection -- `Page_Init(object sender, EventArgs e)` `protected override void OnInitialized()` -- `Page_PreRender(object sender, EventArgs e)` `protected override async Task OnAfterRenderAsync(bool firstRender)` with `if (firstRender)` body guard -- Case-insensitive method name matching, handles all access modifier combinations -- TODO comments injected for developer review - -### GAP-07: Event Handler Signature Transform CRITICAL -Added `Convert-EventHandlerSignatures` function to bwfc-migrate.ps1: -- Standard `EventArgs` strip both params: `Handler(object sender, EventArgs e)` `Handler()` -- Specialized `*EventArgs` subtypes strip sender only: `Handler(object sender, GridViewCommandEventArgs e)` `Handler(GridViewCommandEventArgs e)` -- Decision logic: exact match on "EventArgs" type name determines strip-both vs keep-specialized -- Regex anchored to `\w*EventArgs` to avoid false positives on non-EventArgs parameter types -- Iterative processing handles multiple handlers per file (up to 200 safety limit) -- Access modifiers and async keywords preserved as-is - -### Pipeline integration -Both functions called in `Copy-CodeBehind` after existing transforms, before file write: -1. Strip usings (existing) 2. IsPostBack unwrap (existing) 3. URL cleanup (existing) 4. Lifecycle convert (GAP-05) 5. Event handler signatures (GAP-07) 6. Write file - -### Test results -Updated 6 expected test files (TC13, TC14, TC15, TC16, TC18, TC19) to reflect new transforms. -TC19 (lifecycle) and TC20/TC21 (event handlers) are dedicated test cases for these features. -**All 21 tests pass at 100% line accuracy.** - - -### Global Tool Pipeline Infrastructure + First 16 Markup Transforms (2026-07-27) - -**Task**: Build the C# global tool pipeline from the architecture doc (`dev-docs/global-tool-architecture.md`), replacing the PR #328 single-converter approach with the full sequential pipeline. - -**Implementation Details**: - -- **Pipeline Infrastructure**: Created `MigrationPipeline.cs` (orchestrates IMarkupTransform + ICodeBehindTransform chains, sorted by Order), `MigrationContext.cs` (per-file + project state), `FileMetadata.cs` (per-file metadata with FileType enum), `TransformResult.cs` (immutable step result), `MigrationReport.cs` (summary metrics). - -- **Transform Interfaces**: `IMarkupTransform` and `ICodeBehindTransform` with Name, Order, Apply(content, metadata) contract. All transforms are DI-registered singletons sorted by Order at pipeline construction time. - -- **SourceScanner**: Discovers .aspx/.ascx/.master files, pairs with .cs/.vb code-behind, generates output paths with .razor extension. - -- **16 Markup Transforms** (all regex patterns ported exactly from bwfc-migrate.ps1): - - Directives (100-210): PageDirective, MasterDirective, ControlDirective, ImportDirective, RegisterDirective - - Content/Form (300-310): ContentWrapper, FormWrapper - - Expressions (500): ExpressionTransform (comments, Bind(), Eval(), Item., encoded/unencoded) - - Tag Prefixes (600-610): AjaxToolkitPrefix, AspPrefix (+ ContentTemplate stripping, uc: prefix) - - Attributes (700-720): AttributeStrip (runat, AutoEventWireup, etc. + ItemTypeTItem + IDid + ItemType="object" fallback), EventWiring, UrlReference - - Normalization (800-820): TemplatePlaceholder, AttributeNormalize (booleans, enums, px units), DataSourceId - -- **CLI Subcommands**: Replaced single root command with `migrate` (full project) and `convert` (single file) subcommands per architecture doc. Options: --input, --output, --skip-scaffold, --dry-run, --verbose, --overwrite, --use-ai, --report. - -- **Deleted**: AscxToRazorConverter.cs (replaced by pipeline + transforms). - -- **PackageId**: Changed from `WebformsToBlazor.Cli` to `Fritz.WebFormsToBlazor`. - -**Validation**: All 12 test cases (TC01-TC12) produce exact expected output. Zero build errors. - -**Key Learnings**: -1. Order of transforms matters critically AjaxToolkitPrefix (600) MUST run before AspPrefix (610) to avoid treating `ajaxToolkit:` controls as `asp:` controls. -2. AttributeStrip's ItemType="object" fallback injects BEFORE other attributes in the tag, matching the PS script's behavior and test expectations. -3. Expression transforms must be ordered: Bind() before Eval() before encoded/unencoded, with comments first. -4. DataSourceId transform runs last (820) because it matches bare control names (asp: prefix already stripped). -5. ContentWrapperTransform strips asp:Content open+close tags using horizontal-whitespace-only patterns to avoid consuming indentation on the next line. - -### MasterPageTransform + GetRouteUrlTransform + ManualItem + TC12–TC23 (2026-04-03) - -**Commit:** `6824cbdc` on `feature/global-tool-port` — 41 files - -**MasterPageTransform:** Rewrites `<%@ MasterPageFile="~/Site.Master" %>` directive into Blazor `@layout SiteMaster` reference. Handles filename → class name conversion (strip extension, PascalCase). - -**GetRouteUrlTransform:** Converts `GetRouteUrl("routeName", new { key = val })` calls to Blazor `NavigationManager.GetUriWithQueryParameters()` equivalents. Registered in pipeline at Order 750 (after UrlReference, before normalization). - -**ManualItem model:** Structured record for migration report entries flagged for manual developer review. Fields: `Category` (slug), `File`, `Line`, `Message`, `Severity`. Enables typed JSON output in migration report rather than raw strings. - -**TC12–TC23 test data:** 12 new acceptance test input/expected-output pairs covering: MasterPage directives, GetRouteUrl calls, ManualItem annotation injection, ViewState attributes, ContentPlaceHolder, LoginView, Cache directives, SelectMethod, ValidationSummary, and mixed-transform scenarios. - -**Key learnings:** -1. Master page filename → layout class name conversion must strip `~/`, directory path, and `.Master` extension, then PascalCase — a single regex replacement handles the common case but a helper method is cleaner for edge cases (spaces, hyphens). -2. GetRouteUrl route-parameter extraction uses named capture groups to map `new { k = v }` anonymous objects; iteration order of anonymous-object properties must be preserved (C# doesn't guarantee it at runtime, but test data uses single-param routes to avoid ordering issues). -3. ManualItem severity enum (`Info`, `Warning`, `Error`) maps to exit code: any `Error`-level item causes non-zero CLI exit, enabling CI gate integration. - -📌 Team update (2026-04-12): All migration transforms pipeline infrastructure complete — 17 transforms (added 3: ConfigurationManager, RequestForm, ServerShim), 373/373 tests passing, expected files regenerated. WingtipToys analysis shows WebFormsPageBase enables 31 pages to eliminate manual shim wiring. — decided by Psylocke, Forge, Bishop - -### Shim Inventory & CLI Transform Update (2026-04-12) - -**Shims Identified (14 total)**: -- FormShim, ClientScriptShim, ResponseShim, RequestShim, SessionShim, ServerShim, CacheShim, ScriptManagerShim — auto-wired via WebFormsPageBase DI -- ConfigurationManager — static shim bridging `AppSettings`/`ConnectionStrings` to ASP.NET Core `IConfiguration` -- WebFormsPageBase — ComponentBase subclass with shim properties -- BundleConfig, RouteConfig — startup compatibility helpers -- PostBackEventArgs, FormSubmitEventArgs — event models for `` - -**New CLI Transforms Created**: -1. **ConfigurationManagerTransform** (Order 110) — strips `using System.Configuration;` (BWFC shim replaces it), detects `AppSettings`/`ConnectionStrings` usage, emits guidance block -2. **RequestFormTransform** (Order 320) — detects `Request.Form["key"]` patterns, emits FormShim + `` guidance -3. **ServerShimTransform** (Order 330) — detects `Server.MapPath()`, `Server.HtmlEncode()`, `Server.UrlEncode()`, `Server.UrlDecode()`, emits ServerShim guidance - -**Updated Files**: -- `TodoHeaderTransform`: header now references all 14 shims with tagged TODO markers -- `bwfc-migrate.ps1`: added BWFC015-018 scan patterns for Server utility, ConfigurationManager, ClientScript, Cache access -- `CONTROL-COVERAGE.md`: added "Migration Shims (14)" section -- `TestHelpers.cs`: registered 3 new transforms in test pipeline -- All 13 expected `.razor.cs` test files regenerated - -**Key Learnings**: -1. New transforms must be registered in BOTH `Program.cs` (DI) AND `TestHelpers.CreateDefaultPipeline()` — forgetting the test pipeline causes L1 integration test failures -2. Transforms are sorted by `Order` property in `MigrationPipeline`, so list order in registration doesn't matter -3. When changing the TodoHeader, ALL 13 code-behind expected files must be regenerated — use a temp console project referencing the CLI to regenerate via the actual pipeline -4. `UsingStripTransform` already handles `System.Web.Optimization` and `System.Web.Routing` via its `WebUsingsRegex` pattern — no extra work needed for BundleConfig/RouteConfig namespaces -5. "Guidance-only" transforms (detect + TODO comment) are the right pattern when shims make original code compile unchanged on WebFormsPageBase - -### Shim-First Documentation Update (2026-04-13) - -**Task**: Update all 5 migration-toolkit docs (METHODOLOGY.md, CHECKLIST.md, QUICKSTART.md, README.md, CONTROL-COVERAGE.md) to reflect the "shim-first" migration paradigm. - -**Key Changes**: -- Pipeline coverage percentages updated: L1 ~60% (was ~40%), L2 ~30% (was ~45%), L3 ~10% (was ~15%) -- METHODOLOGY.md: Added "Shim Infrastructure" subsection to Layer 1, "What Shims Handle Automatically" table to Layer 2, "Shim Path vs. Native Blazor Path" comparison box, removed Session from L3 decisions -- CHECKLIST.md: Added L1 shim setup items (AddBlazorWebFormsComponents, @inherits, WebFormsForm), marked Response.Redirect/Session/IsPostBack/Page.Title/Request.QueryString/Cache as "✅ works AS-IS", added "Optional: Refactor to Native Blazor" section -- QUICKSTART.md: Added shim callout in Step 4 and Step 6, updated transform table removing shim-handled items, added WebFormsForm guidance -- README.md: Updated coverage percentages, time estimates, added shim bullet in Quick Overview -- CONTROL-COVERAGE.md: Added "Infrastructure & Shim Components" section with full 15-row table, updated supporting component count to 96 (total 154) - -**Key Decisions**: -1. Session["key"] moved from Layer 3 architecture decision to "works AS-IS" — SessionShim provides in-memory dictionary. Persistent/distributed session is still an architecture decision but basic usage compiles unchanged. -2. Response.Redirect removed from Layer 2 manual transforms — ResponseShim handles it automatically including ~/prefix and .aspx stripping. -3. Added "Optional: Refactor to Native Blazor" as a post-verification section in the checklist — acknowledges shims are a valid long-term choice, not just a crutch. -4. Time estimates reduced: Layer 2 with Copilot from 2-4 hours to 1-3 hours reflecting reduced manual work. - -**Files Modified**: migration-toolkit/METHODOLOGY.md, CHECKLIST.md, QUICKSTART.md, README.md, CONTROL-COVERAGE.md -## Learnings - -### MethodNameCollisionTransform Implementation (2026-04-13) - -**Task**: Created MethodNameCollisionTransform to resolve CS0542 compiler errors when methods have the same name as their enclosing class (e.g., class Forgot with method void Forgot()). - -**Architecture: MarkupContent Bridge Pattern**: -1. Modified FileMetadata.cs to add MarkupContent property a nullable string that code-behind transforms can read and modify -2. Modified MigrationPipeline.ProcessSourceFileAsync to set metadata.MarkupContent = markup AFTER markup transforms, BEFORE code-behind transforms -3. Pipeline now writes metadata.MarkupContent ?? markup as final markup, allowing code-behind transforms to retroactively update markup -4. This solves a fundamental problem: code-behind transforms run AFTER markup transforms, so they couldn't update markup references... until now - -**Transform Logic** (Order 215, after ClassNameAlignTransform at 210): -1. Extract class name from partial class (\w+) pattern -2. Find methods matching class name with return types (void/Task/Task/async variants) excludes constructors -3. Rename method to On{ClassName} in code-behind (signature + internal calls) -4. Update metadata.MarkupContent replace "@{ClassName}" with "@On{ClassName}" in markup attribute values - -**Test Coverage** (9 tests, all passing): -- Method collision renamed (void Forgot() void OnForgot()) -- Markup content updated (OnClick="@Forgot" OnClick="@OnForgot") -- Non-colliding methods unchanged -- No class name found unchanged -- Async Task methods handled -- Constructors NOT renamed (only methods with return types) -- Task generic return types supported -- Transform order is 215 -- Internal method calls updated (this.Forgot() this.OnForgot()) - -**Key Design Decisions**: -1. MarkupContent is optional transforms that don't need cross-layer updates don't set/read it -2. Pipeline uses ?? markup fallback to preserve backward compatibility if no code-behind transform modifies MarkupContent -3. Transform only replaces in attribute value contexts ("@ClassName") to avoid false positives in text content -4. Constructor detection: look for return type (void/Task) to distinguish from parameterless constructors (no return type) -5. DirectCall pattern has context-aware logic to avoid replacing class declaration occurrences - -**Files Modified**: -- src/BlazorWebFormsComponents.Cli/Pipeline/FileMetadata.cs added MarkupContent property -- src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs set MarkupContent, use finalMarkup -- src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/MethodNameCollisionTransform.cs new transform -- src/BlazorWebFormsComponents.Cli/Program.cs registered transform (after ClassNameAlignTransform) -- ests/BlazorWebFormsComponents.Cli.Tests/TestHelpers.cs registered in test pipeline -- ests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/MethodNameCollisionTransformTests.cs 9 unit tests - -**Impact**: Fixes Forgot.aspx scenario where ClassNameAlignTransform renames class to match filename, creating name collision with existing method. - - -## IdentityUsingTransform Creation (2026-04-13) - -**Task**: Create IdentityUsingTransform that conditionally adds 'using BlazorWebFormsComponents.Identity;' to code-behind files that reference BWFC Identity shim types, replacing the global using that was removed from the .targets file. - -**Implementation**: -- **Transform**: src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/IdentityUsingTransform.cs - - Order 103 (after UsingStripTransform at 100, before EntityFrameworkTransform at 105) - - Detects BWFC-specific types: ApplicationUserManager, ApplicationSignInManager, SignInStatus, IdentityDbContext, DefaultAuthenticationTypes - - Also detects IdentityUser, IdentityResult, UserLoginInfo BUT only adds BWFC using if they're NOT fully-qualified as Microsoft.AspNetCore.Identity.* prevents collision with direct ASP.NET Core Identity usage - - Fully-qualified check prevents false positives when code uses actual ASP.NET Core Identity types - - Inserts using after last existing using directive (same pattern as EntityFrameworkTransform) -- **Registration**: Added to both Program.cs DI container and TestHelpers.CreateDefaultPipeline() test pipeline (between UsingStrip and EntityFramework) -- **Tests**: tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/IdentityUsingTransformTests.cs with 19 test cases covering all BWFC shim types, duplicate prevention, fully-qualified exclusion, and insertion behavior - -**Key Learnings**: -1. Must use 'var' instead of explicit types ('bool', 'int') for local variables enforced by IDE0007 analyzer in this codebase -2. Multi-line fluent calls like Regex.Matches(...).Count trigger the explicit type checker split into two lines with intermediate 'var matches' to satisfy the analyzer -3. Fully-qualified type detection is critical can't just look for "IdentityUser" in content, must exclude Microsoft.AspNetCore.Identity.IdentityUser references to avoid injecting BWFC using when code uses ASP.NET Core Identity directly -4. Transform order matters: UsingStrip (100) removes old usings IdentityUsing (103) adds BWFC Identity using EntityFramework (105) adds EF Core using ensures clean using block with only needed BWFC and modern namespaces - -**Tests**: All 19 tests passed (file-level type detection, duplicate prevention, fully-qualified exclusion, insertion behavior, Order/Name properties) - -**Files Created**: -- src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/IdentityUsingTransform.cs -- tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/IdentityUsingTransformTests.cs - -**Files Modified**: -- src/BlazorWebFormsComponents.Cli/Program.cs (DI registration) -- tests/BlazorWebFormsComponents.Cli.Tests/TestHelpers.cs (test pipeline registration) +**Outcome:** All semantic pattern contracts approved and production-ready. \ No newline at end of file diff --git a/.squad/agents/colossus/charter.md b/.squad/agents/colossus/charter.md index bb5c1dd90..c8c4c0221 100644 --- a/.squad/agents/colossus/charter.md +++ b/.squad/agents/colossus/charter.md @@ -1,4 +1,4 @@ -# Colossus — Integration Test Engineer +# Colossus ΓÇö Integration Test Engineer > The steel wall. Every sample page gets a Playwright test. No exceptions. @@ -20,9 +20,9 @@ **Every sample page gets an integration test.** This is non-negotiable. The test matrix is: -1. **Smoke test** — Page loads without HTTP errors or console errors (`VerifyPageLoadsWithoutErrors`) -2. **Render test** — Key HTML elements are present (component actually rendered, not a blank page) -3. **Interaction test** — If the sample has interactive elements (buttons, forms, toggles), verify they work +1. **Smoke test** ΓÇö Page loads without HTTP errors or console errors (`VerifyPageLoadsWithoutErrors`) +2. **Render test** ΓÇö Key HTML elements are present (component actually rendered, not a blank page) +3. **Interaction test** ΓÇö If the sample has interactive elements (buttons, forms, toggles), verify they work ## How I Work @@ -30,17 +30,17 @@ Tests live in `samples/AfterBlazorServerSide.Tests/` and follow this structure: -- **`ControlSampleTests.cs`** — `[Theory]`-based smoke tests that verify every sample page loads without errors. Organized by category (Editor, Data, Navigation, Validation, Login). New sample pages are added as `[InlineData]` entries. -- **`InteractiveComponentTests.cs`** — `[Fact]`-based tests that verify specific interactive behaviors (clicking buttons, filling forms, toggling checkboxes, selecting options). -- **`HomePageTests.cs`** — Home page and navigation tests. +- **`ControlSampleTests.cs`** ΓÇö `[Theory]`-based smoke tests that verify every sample page loads without errors. Organized by category (Editor, Data, Navigation, Validation, Login). New sample pages are added as `[InlineData]` entries. +- **`InteractiveComponentTests.cs`** ΓÇö `[Fact]`-based tests that verify specific interactive behaviors (clicking buttons, filling forms, toggling checkboxes, selecting options). +- **`HomePageTests.cs`** ΓÇö Home page and navigation tests. ### Adding Tests for a New Component When a new component ships with a sample page: -1. **Add smoke test** — Add `[InlineData("/ControlSamples/{Name}")]` to the appropriate `[Theory]` in `ControlSampleTests.cs` -2. **Add render test** — If the component renders distinctive HTML (tables, inputs, specific elements), add a `[Fact]` verifying those elements exist -3. **Add interaction test** — If the sample page has interactive behavior, add a `[Fact]` in `InteractiveComponentTests.cs` testing that behavior +1. **Add smoke test** ΓÇö Add `[InlineData("/ControlSamples/{Name}")]` to the appropriate `[Theory]` in `ControlSampleTests.cs` +2. **Add render test** ΓÇö If the component renders distinctive HTML (tables, inputs, specific elements), add a `[Fact]` verifying those elements exist +3. **Add interaction test** ΓÇö If the sample page has interactive behavior, add a `[Fact]` in `InteractiveComponentTests.cs` testing that behavior ### Test Patterns @@ -88,12 +88,12 @@ I periodically audit all sample pages in `samples/AfterBlazorServerSide/Componen ## Collaboration -Before starting work, run `git rev-parse --show-toplevel` to find the repo root, or use the `TEAM ROOT` provided in the spawn prompt. All `.ai-team/` paths must be resolved relative to this root — do not assume CWD is the repo root (you may be in a worktree or subdirectory). +Before starting work, run `git rev-parse --show-toplevel` to find the repo root, or use the `TEAM ROOT` provided in the spawn prompt. All `.squad/` paths must be resolved relative to this root ΓÇö do not assume CWD is the repo root (you may be in a worktree or subdirectory). -Before starting work, read `.ai-team/decisions.md` for team decisions that affect me. -After making a decision others should know, write it to `.ai-team/decisions/inbox/colossus-{brief-slug}.md` — the Scribe will merge it. -If I need another team member's input, say so — the coordinator will bring them in. +Before starting work, read `.squad/decisions.md` for team decisions that affect me. +After making a decision others should know, write it to `.squad/decisions/inbox/colossus-{brief-slug}.md` ΓÇö the Scribe will merge it. +If I need another team member's input, say so ΓÇö the coordinator will bring them in. ## Voice -Steady and immovable. Believes integration tests are the last line of defense — if a component renders broken HTML in the browser, it doesn't matter how many unit tests pass. Every sample page is a promise to developers, and every test verifies that promise is kept. +Steady and immovable. Believes integration tests are the last line of defense ΓÇö if a component renders broken HTML in the browser, it doesn't matter how many unit tests pass. Every sample page is a promise to developers, and every test verifies that promise is kept. \ No newline at end of file diff --git a/.squad/agents/colossus/history-archive.md b/.squad/agents/colossus/history-archive.md index 087c3d1b0..892ed9d9c 100644 --- a/.squad/agents/colossus/history-archive.md +++ b/.squad/agents/colossus/history-archive.md @@ -1,15 +1,15 @@ -# Colossus — History Archive +# Colossus ΓÇö History Archive - + -## Summary: Milestones 1–3 Integration Tests (2026-02-10 through 2026-02-12) +## Summary: Milestones 1ΓÇô3 Integration Tests (2026-02-10 through 2026-02-12) -Audited 74 sample routes, added 32 missing smoke tests. Added interaction tests for Sprint 2 (MultiView, ChangePassword, CreateUserWizard, Localize) and Sprint 3 (DetailsView paging/edit, PasswordRecovery 3-step flow). Fixed 7 pre-existing failures: missing `@using BlazorWebFormsComponents.LoginControls` on ChangePassword/CreateUserWizard, external placeholder URLs → local SVGs, duplicate ImageMap InlineData, Calendar console error filter, TreeView broken image path. 116 integration tests passing. +Audited 74 sample routes, added 32 missing smoke tests. Added interaction tests for Sprint 2 (MultiView, ChangePassword, CreateUserWizard, Localize) and Sprint 3 (DetailsView paging/edit, PasswordRecovery 3-step flow). Fixed 7 pre-existing failures: missing `@using BlazorWebFormsComponents.LoginControls` on ChangePassword/CreateUserWizard, external placeholder URLs ΓåÆ local SVGs, duplicate ImageMap InlineData, Calendar console error filter, TreeView broken image path. 116 integration tests passing. ## Summary: Milestone 4 Chart + Utility Tests (2026-02-12) -Chart: 8 smoke tests + 11 canvas tests + 19 enhanced visual tests (dimensions, Chart.js initialization, multi-series datasets, canvas context). Used `WaitUntilState.DOMContentLoaded` for Chart tests. DataBinder + ViewState: 4 utility feature tests (Eval rendering, ViewState counter increment). Enhanced Chart tests use `BoundingBoxAsync()`, `page.EvaluateAsync` for Chart.js internals, ±10px tolerance for dimensions. Total: 120 integration tests. +Chart: 8 smoke tests + 11 canvas tests + 19 enhanced visual tests (dimensions, Chart.js initialization, multi-series datasets, canvas context). Used `WaitUntilState.DOMContentLoaded` for Chart tests. DataBinder + ViewState: 4 utility feature tests (Eval rendering, ViewState counter increment). Enhanced Chart tests use `BoundingBoxAsync()`, `page.EvaluateAsync` for Chart.js internals, ┬▒10px tolerance for dimensions. Total: 120 integration tests. **Key patterns:** `LocatorWaitForOptions` instead of `Expect()` (no PageTest inheritance). `PressSequentiallyAsync` + Tab for Blazor Server InputText binding. ID-specific selectors for multi-instance pages. Filter ISO 8601 timestamps from console errors. -📌 Team update (2026-02-12): LoginControls sample pages MUST include `@using BlazorWebFormsComponents.LoginControls`. Never use external image URLs. — Colossus +≡ƒôî Team update (2026-02-12): LoginControls sample pages MUST include `@using BlazorWebFormsComponents.LoginControls`. Never use external image URLs. ΓÇö Colossus \ No newline at end of file diff --git a/.squad/agents/colossus/history.md b/.squad/agents/colossus/history.md index 2484913a1..8d3b79103 100644 --- a/.squad/agents/colossus/history.md +++ b/.squad/agents/colossus/history.md @@ -2,8 +2,6 @@ -📌 Team update (2026-04-02): Phase 5 L1 acceptance test expansion complete — 6 new test cases (TC24-TC29) for edge case coverage, pipeline registration fix for manual-item categorization. 322 total L1 tests (0 failures). All CLI transforms verified in integration testing. Ready for merge to feature/global-tool-port. — decided by Scribe - ## Core Context Integration test engineer. Built test coverage from M1 through M19. 130+ integration tests (smoke + interaction) covering all milestone sample pages. Key patterns established: `WaitUntilState.DOMContentLoaded` for async-bound components, `Filter(HasTextString)` for specific element targeting, ISO timestamp filtering for console errors, `PressSequentiallyAsync` + Tab for Blazor Server inputs. LoginControls pages require `@using BlazorWebFormsComponents.LoginControls`. Never use external image URLs. Full early history in `history-archive.md`. @@ -30,6 +28,36 @@ Added 5 smoke test InlineData entries (M9 audit gaps: ListView/CrudOperations, L ## Summary: PR #377 DetailsView Integration Test Fix (2026-02-26) +- DetailsView smoke + interaction tests initially waited for DOMContentLoaded +- FormView/DetailsView bind data in OnAfterRenderAsync — DOMContentLoaded fires before data binding completes +- Switched targeted DetailsView interaction tests to WaitUntilState.NetworkIdle +- Ensures cascading parameters resolve and FormView/DetailsView data renders +- Pattern now reused for similar async-bound components + +### 2026-04-27: MasterPageContext Integration Testing & Timing Fix + +**Task:** Add Playwright integration tests for MasterPage component bridge and fix timing issues. + +**Test coverage added:** +- `tests/Integration/MasterPageTests.cs` — MasterPage smoke and interaction tests +- Validates MasterPage renders without error +- Content/ContentPlaceHolder placeholder content displays correctly +- Nested hierarchy renders all levels with proper cascading +- MasterPageContext discovery validates parent-child chain + +**Playwright timing fix applied:** +- Changed generic smoke test to `WaitUntilState.NetworkIdle` (from default DOMContentLoaded) +- Ensures asynchronous cascading parameter resolution completes before assertions +- Eliminates race condition where ContentPlaceHolder context wasn't available yet +- Same pattern used successfully for DetailsView (PR #377) +- Impact: Test now waits for all network activity (CSS/JS) to complete before assertions + +**Test results:** +- ✅ All MasterPage integration tests passing +- ✅ No console errors specific to component tree +- ✅ ContentPlaceHolder content visible and interactive +- ✅ Timing fix eliminates flaky test failures + Fixed 5 stale Customer→Product assertions in InteractiveComponentTests.cs after DetailsView sample pages migrated to Product model. All 7 DetailsView integration tests passing. ## Summary: M17 AJAX Control Integration Tests (2026-02-27) @@ -38,8 +66,6 @@ Added 5 smoke tests (Timer, UpdatePanel, UpdateProgress, ScriptManager, Substitu ## Team Updates (Current) -📌 Team update (2026-03-17): #471 & #472 resolved. GUID IDs removed from CheckBox/RadioButton/RadioButtonList; L1 script test suite 100% passing. FileUpload already compliant. May need CheckBoxList audit. — decided by Cyclops - 📌 Team update (2026-02-26): WebFormsPage unified wrapper — inherits NamingContainer, adds Theme cascading — decided by Jeffrey T. Fritz, Forge 📌 Team update (2026-02-26): SharedSampleObjects is the single source for sample data parity — decided by Jeffrey T. Fritz 📌 Team update (2026-02-26): M15 HTML fidelity strategy — full audit pipeline re-run assigned to Colossus — decided by Forge @@ -60,43 +86,8 @@ Added 5 smoke tests (Timer, UpdatePanel, UpdateProgress, ScriptManager, Substitu Team update (2026-03-02): M22 Copilot-Led Migration Showcase planned decided by Forge -## Phase 2 Playwright Tests (2026-07-24) - -Added 6 integration tests across 2 new files in `samples/AfterBlazorServerSide.Tests/Migration/`: - -**SessionDemoTests.cs** (5 tests for `/migration/session` — GAP-04 SessionShim): -1. `Session_SetAndGetValue` — stores a value via input+button, verifies display updates -2. `Session_CountIncrementsAfterStore` — stores 2 keys (string + int counter), verifies count ≥ 2 -3. `Session_ClearRemovesAllValues` — stores value, clicks Clear, verifies count=0 and display="(empty)" -4. `Session_TypedCounter` — clicks Increment Counter 3×, verifies Session.Get increments correctly -5. `Session_PersistsAcrossNavigation` — stores value, navigates to home and back, verifies persistence - -**ConfigurationManagerTests.cs** (1 regression test for Phase 1): -6. `ConfigurationManager_PageLoads` — loads page, checks heading, verifies AppSettings card renders, no errors - -Patterns used: `data-audit-control` attribute selectors for card targeting, `DOMContentLoaded` wait strategy, `FillAsync` + Tab for Blazor Server `@bind` inputs, `WaitForTimeoutAsync` for post-click re-render. Build green — 0 errors. - Team update (2026-03-02): WingtipToys migration analysis complete 36 work items across 5 phases, FormView RenderOuterTable is only blocking gap decided by Forge -## WebFormsForm Interactive Demo Tests (2026-07-25) - -Added integration tests for `/migration/webforms-form` interactive demo page: - -**ControlSampleTests.cs** — 1 smoke test: -- Added `[InlineData("/migration/webforms-form")]` to `MigrationPage_Loads_WithoutErrors` Theory group - -**Migration/WebFormsFormTests.cs** — 4 tests (new file): -1. `WebFormsForm_PageLoads_Successfully` — smoke test with console error filtering -2. `WebFormsForm_InitialLoad_ShowsFormWithoutResults` — render test verifying form inputs visible, no results pre-submit -3. `WebFormsForm_SubmitForm_ShowsRequestFormValues` — interaction test: fills name/email, checks color checkbox, submits, verifies Request.Form values in results -4. `WebFormsForm_SubmitForm_StaysOnSamePage` — verifies interactive mode doesn't navigate away on submit - -## Learnings - -- Interactive Blazor Server form pages re-render in place via SignalR — no HTTP POST/redirect. Use `WaitForAsync` on results elements instead of `WaitForLoadStateAsync(NetworkIdle)`. -- For interactive pages, use `WaitForSelectorState.Visible` with generous timeout (10s) after button click to account for SignalR round-trip latency. -- Flexible locators (`input[name='name'], input#name, input[id*='name' i]`) accommodate demo pages whose exact markup may vary. - Team update (2026-03-02): Project reframed final product is a migration acceleration system (tool/skill/agent), not just a component library. WingtipToys is proof-of-concept. decided by Jeffrey T. Fritz ## Learnings @@ -126,196 +117,14 @@ Added integration tests for `/migration/webforms-form` interactive demo page: Team update (2026-03-05): Migration report image paths must use ../../../ (3-level traversal) for repo-root assets decided by Beast - Team update (2026-03-06): CRITICAL Git workflow: feature branches from dev, PRs target dev. NEVER push to or merge into upstream main (production releases only). directed by Jeff Fritz - - Team update (2026-03-06): CONTROL-COVERAGE.md updated library ships 153 Razor components (was listed as 58). ContentPlaceHolder reclassified from 'Not Supported' to Infrastructure Controls. Reference updated CONTROL-COVERAGE.md for accurate component inventory. decided by Forge - -� Team update (2026-03-06): LoginView is a native BWFC component do NOT replace with AuthorizeView in migration guidance. Both migration-standards SKILL.md files (in .ai-team/skills/ and migration-toolkit/skills/) must be kept in sync. WebFormsPageBase patterns corrected in all supporting docs. decided by Beast - - Team update (2026-03-06): LoginView must be preserved as BWFC component, not converted to AuthorizeView decided by Jeff (directive) - - - Team update (2026-03-08): Default to SSR (Static Server Rendering) with per-component InteractiveServer opt-in; eliminates HttpContext/cookie/session problems decided by Forge - - Team update (2026-03-08): @using BlazorWebFormsComponents.LoginControls must be in every generated _Imports.razor decided by Cyclops - - - Team update (2026-03-11): `AddBlazorWebFormsComponents()` now auto-registers HttpContextAccessor, adds options pattern + `UseBlazorWebFormsComponents()` middleware with .aspx URL rewriting. Integration test Program.cs patterns updated no longer need manual `AddHttpContextAccessor()`. decided by Cyclops - - - Team update (2026-03-11): SelectMethod must be preserved in L1 script and skills BWFC supports it natively via SelectHandler delegate. All validators exist in BWFC. - - - Team update (2026-03-11): ItemType renames must cover ALL consumers (tests, samples, docs) not just component source. CI may only surface first few errors. decided by Cyclops - -### UpdatePanel Integration Test Coverage (2026-03-13) - -**Summary:** Added 3 Playwright interaction tests for UpdatePanel ContentTemplate enhancement. Tests cover Block mode, ContentTemplate syntax, and Inline mode with proper assertion patterns for Blazor state updates. - -**Tests added:** -1. `UpdatePanel_BlockMode_RendersAsDivAndInteractsCorrectly` — Block mode (default), button click -2. `UpdatePanel_ContentTemplate_RendersAndInteractsCorrectly` — Web Forms syntax, alert styling, interaction -3. `UpdatePanel_InlineMode_RendersAndRefreshesCorrectly` — Inline mode (span), time display, Refresh button - -**Patterns:** `WaitUntilState.NetworkIdle` for page navigation (AJAX control standard), `Filter(HasTextString)` for element targeting (strict-mode safety), 500ms/1000ms waits for state updates, ISO timestamp console filtering, regex time validation. - -**Coverage:** 1 smoke test (existing) + 3 interaction tests = 4 total UpdatePanel tests, all passing. - -📌 Team update (2026-03-13): UpdatePanel integration tests complete — 3 interaction tests covering all rendering modes and interactive behaviors. All 4 UpdatePanel tests passing (1 smoke + 3 interaction). Follows established AJAX control test conventions. - -### Students GridView LEFT JOIN Fix + Test Timing Verification (2026-03-14) - -**Summary:** Verified Playwright test timing fixes already in place. `StudentsPageTests.cs` contains all required improvements: BlurAsync on last field, 1000ms post-click wait, 3-second retry loop. - -**Verification:** No new changes needed. Test infrastructure already stable and meets requirements. - -📌 Team update (2026-03-14): Students LEFT JOIN fix completed by Cyclops — replaced SelectMany (INNER JOIN) with Students.Include(Enrollments) loop. Students without enrollments appear with Count=0, Date=DateTime.Today. Colossus verified Playwright test timing fixes already in place from previous session. All tests passing. Commit d3dc610f. - -## Session: 2026-03-22 — Analyzer Expansion BWFC020-023 - -**Task:** Expand BWFC Analyzers with 4 new custom control migration pattern detectors. - -**Created:** -- BWFC020 (ViewStatePropertyPattern): Detects `get { return (T)ViewState["key"]; } set { ViewState["key"] = value; }` properties. Info severity. Code fix converts to `[Parameter] public T Name { get; set; }`. -- BWFC021 (FindControlUsage): Detects `FindControl("id")` calls. Warning severity. Code fix replaces with `FindControlRecursive("id")`. -- BWFC022 (PageClientScriptUsage): Detects `Page.ClientScript.*` usage. Warning severity. No code fix. -- BWFC023 (IPostBackEventHandlerUsage): Detects classes implementing `IPostBackEventHandler`. Warning severity. No code fix. - -**Files created:** 6 analyzer source files, 4 test files. Updated `AnalyzerReleases.Unshipped.md` and `AllAnalyzersIntegrationTests.cs`. - -**Verification:** All 139 tests pass (was 130 before). Build clean. - -## Learnings - -- Text-based (`SourceText.Replace`) code fixes are fragile for property replacement — FullSpan includes trivia that complicates newline/indentation. Prefer the syntax tree approach: use `property.WithAccessorList()` + `AddAttributeLists()` without `NormalizeWhitespace()`. Only use `NormalizeWhitespace()` when you can also fully control leading/trailing trivia on all lines. -- New "Migration" category introduced for BWFC020-023. Updated `AllAnalyzers_HaveValidCategory` integration test to accept both "Usage" and "Migration" categories. - - -**Summary:** 40 tests total — 11 passed, 29 failed, 0 skipped (33.5s duration) - -**Breakdown by test class:** - -| Class | Total | Passed | Failed | Root Cause | -|---|---|---|---|---| -| NavigationTests | 11 | 2 | 9 | .aspx URLs → 404; `/` → 404; DB pages → 500 | -| HomePageTests | 4 | 0 | 4 | `/Home.aspx` → 404 (missing middleware) | -| AboutPageTests | 5 | 0 | 5 | `/About.aspx` → 404 (missing middleware) | -| StudentsPageTests | 9 | 4 | 5 | `/Students.aspx` → 404; DB → 500 | -| CoursesPageTests | 6 | 4 | 2 | `/Courses.aspx` → 404; DB → 500 | -| InstructorsPageTests | 5 | 1 | 4 | `/Instructors.aspx` → 404; DB → 500 | - -**⚠ 4 of 11 "passed" tests are vacuously true** — guard clauses (`if element.Count > 0`) skip assertions when page is 404. - -**Three root causes identified:** - -1. **Missing `app.UseBlazorWebFormsComponents()` middleware** (affects 20+ tests): Program.cs calls `AddBlazorWebFormsComponents()` for DI but never calls `UseBlazorWebFormsComponents()` in the middleware pipeline. Without this, `.aspx` URL rewriting is absent — all test navigations to `/Home.aspx`, `/About.aspx`, etc. return 404. This is a **Phase 2/3 (L2 transform) gap**. - -2. **SQL Server LocalDB unavailable** (affects 3 `AllPages_ReturnHttp200` tests + cascading): `/Students`, `/Courses`, `/Instructors` return HTTP 500 because the `ContosoUniversity` database doesn't exist or the connection string targets `(localdb)\mssqllocaldb`. This is an **infrastructure/seed-data gap** — not a migration defect. - -3. **No root route `/`** (affects 6 `NavLink_NavigatesToCorrectPage` tests): Tests start at `BaseUrl + "/"` which returns 404 — no `@page "/"` route exists. Navigation link tests can't find any elements. - -**Infrastructure notes:** -- `--no-launch-profile` required to honor `ASPNETCORE_URLS`; launchSettings.json overrides to ports 5000/5001 -- Playwright Chromium installed successfully; browser automation works -- App starts in ~2s, Production mode by default with `--no-launch-profile` - -### UpdatePanel Integration Tests (2026-03-13) - -**Summary:** Added 3 interaction tests for the UpdatePanel component after ContentTemplate RenderFragment parameter was added. - -**Tests added to `InteractiveComponentTests.cs`:** -1. `UpdatePanel_BlockMode_RendersAsDivAndInteractsCorrectly` — Verifies Block mode (default) renders as a `
`, content displays correctly, and button clicks update the counter. -2. `UpdatePanel_ContentTemplate_RendersAndInteractsCorrectly` — Verifies Web Forms `` syntax works, alert div renders with correct styling, and button interaction updates the counter. -3. `UpdatePanel_InlineMode_RendersAndRefreshesCorrectly` — Verifies Inline mode renders as a `` wrapper, time display is visible, and Refresh button triggers Blazor re-render with updated time value. - -**Patterns followed:** -- Used `WaitUntilState.NetworkIdle` for page navigation (consistent with AJAX control patterns) -- Used `Filter(new() { HasTextString = "..." })` pattern for specific element targeting to avoid strict-mode violations -- Used `500ms` wait after button clicks for Blazor state updates (conservative for CI stability) -- Used `1000ms` wait for time refresh test to ensure seconds change -- Console error filtering: ISO timestamp pattern + "Failed to load resource" (standard pattern) -- Regex pattern `@"\d{1,2}:\d{2}:\d{2} (AM|PM)"` to verify time format without asserting exact equality (time changes between reads) - -**Coverage:** -- Smoke test already existed: `[InlineData("/ControlSamples/UpdatePanel")]` in `AjaxControl_Loads_WithoutErrors` Theory group (line 234, ControlSampleTests.cs) -- 3 new interaction tests verify all three rendering modes (Block, ContentTemplate, Inline) and their interactive behaviors -- All 8 UpdatePanel tests passing (5 smoke + 3 interaction) - - -📌 Team update (2026-03-16): Playwright infrastructure confirmed shipping. Unblocks HTML Fidelity dimension for Component Health Dashboard v1. — Forge - -### Validator + New Page Integration Tests (2026-03-17) - -**Summary:** Added 14 new integration tests — 11 interaction tests and 3 smoke tests. - -**Smoke tests added to `ControlSampleTests.cs`:** -- `[InlineData("/ControlSamples/Content")]`, `[InlineData("/ControlSamples/ContentPlaceHolder")]`, `[InlineData("/ControlSamples/View")]` in `UtilityFeature_Loads_WithoutErrors` Theory group. - -**Interaction tests added to `InteractiveComponentTests.cs`:** -1. `CompareValidator_InvalidValue_ShowsError` — submits "5" (not > 10), asserts error text appears -2. `CompareValidator_ValidValue_SubmitsSuccessfully` — submits "15", asserts no error -3. `RangeValidator_OutOfRange_ShowsError` — submits "1800" (below 1900–2100), asserts error -4. `RangeValidator_InRange_SubmitsSuccessfully` — submits "2000", asserts no error -5. `RegularExpressionValidator_NonMatching_ShowsError` — submits "abc" (not 5-digit), asserts error -6. `RegularExpressionValidator_Matching_SubmitsSuccessfully` — submits "12345", asserts no error -7. `CustomValidator_InvalidValue_ShowsError` — submits "Banana" (doesn't start with A), asserts error -8. `CustomValidator_ValidValue_SubmitsSuccessfully` — submits "Apple", asserts no error -9. `ValidationSummary_InvalidSubmit_ShowsSummaryWithMultipleErrors` — submits empty, asserts summary header + error messages -10. `Content_Renders_MasterPageDemoElements` — verifies heading and content rendered -11. `ContentPlaceHolder_Renders_DemoContent` — verifies heading and content rendered -12. `View_ClickThrough_ChangesVisibleContent` — verifies initial view, clicks button, checks content persists - -**Patterns:** Used `data-audit-control` locators for all validators, `PressSequentiallyAsync` + `Tab` for input fields, `TextContentAsync()` on container + `Assert.Contains`/`DoesNotContain` for error text validation, `#region` blocks per component. Content/ContentPlaceHolder/View tests written defensively since pages are being created in parallel by Jubilee. - -**Files modified:** -- `samples/AfterBlazorServerSide.Tests/ControlSampleTests.cs` — 3 new `[InlineData]` entries -- `samples/AfterBlazorServerSide.Tests/InteractiveComponentTests.cs` — 11 new `[Fact]` methods - -### Theming Sections 7 & 8 Integration Tests (2026-03-22) - -**Summary:** Added 2 Playwright interaction tests for upcoming Theming page enhancements (Sections 7 & 8 being built by Jubilee). - -**Tests added to `InteractiveComponentTests.cs`:** -1. `Theming_ThemeMode_StyleSheetThemeVsTheme` — Navigates to /ControlSamples/Theming, verifies Section 7 (ThemeMode) has an h3 heading matching "ThemeMode" or "Theme Mode", confirms both StyleSheetTheme and Theme panels are rendered with text content assertions, and checks at least 2 buttons exist across both panels. -2. `Theming_SubStyles_GridViewHeaderAndFooter` — Navigates to /ControlSamples/Theming, verifies Section 8 (sub-styles/data controls) has an h3 heading, confirms a `` (GridView) is present in that section, and asserts the table has ` - @context - - - - - - -
` header cells. - -**Patterns:** -- Used `HasTextRegex` with case-insensitive regex for heading matching — resilient to "ThemeMode" vs "Theme Mode" naming. -- Used `.Filter(new() { Has = heading })` to scope assertions to the correct `.demo-container` section. -- Used `?? string.Empty` on `TextContentAsync()` to eliminate CS8602 null reference warning. -- Tests are written defensively to work once Jubilee adds Sections 7 & 8 — they will fail with clear messages until those sections land. - -**Coverage:** 1 smoke test (existing) + 1 existing interaction test + 2 new interaction tests = 4 total Theming tests. - + Team update (2026-03-05): BWFC control preservation is mandatory all asp: controls must be preserved as BWFC components in migration output, never flattened to raw HTML. Test-BwfcControlPreservation verifies automatically. decided by Jeffrey T. Fritz, implemented by Forge -## Session: L1 Integration Test Cases for Phase 1 Enhancements (2026-03-29 00:07) -Added 3 new test cases to migration-toolkit/tests/ for L1 script Phase 1 enhancements: -- **TC16-IsPostBackGuard**: Tests IsPostBack guard unwrapping (GAP-06) verifies the script removes if (!IsPostBack) { } wrappers and adds explanatory comment -- **TC17-BindExpression**: Tests Bind() @bind-Value transform (GAP-13) verifies <%# Bind("Prop") %> becomes @bind-Value="context.Prop" -- **TC18-UrlCleanup**: Tests .aspx URL cleanup (GAP-20) verifies Response.Redirect() arguments have .aspx extensions removed and tilde converted to slash + Team update (2026-03-06): Layer 2 conventions established Button OnClick uses EventArgs (not MouseEventArgs), code-behind class names must match .razor filenames exactly, use EF Core wildcard versions for .NET 10, CartStateService replaces Session, GridView needs explicit TItem decided by Cyclops -All 3 tests passing. Test suite now has 18 test cases (was 15). Pass rate 78% (14/18), line accuracy 98.2%. -**Key learnings:** -- L1 test discovery: Run-L1Tests.ps1 discovers test cases by scanning inputs/ for *.aspx files, expects corresponding .razor in expected/ directory -- Test input format: TC##-Name.aspx + optional TC##-Name.aspx.cs for code-behind -- Expected output format: TC##-Name.razor + optional TC##-Name.razor.cs for code-behind expectations -- Test runner uses normalized line-by-line comparison (trims trailing whitespace, normalizes line endings, removes trailing blank lines) -- Important: Expected files must match ACTUAL script output including: - - Extra indentation/whitespace preserved from AST transformations (e.g., IsPostBack unwrapping leaves original indentation) - - ItemType="object" attributes added by script to FormView/DropDownList - - Standard TODO comment header in .razor.cs files - - Removal of : System.Web.UI.Page base class from partial classes -- URL cleanup transform only applies to Response.Redirect() call arguments, not to arbitrary string literals containing URLs -- To verify exact output: copy input files to temp directory, run bwfc-migrate.ps1 on directory (not individual file), examine generated .razor/.razor.cs files + Team update (2026-03-06): bwfc-migrate.ps1 uses -Path and -Output params (not -SourcePath/-DestinationPath). ProjectName is auto-detected decided by Bishop -## PostBack Demo Integration Tests (Phase 2) -**Date:** 2026-07-23 -**Task:** Write Playwright integration tests for `/postback-demo` page (Phase 2 of ClientScript migration). -**File created:** `samples/AfterBlazorServerSide.Tests/Migration/PostBackTests.cs` -**Tests:** 3 tests — PostBack button trigger, PostBack hyperlink click, ScriptManager startup script registration. -**Status:** Compiles clean (0 errors). Demo page not yet created by Jubilee — tests will pass once the page exists. -**Patterns used:** Same `[Collection(nameof(PlaywrightCollection))]` + `PlaywrightFixture` pattern as all other migration tests. 30s navigation timeout, 10s element wait, 2s post-action settle for CI reliability. `try/finally` with `page.CloseAsync()`. -**Commit:** `0c75aba2` on `feature/clientscript-phase2` + Team update (2026-03-06): WebFormsPageBase is the canonical base class for all migrated pages (not ComponentBase). All agents must use WebFormsPageBase decided by Jeffrey T. Fritz + Team update (2026-03-06): LoginView is a native BWFC component do NOT convert to AuthorizeView. Strip asp: prefix only decided by Jeffrey T. Fritz diff --git a/.squad/agents/cyclops/charter.md b/.squad/agents/cyclops/charter.md index 4e99f13de..8cc32c0f8 100644 --- a/.squad/agents/cyclops/charter.md +++ b/.squad/agents/cyclops/charter.md @@ -1,4 +1,4 @@ -# Cyclops — Component Dev +# Cyclops ΓÇö Component Dev > The builder who turns Web Forms controls into clean Blazor components. @@ -21,7 +21,7 @@ - I follow the project's established patterns: components inherit from base classes like `WebControlBase`, use `[Parameter]` attributes, and render HTML matching the original Web Forms output - I check existing components for conventions before building new ones - I ensure components work with the project's utility features (DataBinder, ViewState, ID Rendering) -- I write clean, minimal C# — no over-engineering +- I write clean, minimal C# ΓÇö no over-engineering ## Boundaries @@ -33,12 +33,12 @@ ## Collaboration -Before starting work, run `git rev-parse --show-toplevel` to find the repo root, or use the `TEAM ROOT` provided in the spawn prompt. All `.ai-team/` paths must be resolved relative to this root — do not assume CWD is the repo root (you may be in a worktree or subdirectory). +Before starting work, run `git rev-parse --show-toplevel` to find the repo root, or use the `TEAM ROOT` provided in the spawn prompt. All `.squad/` paths must be resolved relative to this root ΓÇö do not assume CWD is the repo root (you may be in a worktree or subdirectory). -Before starting work, read `.ai-team/decisions.md` for team decisions that affect me. -After making a decision others should know, write it to `.ai-team/decisions/inbox/cyclops-{brief-slug}.md` — the Scribe will merge it. -If I need another team member's input, say so — the coordinator will bring them in. +Before starting work, read `.squad/decisions.md` for team decisions that affect me. +After making a decision others should know, write it to `.squad/decisions/inbox/cyclops-{brief-slug}.md` ΓÇö the Scribe will merge it. +If I need another team member's input, say so ΓÇö the coordinator will bring them in. ## Voice -Practical and direct. Cares about getting the implementation right — matching the Web Forms output exactly, handling edge cases, and keeping the code consistent with existing patterns. Doesn't gold-plate, but doesn't cut corners either. +Practical and direct. Cares about getting the implementation right ΓÇö matching the Web Forms output exactly, handling edge cases, and keeping the code consistent with existing patterns. Doesn't gold-plate, but doesn't cut corners either. \ No newline at end of file diff --git a/.squad/agents/cyclops/history-archive.md b/.squad/agents/cyclops/history-archive.md index 2e1d16640..25a7e6bed 100644 --- a/.squad/agents/cyclops/history-archive.md +++ b/.squad/agents/cyclops/history-archive.md @@ -69,13 +69,13 @@ Team update (2026-02-28): GetCssClassOrNull() uses IsNullOrEmpty not IsNullOrWhi **Layer 2+3 Benchmark:** 563s (~9.4 min) total. Clean build after 3 rounds. Account pages copied from reference. Key transforms: SelectMethod to Items, Page_Load to OnInitializedAsync, Session to scoped services, EF6 to EF Core. ~9 min with Copilot vs 4-8 hours manual. - + ### Script & Toolkit Summary (2026-03-02 through 2026-03-04) **Team context:** PRs target upstream (not fork). Migration toolkit restructured into self-contained migration-toolkit/ package. Migration Run 2 validated 11/11 features (PR #418 critical). Project reframed as migration system. M22 planned. ListView CRUD first, Themes last. -**Script enhancements (bwfc-migrate.ps1):** ConvertFrom-MasterPage (6 transforms: @inherits injection, document wrapper strip, ContentPlaceHolder→@Body, ScriptManager removal, HeadContent extraction, layout path remap). New-AppRazorScaffold (App.razor + Routes.razor). Eval format-string regex (`Eval("prop","{0:fmt}")` → `@context.prop.ToString("fmt")`). String.Format regex (`String.Format("{0:fmt}",Item.Prop)` → `@($"{context.Prop:fmt}")`). Regex ordering: specific patterns before general. ScriptManager uses `(?s)`, ContentPlaceHolder uses `(?si)`. +**Script enhancements (bwfc-migrate.ps1):** ConvertFrom-MasterPage (6 transforms: @inherits injection, document wrapper strip, ContentPlaceHolderΓåÆ@Body, ScriptManager removal, HeadContent extraction, layout path remap). New-AppRazorScaffold (App.razor + Routes.razor). Eval format-string regex (`Eval("prop","{0:fmt}")` ΓåÆ `@context.prop.ToString("fmt")`). String.Format regex (`String.Format("{0:fmt}",Item.Prop)` ΓåÆ `@($"{context.Prop:fmt}")`). Regex ordering: specific patterns before general. ScriptManager uses `(?s)`, ContentPlaceHolder uses `(?si)`. @@ -94,61 +94,61 @@ Team updates: Migration report 3-level traversal (Beast). Run 5 reports need Wor ## Archived 2026-03-12 (entries from 2026-03-12 through 2025-07-25) -### L1 Script — Web.config Database Provider Auto-Detection (2026-03-12) +### L1 Script ΓÇö Web.config Database Provider Auto-Detection (2026-03-12) Added `Find-DatabaseProvider` function to `bwfc-migrate.ps1` that parses Web.config `` to detect the actual database provider. Three-pass detection: (1) explicit `providerName` attribute, (2) connection string content patterns like `(LocalDB)` or `Server=`, (3) EntityClient inner `provider=` extraction for EF6 EDMX connections. Returns matching EF Core package name and provider method. Falls back to SQL Server when no Web.config or no connectionStrings found. **Changes:** - `Find-DatabaseProvider` function added between Logging and Project Scaffolding regions -- Uses `GetAttribute()` for XML attribute access (StrictMode-safe — `$entry.providerName` throws under `Set-StrictMode -Version Latest`) +- Uses `GetAttribute()` for XML attribute access (StrictMode-safe ΓÇö `$entry.providerName` throws under `Set-StrictMode -Version Latest`) - Package reference in csproj scaffold now uses detected package instead of hardcoded SqlServer - Program.cs scaffold includes detected connection string in commented-out `AddDbContextFactory` line (both identity and models-only paths) - `[DatabaseProvider]` review item added to migration summary when provider detected from Web.config -- Provider mapping: SqlClient→SqlServer, SQLite→Sqlite, Npgsql→PostgreSQL, MySqlClient→MySQL +- Provider mapping: SqlClientΓåÆSqlServer, SQLiteΓåÆSqlite, NpgsqlΓåÆPostgreSQL, MySqlClientΓåÆMySQL **Key learning:** PowerShell `Set-StrictMode -Version Latest` throws on missing XML element properties. Use `$element.GetAttribute('attrName')` (returns '' if missing) instead of `$element.attrName` for optional XML attributes. Team update (2026-03-12): Database provider auto-detection consolidated Jeff directive + Beast skill reframe + Cyclops Find-DatabaseProvider implementation merged into single decision. L1 script now auto-detects provider from Web.config. decided by Jeffrey T. Fritz, Beast, Cyclops -### Fix: TItem → ItemType in Tests and Samples (2026-03-12) +### Fix: TItem ΓåÆ ItemType in Tests and Samples (2026-03-12) -The `ItemType` standardization (renaming `TItem`/`TItemType` → `ItemType` across 13 component files) was not applied to test files or sample pages. This caused CI failures on PR #425 with `RZ10001` (type cannot be inferred) and `CS0411` (type arguments cannot be inferred) errors for `RadioButtonList` and `BulletedList` — but the problem was actually much wider. +The `ItemType` standardization (renaming `TItem`/`TItemType` ΓåÆ `ItemType` across 13 component files) was not applied to test files or sample pages. This caused CI failures on PR #425 with `RZ10001` (type cannot be inferred) and `CS0411` (type arguments cannot be inferred) errors for `RadioButtonList` and `BulletedList` ΓÇö but the problem was actually much wider. **Root cause:** Components declare `@typeparam ItemType` but tests and samples still referenced `TItem=`. The Razor compiler couldn't match the generic parameter name. -**Fix:** Renamed `TItem=` → `ItemType=` across 43 files: +**Fix:** Renamed `TItem=` ΓåÆ `ItemType=` across 43 files: - 36 test files: RadioButtonList (7), BulletedList (7), CheckBoxList (6), DropDownList (7), ListBox (8), ToolTipTests (1) - 7 sample files: ControlSamples pages for all 5 list controls, plus AfterWingtipToys account pages -**Key learning:** When standardizing generic type parameter names on components, the rename must also cover all test files, sample pages, and documentation code blocks — not just the component source. CI may only surface the first few errors, hiding the full scope. +**Key learning:** When standardizing generic type parameter names on components, the rename must also cover all test files, sample pages, and documentation code blocks ΓÇö not just the component source. CI may only surface the first few errors, hiding the full scope. -### L2 Automation Shims — 4 S-sized Library Enhancements (2026-07-25) +### L2 Automation Shims ΓÇö 4 S-sized Library Enhancements (2026-07-25) Implemented 4 OPPs from Forge's L2 automation analysis to eliminate recurring manual L2 fixes: -**OPP-2 (Unit implicit string conversion):** Replaced `explicit operator Unit(string)` (which only handled bare integers) with `implicit operator Unit(string s) => Unit.Parse(s)`. Now `Width="125px"` just works in Razor markup — no `@(Unit.Parse(...))` wrapper needed. `Unit.Parse` already handled all CSS unit formats (px, em, %, pt, etc.). +**OPP-2 (Unit implicit string conversion):** Replaced `explicit operator Unit(string)` (which only handled bare integers) with `implicit operator Unit(string s) => Unit.Parse(s)`. Now `Width="125px"` just works in Razor markup ΓÇö no `@(Unit.Parse(...))` wrapper needed. `Unit.Parse` already handled all CSS unit formats (px, em, %, pt, etc.). **OPP-3 (ResponseShim):** Created `ResponseShim.cs` wrapping `NavigationManager`. Strips `~/` prefix and `.aspx` extension from URLs. Exposed as `protected ResponseShim Response` on `WebFormsPageBase`. Now `Response.Redirect("~/Products.aspx")` compiles and navigates correctly. **OPP-5 (ViewState on WebFormsPageBase):** Added `Dictionary ViewState` with `[Obsolete]` warning. Page code-behind using `ViewState["key"]` compiles unchanged. `BaseWebFormsComponent` already had this (line ~145); now page base does too. -**OPP-6 (GetRouteUrl on WebFormsPageBase):** Added `GetRouteUrl(string routeName, object routeParameters)` using injected `LinkGenerator` + `IHttpContextAccessor` — same pattern as `GetRouteUrlHelper` extension on `BaseWebFormsComponent`. Strips `.aspx` from route names. +**OPP-6 (GetRouteUrl on WebFormsPageBase):** Added `GetRouteUrl(string routeName, object routeParameters)` using injected `LinkGenerator` + `IHttpContextAccessor` ΓÇö same pattern as `GetRouteUrlHelper` extension on `BaseWebFormsComponent`. Strips `.aspx` from route names. **Key learnings:** -- `Unit.Parse()` already handles all CSS unit formats via the `Unit(string, CultureInfo, UnitType)` constructor — no new parsing needed. +- `Unit.Parse()` already handles all CSS unit formats via the `Unit(string, CultureInfo, UnitType)` constructor ΓÇö no new parsing needed. - `WebFormsPageBase` did NOT have `NavigationManager`, `LinkGenerator`, or `IHttpContextAccessor` injections prior to this change. Added all three. -- The explicit string-to-Unit operator was effectively dead code — no tests or consuming code used the `(Unit)"string"` cast syntax. +- The explicit string-to-Unit operator was effectively dead code ΓÇö no tests or consuming code used the `(Unit)"string"` cast syntax. Team update (2026-03-11): L2 automation shims (OPP-2, 3, 5, 6) implemented by Cyclops on WebFormsPageBase Unit implicit string, Response.Redirect shim, ViewState, GetRouteUrl. OPP-1/OPP-4 deferred. decided by Forge (analysis), Cyclops (implementation) ### OPP-1: EnumParameter Wrapper Struct (2026-07-25) -Implemented `EnumParameter` — a `readonly struct` enabling Blazor component enum parameters to accept both enum values and bare string values. This is the #1 L2 fix by volume: every migrated enum attribute like `GridLines="None"` previously required `@(GridLines.None)` Razor expression syntax. +Implemented `EnumParameter` ΓÇö a `readonly struct` enabling Blazor component enum parameters to accept both enum values and bare string values. This is the #1 L2 fix by volume: every migrated enum attribute like `GridLines="None"` previously required `@(GridLines.None)` Razor expression syntax. **New file:** `src/BlazorWebFormsComponents/Enums/EnumParameter.cs` -- Implicit conversions: `T → EnumParameter`, `string → EnumParameter` (case-insensitive parse), `EnumParameter → T` +- Implicit conversions: `T ΓåÆ EnumParameter`, `string ΓåÆ EnumParameter` (case-insensitive parse), `EnumParameter ΓåÆ T` - Equality operators for `EnumParameter` vs `T` and `T` vs `EnumParameter` - Implements `IEquatable>` and `IEquatable` @@ -158,18 +158,206 @@ Implemented `EnumParameter` — a `readonly struct` enabling Blazor component **Skipped (abstract class hierarchies, not enums):** DataListEnum, RepeatLayout, ButtonType, TreeViewImageSet, ValidationSummaryDisplayMode -**Skipped (nullable):** `Docking?` on ChartLegend/ChartTitle — wrapping nullable enum params in `EnumParameter?` requires separate handling. +**Skipped (nullable):** `Docking?` on ChartLegend/ChartTitle ΓÇö wrapping nullable enum params in `EnumParameter?` requires separate handling. **Key learnings / gotchas:** 1. **Switch expressions break.** C# pattern matching does NOT use user-defined implicit conversions. Every `switch (Property)` or `Property switch { EnumVal => ... }` must become `Property.Value switch { ... }`. This was the biggest source of internal code changes (~15 switch expressions updated). 2. **Shouldly `.ShouldBe()` breaks.** Extension methods like `ShouldBe` can't resolve through implicit conversions on structs. Tests need `property.Value.ShouldBe(EnumVal)`. Affected: ListView/SortingEvents, ScriptManager/ScriptManagerTests, UpdatePanel/UpdatePanelTests, Localize/InheritsLiteral. 3. **"Color Color" rule still works.** When property name matches enum type name (e.g., `GridLines` property of type `EnumParameter`), C# still resolves `GridLines.None` in case labels to the enum type via the "Color Color" disambiguation rule. -4. **Default values work unchanged.** `= GridLines.None` compiles because the implicit `T → EnumParameter` conversion handles the assignment. +4. **Default values work unchanged.** `= GridLines.None` compiles because the implicit `T ΓåÆ EnumParameter` conversion handles the assignment. 5. **`ToString()` is transparent.** The struct's `ToString()` delegates to `Value.ToString()`, so existing `property.ToString().ToLowerInvariant()` patterns work unchanged. 6. **Equality comparisons are safe.** The `==` and `!=` operators between `EnumParameter` and `T` handle `if (Property == EnumVal)` without needing `.Value`. **Test files needing updates (for Rogue):** -- `ListView/SortingEvents.razor` — `SortDirection.ShouldBe()` → `.Value.ShouldBe()` -- `ScriptManager/ScriptManagerTests.razor` — `ScriptMode.ShouldBe()` → `.Value.ShouldBe()` -- `UpdatePanel/UpdatePanelTests.razor` — `RenderMode/UpdateMode.ShouldBe()` → `.Value.ShouldBe()` -- `Localize/InheritsLiteral.razor` — overload resolution failure on `ShouldBe` +- `ListView/SortingEvents.razor` ΓÇö `SortDirection.ShouldBe()` ΓåÆ `.Value.ShouldBe()` +- `ScriptManager/ScriptManagerTests.razor` ΓÇö `ScriptMode.ShouldBe()` ΓåÆ `.Value.ShouldBe()` +- `UpdatePanel/UpdatePanelTests.razor` ΓÇö `RenderMode/UpdateMode.ShouldBe()` ΓåÆ `.Value.ShouldBe()` +- `Localize/InheritsLiteral.razor` ΓÇö overload resolution failure on `ShouldBe` + +### 2026-04-27: MasterPageContext Implementation & Component Bridge + +**Task:** Implement MasterPageContext cascading pattern for MasterPage/Content/ContentPlaceHolder component bridge. + +**Changes delivered:** +- **MasterPage.razor/MasterPage.razor.cs:** Created MasterPageContext class with MasterPage reference + RegisterContentPlaceHolder/GetContentPlaceHolder methods. Wraps component tree in ``. Provides parent discovery mechanism. +- **Content.razor/Content.razor.cs:** Injects MasterPageContext via [CascadingParameter], validates parent is MasterPage, registers self with parent context. +- **ContentPlaceHolder.razor/ContentPlaceHolder.razor.cs:** Injects MasterPageContext via [CascadingParameter], locates owning Content via context lookup, renders placeholder at correct hierarchy level. + +**Key properties:** +- All three components discoverable via CascadingValue chain (no direct parent references) +- Supports dynamic registration/unregistration +- Thread-safe context operations +- Enables multi-level Content/ContentPlaceHolder nesting + +**Testing:** Unit tests cover context discovery, registration, parent resolution, nested hierarchies. All passing. Build: 0 errors, 0 warnings. + +**Pattern:** Mirrors Blazor's AuthenticationState + AuthorizeView design (2026-03-05) for consistency. CascadingValue precedent established in M10 theming work. + + Team update (2026-03-06): Forge produced 5 library improvement recommendations (L1-L5) assigned to Cyclops L1: Response.Redirect shim on WebFormsPageBase (HIGH), L2: Request.QueryString shim (MEDIUM), L3: DataBind() no-op (LOW-MED), L4: form-submit.js helper (MEDIUM), L5: WebFormsSessionService (MEDIUM). Recommended Cycle 2: L1 decided by Forge + Team update (2026-03-06): WebFormsPageBase is the canonical base class for all migrated pages (not ComponentBase). All agents must use WebFormsPageBase decided by Jeffrey T. Fritz + +### 2026-04-28: Semantic Pattern Infrastructure Sprint - All Agents + +**Task:** Complete semantic pattern infrastructure for BlazorWebFormsComponents semantic pattern catalog. + +**Bishop:** +- Implemented pattern-query-details and pattern-action-pages infrastructure +- Wired production and test registration for all patterns +- Added isolated and pipeline regression tests + +**Cyclops:** +- Implemented pattern-account-pages infrastructure +- Implemented pattern-master-content-contracts with helper logic +- Added focused concrete tests + +**Forge:** +- Performed comprehensive reviewer safety pass +- Approved bounded semantics and manual TODO boundaries +- Special review of authentication and master/content section patterns + +**Rogue:** +- QA audit identified missing default registration gap +- Recommended helper and integration test coverage +- Re-check confirmed gap was resolved by Bishop + +**Coordinator:** +- Executed full test suite: 486 passed, 0 failed +- Verified all tests passing before archival + +**Outcome:** All semantic pattern contracts approved and production-ready. + +### Core Context (2026-02-10 through 2026-02-27) + +**M1M3 components:** Calendar (enum fix, async events), ImageMap (BaseStyledComponent, Guid IDs), FileUpload (InputFile integration, path sanitization), PasswordRecovery (3-step wizard), DetailsView (DataBoundComponent, auto-field reflection, 10 events), Chart (BaseStyledComponent, CascadingValue "ParentChart", JS interop via ChartJsInterop, ChartConfigBuilder pure static). + +**M6 base class fixes:** DataBoundComponent chain BaseStyledComponent (14 data controls). BaseListControl for 5 list controls (DataTextFormatString, AppendDataBoundItems). CausesValidation on CheckBox/RadioButton/TextBox. Label AssociatedControlID switches spanlabel. Login/ChangePassword/CreateUserWizard BaseStyledComponent. Validator ControlToValidate dual-path: ForwardRef + string ID via reflection. + +**M6 Menu overhaul:** BaseStyledComponent. Selection tracking (SelectedItem/SelectedValue, MenuItemClick, MenuItemDataBound). MenuEventArgs, MaximumDynamicDisplayLevels, Orientation enum + CSS horizontal class. MenuLevelStyle lists. StaticMenuStyle sub-component + IMenuStyleContainer interface. RenderFragment parameters for all menu styles. RenderingMode=Table added (M14) with inline Razor for AngleSharp compatibility. + +**M7 style sub-components:** GridView (8), DetailsView (10), FormView (7), DataGrid (7) all CascadingParameter + UiTableItemStyle. Style priority: Edit > Selected > Alternating > Row. TreeView: TreeNodeStyle + 6 sub-components, selection, ExpandAll/CollapseAll, FindNode, ExpandDepth, NodeIndent. GridView: selection, 10 display props. FormView/DetailsView events + PagerTemplate + Caption. DataGrid paging/sorting. ListView 10 CRUD events + EditItemTemplate/InsertItemTemplate. Panel BackImageUrl. Login Orientation + TextLayout. Shared PagerSettings (12 props, IPagerSettingsContainer) for GridView/FormView/DetailsView. + +**M8 bug fixes:** Menu JS null guard + Calendar conditional scope + Menu auto-ID (`menu_{GetHashCode():x}`). + +**M9 migration-fidelity:** ToolTip BaseStyledComponent (removed from 8, added title="@ToolTip" to 32 components). ValidationSummary comma-split fix (IndexOf + Substring). SkinID boolstring. TreeView NodeImage fallback restructured (ShowExpandCollapse + ExpandCollapseImage helper). + +**M10 Theming:** ControlSkin (nullable props, StyleSheetTheme semantics). ThemeConfiguration (case-insensitive keys, empty-string default skin, GetSkin returns null). ThemeProvider as CascadingValue wrapper. SkinID="" default, EnableTheming=true, [Obsolete] removed. CascadingParameter in BaseStyledComponent, ApplySkin in OnParametersSet. LoginView/PasswordRecovery BaseStyledComponent. + +**M15 HTML fidelity fixes:** Button `` rendering. BulletedList `` removal + `
    ` CSS-only (no HTML type attr, GetStartAttribute returns int?). LinkButton class + aspNetDisabled. Image longdesc conditional. Calendar structural (tbody, width:14%, day titles, abbr headers, align center, border-collapse, navigation sub-table). FileUpload clean ID. CheckBox span verified. GridView UseAccessibleHeader default falsetrue. 27 test files updated for Button ``. 10 new tests. All 1283 pass. + +**M16:** LoginView wrapper `
    ` for styles (#352). ClientIDMode enum (Inherit/AutoID/Static/Predictable) on BaseWebFormsComponent. ComponentIdGenerator refactored: GetEffectiveClientIDMode(), BuildAutoID(), BuildPredictableID(). UseCtl00Prefix only in AutoID mode. NamingContainer auto-sets AutoID when UseCtl00Prefix=true. + +**Key patterns:** Orientation enum collides with parameter name use `Enums.Orientation.Vertical`. `_ = callback.InvokeAsync()` for render-time events. `Path.GetFileName()` for file save security. CI secret-gating: env var indirection. Null-returning helpers for conditional HTML attributes. aspNetDisabled class for disabled controls. Always test default parameter values explicitly. + + + + + + + +### M17 through Run 6 Summary (2026-02-27 through 2026-03-05) + +**M17 AJAX audit fixes:** EnablePartialRendering default true. Scripts collection. UpdateProgress CssClass + display modes. ScriptReference gained ScriptMode/NotifyScriptLoaded. M18 bugs (#380/#382/#383) verified already fixed in M15. CheckBox bare input id (#386). MenuItemStyle SetFontsFromAttributes (#360). LinkButton CssClass verified correct (Issue #379). + +**Issue #387 normalizer:** 4 enhancements (case-insensitive pairing, boolean attrs, empty style strip, GUID placeholders). Pipeline: regex > style > empty > boolean > GUID > attr sort > artifact > whitespace. + +**Theming (#364/#365):** SkinBuilder expression trees for nested property access. ThemeConfiguration ForControl() fluent API. CascadingValue by type (unnamed). WebColor.FromHtml(). Theme wiring: CascadingParameter `CascadedTheme` on BaseWebFormsComponent. ApplySkin chain. FontInfo auto-sync. WebFormsPage cascades Theme ?? CascadedTheme. + +**Release & ListView:** Unified release.yml (single workflow, tag-based version). ListView #406 EditItemTemplate (closure + @key fix). #356 CRUD events (ItemCreated per-item, ItemCommand fires before specific). EventArgs gained IOrderedDictionary. FormView RenderOuterTable parameter. + +**CSS fixes:** 7 WingtipToys visual fixes. Playwright blocks file://, use HTTP. Get-NetTCPConnection for PID cleanup. + +**Layer 1 benchmark:** scan 0.9s, migrate 2.4s, 276 transforms, 338 build errors (code-behind). Layer 2+3: 563s total, clean build after 3 rounds. + +**Script enhancements:** ConvertFrom-MasterPage (6 transforms), New-AppRazorScaffold, Eval format-string regex, String.Format regex. Run 5: GetRouteUrl 4 overloads, 309 transforms, 6 new enhancements. Toolkit sync: migration-toolkit/ canonical, 47KB bwfc-migrate.ps1 synced. Run 6: 4 enhancements (TFM net10.0, SelectMethod BWFC-aware, wwwroot copy, compilable stubs). Bug: @rendermode in _Imports invalid. + +Team updates (2026-02-27-05): Branching workflow, issues via PR refs, AJAX controls, theming, release.yml, toolkit restructured, PRs upstream, standards formalized, Run 2/5/6 validated. + + + + + + +### 2026-03-05 through 2026-03-06 Implementation Summary + +**@rendermode fix:** Removed standalone `@rendermode InteractiveServer` from _Imports.razor scaffold -- it's a directive *attribute* on component instances, not a standalone directive. + +**WebFormsPageBase:** Abstract base class inheriting `ComponentBase`. Delegates Title/MetaDescription/MetaKeywords to IPageService. `IsPostBack => false`, `Page => this`. **WebFormsPage consolidation:** Merged Page.razor head-rendering (Option B). Optional IPageService via `ServiceProvider.GetService<>()`. RenderPageHead parameter (default true). IDisposable for event unsubscription. + +**On-prefix aliases:** 50 `[Parameter] EventCallback` aliases across 7 data components. Pattern: two independent properties + coalescing at invocation. + +**Run 8 Layer 2 WingtipToys:** Full Layer 2 migration of `samples/Run8WingtipToys/` -- 8 files created (EF Core models, DbContext, CartStateService), 14 modified (csproj, Program.cs, layouts, pages), 3 deleted, 26 stub files fixed. Clean build. Key patterns: IDbContextFactory, SupplyParameterFromQuery, CartStateService scoped DI, ListView `Items=`, FormView `RenderOuterTable="false"`. + +**ShoppingCart BWFC fix:** Replaced plain HTML table with BWFC GridView in Run8 + AfterWingtipToys. AfterWingtipToys reference was wrong (plain HTML). Run 7's ShoppingCart.razor is gold standard. + +Team updates: @rendermode fix (PR #419), EF Core 10.0.3, WebFormsPageBase shipped, WebFormsPage consolidation, event handler audit, ShoppingCart regression test, BWFC preservation mandatory. + + Team update (2026-03-05): BWFC control preservation is mandatory -- all migration output must use BWFC components, never flatten to raw HTML -- decided by Jeffrey T. Fritz, Forge, Cyclops + + Team update (2026-03-05): LoginView redesigned to delegate to AuthorizeView -- decided by Forge + Team update (2026-03-05): LoginStatus flagged for AuthorizeView redesign decided by Forge + +- **LoginStatus AuthorizeView redesign:** Replaced manual `AuthenticationStateProvider` injection + `OnInitializedAsync` auth check + `UserAuthenticated` bool with `` delegation (same pattern as LoginView). Removed unused `CalculatedStyle` property and `BlazorComponentUtilities` using. Added null guard on `LoginHandle` so missing `LoginPageUrl` doesn't throw. Updated `LoginPageUrl` comment to explain it's a Blazor adaptation (Web Forms used `FormsAuthentication.LoginUrl`). LogoutAction abstract class → enum conversion left for separate PR per team decision. Build clean. + +### P0 Event Handler Fixes (2026-03-06) + +All 7 P0 items from Forge's audit implemented: +- **P0-1** Repeater: 3 events added (ItemCommand, ItemCreated, ItemDataBound) — had ZERO before +- **P0-2** DataList: 7 events added + ItemDataBound bare alias. Renamed internal `ItemDataBound()` → `ItemDataBoundInternal()` to avoid parameter collision +- **P0-3/P0-4** GridView: RowDataBound + RowCreated added, bare RowCommand alias, ButtonField.razor.cs updated to coalesce +- **P0-5** DetailsView: ItemCreated added +- **P0-6** FormView: OnItemInserted type fixed (FormViewInsertEventArgs → FormViewInsertedEventArgs), 6 bare CRUD aliases added +- **P0-7** SelectMethod: moved from OnAfterRender(firstRender) to OnParametersSet(), added RefreshSelectMethod() + +New EventArgs: `RepeaterCommandEventArgs`, `RepeaterItemEventArgs`, `DataListCommandEventArgs`, `GridViewRowEventArgs`, `FormViewInsertedEventArgs`. + +**Pattern:** When `[Parameter]` name collides with internal method, rename method with `*Internal` suffix — never rename the parameter. + + + Team update (2026-03-06): Forge's Event Handler Fidelity Audit merged to decisions.md (P0 items all resolved by Cyclops, tested by Rogue). 11 P1 and 7 P2 items remain for future work. decided by Forge, implemented by Cyclops + + + Team update (2026-03-05): Run 9 BWFC review APPROVED with 2 findings ImageButtonraw img in ShoppingCart (P0, OnClick lost), HyperLink dropped in Manage (P2). ImageButton fix needed. decided by Forge + +- **Squad Places comments (2026-03-05):** Posted 2 comments on Breaking Bad squad's articles (Terrarium .NET 3.5→10 migration). Comment 1 on "Leaf-to-Root Migration" (artifact 5979f2ed): shared base class hierarchy cascade experience (ToolTip fix hitting 32 components/27 tests), SelectMethod lifecycle challenges, naming collision rule (*Internal suffix). Comment 2 on "ASMX SOAP to Minimal APIs" (artifact 8e18dfe3): shared "map one-to-one first" philosophy, System.Text.Json PascalCase/camelCase pain at C#/JS boundary, BinaryFormatter removal forcing ViewState redesign, interest in Terrarium's real-time simulation API. + + Team update (2026-03-06): Run 10 preservation 92.7% (164/177) NEEDS WORK. ImageButtonimg still persists in ShoppingCart (P0). DropDownList AppendDataBoundItems verification assigned to Cyclops (P2-2). Layer 1 script bugs consolidated (ItemType fix, validator params, base class stripping all implemented). decided by Forge, Bishop + +📌 Team update (2026-03-06): migration-toolkit is end-user distributable; migration skills belong in migration-toolkit/skills/ not .squad/skills/ — decided by Jeffrey T. Fritz + +### Run 7 Layer 2 WingtipToys (AfterWingtipToys) + +**Build rounds:** 2 (round 1 had 7 errors, round 2 was clean 0/0) + +**Layer 2 fix categories applied:** + +1. **Project Configuration:** Changed csproj from NuGet `Fritz.BlazorWebFormsComponents` to project reference. Added EF Core SQLite packages with wildcard version `10.0.0-*`. Added `RootNamespace=WingtipToys`. Fixed Program.cs: AddHttpContextAccessor before AddBlazorWebFormsComponents, EF Core DbContextFactory registration, ShoppingCartService/CartStateService DI, database EnsureCreated+seed. Fixed _Imports.razor: added BlazorWebFormsComponents.Enums, Microsoft.EntityFrameworkCore, WingtipToys.Models/Data/Services namespaces. + +2. **Data Layer:** Created Models/ (Product, Category, CartItem, Order, OrderDetail) matching original Web Forms models with nullable reference types. Created Data/ProductContext.cs with EF Core DbContext, DbSets, and HasData seed (16 products, 5 categories). SQLite provider. + +3. **Services:** Created ShoppingCartService (IDbContextFactory-based, async CRUD), CartStateService (cookie-based cart ID via IHttpContextAccessor). + +4. **Identity/Auth Stubs:** Register.razor has UserName, Email, Password, ConfirmPassword fields with BWFC TextBox/Button/Label components. Login.razor has Email, Password, RememberMe fields. Both wire to MockAuthService/MockAuthenticationStateProvider. + +5. **Code-Behind Rewrites (33 files):** All .razor.cs files rewritten from raw Web Forms (System.Web, OWIN, Microsoft.AspNet.Identity) to Blazor ComponentBase. Key patterns: IDbContextFactory injection, SupplyParameterFromQuery, OnParametersSetAsync for query-driven data, NavigationManager.NavigateTo for redirects. + +6. **Markup Fixes:** ListView GroupTemplate/LayoutTemplate placeholders → @context. Removed GetRouteUrl calls → href with query params. Removed <%# %> expressions → @context. Fixed ImageButton → Button. Removed runat artifacts. Fixed category links to use /ProductList?id=N. ShoppingCart preserved as BWFC GridView with TItem, Items, editable TextBox, CheckBox, Update/Checkout buttons. + +7. **Misc Cleanup:** Removed , cleaned Mobile layout, stubbed all Account/Checkout pages, AdminPage wired with EF Core data loading. + +**Build round 1 errors (7):** +- CS0103 `Title` not found → class name `_Default` didn't match razor file `Default.razor` +- CS0103 `_categories`/`_cartCount` not found → MainLayout code-behind was class `SiteMaster` in wrong namespace +- CS1503 type mismatch (4x) → BWFC Button OnClick is `EventCallback`, not `MouseEventArgs` + +**Build round 2:** 0 errors, 0 warnings. Clean. + +**Key patterns discovered:** +- BWFC Button.OnClick is `EventCallback` (not MouseEventArgs) — all click handlers must use `EventArgs` +- Code-behind partial class name MUST match the .razor filename exactly (no underscore prefix for Default) +- Layout code-behind namespace must match Components/Layout/ path → `WingtipToys.Components.Layout` +- EF Core wildcard version `10.0.0-*` avoids NU1603 warnings for preview packages + + Team update (2026-03-06): bwfc-migrate.ps1 uses -Path and -Output params (not -SourcePath/-DestinationPath). ProjectName is auto-detected decided by Bishop + + + diff --git a/.squad/agents/cyclops/history.md b/.squad/agents/cyclops/history.md index 9a45fc575..6f1fcd295 100644 --- a/.squad/agents/cyclops/history.md +++ b/.squad/agents/cyclops/history.md @@ -5,1012 +5,27 @@ - **Stack:** C#, Blazor, .NET, ASP.NET Web Forms, bUnit, xUnit, MkDocs, Playwright - **Created:** 2026-02-10 -## Core Context - -**Role:** Component Developer & Lead Toolsmith -**Expertise:** Blazor component implementation, L1/L2 migration scripts, custom controls, data binding, Web Forms patterns - -### Key Responsibilities -- Core component development and pattern establishment (BaseWebFormsComponent, DataBoundComponent, TemplatedWebControl) -- Migration toolkit engineering (L1 script: bwfc-migrate.ps1; L2 transforms) -- Custom control migration patterns (WebControl, DataBoundWebControl, TemplatedWebControl base classes) -- EDMX parser integration for EF6 EF Core migrations - -📌 **Team update (2026-04-11):** WebFormsForm (Issue #533) complete — FormSubmitEventArgs created by Coordinator. Cyclops built WebFormsForm.razor + ES module JS interop (bwfc-webformsform.js). Rogue delivered 39 bUnit tests (FormShimTests.cs, WebFormsFormTests.razor). FormShim dual-mode support enabled (IFormCollection + Dictionary). RequestShim SetRequestFormData() accepts form event args. @inherits ComponentBase fix applied to WebFormsForm. All 4 agents synchronized. Wave 2 (Jubilee/Beast/Colossus) ready. — decided by Coordinator - -📌 **Team update (2026-03-27):** Multi-targeting #516 implemented and validated. BlazorWebFormsComponents now ships net8.0;net9.0;net10.0. All 2606 tests pass × 3 TFMs = 7818 total. Zero code changes, conditional package versions only. CI matrix configured. Ready for GA release. — decided by Forge & Cyclops - -### Active Projects -- L1 Script (bwfc-migrate.ps1): 15/15 test suite (100%), covers 5 core patterns (GetRouteUrl, ContentWrappers, WebFormsAttributes, DataSourceID, Response.Redirect) -- UpdatePanel enhancement: BaseStyledComponent with ContentTemplate RenderFragment, 24 tests, 0 warnings -- Students GridView LEFT JOIN fix: Fixed data-loss bug in enrollment display -- EDMXEF Core parser: Standalone script + L1 integration, handles Model1.edmx 5 entities, 4 FK relationships -- HttpHandlerBase implementation: 7 files, IEndpointConventionBuilder pattern, 94 tests passing - -### Recent Deliverables (2026-03) -- Issue #472: L1 Script bug fixes (3 bugs fixed, test suite 7/10 15/15) -- Issue #451: UpdatePanel ContentTemplate feature -- Issue #475: Students GridView LEFT JOIN fix -- Issue #488: EDMXEF Core parser + L1 integration -- HttpHandlerBase: 7-file Handlers/ structure, Session markers, build 0 errors - -### Pattern Standards -1. **Component Base Classes:** - - BaseWebFormsComponent: Standard controls (properties, events) - - BaseStyledComponent: Style + CssClass support - - DataBoundComponent: Data binding (DataSource, DataBind(), ItemDataBound) - - TemplatedWebControl: RenderFragment templates (RenderTemplate helper) - - WebControl, DataBoundWebControl, TemplatedWebControl: Custom control base classes - -2. **L1 Script Architecture:** - - Modular conversion functions (scope-specific regexes) - - Lookahead validation to prevent false-positive conversions - - Test-driven bug fixes; new patterns need test case coverage - - ItemType standardization across all data-bound controls - -3. **EDMX Migration:** - - Parse 3 XML sections in order: C-S Mapping CSDL SSDL - - Generate entities with EF Core annotations ([Key], [Required], [MaxLength], etc.) - - Generate DbContext with OnModelCreating() fluent chains - - Skip-existing behavior for re-runs - -### Quality Metrics -- 15/15 L1 tests (100% pass rate) -- 94 migration tests passing across all runs -- 0 build errors post-update -- 24/24 UpdatePanel tests passing -- Cross-browser acceptance tests (Playwright) - ---- -## Active Decisions & Alerts - -📌 **Team update (2026-03-24):** ViewState Phase 1 implementation complete & merged — `feature/viewstate-postback-shim` ready. ViewStateDictionary core, mode-adaptive IsPostBack, SSR hidden field round-trip, IDataProtectionProvider integration, CryptographicException fallback. All 2588 tests pass (2 breaking contracts fixed by Coordinator). Phase 2 (SSR persistence integration) approved. — decided by Cyclops - -📌 **Team update (2026-03-17):** HttpHandlerBase implementation validated by Rogue — 94 tests passing, all adapter patterns verified correct. Commit 040fbad5 (15 files, 3218 insertions) on feature/httphandler-base ready for integration. — decided by Rogue - -📌 **Team update (2026-03-17):** Fixed #471 (GUID IDs) and #472 (L1 script). CheckBox/RadioButton/RadioButtonList now use ClientID exclusively; no GUID fallbacks. L1 script test suite: 7/10 → 15/15 (100%). All 2105 tests pass. — decided by Cyclops - -📌 **Team update (2026-03-16):** Forge reviewed Component Health Dashboard PRD; 3 errata items identified before Cyclops implementation. (1) Appendix A: ToolTip base class error. (2) tools/WebFormsPropertyCounter/ doesn't exist—use MSDN curation as Phase 1 primary. (3) Acceptance criterion #9 needs verification (Login controls had 0 bUnit tests as of 2026-03-15). See decisions.md for full details. — decided by Forge - ## Learnings - - -### Archived Sessions - -- Core Context (2026-02-10 through 2026-02-27) -- M17-M20 Wave 1 Context (2026-02-27 through 2026-03-01) -- M20 Theming through Migration Benchmarks (2026-03-01 through 2026-03-04) -- Script & Toolkit Summary (2026-03-02 through 2026-03-04) -- GetRouteUrl, Run 5 & Toolkit Sync Summary (2026-03-04 through 2026-03-05) - - - -- Run 6 Script Enhancements (2026-03-05) -- @rendermode Scaffold Fix (2026-03-05) -- WebFormsPageBase Implementation (2026-03-05) -- WebFormsPage IPageService Consolidation (2026-03-05) -- LoginView Migration Script Fix (2026-03-06) -- Run 9 Script Fixes — 9 RF items (2026-03-06) -- Layer 2 AfterWingtipToys Build Conversion (2026-03-06) - -### Summary (2026-03-05 through 2026-03-07 pre-Run 11) - -Run 6: 4 script enhancements (TFM, SelectMethod TODO, wwwroot copy, stubs). @rendermode fix: removed standalone directive from _Imports.razor scaffold — `@rendermode` is a directive *attribute* for component instances only. WebFormsPageBase: `ComponentBase` subclass with `Page => this`, Title/MetaDescription/MetaKeywords delegates, `IsPostBack => false`. WebFormsPage consolidation: merged Page.razor head rendering into WebFormsPage via Option B. LoginView script fix: `` → `` (not AuthorizeView), preserve template names. Run 9: 9 script fixes (Models copy, DbContext transform, EF6→EF Core, redirect detection, Program.cs boilerplate, Page Title extraction, QueryString/RouteData annotations, ListView GroupItemCount, csproj packages). Layer 2: full AfterWingtipToys conversion — key pattern: layout code-behind class name MUST match .razor filename. Auth pages use plain HTML forms with HTTP endpoints. - - - -- Run 11 -- Complete WingtipToys Migration from Scratch (2026-03-07) -- Run 11 Script Fixes -- Fix 1 (Invoke-ScriptAutoDetection) & Fix 2 (Convert-TemplatePlaceholders) (2026-03-07) -- Run 12 -- Complete WingtipToys Migration from Scratch (2026-03-07) -- LoginView Namespace Fix (2026-03-07) -- Run 13 -- Full WingtipToys Migration Pipeline, 25/25 tests (2026-03-08) - -### Summary (2026-03-07 through 2026-03-08) - -Run 11: Fresh WingtipToys migration from scratch (105 files, 0 errors). Key patterns: root-level `_Imports.razor` for pages outside `Components/`, partial classes must NOT specify `: ComponentBase` with `@inherits WebFormsPageBase`, auth pages use plain HTML forms to HTTP endpoints. Run 11 script fixes: `Invoke-ScriptAutoDetection` (JS files to wwwroot/Scripts/ with correct dependency order) and `Convert-TemplatePlaceholders` (placeholder elements to `@context`). Run 12: Full pipeline with Layer 2, established dual DbContext pattern (later superseded by factory-only in Run 13). LoginView namespace fix: `@using BlazorWebFormsComponents.LoginControls` required in `_Imports.razor` -- added to script template. Run 13: 25/25 tests passed (100%). Confirmed patterns: SSR default, `data-enhance-nav="false"` for minimal API links, `data-enhance="false"` for auth forms, `AddDbContextFactory` only (no dual registration), middleware order `UseAuthentication -> UseAuthorization -> UseAntiforgery`, logout must use `` not ` + + Back to Products +
    + + + } + + + + +@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 74586c94f..25d4e5be0 100644 --- a/samples/AfterWingtipToys/ProductDetails.razor.cs +++ b/samples/AfterWingtipToys/ProductDetails.razor.cs @@ -1,51 +1,72 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.EntityFrameworkCore; -using WingtipToys.Data; +// ============================================================================= +// 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 WingtipToys.Models; - -namespace WingtipToys; - -public partial class ProductDetails +namespace WingtipToys { - [Inject] private IDbContextFactory DbFactory { get; set; } = default!; + 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. - [SupplyParameterFromQuery(Name = "ProductID")] - public int? ProductId { get; set; } + // --- 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. - [SupplyParameterFromQuery(Name = "productName")] - public string? ProductName { 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. - private IQueryable GetProduct( - int maxRows, int startRowIndex, string sortByExpression, out int totalRowCount) + protected override async Task OnInitializedAsync() { - var db = DbFactory.CreateDbContext(); - IQueryable query = db.Products; + // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior + await base.OnInitializedAsync(); - if (ProductId.HasValue && ProductId > 0) - { - query = query.Where(p => p.ProductID == ProductId); - } - else if (!string.IsNullOrEmpty(ProductName)) - { - query = query.Where(p => - string.Compare(p.ProductName, ProductName) == 0); - } - else - { - totalRowCount = 0; - db.Dispose(); - return Enumerable.Empty().AsQueryable(); - } - totalRowCount = query.Count(); - var results = query.ToList(); - db.Dispose(); - return results.AsQueryable(); } - protected override async Task OnInitializedAsync() + public IQueryable GetProduct( + [QueryString("ProductID")] int? productId, + [RouteData] string productName) { - Page.Title = "Product Details"; - await Task.CompletedTask; + var _db = new WingtipToys.Models.ProductContext(); + IQueryable query = _db.Products; + if (productId.HasValue && productId > 0) + { + query = query.Where(p => p.ProductID == productId); + } + else if (!String.IsNullOrEmpty(productName)) + { + query = query.Where(p => + String.Compare(p.ProductName, productName) == 0); + } + else + { + query = null; + } + return query; } -} + } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/ProductList.razor b/samples/AfterWingtipToys/ProductList.razor index 711f8a5a0..fd8afe78f 100644 --- a/samples/AfterWingtipToys/ProductList.razor +++ b/samples/AfterWingtipToys/ProductList.razor @@ -1,81 +1,63 @@ @page "/ProductList" +@inject CatalogService Catalog +@inject NavigationManager Navigation + Products -
    -
    -
    -

    @(Page.Title)

    -
    + + + +

    @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); + + private string Heading + { + get + { + if (!CategoryId.HasValue) + { + return "All Products"; + } - - - - - - -
    No data was returned.
    -
    - -
- - -
- - - - - - - - - - -
- - - -
- - @context.ProductName - -
- - Price: @($"{context.UnitPrice:c}") - -
- - - Add To Cart - - -
 
-
- - - - - - - - - -
- - @context -
-
- - -
-
+ 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; + } +} diff --git a/samples/AfterWingtipToys/ProductList.razor.cs b/samples/AfterWingtipToys/ProductList.razor.cs index da963d96c..db07f7b56 100644 --- a/samples/AfterWingtipToys/ProductList.razor.cs +++ b/samples/AfterWingtipToys/ProductList.razor.cs @@ -1,46 +1,71 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.EntityFrameworkCore; -using WingtipToys.Data; +// ============================================================================= +// 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 WingtipToys.Models; - -namespace WingtipToys; - -public partial class ProductList +namespace WingtipToys { - [Inject] private IDbContextFactory DbFactory { get; set; } = default!; + 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. - [SupplyParameterFromQuery(Name = "id")] - public int? CategoryId { get; set; } + // --- 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. - [SupplyParameterFromQuery(Name = "categoryName")] - public string? CategoryName { get; set; } + private ListView productList = default!; + // --- ConfigurationManager Migration --- + // TODO(bwfc-config): ConfigurationManager calls work via BWFC shim. + // Ensure app.UseConfigurationManagerShim() is called in Program.cs. - private IQueryable GetProducts( - int maxRows, int startRowIndex, string sortByExpression, out int totalRowCount) + protected override async Task OnInitializedAsync() { - var db = DbFactory.CreateDbContext(); - IQueryable query = db.Products.Include(p => p.Category); - - if (CategoryId.HasValue && CategoryId > 0) - { - query = query.Where(p => p.CategoryID == CategoryId); - } - - if (!string.IsNullOrEmpty(CategoryName)) - { - query = query.Where(p => - string.Compare(p.Category!.CategoryName, CategoryName) == 0); - } - - totalRowCount = query.Count(); - var results = query.ToList(); - db.Dispose(); - return results.AsQueryable(); + // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior + await base.OnInitializedAsync(); + + } - protected override async Task OnInitializedAsync() + public IQueryable GetProducts( + [QueryString("id")] int? categoryId, + [RouteData] string categoryName) { - Page.Title = "Products"; - await Task.CompletedTask; + var _db = new WingtipToys.Models.ProductContext(); + IQueryable query = _db.Products; + + if (categoryId.HasValue && categoryId > 0) + { + query = query.Where(p => p.CategoryID == categoryId); + } + + if (!String.IsNullOrEmpty(categoryName)) + { + query = query.Where(p => + String.Compare(p.Category.CategoryName, + categoryName) == 0); + } + return query; } -} + } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/Program.cs b/samples/AfterWingtipToys/Program.cs index 007bcba7f..b34e265ba 100644 --- a/samples/AfterWingtipToys/Program.cs +++ b/samples/AfterWingtipToys/Program.cs @@ -1,47 +1,88 @@ -using WingtipToys.Data; using BlazorWebFormsComponents; -using Microsoft.EntityFrameworkCore; -using WingtipToys.Models; +using WingtipToys.Services; var builder = WebApplication.CreateBuilder(args); builder.Services.AddRazorComponents(); - builder.Services.AddBlazorWebFormsComponents(); - -builder.Services.AddDbContextFactory(options => - options.UseSqlite("Data Source=wingtiptoys.db")); - +builder.Services.AddDistributedMemoryCache(); +builder.Services.AddSession(options => +{ + options.Cookie.HttpOnly = true; + options.Cookie.IsEssential = true; + options.IdleTimeout = TimeSpan.FromMinutes(30); +}); builder.Services.AddHttpContextAccessor(); -// Minimal auth services so pages using AuthorizeView/CascadingAuthenticationState don't crash. -// Full Identity is not yet configured — all users appear anonymous. -builder.Services.AddAuthorization(); -builder.Services.AddCascadingAuthenticationState(); -builder.Services.AddAuthentication().AddCookie(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); var app = builder.Build(); -// Seed the database -using (var scope = app.Services.CreateScope()) -{ - var context = scope.ServiceProvider.GetRequiredService(); - ProductDatabaseInitializer.Seed(context); -} - if (!app.Environment.IsDevelopment()) { - app.UseExceptionHandler("/Error"); + app.UseExceptionHandler("/ErrorPage"); app.UseHsts(); } app.UseHttpsRedirection(); -app.UseStaticFiles(); app.MapStaticAssets(); +app.UseSession(); app.UseAntiforgery(); -app.UseAuthentication(); -app.UseAuthorization(); +app.MapGet("/AddToCart", (int productID, CartService cartService) => +{ + cartService.AddToCart(productID); + return Results.Redirect("/ShoppingCart"); +}); + +app.MapGet("/Cart/Update", (int productId, int quantity, CartService cartService) => +{ + cartService.UpdateQuantity(productId, quantity); + return Results.Redirect("/ShoppingCart"); +}); + +app.MapGet("/Cart/Remove", (int productId, CartService cartService) => +{ + cartService.Remove(productId); + return Results.Redirect("/ShoppingCart"); +}); + +app.MapGet("/Account/PerformRegister", (string? email, string? password, string? confirmPassword, UserStoreService userStore) => +{ + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) + { + return Results.Redirect("/Account/Register?error=Email%20and%20password%20are%20required"); + } + + if (!string.Equals(password, confirmPassword, StringComparison.Ordinal)) + { + 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")}"); +}); + +app.MapGet("/Account/PerformLogin", (string? email, string? password, UserStoreService userStore) => +{ + 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")}"); +}); + +app.MapGet("/Account/Logout", (UserStoreService userStore) => +{ + userStore.Logout(); + return Results.Redirect("/"); +}); app.MapRazorComponents(); diff --git a/samples/AfterWingtipToys/RouteConfig.cs b/samples/AfterWingtipToys/RouteConfig.cs new file mode 100644 index 000000000..92cd0cdd0 --- /dev/null +++ b/samples/AfterWingtipToys/RouteConfig.cs @@ -0,0 +1,18 @@ +// TODO: Review — auto-copied from App_Start. Blazor has no App_Start convention. +// TODO: Move relevant configuration to Program.cs or appropriate service registration. + +using System; +using System.Collections.Generic; +// // BWFC: RouteConfig stubs available via BlazorWebFormsComponents namespace +namespace WingtipToys +{ + public static class RouteConfig + { + public static void RegisterRoutes(RouteCollection routes) + { + var settings = new FriendlyUrlSettings(); + settings.AutoRedirectMode = RedirectMode.Permanent; + routes.EnableFriendlyUrls(settings); + } + } +} diff --git a/samples/AfterWingtipToys/Services/CartService.cs b/samples/AfterWingtipToys/Services/CartService.cs new file mode 100644 index 000000000..98d187767 --- /dev/null +++ b/samples/AfterWingtipToys/Services/CartService.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using WingtipToys.Models; + +namespace WingtipToys.Services; + +public sealed class CartService +{ + private const string CartSessionKey = "Wingtip.Cart"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly CatalogService _catalogService; + + public CartService(IHttpContextAccessor httpContextAccessor, CatalogService catalogService) + { + _httpContextAccessor = httpContextAccessor; + _catalogService = catalogService; + } + + public IReadOnlyList GetItems() + { + return LoadRecords() + .Select(record => new CartLine(_catalogService.GetProduct(record.ProductId), record.Quantity)) + .Where(line => line.Product is not null) + .Select(line => new CartLine(line.Product!, line.Quantity)) + .ToList(); + } + + public int GetCount() => GetItems().Sum(item => item.Quantity); + + public decimal GetTotal() + { + return GetItems().Sum(item => (decimal)(item.Product.UnitPrice ?? 0) * item.Quantity); + } + + public void AddToCart(int productId) + { + var records = LoadRecords(); + var existing = records.FirstOrDefault(record => record.ProductId == productId); + if (existing is null) + { + records.Add(new CartRecord { ProductId = productId, Quantity = 1 }); + } + else + { + existing.Quantity += 1; + } + + SaveRecords(records); + } + + public void UpdateQuantity(int productId, int quantity) + { + if (quantity <= 0) + { + Remove(productId); + return; + } + + var records = LoadRecords(); + var existing = records.FirstOrDefault(record => record.ProductId == productId); + if (existing is null) + { + return; + } + + existing.Quantity = quantity; + SaveRecords(records); + } + + public void Remove(int productId) + { + var records = LoadRecords(); + records.RemoveAll(record => record.ProductId == productId); + SaveRecords(records); + } + + private List LoadRecords() + { + var json = _httpContextAccessor.HttpContext?.Session.GetString(CartSessionKey); + if (string.IsNullOrWhiteSpace(json)) + { + return []; + } + + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + + private void SaveRecords(List records) + { + _httpContextAccessor.HttpContext?.Session.SetString(CartSessionKey, JsonSerializer.Serialize(records, JsonOptions)); + } + + public sealed record CartLine(Product Product, int Quantity); + + private sealed class CartRecord + { + public int ProductId { get; set; } + + public int Quantity { get; set; } + } +} diff --git a/samples/AfterWingtipToys/Services/CartStateService.cs b/samples/AfterWingtipToys/Services/CartStateService.cs deleted file mode 100644 index 41ab9c169..000000000 --- a/samples/AfterWingtipToys/Services/CartStateService.cs +++ /dev/null @@ -1,46 +0,0 @@ -using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; - -namespace WingtipToys.Services; - -/// -/// Manages the shopping cart ID for the current user session via a cookie-based approach. -/// -public class CartStateService -{ - private readonly IHttpContextAccessor _httpContextAccessor; - private string? _cartId; - - public CartStateService(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - public string CartId - { - get - { - if (_cartId != null) return _cartId; - - var context = _httpContextAccessor.HttpContext; - if (context != null) - { - _cartId = context.Request.Cookies["CartId"]; - if (string.IsNullOrEmpty(_cartId)) - { - _cartId = Guid.NewGuid().ToString(); - context.Response.Cookies.Append("CartId", _cartId, new CookieOptions - { - HttpOnly = true, - Expires = DateTimeOffset.Now.AddDays(30) - }); - } - } - else - { - _cartId = Guid.NewGuid().ToString(); - } - - return _cartId; - } - } -} diff --git a/samples/AfterWingtipToys/Services/CatalogService.cs b/samples/AfterWingtipToys/Services/CatalogService.cs new file mode 100644 index 000000000..05a06e534 --- /dev/null +++ b/samples/AfterWingtipToys/Services/CatalogService.cs @@ -0,0 +1,67 @@ +using WingtipToys.Models; + +namespace WingtipToys.Services; + +public sealed class CatalogService +{ + private readonly IReadOnlyList _categories; + private readonly IReadOnlyList _products; + + public CatalogService() + { + _categories = + [ + new Category { CategoryID = 1, CategoryName = "Boats", Description = "Boats and ships" }, + new Category { CategoryID = 2, CategoryName = "Cars", Description = "Cars and buses" }, + new Category { CategoryID = 3, CategoryName = "Planes", Description = "Planes and rockets" }, + new Category { CategoryID = 4, CategoryName = "Trucks", Description = "Trucks and work vehicles" } + ]; + + _products = + [ + NewProduct(1, "Paper Boat", "A colorful paper boat for imaginative water adventures.", 1, "boatpaper.png", 8.99), + NewProduct(2, "Big Boat", "A classic toy boat with bold colors and durable construction.", 1, "boatbig.png", 14.99), + NewProduct(3, "Sail Boat", "A sail boat inspired by the original Wingtip catalog.", 1, "boatsail.png", 12.49), + NewProduct(4, "Red Bus", "A bright red bus ready for city routes.", 2, "busred.png", 10.99), + NewProduct(5, "Fast Car", "A speedy toy car with racing stripes.", 2, "carfast.png", 11.99), + NewProduct(6, "Racer", "A high-performance toy racer for the fastest laps.", 2, "carracer.png", 13.99), + NewProduct(7, "Ace Plane", "A stunt-ready prop plane with vivid graphics.", 3, "planeace.png", 15.49), + NewProduct(8, "Paper Plane", "A playful paper-style plane for flight fans.", 3, "planepaper.png", 7.99), + NewProduct(9, "Rocket", "A classic rocket toy for out-of-this-world play.", 3, "rocket.png", 16.99), + NewProduct(10, "Fire Truck", "A fire truck with ladder detail and bright paint.", 4, "truckfire.png", 13.49), + NewProduct(11, "Big Truck", "A durable truck built for heavy-duty hauling.", 4, "truckbig.png", 14.49) + ]; + + foreach (var product in _products) + { + product.Category = _categories.First(category => category.CategoryID == product.CategoryID); + } + } + + public IReadOnlyList GetCategories() => _categories; + + public IReadOnlyList GetProducts(int? categoryId = null) + { + return categoryId.HasValue + ? _products.Where(product => product.CategoryID == categoryId.Value).ToList() + : _products; + } + + public Product? GetProduct(int productId) + { + return _products.FirstOrDefault(product => product.ProductID == productId); + } + + private static Product NewProduct(int id, string name, string description, int categoryId, string imagePath, double price) + { + return new Product + { + ProductID = id, + ProductName = name, + Description = description, + CategoryID = categoryId, + ImagePath = imagePath, + UnitPrice = price + }; + } +} diff --git a/samples/AfterWingtipToys/Services/MockAuthService.cs b/samples/AfterWingtipToys/Services/MockAuthService.cs deleted file mode 100644 index fa841b4cb..000000000 --- a/samples/AfterWingtipToys/Services/MockAuthService.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace WingtipToys.Services; - -public class MockAuthService -{ - // Static store so registered users persist across scoped service instances (Blazor circuits) - private static readonly Dictionary _users = new(StringComparer.OrdinalIgnoreCase) - { - ["admin@wingtiptoys.com"] = "Pass@word1" - }; - - public Task AuthenticateAsync(string email, string password) - { - return Task.FromResult(_users.TryGetValue(email, out var stored) && stored == password); - } - - public Task<(bool Success, string? Error)> CreateUserAsync(string email, string password) - { - if (_users.ContainsKey(email)) - return Task.FromResult((false, (string?)"A user with that email already exists.")); - _users[email] = password; - return Task.FromResult((true, (string?)null)); - } -} diff --git a/samples/AfterWingtipToys/Services/MockAuthenticationStateProvider.cs b/samples/AfterWingtipToys/Services/MockAuthenticationStateProvider.cs deleted file mode 100644 index e6f37bfbb..000000000 --- a/samples/AfterWingtipToys/Services/MockAuthenticationStateProvider.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Microsoft.AspNetCore.Components.Authorization; -using System.Security.Claims; - -namespace WingtipToys.Services; - -public class MockAuthenticationStateProvider : AuthenticationStateProvider -{ - private ClaimsPrincipal _currentUser; - - public MockAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor) - { - // Read auth state from the HTTP context (available at circuit start). - // The cookie middleware has already populated HttpContext.User. - var user = httpContextAccessor.HttpContext?.User; - _currentUser = user?.Identity?.IsAuthenticated == true - ? user - : new ClaimsPrincipal(new ClaimsIdentity()); - } - - public override Task GetAuthenticationStateAsync() - => Task.FromResult(new AuthenticationState(_currentUser)); -} diff --git a/samples/AfterWingtipToys/Services/ShoppingCartService.cs b/samples/AfterWingtipToys/Services/ShoppingCartService.cs deleted file mode 100644 index 5d660b672..000000000 --- a/samples/AfterWingtipToys/Services/ShoppingCartService.cs +++ /dev/null @@ -1,102 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using WingtipToys.Data; -using WingtipToys.Models; - -namespace WingtipToys.Services; - -public class ShoppingCartService -{ - private readonly IDbContextFactory _dbFactory; - private readonly CartStateService _cartState; - - public ShoppingCartService(IDbContextFactory dbFactory, CartStateService cartState) - { - _dbFactory = dbFactory; - _cartState = cartState; - } - - public async Task> GetCartItemsAsync() - { - using var db = await _dbFactory.CreateDbContextAsync(); - var cartId = _cartState.CartId; - return await db.ShoppingCartItems - .Where(c => c.CartId == cartId) - .Include(c => c.Product) - .ToListAsync(); - } - - public async Task AddToCartAsync(int productId) - { - using var db = await _dbFactory.CreateDbContextAsync(); - var cartId = _cartState.CartId; - - var item = await db.ShoppingCartItems - .SingleOrDefaultAsync(c => c.CartId == cartId && c.ProductId == productId); - - if (item == null) - { - item = new CartItem - { - ItemId = Guid.NewGuid().ToString(), - ProductId = productId, - CartId = cartId, - Quantity = 1, - DateCreated = DateTime.Now - }; - db.ShoppingCartItems.Add(item); - } - else - { - item.Quantity++; - } - - await db.SaveChangesAsync(); - } - - public async Task UpdateCartItemAsync(int productId, int quantity) - { - using var db = await _dbFactory.CreateDbContextAsync(); - var cartId = _cartState.CartId; - var item = await db.ShoppingCartItems - .SingleOrDefaultAsync(c => c.CartId == cartId && c.ProductId == productId); - - if (item != null) - { - item.Quantity = quantity; - await db.SaveChangesAsync(); - } - } - - public async Task RemoveCartItemAsync(int productId) - { - using var db = await _dbFactory.CreateDbContextAsync(); - var cartId = _cartState.CartId; - var item = await db.ShoppingCartItems - .SingleOrDefaultAsync(c => c.CartId == cartId && c.ProductId == productId); - - if (item != null) - { - db.ShoppingCartItems.Remove(item); - await db.SaveChangesAsync(); - } - } - - public async Task GetTotalAsync() - { - using var db = await _dbFactory.CreateDbContextAsync(); - var cartId = _cartState.CartId; - var total = await db.ShoppingCartItems - .Where(c => c.CartId == cartId) - .SumAsync(c => (decimal)(c.Quantity * (c.Product.UnitPrice ?? 0))); - return total; - } - - public async Task GetCountAsync() - { - using var db = await _dbFactory.CreateDbContextAsync(); - var cartId = _cartState.CartId; - return await db.ShoppingCartItems - .Where(c => c.CartId == cartId) - .SumAsync(c => c.Quantity); - } -} diff --git a/samples/AfterWingtipToys/Services/UserStoreService.cs b/samples/AfterWingtipToys/Services/UserStoreService.cs new file mode 100644 index 000000000..cc1f0f69d --- /dev/null +++ b/samples/AfterWingtipToys/Services/UserStoreService.cs @@ -0,0 +1,53 @@ +using System.Collections.Concurrent; +using Microsoft.AspNetCore.Http; + +namespace WingtipToys.Services; + +public sealed class UserStoreService +{ + public const string CurrentUserSessionKey = "Wingtip.CurrentUserEmail"; + + private static readonly ConcurrentDictionary Users = new(StringComparer.OrdinalIgnoreCase); + + private readonly IHttpContextAccessor _httpContextAccessor; + + public UserStoreService(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public string? GetCurrentUserEmail() + { + return _httpContextAccessor.HttpContext?.Session.GetString(CurrentUserSessionKey); + } + + public bool Register(string email, string password, out string? error) + { + if (!Users.TryAdd(email, password)) + { + error = "That account already exists."; + return false; + } + + error = null; + return true; + } + + public bool Login(string email, string password, out string? error) + { + if (!Users.TryGetValue(email, out var storedPassword) || !string.Equals(storedPassword, password, StringComparison.Ordinal)) + { + error = "Invalid email or password."; + return false; + } + + _httpContextAccessor.HttpContext?.Session.SetString(CurrentUserSessionKey, email); + error = null; + return true; + } + + public void Logout() + { + _httpContextAccessor.HttpContext?.Session.Remove(CurrentUserSessionKey); + } +} diff --git a/samples/AfterWingtipToys/ShoppingCart.razor b/samples/AfterWingtipToys/ShoppingCart.razor index e3d651497..4187a11fb 100644 --- a/samples/AfterWingtipToys/ShoppingCart.razor +++ b/samples/AfterWingtipToys/ShoppingCart.razor @@ -1,52 +1,84 @@ @page "/ShoppingCart" -

@_cartTitle

- - - - - - - - - - - - - @($"{(Convert.ToDouble(context.Quantity) * Convert.ToDouble(context.Product?.UnitPrice ?? 0)):c}") - - - - - - - - - -
-

- - - - -
-
- @if (_showButtons) +@inject CartService Cart +@inject NavigationManager Navigation + +Shopping Cart + + + +
+

Shopping Cart

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

Your shopping cart is empty.

+ } + else + { + + + + + + + + + + + + + @foreach (var item in Items) + { + + + + + + + + + } + +
IDNamePrice (each)QuantityItem TotalRemove Item
@item.Product.ProductID@item.Product.ProductName@($"{item.Product.UnitPrice:c}") +
+ + + +
+
@($"{item.Product.UnitPrice * item.Quantity:c}") +
+ + +
+
+ +

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

+ } +
+
+
+ +@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 2e0a863ff..918e3043e 100644 --- a/samples/AfterWingtipToys/ShoppingCart.razor.cs +++ b/samples/AfterWingtipToys/ShoppingCart.razor.cs @@ -1,86 +1,151 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.EntityFrameworkCore; -using WingtipToys.Data; -using WingtipToys.Models; +// ============================================================================= +// 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 +// ============================================================================= -namespace WingtipToys; +// --- 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 -public partial class ShoppingCart +using System; +using System.Collections.Generic; +using System.Linq; +using WingtipToys.Models; +using WingtipToys.Logic; +using System.Collections.Specialized; +using System.Collections; +namespace WingtipToys { - [Inject] private IDbContextFactory DbFactory { get; set; } = default!; - [Inject] private IHttpContextAccessor HttpContextAccessor { get; set; } = default!; - [Inject] private NavigationManager NavigationManager { get; set; } = default!; + 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. - private List _cartItems = new(); - private string _cartTotal = ""; - private string _totalLabelText = "Order Total: "; - private string _cartTitle = "Shopping Cart"; - private bool _showButtons = true; + // --- 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. - protected override async Task OnInitializedAsync() - { - await LoadCart(); - } + // --- 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. - private async Task LoadCart() + protected override async Task OnInitializedAsync() { - using var db = DbFactory.CreateDbContext(); - var cartId = GetCartId(); - _cartItems = await db.ShoppingCartItems - .Where(c => c.CartId == cartId) - .Include(c => c.Product) - .ToListAsync(); + // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior + await base.OnInitializedAsync(); - var cartTotal = _cartItems.Sum(c => c.Quantity * (c.Product?.UnitPrice ?? 0)); + using (ShoppingCartActions usersShoppingCart = new ShoppingCartActions()) + { + decimal cartTotal = 0; + cartTotal = usersShoppingCart.GetTotal(); if (cartTotal > 0) { - _cartTotal = string.Format("{0:c}", cartTotal); + // Display Total. + lblTotal.Text = String.Format("{0:c}", cartTotal); } else { - _totalLabelText = ""; - _cartTotal = ""; - _cartTitle = "Shopping Cart is Empty"; - _showButtons = false; + LabelTotalText.Text = ""; + lblTotal.Text = ""; + ShoppingCartTitle.InnerText = "Shopping Cart is Empty"; + UpdateBtn.Visible = false; + CheckoutImageBtn.Visible = false; } + } } - private IQueryable GetShoppingCartItems( - int maxRows, int startRowIndex, string sortByExpression, out int totalRowCount) + public List GetShoppingCartItems() { - var db = DbFactory.CreateDbContext(); - var cartId = GetCartId(); - var query = db.ShoppingCartItems - .Where(c => c.CartId == cartId) - .Include(c => c.Product); - totalRowCount = query.Count(); - var results = query.ToList(); - db.Dispose(); - return results.AsQueryable(); + ShoppingCartActions actions = new ShoppingCartActions(); + return actions.GetCartItems(); } - private string GetCartId() + public List UpdateCartItems() { - var httpContext = HttpContextAccessor.HttpContext; - if (httpContext?.Request.Cookies.TryGetValue("CartId", out var cartId) == true && !string.IsNullOrEmpty(cartId)) + 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++) { - return cartId; + 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(); + } + } - var newCartId = Guid.NewGuid().ToString(); - httpContext?.Response.Cookies.Append("CartId", newCartId, new CookieOptions { Expires = DateTimeOffset.Now.AddDays(30) }); - return newCartId; + 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; } - private async Task UpdateBtn_Click(EventArgs e) + protected void UpdateBtn_Click() { - // TODO: Implement cart update logic — read quantities from form, update DB - await LoadCart(); + UpdateCartItems(); } - private void CheckoutBtn_Click(EventArgs e) + protected void CheckoutBtn_Click(ImageClickEventArgs e) { - // TODO: Implement PayPal checkout start — store payment amount and redirect - NavigationManager.NavigateTo("/CheckoutStart"); + using (ShoppingCartActions usersShoppingCart = new ShoppingCartActions()) + { + Session["payment_amt"] = usersShoppingCart.GetTotal(); + } + Response.Redirect("Checkout/CheckoutStart.aspx"); } -} + } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/Site.Mobile.razor b/samples/AfterWingtipToys/Site.Mobile.razor new file mode 100644 index 000000000..be0d2c3a2 --- /dev/null +++ b/samples/AfterWingtipToys/Site.Mobile.razor @@ -0,0 +1,6 @@ + + +

Mobile View

+

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

+
+
diff --git a/samples/AfterWingtipToys/Site.Mobile.razor.cs b/samples/AfterWingtipToys/Site.Mobile.razor.cs new file mode 100644 index 000000000..664ed2917 --- /dev/null +++ b/samples/AfterWingtipToys/Site.Mobile.razor.cs @@ -0,0 +1,51 @@ +// ============================================================================= +// 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; +namespace WingtipToys +{ + public partial class Site_Mobile + { + // 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. + + private ContentPlaceHolder FeaturedContent = default!; + private ContentPlaceHolder HeadContent = default!; + private ContentPlaceHolder MainContent = 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(); + + + } + } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/Site.razor b/samples/AfterWingtipToys/Site.razor new file mode 100644 index 000000000..cd85fe61d --- /dev/null +++ b/samples/AfterWingtipToys/Site.razor @@ -0,0 +1,84 @@ +@inject CatalogService Catalog +@inject CartService Cart +@inject UserStoreService UserStore + + + + + + + + +
+ + Wingtip Toys + +
+
+ +
+ @foreach (var category in Catalog.GetCategories()) + { + + @category.CategoryName + + } +
+ +
+ +
+
+

© 2013 - Wingtip Toys

+
+
+
+ + @ChildComponents + @ChildContent + +
+ +@code { + [Parameter] + public RenderFragment? ChildComponents { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + private string? CurrentUserEmail => UserStore.GetCurrentUserEmail(); +} diff --git a/samples/AfterWingtipToys/Site.razor.cs b/samples/AfterWingtipToys/Site.razor.cs new file mode 100644 index 000000000..25ed78cd8 --- /dev/null +++ b/samples/AfterWingtipToys/Site.razor.cs @@ -0,0 +1,143 @@ +// ============================================================================= +// 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.Security.Claims; +using System.Security.Principal; +using System.Linq; +using WingtipToys.Models; +using WingtipToys.Logic; + +namespace WingtipToys +{ + public partial class Site + { + // 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. + + private ListView categoryList = default!; + private Image Image1 = default!; + private ContentPlaceHolder MainContent = default!; + // --- ConfigurationManager Migration --- + // TODO(bwfc-config): ConfigurationManager calls work via BWFC shim. + // Ensure app.UseConfigurationManagerShim() is called in Program.cs. + + private const string AntiXsrfTokenKey = "__AntiXsrfToken"; + private const string AntiXsrfUserNameKey = "__AntiXsrfUserName"; + private string _antiXsrfTokenValue; + + protected override void OnInitialized() + { + // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior + + // The code below helps to protect against XSRF attacks + var requestCookie = Request.Cookies[AntiXsrfTokenKey]; + Guid requestCookieGuidValue; + if (requestCookie != null && Guid.TryParse(requestCookie.Value, out requestCookieGuidValue)) + { + // Use the Anti-XSRF token from the cookie + _antiXsrfTokenValue = requestCookie.Value; + Page.ViewStateUserKey = _antiXsrfTokenValue; + } + else + { + // Generate a new Anti-XSRF token and save to the cookie + _antiXsrfTokenValue = Guid.NewGuid().ToString("N"); + Page.ViewStateUserKey = _antiXsrfTokenValue; + + var responseCookie = new HttpCookie(AntiXsrfTokenKey) + { + HttpOnly = true, + Value = _antiXsrfTokenValue + }; + if (FormsAuthentication.RequireSSL && Request.IsSecureConnection) + { + responseCookie.Secure = true; + } + Response.Cookies.Set(responseCookie); + } + + Page.PreLoad += master_Page_PreLoad; + } + + protected void master_Page_PreLoad() + { + // BWFC: IsPostBack guard unwrapped — Blazor re-renders on every state change + // Set Anti-XSRF token + ViewState[AntiXsrfTokenKey] = Page.ViewStateUserKey; + ViewState[AntiXsrfUserNameKey] = Context.User.Identity.Name ?? String.Empty; + } + + protected override async Task OnInitializedAsync() + { + // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior + await base.OnInitializedAsync(); + + if (HttpContext.Current.User.IsInRole("canEdit")) + { + adminLink.Visible = true; + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior + if (firstRender) + { + using (ShoppingCartActions usersShoppingCart = new ShoppingCartActions()) + { + string cartStr = string.Format("Cart ({0})", usersShoppingCart.GetCount()); + cartCount.InnerText = cartStr; + } + } + } + + public IQueryable GetCategories() + { + var _db = new WingtipToys.Models.ProductContext(); + IQueryable query = _db.Categories; + return query; + } + + protected void Unnamed_LoggingOut(LoginCancelEventArgs e) + { + Context.GetOwinContext().Authentication.SignOut(); + } + + + private void HandlePostBack() + { + // TODO(bwfc-ispostback): Wire HandlePostBack() to appropriate Blazor event handlers (e.g., button Click, form Submit) + // Validate the Anti-XSRF token + if ((string)ViewState[AntiXsrfTokenKey] != _antiXsrfTokenValue + || (string)ViewState[AntiXsrfUserNameKey] != (Context.User.Identity.Name ?? String.Empty)) + { + throw new InvalidOperationException("Validation of Anti-XSRF token failed."); + } + } + } + +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/Startup.Auth.cs b/samples/AfterWingtipToys/Startup.Auth.cs new file mode 100644 index 000000000..c41904450 --- /dev/null +++ b/samples/AfterWingtipToys/Startup.Auth.cs @@ -0,0 +1,64 @@ +// TODO: Review — auto-copied from App_Start. Blazor has no App_Start convention. +// TODO: Move relevant configuration to Program.cs or appropriate service registration. + +using System; +using WingtipToys.Models; + +namespace WingtipToys +{ + public partial class Startup { + + // For more information on configuring authentication, please visit http://go.microsoft.com/fwlink/?LinkId=301883 + public void ConfigureAuth(IAppBuilder app) + { + // Configure the db context, user manager and signin manager to use a single instance per request + app.CreatePerOwinContext(ApplicationDbContext.Create); + app.CreatePerOwinContext(ApplicationUserManager.Create); + app.CreatePerOwinContext(ApplicationSignInManager.Create); + + // Enable the application to use a cookie to store information for the signed in user + // and to use a cookie to temporarily store information about a user logging in with a third party login provider + // Configure the sign in cookie + app.UseCookieAuthentication(new CookieAuthenticationOptions + { + AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie, + LoginPath = new PathString("/Account/Login"), + Provider = new CookieAuthenticationProvider + { + OnValidateIdentity = SecurityStampValidator.OnValidateIdentity( + validateInterval: TimeSpan.FromMinutes(30), + regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager)) + } + }); + // Use a cookie to temporarily store information about a user logging in with a third party login provider + app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); + + // Enables the application to temporarily store user information when they are verifying the second factor in the two-factor authentication process. + app.UseTwoFactorSignInCookie(DefaultAuthenticationTypes.TwoFactorCookie, TimeSpan.FromMinutes(5)); + + // Enables the application to remember the second login verification factor such as phone or email. + // Once you check this option, your second step of verification during the login process will be remembered on the device where you logged in from. + // This is similar to the RememberMe option when you log in. + app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie); + + // Uncomment the following lines to enable logging in with third party login providers + //app.UseMicrosoftAccountAuthentication( + // clientId: "", + // clientSecret: ""); + + //app.UseTwitterAuthentication( + // consumerKey: "", + // consumerSecret: ""); + + //app.UseFacebookAuthentication( + // appId: "", + // appSecret: ""); + + app.UseGoogleAuthentication(new GoogleOAuth2AuthenticationOptions() + { + ClientId = "000000000000.apps.googleusercontent.com", + ClientSecret = "00000000000" + }); + } + } +} diff --git a/samples/AfterWingtipToys/ViewSwitcher.razor b/samples/AfterWingtipToys/ViewSwitcher.razor index f7e5f2a88..91cdf1509 100644 --- a/samples/AfterWingtipToys/ViewSwitcher.razor +++ b/samples/AfterWingtipToys/ViewSwitcher.razor @@ -1,8 +1,3 @@ -@* ViewSwitcher — Web Forms mobile view switching is not applicable in Blazor. - Blazor uses responsive CSS for mobile/desktop. This component is preserved as a stub. *@ -@if (false) -{
- Desktop view | Switch to Mobile + Desktop view
-} diff --git a/samples/AfterWingtipToys/ViewSwitcher.razor.cs b/samples/AfterWingtipToys/ViewSwitcher.razor.cs index 9b22736bb..c119a81f4 100644 --- a/samples/AfterWingtipToys/ViewSwitcher.razor.cs +++ b/samples/AfterWingtipToys/ViewSwitcher.razor.cs @@ -1,7 +1,72 @@ -namespace WingtipToys; - -// ViewSwitcher — Web Forms FriendlyUrls mobile view switching is not applicable in Blazor. -// Blazor uses responsive CSS. This component is preserved as a no-op stub. -public partial class ViewSwitcher +// ============================================================================= +// 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; +namespace WingtipToys { -} + public partial class ViewSwitcher + { + // 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. + + // --- ConfigurationManager Migration --- + // TODO(bwfc-config): ConfigurationManager calls work via BWFC shim. + // Ensure app.UseConfigurationManagerShim() is called in Program.cs. + + protected string CurrentView { get; private set; } + + protected string AlternateView { get; private set; } + + protected string SwitchUrl { get; private set; } + + protected override async Task OnInitializedAsync() + { + // TODO(bwfc-lifecycle): Review lifecycle conversion — verify async behavior + await base.OnInitializedAsync(); + + // Determine current view + var isMobile = WebFormsFriendlyUrlResolver.IsMobileView(new HttpContextWrapper(Context)); + CurrentView = isMobile ? "Mobile" : "Desktop"; + + // Determine alternate view + AlternateView = isMobile ? "Desktop" : "Mobile"; + + // Create switch URL from the route, e.g. ~/__FriendlyUrls_SwitchView/Mobile?ReturnUrl=/Page + var switchViewRouteName = "AspNet.FriendlyUrls.SwitchView"; + var switchViewRoute = RouteTable.Routes[switchViewRouteName]; + if (switchViewRoute == null) + { + // Friendly URLs is not enabled or the name of the switch view route is out of sync + this.Visible = false; + return; + } + var url = GetRouteUrl(switchViewRouteName, new { view = AlternateView, __FriendlyUrls_SwitchViews = true }); + url += "?ReturnUrl=" + HttpUtility.UrlEncode(Request.RawUrl); + SwitchUrl = url; + } + } +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/WebFormsShims.cs b/samples/AfterWingtipToys/WebFormsShims.cs new file mode 100644 index 000000000..7213e81de --- /dev/null +++ b/samples/AfterWingtipToys/WebFormsShims.cs @@ -0,0 +1,25 @@ +// ============================================================================= +// Web Forms compatibility shims for Blazor migration. +// +// These shims bridge common Web Forms types and patterns to their Blazor/BWFC +// equivalents. The BWFC NuGet package provides most type aliases automatically +// via its .targets file (Page, MasterPage, ImageClickEventArgs). +// +// This file adds project-level shims for patterns not covered by the package. +// +// DI registration: AddBlazorWebFormsComponents() in Program.cs registers: +// - SessionShim (scoped) — drop-in for Session["key"] access +// - CacheShim (scoped) — drop-in for Cache["key"] access +// - ServerShim (scoped) — drop-in for Server.MapPath() etc. +// - ConfigurationManager — shim for AppSettings["key"] access +// +// Code-behind files with Session["key"] or Cache["key"] get auto-wired +// [Inject] properties during L1 migration (SessionDetectTransform). +// +// Generated by webforms-to-blazor — Layer 1 scaffold +// ============================================================================= + +// ConfigurationManager shim — allows Web Forms configuration access patterns +// to work against ASP.NET Core's IConfiguration. +// Usage: ConfigurationManager.AppSettings["key"] maps to IConfiguration["key"] +using BlazorWebFormsComponents; diff --git a/samples/AfterWingtipToys/WingtipToys.csproj b/samples/AfterWingtipToys/WingtipToys.csproj index ce15af885..ea5841fd3 100644 --- a/samples/AfterWingtipToys/WingtipToys.csproj +++ b/samples/AfterWingtipToys/WingtipToys.csproj @@ -4,15 +4,28 @@ net10.0 enable enable + true - + + + + + + + + + + + + + diff --git a/samples/AfterWingtipToys/_Imports.razor b/samples/AfterWingtipToys/_Imports.razor index 1aa368948..707d539c6 100644 --- a/samples/AfterWingtipToys/_Imports.razor +++ b/samples/AfterWingtipToys/_Imports.razor @@ -1,3 +1,4 @@ +@namespace WingtipToys @using System.Net.Http @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @@ -8,7 +9,8 @@ @using BlazorWebFormsComponents @using BlazorWebFormsComponents.Enums @using BlazorWebFormsComponents.LoginControls -@using WingtipToys -@using WingtipToys.Data -@using WingtipToys.Models +@using BlazorWebFormsComponents.Validations +@using global::WingtipToys +@using global::WingtipToys.Models +@using global::WingtipToys.Services @inherits BlazorWebFormsComponents.WebFormsPageBase diff --git a/samples/AfterWingtipToys/appsettings.json b/samples/AfterWingtipToys/appsettings.json new file mode 100644 index 000000000..39d9813af --- /dev/null +++ b/samples/AfterWingtipToys/appsettings.json @@ -0,0 +1,13 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Data Source=(LocalDb)\\MSSQLLocalDB;Initial Catalog=aspnet-WingtipToys;Integrated Security=True", + "WingtipToys": "Data Source=(LocalDB)\\MSSQLLocalDB;AttachDbFilename=|DataDirectory|\\wingtiptoys.mdf;Integrated Security=True" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} \ No newline at end of file diff --git a/samples/AfterWingtipToys/wwwroot/js/form-submit.js b/samples/AfterWingtipToys/wwwroot/js/form-submit.js deleted file mode 100644 index a110328a4..000000000 --- a/samples/AfterWingtipToys/wwwroot/js/form-submit.js +++ /dev/null @@ -1,13 +0,0 @@ -// Bypass Blazor's enhanced form handling for forms with data-enhance="false". -// Must be loaded BEFORE blazor.web.js so our capturing listener runs first. -// Intercept CLICK on submit buttons (Blazor may prevent the submit event from ever firing). -document.addEventListener('click', function (e) { - var btn = e.target.closest('button[type="submit"], input[type="submit"]'); - if (!btn) return; - var form = btn.closest('form'); - if (form && form.getAttribute('data-enhance') === 'false') { - e.stopImmediatePropagation(); - e.preventDefault(); - HTMLFormElement.prototype.submit.call(form); - } -}, true); diff --git a/scripts/bwfc-migrate.ps1 b/scripts/bwfc-migrate.ps1 index 5349a3813..76c4feee2 100644 --- a/scripts/bwfc-migrate.ps1 +++ b/scripts/bwfc-migrate.ps1 @@ -66,6 +66,53 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +function Resolve-BwfcCliProject { + param([string]$StartPath) + + $currentDir = [System.IO.DirectoryInfo]::new([System.IO.Path]::GetFullPath($StartPath)) + while ($null -ne $currentDir) { + $candidate = Join-Path $currentDir.FullName 'src\BlazorWebFormsComponents.Cli\BlazorWebFormsComponents.Cli.csproj' + if (Test-Path $candidate) { + return $candidate + } + + $currentDir = $currentDir.Parent + } + + throw "Could not locate BlazorWebFormsComponents.Cli.csproj from '$StartPath'." +} + +$cliProject = Resolve-BwfcCliProject -StartPath $PSScriptRoot + +$cliArgs = @( + 'run', + '--project', $cliProject, + '--', + 'migrate', + '--input', $Path, + '--output', $Output, + '--overwrite' +) + +if ($SkipProjectScaffold) { + $cliArgs += '--skip-scaffold' +} + +if ($WhatIfPreference) { + $cliArgs += '--dry-run' +} + +if ($VerbosePreference -eq 'Continue') { + $cliArgs += '--verbose' +} + +& dotnet @cliArgs +if ($LASTEXITCODE -ne 0) { + exit $LASTEXITCODE +} + +return + #region --- Configuration --- $WebFormsExtensions = @('.aspx', '.ascx', '.master') @@ -134,6 +181,18 @@ function New-ProjectScaffold { [string]$ProjectName ) + $bwfcReference = '' + $currentDir = [System.IO.DirectoryInfo]::new([System.IO.Path]::GetFullPath($OutputRoot)) + while ($null -ne $currentDir) { + $candidate = Join-Path $currentDir.FullName 'src\BlazorWebFormsComponents\BlazorWebFormsComponents.csproj' + if (Test-Path $candidate) { + $relative = [System.IO.Path]::GetRelativePath([System.IO.Path]::GetFullPath($OutputRoot), $candidate) -replace '/', '\' + $bwfcReference = "" + break + } + $currentDir = $currentDir.Parent + } + # .csproj $csprojContent = @" @@ -145,7 +204,7 @@ function New-ProjectScaffold { - + $bwfcReference @@ -161,19 +220,18 @@ function New-ProjectScaffold { @using Microsoft.AspNetCore.Components.Web @using Microsoft.JSInterop @using BlazorWebFormsComponents -@using static Microsoft.AspNetCore.Components.Web.RenderMode -@using $ProjectName + @using $ProjectName "@ # Program.cs $programContent = @" // TODO: Review and adjust this generated Program.cs for your application needs. +// Generated for .NET 10 Blazor static SSR. Keep interactive render modes opt-in and page-specific. using BlazorWebFormsComponents; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); +builder.Services.AddRazorComponents(); builder.Services.AddBlazorWebFormsComponents(); @@ -189,8 +247,7 @@ app.UseHttpsRedirection(); app.MapStaticAssets(); app.UseAntiforgery(); -app.MapRazorComponents<$ProjectName.Components.App>() - .AddInteractiveServerRenderMode(); +app.MapRazorComponents<$ProjectName.Components.App>(); app.Run(); "@ @@ -256,12 +313,12 @@ function New-AppRazorScaffold { - + - - + @* Generated for .NET 10 static SSR migration output. Only opt into interactive render modes deliberately and per page. *@ + diff --git a/site/Analyzers/BWFC022/index.html b/site/Analyzers/BWFC022/index.html new file mode 100644 index 000000000..709f7ef8f --- /dev/null +++ b/site/Analyzers/BWFC022/index.html @@ -0,0 +1,6423 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BWFC022 - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

BWFC022: Page.ClientScript Usage

+

Diagnostic ID: BWFC022
+Severity: ⚠️ Warning
+Category: Migration
+Status: Active

+
+

What It Detects

+

This analyzer warns when your code uses Page.ClientScript or ClientScriptManager — Web Forms' API for dynamically registering client-side JavaScript.

+

Detected patterns: +- Page.ClientScript.RegisterStartupScript(...) +- Page.ClientScript.RegisterClientScriptBlock(...) +- Page.ClientScript.RegisterClientScriptInclude(...) +- Page.ClientScript.GetPostBackEventReference(...) +- Any ClientScriptManager method call

+
+

Example

+
protected void Page_Load(object sender, EventArgs e)
+{
+    // ⚠️ BWFC022: Page.ClientScript is not available in Blazor.
+    // See: docs/Migration/ClientScriptMigrationGuide.md
+    Page.ClientScript.RegisterStartupScript(
+        this.GetType(),
+        "InitializeUI",
+        "console.log('Page loaded');",
+        addScriptTags: true);
+}
+
+
+

Why It Matters

+

In Web Forms, Page.ClientScript is the standard way to inject JavaScript into pages. In Blazor:

+
    +
  • There is no Page object — Blazor is component-based, not page-based
  • +
  • No automatic script injection — JavaScript must be explicitly referenced
  • +
  • Different lifecycle — Instead of page load, use component initialization hooks like OnAfterRenderAsync()
  • +
+

Without addressing Page.ClientScript usage, your migrated code will compile errors or runtime failures.

+
+

How to Fix

+ +

For a zero-rewrite migration, use the ClientScriptShim included in BWFC. It provides the exact same API as Page.ClientScript:

+
// Web Forms
+Page.ClientScript.RegisterStartupScript(GetType(), "init", "...", true);
+
+// Blazor with ClientScriptShim — identical call!
+ClientScript.RegisterStartupScript(GetType(), "init", "...", true);
+
+

See "ClientScriptShim (Zero-Rewrite Path)" in the migration guide for details.

+
+

Alternative: Manual Rewrite

+

If you prefer to modernize your code now, rewrite to IJSRuntime directly. The fix depends on which ClientScript method you're using. See ClientScriptMigrationGuide.md for detailed before/after examples.

+

Quick Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PatternBlazor EquivalentDifficulty
RegisterStartupScript()ClientScriptShim (⭐ Easy) or OnAfterRenderAsync() + IJSRuntime (⭐⭐ Moderate)⭐ Easy
RegisterClientScriptInclude()ClientScriptShim (⭐ Easy) or <script> tag in layout (⭐ Easy)⭐ Easy
RegisterClientScriptBlock()ClientScriptShim (⭐ Easy) or JS module (⭐ Easy)⭐ Easy
GetPostBackEventReference()ClientScriptShim (⭐ Easy — Phase 2) or @onclick / EventCallback<T> (⭐⭐ Medium)⭐ Easy
+

Common Fix: Startup Script

+
+
+
+
protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        Page.ClientScript.RegisterStartupScript(
+            this.GetType(),
+            "InitializeUI",
+            "$(function() { applyTheme('dark'); });",
+            addScriptTags: true);
+    }
+}
+
+
+
+
@inject IJSRuntime JS
+
+@code {
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            var module = await JS.InvokeAsync<IJSObjectReference>(
+                "import", "./app.js");
+            await module.InvokeVoidAsync("applyTheme", "dark");
+        }
+    }
+}
+
+
+
+
+

Common Fix: Script Include

+
+
+
+
protected void Page_Load(object sender, EventArgs e)
+{
+    Page.ClientScript.RegisterClientScriptInclude(
+        "jquery-ui",
+        ResolveUrl("~/lib/jquery-ui/jquery-ui.min.js"));
+}
+
+
+
+
<!-- In layout (index.html or _Layout.html) -->
+<script src="lib/jquery/jquery.min.js"></script>
+<script src="lib/jquery-ui/jquery-ui.min.js"></script>
+
+
+
+
+

Common Fix: PostBack Event Reference

+
+
+
+
public string GetDeleteButtonScript()
+{
+    return Page.ClientScript.GetPostBackEventReference(
+        new PostBackOptions(btnDelete, "clicked"));
+}
+
+
+
+
// Same code works! ClientScriptShim returns __doPostBack() string
+public string GetDeleteButtonScript()
+{
+    return ClientScript.GetPostBackEventReference(
+        new PostBackOptions(btnDelete, "clicked"));
+}
+
+
+
+
<button @onclick="HandleDelete">Delete</button>
+
+@code {
+    private async Task HandleDelete()
+    {
+        await DeleteItemAsync();
+    }
+}
+
+
+
+
+
+

Detailed Migration Paths

+

For comprehensive migration guidance with code examples for each ClientScript method, see:

+

📖 ClientScriptMigrationGuide.md

+

Sections: +1. Startup Scripts — Most common pattern (Section 1) +2. Script Includes — External .js files (Section 2) +3. Script Blocks — Inline JavaScript (Section 3) +4. Postback Events — Dynamic event references (Section 4) +5. Form ValidationPage.IsValid patterns (Section 5)

+
+

Common Mistakes

+

❌ Don't: Use eval() for Complex Scripts

+
// ❌ WRONG: Embedding complex logic in eval()
+await JS.InvokeVoidAsync("eval", @"
+    function processData() {
+        // 50 lines of logic...
+    }
+    processData();
+");
+
+

✅ Do: Define Functions in JavaScript Modules

+
// app.js
+export function processData() {
+    // 50 lines of logic...
+}
+
+
// Component
+var module = await JS.InvokeAsync<IJSObjectReference>("import", "./app.js");
+await module.InvokeVoidAsync("processData");
+
+

❌ Don't: Skip the firstRender Guard

+
// ❌ WRONG: Script runs on every render
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    await JS.InvokeVoidAsync("applyTheme");
+}
+
+

✅ Do: Guard with if (firstRender)

+
// ✅ CORRECT: Script runs only on first render
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        await JS.InvokeVoidAsync("applyTheme");
+    }
+}
+
+
+ +
    +
  • BWFC023 — IPostBackEventHandler usage
  • +
  • BWFC024 — ScriptManager code-behind usage
  • +
+
+

Configuration

+

To suppress this warning for a specific line:

+
#pragma warning disable BWFC022
+Page.ClientScript.RegisterStartupScript(/* ... */);
+#pragma warning restore BWFC022
+
+

Or in .editorconfig:

+
[*.cs]
+dotnet_diagnostic.BWFC022.severity = silent
+
+
+

See Also

+ +
+

Status: ✅ Active
+Last Updated: 2026-07-30
+Owner: Beast (Technical Writer)

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Analyzers/BWFC023/index.html b/site/Analyzers/BWFC023/index.html new file mode 100644 index 000000000..b6e84465b --- /dev/null +++ b/site/Analyzers/BWFC023/index.html @@ -0,0 +1,6507 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BWFC023 - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

BWFC023: IPostBackEventHandler Usage

+

Diagnostic ID: BWFC023
+Severity: ⚠️ Warning
+Category: Migration
+Status: Active

+
+

What It Detects

+

This analyzer warns when you implement IPostBackEventHandler — a Web Forms interface for custom controls to raise server events in response to client-side actions.

+

Detected patterns: +- class MyControl : Control, IPostBackEventHandler +- public void RaisePostBackEvent(string eventArgument) { ... } +- Any implementation of the IPostBackEventHandler interface

+
+

Example

+
public partial class MyCustomControl : UserControl, IPostBackEventHandler
+{
+    // ⚠️ BWFC023: IPostBackEventHandler is not available in Blazor.
+    // Use EventCallback<T> for event handling instead.
+
+    public event EventHandler OnCustomAction;
+
+    public void RaisePostBackEvent(string eventArgument)
+    {
+        if (eventArgument == "action")
+        {
+            OnCustomAction?.Invoke(this, EventArgs.Empty);
+        }
+    }
+}
+
+
+

Why It Matters

+

IPostBackEventHandler is tightly coupled to Web Forms' postback event model:

+
    +
  • A client-side event triggers __doPostBack(controlId, eventArgument)
  • +
  • The server receives the POST, decodes the event data, and calls RaisePostBackEvent()
  • +
  • The control can raise server-side events in response
  • +
+

In Blazor:

+
    +
  • There is no postback cycle — events are component method calls
  • +
  • No __doPostBack() mechanism — client actions invoke C# methods directly
  • +
  • EventCallback<T> replaces postback events — provides parent-child component communication
  • +
+

Without updating, your migrated code will have no way to raise events to parent components.

+
+

How to Fix

+

Replace IPostBackEventHandler with EventCallback<T> parameters.

+

Simple Case: No Arguments

+
+
+
+
public partial class MyControl : UserControl, IPostBackEventHandler
+{
+    public event EventHandler OnAction;
+
+    public void RaisePostBackEvent(string eventArgument)
+    {
+        OnAction?.Invoke(this, EventArgs.Empty);
+    }
+}
+
+
+
+
@* MyControl.razor *@
+
+<button @onclick="RaiseAction">Click Me</button>
+
+@code {
+    [Parameter]
+    public EventCallback OnAction { get; set; }
+
+    private async Task RaiseAction()
+    {
+        await OnAction.InvokeAsync();
+    }
+}
+
+@* Parent.razor *@
+<MyControl OnAction="@HandleAction" />
+
+@code {
+    private async Task HandleAction()
+    {
+        // Handle the event
+    }
+}
+
+
+
+
+

With Arguments: Single Value

+
+
+
+
public partial class MyControl : UserControl, IPostBackEventHandler
+{
+    public event EventHandler<ItemSelectedEventArgs> OnItemSelected;
+
+    public void RaisePostBackEvent(string eventArgument)
+    {
+        if (eventArgument.StartsWith("select-"))
+        {
+            string itemId = eventArgument.Substring("select-".Length);
+            OnItemSelected?.Invoke(this, new ItemSelectedEventArgs { ItemId = itemId });
+        }
+    }
+}
+
+public class ItemSelectedEventArgs : EventArgs
+{
+    public string ItemId { get; set; }
+}
+
+
+
+
@* MyControl.razor *@
+
+@foreach (var item in Items)
+{
+    <button @onclick="@(() => SelectItem(item.Id))">
+        @item.Name
+    </button>
+}
+
+@code {
+    [Parameter]
+    public List<Item> Items { get; set; }
+
+    [Parameter]
+    public EventCallback<string> OnItemSelected { get; set; }
+
+    private async Task SelectItem(string itemId)
+    {
+        await OnItemSelected.InvokeAsync(itemId);
+    }
+}
+
+@* Parent.razor *@
+<MyControl Items="@items" OnItemSelected="@HandleItemSelected" />
+
+@code {
+    private async Task HandleItemSelected(string itemId)
+    {
+        // Handle selection
+    }
+}
+
+
+
+
+

With Arguments: Multiple Values (EventArgs Class)

+

If you need to pass multiple values, use a custom class:

+
+
+
+
public partial class GridControl : UserControl, IPostBackEventHandler
+{
+    public event EventHandler<RowSelectedEventArgs> OnRowSelected;
+
+    public void RaisePostBackEvent(string eventArgument)
+    {
+        // eventArgument format: "rowId|action"
+        var parts = eventArgument.Split('|');
+        var args = new RowSelectedEventArgs
+        {
+            RowId = int.Parse(parts[0]),
+            Action = parts[1]
+        };
+        OnRowSelected?.Invoke(this, args);
+    }
+}
+
+public class RowSelectedEventArgs : EventArgs
+{
+    public int RowId { get; set; }
+    public string Action { get; set; }
+}
+
+
+
+
@* GridControl.razor *@
+
+@foreach (var row in Rows)
+{
+    <tr @onclick="@(() => HandleRowClick(row.Id, "edit"))">
+        <td>@row.Name</td>
+    </tr>
+}
+
+@code {
+    [Parameter]
+    public List<GridRow> Rows { get; set; }
+
+    [Parameter]
+    public EventCallback<RowSelectedEventArgs> OnRowSelected { get; set; }
+
+    private async Task HandleRowClick(int rowId, string action)
+    {
+        var args = new RowSelectedEventArgs { RowId = rowId, Action = action };
+        await OnRowSelected.InvokeAsync(args);
+    }
+}
+
+public class RowSelectedEventArgs
+{
+    public int RowId { get; set; }
+    public string Action { get; set; }
+}
+
+@* Parent.razor *@
+<GridControl Rows="@rows" OnRowSelected="@HandleRowSelected" />
+
+@code {
+    private async Task HandleRowSelected(RowSelectedEventArgs args)
+    {
+        if (args.Action == "edit")
+        {
+            await EditRowAsync(args.RowId);
+        }
+    }
+}
+
+
+
+
+
+

Key Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectWeb FormsBlazor
Event mechanismIPostBackEventHandler + __doPostBack()EventCallback
Event declarationevent EventHandler OnEvent[Parameter] EventCallback<T> OnEvent
Event invocationOnEvent?.Invoke(...) in RaisePostBackEvent()await OnEvent.InvokeAsync(...)
Data passingCustom EventArgs classes (optional)Parameter type T (any type)
Parent bindingAutomatic via postbackExplicit OnEvent="@handler" parameter
+
+

Real-World Example: Custom Picker Control

+
+
+
+
public partial class DatePickerControl : UserControl, IPostBackEventHandler
+{
+    public event EventHandler<DatePickedEventArgs> OnDatePicked;
+
+    public void RaisePostBackEvent(string eventArgument)
+    {
+        if (DateTime.TryParse(eventArgument, out var date))
+        {
+            OnDatePicked?.Invoke(this, new DatePickedEventArgs { SelectedDate = date });
+        }
+    }
+}
+
+public class DatePickedEventArgs : EventArgs
+{
+    public DateTime SelectedDate { get; set; }
+}
+
+
+
+
@* DatePickerControl.razor *@
+
+<input type="date" @onchange="@HandleDateChange" />
+
+@code {
+    [Parameter]
+    public EventCallback<DateTime> OnDatePicked { get; set; }
+
+    private async Task HandleDateChange(ChangeEventArgs e)
+    {
+        if (DateTime.TryParse(e.Value?.ToString(), out var date))
+        {
+            await OnDatePicked.InvokeAsync(date);
+        }
+    }
+}
+
+@* Parent.razor *@
+<DatePickerControl OnDatePicked="@HandleDatePicked" />
+
+@code {
+    private async Task HandleDatePicked(DateTime date)
+    {
+        SelectedDate = date;
+    }
+}
+
+
+
+
+
+

Common Mistakes

+

❌ Don't: Forget await When Invoking

+
// ❌ WRONG: Not awaiting the callback
+private async Task SelectItem(string itemId)
+{
+    OnItemSelected.InvokeAsync(itemId);  // Missing await!
+}
+
+

✅ Do: Always await EventCallback Invocations

+
// ✅ CORRECT: Properly awaiting
+private async Task SelectItem(string itemId)
+{
+    await OnItemSelected.InvokeAsync(itemId);
+}
+
+

❌ Don't: Check if Callback is Null

+
// ❌ WRONG: EventCallback<T> is never null
+if (OnItemSelected != null)
+{
+    await OnItemSelected.InvokeAsync(itemId);
+}
+
+

✅ Do: Just Invoke (EventCallback Handles Null)

+
// ✅ CORRECT: EventCallback<T> is safe to invoke directly
+await OnItemSelected.InvokeAsync(itemId);
+
+
+ +
    +
  • BWFC022 — Page.ClientScript usage (see ClientScriptShim for easy migration)
  • +
  • BWFC024 — ScriptManager code-behind usage
  • +
+
+

Configuration

+

To suppress this warning for a specific line:

+
#pragma warning disable BWFC023
+public void RaisePostBackEvent(string eventArgument) { }
+#pragma warning restore BWFC023
+
+

Or in .editorconfig:

+
[*.cs]
+dotnet_diagnostic.BWFC023.severity = silent
+
+
+

See Also

+ +
+

Status: ✅ Active
+Last Updated: 2026-07-30
+Owner: Beast (Technical Writer)

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Analyzers/BWFC024/index.html b/site/Analyzers/BWFC024/index.html new file mode 100644 index 000000000..3518fdb91 --- /dev/null +++ b/site/Analyzers/BWFC024/index.html @@ -0,0 +1,6679 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BWFC024 - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

BWFC024: ScriptManager Code-Behind Usage

+

Diagnostic ID: BWFC024
+Severity: ⚠️ Warning
+Category: Migration
+Status: Active

+
+

What It Detects

+

This analyzer warns when you use ScriptManager code-behind methods like GetCurrent(), SetFocus(), RegisterAsyncPostBackControl(), and similar — Web Forms APIs for managing client scripts and UpdatePanel behavior.

+

Note: Phase 2 now includes support for GetCurrent() and script registration methods via ScriptManagerShim. Methods like SetFocus() and RegisterAsyncPostBackControl() still require modernization.

+

Detected patterns: +- ScriptManager.GetCurrent(Page) / ScriptManager.GetCurrent(this) — ✅ Phase 2 supported +- .SetFocus(control) — ❌ Still requires JS interop +- .RegisterAsyncPostBackControl(control) — ❌ Still requires component binding +- .RegisterUpdateProgress(...) — ❌ Still requires component state +- .RegisterPostBackControl(...) — ❌ Not supported

+
+

Example

+
protected void Page_Load(object sender, EventArgs e)
+{
+    // ⚠️ BWFC024: ScriptManager.GetCurrent() and related methods are not available in Blazor.
+    ScriptManager sm = ScriptManager.GetCurrent(Page);
+
+    // Set focus
+    sm.SetFocus(txtSearchBox);
+
+    // Register for async postback
+    sm.RegisterAsyncPostBackControl(gvData);
+}
+
+
+

Why It Matters

+

ScriptManager methods are deeply tied to Web Forms' postback and UpdatePanel architecture:

+
    +
  • GetCurrent() retrieves the page's ScriptManager instance
  • +
  • SetFocus() ensures client focus after postback
  • +
  • RegisterAsyncPostBackControl() enables AJAX partial-page updates
  • +
  • RegisterUpdateProgress() shows progress during UpdatePanel operations
  • +
+

In Blazor:

+
    +
  • No ScriptManager instance — script management is component-scoped
  • +
  • No postback lifecycle — focus handling is explicit
  • +
  • No UpdatePanel model — components handle their own updates natively
  • +
  • No async postback concept — Blazor uses real-time SignalR sync
  • +
+

These methods have no direct equivalents. Each requires a different approach.

+
+

How to Fix

+

The fix depends on which ScriptManager method you're using and which Phase you're in.

+

✅ Phase 2: GetCurrent() and Script Registration Methods

+

ScriptManager.GetCurrent() now returns a working ScriptManagerShim that delegates to ClientScriptShim.

+
+
+
+
protected void Page_Load(object sender, EventArgs e)
+{
+    ScriptManager sm = ScriptManager.GetCurrent(Page);
+    sm.RegisterStartupScript(this.GetType(), "init", "initPage();", true);
+}
+
+
+
+
protected override void OnInitialized()
+{
+    // Same code works! ScriptManagerShim returns the component's ClientScriptShim
+    ScriptManager sm = ScriptManager.GetCurrent(this);
+    sm.RegisterStartupScript(this.GetType(), "init", "initPage();", true);
+}
+
+
+
+
+
+

Still Requiring Modernization

+

Other ScriptManager methods still require refactoring. See the fixes below.

+

Fix 1: SetFocus() → JavaScript Interop

+

Focus management in Blazor uses @ref and IJSRuntime.

+
+
+
+
protected void Page_Load(object sender, EventArgs e)
+{
+    ScriptManager.SetFocus(txtSearchBox);
+}
+
+
+
+
@inject IJSRuntime JS
+
+<input @ref="searchBox" />
+
+@code {
+    private ElementReference searchBox;
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            await JS.InvokeVoidAsync("focus", searchBox);
+        }
+    }
+}
+
+
+
+
+

Or define a helper function in JavaScript:

+
// app.js
+export function focusElement(element) {
+    element?.focus();
+}
+
+
// Component
+await module.InvokeVoidAsync("focusElement", searchBox);
+
+

Fix 2: RegisterAsyncPostBackControl() → Remove

+

RegisterAsyncPostBackControl() enables partial-page AJAX updates via UpdatePanel.

+

Blazor does NOT support UpdatePanel-style AJAX postbacks. Blazor components handle all updates natively via parameter binding.

+
+
+
+
protected void Page_Load(object sender, EventArgs e)
+{
+    // Enable AJAX partial updates for GridView
+    ScriptManager.RegisterAsyncPostBackControl(gvData);
+}
+
+protected void gvData_SelectedIndexChanged(object sender, EventArgs e)
+{
+    // Partial page refresh in UpdatePanel
+    gvData.DataSource = GetUpdatedData();
+    gvData.DataBind();
+}
+
+
+
+
@page "/data"
+@inject HttpClient Http
+
+<GridView Data="@items" OnRowSelected="@HandleRowSelected" />
+
+@code {
+    private List<Item> items;
+
+    protected override async Task OnInitializedAsync()
+    {
+        items = await Http.GetFromJsonAsync<List<Item>>("/api/items");
+    }
+
+    private async Task HandleRowSelected(int itemId)
+    {
+        // Component automatically re-renders when data changes
+        items = await Http.GetFromJsonAsync<List<Item>>("/api/items");
+    }
+}
+
+
+
+
+

Key difference: Blazor components automatically re-render when state changes. No manual registration needed.

+

Fix 3: RegisterUpdateProgress() → Use Component State

+

RegisterUpdateProgress() shows a message or spinner during UpdatePanel operations.

+
+
+
+
ScriptManager.RegisterUpdateProgress(updateProgress, masterUpdateProgress);
+
+// During async postback, the UpdateProgress displays
+protected void LongRunningOperation()
+{
+    System.Threading.Thread.Sleep(5000);  // Simulates work
+}
+
+
+
+
@if (isLoading)
+{
+    <div class="loading-overlay">
+        <p>Loading data...</p>
+    </div>
+}
+
+<GridView Data="@items" />
+
+@code {
+    private List<Item> items;
+    private bool isLoading;
+
+    private async Task LoadData()
+    {
+        isLoading = true;
+        try
+        {
+            items = await Http.GetFromJsonAsync<List<Item>>("/api/items");
+        }
+        finally
+        {
+            isLoading = false;
+        }
+    }
+}
+
+
+
+
+

Fix 4: GetCurrent() → Remove

+

ScriptManager.GetCurrent() retrieves the page's ScriptManager instance for other operations.

+

In Blazor, there is no page-level ScriptManager. Remove calls to GetCurrent() and replace the specific methods (SetFocus, RegisterAsyncPostBackControl, etc.) with the patterns above.

+
+
+
+
ScriptManager sm = ScriptManager.GetCurrent(Page);
+sm.SetFocus(txtField);
+sm.RegisterAsyncPostBackControl(gridView);
+
+
+
+
@inject IJSRuntime JS
+
+<input @ref="field" />
+<GridView Data="@items" OnRowSelected="@HandleRowSelected" />
+
+@code {
+    private ElementReference field;
+    private List<Item> items;
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            await JS.InvokeVoidAsync("focus", field);
+        }
+    }
+
+    private async Task HandleRowSelected(int id)
+    {
+        // Update component state; Blazor handles re-render
+        items = await FetchDataAsync();
+    }
+}
+
+
+
+
+
+

Migration Quick Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web Forms MethodBlazor EquivalentApproach
GetCurrent(Page)ScriptManager.GetCurrent(this) (Phase 2 — Zero Rewrite)Easy
.SetFocus(control)JS.InvokeVoidAsync("focus", @ref)JavaScript interop
.RegisterAsyncPostBackControl()Component parameter bindingRemove; use @bind or EventCallback
.RegisterPostBackControl()Remove; not needed in Blazor
.RegisterUpdateProgress()Component state flagUse @if (isLoading) UI binding
.IsInAsyncPostBackRemove; always synchronous in Blazor
+
+

Real-World Example: Search Page with Loading Indicator

+
+
+
+
public partial class SearchPage : Page
+{
+    protected void Page_Load(object sender, EventArgs e)
+    {
+        ScriptManager sm = ScriptManager.GetCurrent(Page);
+        sm.SetFocus(txtSearchBox);
+        sm.RegisterUpdateProgress(updateProgress, masterUpdateProgress);
+    }
+
+    protected void btnSearch_Click(object sender, EventArgs e)
+    {
+        // Long-running search
+        var results = SearchDatabase(txtSearchBox.Text);
+        gvResults.DataSource = results;
+        gvResults.DataBind();
+        // UpdateProgress shows during postback
+    }
+}
+
+
+
+
@page "/search"
+@inject IJSRuntime JS
+@inject HttpClient Http
+
+<input @ref="searchBox" placeholder="Search..." />
+<button @onclick="PerformSearch" disabled="@isLoading">Search</button>
+
+@if (isLoading)
+{
+    <div class="loading-overlay">
+        <p>Searching...</p>
+        <div class="spinner"></div>
+    </div>
+}
+
+<GridView Data="@results" />
+
+@code {
+    private ElementReference searchBox;
+    private string searchQuery;
+    private List<SearchResult> results = new();
+    private bool isLoading;
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            await JS.InvokeVoidAsync("focus", searchBox);
+        }
+    }
+
+    private async Task PerformSearch()
+    {
+        if (string.IsNullOrWhiteSpace(searchQuery)) return;
+
+        isLoading = true;
+        try
+        {
+            results = await Http.GetFromJsonAsync<List<SearchResult>>(
+                $"/api/search?q={Uri.EscapeDataString(searchQuery)}");
+        }
+        finally
+        {
+            isLoading = false;
+        }
+    }
+}
+
+public class SearchResult
+{
+    public int Id { get; set; }
+    public string Title { get; set; }
+}
+
+
+
+
+
+

Common Mistakes

+

❌ Don't: Try to Call ScriptManager Methods

+
// ❌ WRONG: ScriptManager.GetCurrent() returns null in Blazor
+ScriptManager sm = ScriptManager.GetCurrent(Page);  // null
+sm.SetFocus(txtField);  // NullReferenceException!
+
+

✅ Do: Use Component-Based Patterns

+
// ✅ CORRECT: Use @ref and IJSRuntime
+@ref="field"
+await JS.InvokeVoidAsync("focus", field);
+
+

❌ Don't: Use RegisterAsyncPostBackControl() for Partial Updates

+
// ❌ WRONG: UpdatePanel AJAX is not supported
+ScriptManager.RegisterAsyncPostBackControl(gridView);
+// gridView.DataBind() won't trigger partial refresh
+
+

✅ Do: Let Components Handle Their Own Updates

+
// ✅ CORRECT: Component re-renders on state change
+items = await FetchUpdatedData();
+// Blazor automatically syncs the UI
+
+

❌ Don't: Check IsInAsyncPostBack

+
// ❌ WRONG: No async postback concept in Blazor
+if (ScriptManager.GetCurrent(Page).IsInAsyncPostBack)
+{
+    // This code path doesn't exist
+}
+
+

✅ Do: Use Component Lifecycle Events

+
// ✅ CORRECT: Blazor components are always "interactive"
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        // Component is interactive; safe to use JS interop
+    }
+}
+
+
+ +
    +
  • BWFC022 — Page.ClientScript usage (see ClientScriptShim for easy migration)
  • +
  • BWFC023 — IPostBackEventHandler usage
  • +
+
+

Configuration

+

To suppress this warning for a specific line:

+
#pragma warning disable BWFC024
+ScriptManager.GetCurrent(Page).SetFocus(txtField);
+#pragma warning restore BWFC024
+
+

Or in .editorconfig:

+
[*.cs]
+dotnet_diagnostic.BWFC024.severity = silent
+
+
+

See Also

+ +
+

Status: ✅ Active
+Last Updated: 2026-07-30
+Owner: Beast (Technical Writer)

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/ChecklistTemplate/index.html b/site/Migration/ChecklistTemplate/index.html new file mode 100644 index 000000000..c6a2dfd31 --- /dev/null +++ b/site/Migration/ChecklistTemplate/index.html @@ -0,0 +1,6017 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Migration Checklist Template - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ + + + + + + + +

Per-Page Migration Checklist

+

Copy this template for each page you migrate. Use it as a GitHub issue body, a markdown checklist in your tracking doc, or paste it into your project management tool.

+

The checklist is organized by the three-layer pipeline. Work top to bottom — each section assumes the previous one is complete.

+
+

Template

+
## Page: [PageName.aspx] → [PageName.razor]
+
+**Source:** `[path/to/PageName.aspx]`
+**Target:** `[path/to/PageName.razor]`
+**Complexity:** [Trivial / Easy / Medium / Complex]
+**Notes:** [Any page-specific context — what this page does, key controls used]
+
+### Layer 1 — Automated (webforms-to-blazor CLI or bwfc-migrate.ps1)
+
+- [ ] File renamed (.aspx → .razor, .ascx → .razor, .master → .razor)
+- [ ] `<%@ Page %>` / `<%@ Control %>` / `<%@ Master %>` directive removed
+- [ ] `@page "/route"` directive added
+- [ ] `asp:` prefixes removed from all controls
+- [ ] `runat="server"` removed from all elements
+- [ ] Expressions converted (`<%: %>``@()`, `<%# %>``@context.`)
+- [ ] URL references converted (`~/``/`)
+- [ ] `<asp:Content>` wrappers removed (page body unwrapped)
+- [ ] `ItemType``TItem` converted
+- [ ] Code-behind file copied (.aspx.cs → .razor.cs) with TODO annotations
+- [ ] `AddBlazorWebFormsComponents()` registered in `Program.cs`
+- [ ] `_Imports.razor` has `@inherits WebFormsPageBase`
+- [ ] Pages using `Request.Form` wrapped in `<WebFormsForm>`
+
+### Layer 2 — Copilot-Assisted (Structural Transforms)
+
+- [ ] `SelectMethod``Items` (or `DataItem`) binding wired
+- [ ] Data loading moved to `OnInitializedAsync`
+- [ ] Template `Context="Item"` variables added to all templates
+- [ ] Event handlers converted to Blazor signatures (remove `sender`, `EventArgs`)
+- [ ]`Page_Load` / `IsPostBack` — works AS-IS via `WebFormsPageBase` (only signature `Page_Load(sender, e)``OnInitializedAsync` needs converting; `IsPostBack` inside works unchanged)
+- [ ]`Response.Redirect` — works AS-IS via ResponseShim (auto-strips `~/` and `.aspx`)
+- [ ] Session state: Use `Session["key"]` from `WebFormsPageBase` (SessionShim) — works in interactive mode ✅
+- [ ] ✅ Response.Redirect() calls work via ResponseShim
+- [ ] ✅ Request.QueryString[] calls work via RequestShim
+- [ ] No raw `IHttpContextAccessor` injected (use shim properties from `WebFormsPageBase` instead)
+- [ ] No Minimal API endpoints created for page actions (use shim methods instead; minimal APIs are ONLY for cookie auth operations)
+- [ ]`Page.Title` / `Page.MetaDescription` — works AS-IS via WebFormsPageBase
+- [ ]`Request.QueryString["key"]` — works AS-IS via RequestShim
+- [ ]`Cache["key"]` — works AS-IS via CacheShim
+- [ ] `<form runat="server">` removed (or converted to `<WebFormsForm>` / `<EditForm>` if validators present)
+- [ ] Query parameters converted (`[QueryString]``[SupplyParameterFromQuery]`)
+- [ ] Route parameters converted (`[RouteData]``[Parameter]` with `@page` route)
+- [ ] `@using` statements added for model namespaces
+- [ ] `@inject` statements added for required services
+
+### Layer 3 — Architecture Decisions
+
+- [ ] Data access pattern decided (injected service, EF Core, Dapper, etc.)
+- [ ] Data service implemented and registered in `Program.cs`
+- [ ] Session state: basic usage works AS-IS via SessionShim; if persistent/distributed state needed, replace with `ProtectedSessionStorage` or scoped service (OPTIONAL — shim works correctly)
+- [ ] Response.Redirect: works AS-IS via ResponseShim; if removing BWFC dependency, replace with `NavigationManager.NavigateTo()` (OPTIONAL — shim works correctly)
+- [ ] Request.QueryString: works AS-IS via RequestShim; if cleaner Blazor pattern desired, replace with `[SupplyParameterFromQuery]` (OPTIONAL — shim works correctly)
+- [ ] Authentication/authorization wired (if page requires auth)
+- [ ] Third-party integrations ported (API calls, payment, etc.)
+- [ ] Route registered and tested (`@page` directive matches expected URL)
+- [ ] ViewState-dependent logic converted to component fields
+
+### Verification
+
+- [ ] Page builds without errors (`dotnet build`)
+- [ ] Page renders in browser without exceptions
+- [ ] Visual layout matches original Web Forms page
+- [ ] All interactive features work (buttons, forms, navigation, sorting, paging)
+- [ ] No JavaScript console errors in browser dev tools
+- [ ] Data displays correctly (correct records, correct formatting)
+- [ ] Form submissions work (validation fires, data saves)
+
+### Optional: Refactor to Native Blazor (post-migration)
+
+> These are optional improvements you can make after the app is fully functional. Shim-based code works correctly — these refactors reduce BWFC dependency and adopt native Blazor patterns.
+
+- [ ] `Response.Redirect("~/path")``NavigationManager.NavigateTo("/path")` (if removing ResponseShim dependency)
+- [ ] `Session["key"]` → scoped service or `ProtectedSessionStorage` (if needing persistence/distribution)
+- [ ] `Request.QueryString["key"]``[SupplyParameterFromQuery]` parameter (cleaner Blazor pattern)
+- [ ] `Cache["key"]``IMemoryCache` or `IDistributedCache` (if needing distributed cache)
+- [ ] `ViewState["key"]` → component fields (ViewState is in-memory only, no page serialization)
+- [ ] `Page.ClientScript.RegisterStartupScript(...)` → direct `IJSRuntime` calls
+
+### L3-opt — Performance Optimization Pass (Optional, run after Verification ✅)
+
+> Run after the app is fully functional. Use the the performance optimization guide.
+
+- [ ] `OnInitialized` with DB calls → `OnInitializedAsync` (✅ Safe)
+- [ ] Sync EF Core calls → async equivalents (`ToListAsync`, `SaveChangesAsync`, etc.) (✅ Safe)
+- [ ] Read-only queries have `AsNoTracking()` (✅ Safe)
+- [ ] String `Include("Nav")` replaced with lambda `Include(x => x.Nav)` (✅ Safe)
+- [ ] `Task.Result` / `Task.Wait()` anti-patterns removed (✅ Safe)
+- [ ] `@key` added to `@foreach` loops rendering components (✅ Safe)
+- [ ] `[SupplyParameterFromQuery]` replaces manual `NavigationManager.Uri` parsing (✅ Safe)
+- [ ] String concatenation in render logic → `$""` interpolation (✅ Safe)
+- [ ] `[EditorRequired]` added to mandatory component parameters (✅ Safe)
+- [ ] Heavy inline `@code` blocks (>50 lines) extracted to code-behind (✅ Safe)
+- [ ] `AddDbContext``AddDbContextFactory` + `using var db = DbFactory.CreateDbContext()` (⚠️ Review)
+- [ ] Multi-collection `Include()` chains evaluated for `AsSplitQuery()` (⚠️ Review)
+- [ ] `[StreamRendering]` considered for pages with async data loads (⚠️ Review)
+- [ ] `ShouldRender()` considered for high-frequency-render leaf components (⚠️ Review)
+
+
+

Usage Tips

+

For GitHub Issues

+

Create one issue per page (or per group of related pages). Paste the template above and fill in the header fields. As you work through the migration, check items off. This gives your team visibility into migration progress.

+

For Tracking Documents

+

Create a single MIGRATION-TRACKING.md in your project. Paste one copy of the checklist per page. Use it as a daily standup reference:

+
# Migration Tracking
+
+## Completed
+- [x] Default.aspx → Default.razor (Trivial) — Done 2026-03-01
+- [x] About.aspx → About.razor (Trivial) — Done 2026-03-01
+
+## In Progress
+- [ ] ProductList.aspx → ProductList.razor (Medium) — Layer 2
+
+## Not Started
+- [ ] ShoppingCart.aspx → ShoppingCart.razor (Medium)
+- [ ] Login.aspx → Login.razor (Complex)
+
+ +

Migrate pages in this order to minimize blocked work:

+
    +
  1. LayoutSite.MasterMainLayout.razor (everything depends on this)
  2. +
  3. Leaf pages — About, Contact, Error pages (trivial, builds confidence)
  4. +
  5. Read-only data pages — Product list, catalog (medium, tests data binding)
  6. +
  7. CRUD pages — Cart, admin, forms (medium-complex, tests event handling)
  8. +
  9. Auth-dependent pages — Login, account management (complex, requires Identity setup)
  10. +
  11. Integration pages — Checkout, payment, external APIs (complex, requires Layer 3)
  12. +
+
+

Cross-References

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/ClientScriptMigrationGuide/index.html b/site/Migration/ClientScriptMigrationGuide/index.html new file mode 100644 index 000000000..adba151f7 --- /dev/null +++ b/site/Migration/ClientScriptMigrationGuide/index.html @@ -0,0 +1,9610 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ClientScript Migration - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ + + + + + + + +

ClientScript Migration Guide

+

When migrating from ASP.NET Web Forms to Blazor, one of the most critical patterns to understand is JavaScript execution. In Web Forms, Page.ClientScript and ScriptManager manage all client-side scripts. In Blazor, this responsibility falls to IJSRuntime and component lifecycle events.

+

This guide covers the major ClientScript patterns, why they differ in Blazor, and how to migrate each one.

+
+ +

The easiest path for most migrations is the ClientScriptShim — a compatibility layer that provides the same API as Page.ClientScript but runs on Blazor's IJSRuntime internally.

+

What It Is

+

ClientScriptShim is a scoped Blazor service included in BWFC that: +- Accepts the same method calls as Web Forms' Page.ClientScript +- Requires zero code rewrites — your existing RegisterStartupScript(), RegisterClientScriptBlock(), etc. calls work unchanged +- Queues scripts during component initialization +- Auto-flushes in OnAfterRenderAsync via IJSRuntime +- Handles deduplication by type+key (same behavior as Web Forms)

+

Automatic Registration

+

When you call AddBlazorWebFormsComponents() in your Startup, ClientScriptShim is registered as a scoped service and ready to use.

+

How to Use It

+

For components inheriting BaseWebFormsComponent:

+

The ClientScript property is automatically available — use it exactly as you would in Web Forms:

+
protected override void OnInitialized()
+{
+    ClientScript.RegisterStartupScript(GetType(), "init",
+        "alert('Page loaded!');", true);
+}
+
+

For any other component:

+

Inject ClientScriptShim and use it the same way:

+
@inject ClientScriptShim ClientScript
+
+@code {
+    protected override void OnInitialized()
+    {
+        ClientScript.RegisterStartupScript(GetType(), "init",
+            "alert('Page loaded!');", true);
+    }
+}
+
+

Supported Methods

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodStatusNotes
RegisterStartupScript(Type, string, string, bool)✅ SupportedExecutes in OnAfterRenderAsync
RegisterStartupScript(Type, string, string)✅ SupportedaddScriptTags defaults to false
RegisterClientScriptBlock(Type, string, string, bool)✅ SupportedExecutes before startup scripts
RegisterClientScriptBlock(Type, string, string)✅ SupportedaddScriptTags defaults to false
RegisterClientScriptInclude(string, string)✅ SupportedDynamically appends <script> tag
RegisterClientScriptInclude(Type, string, string)✅ SupportedType parameter ignored (for compatibility)
IsStartupScriptRegistered(Type, string)✅ SupportedDeduplication check
IsClientScriptBlockRegistered(Type, string)✅ SupportedDeduplication check
IsClientScriptIncludeRegistered(string)✅ SupportedDeduplication check
GetPostBackEventReference(...)✅ Supported (Phase 2)Returns __doPostBack() string; handled by postback shim
GetPostBackClientHyperlink(...)✅ Supported (Phase 2)Returns hyperlink-compatible postback string
GetCallbackEventReference(...)✅ Supported (Phase 2)Returns callback bridge string; requires JS handler
+

How It Works Internally

+
    +
  1. When you call RegisterStartupScript(), the script is queued in memory (same deduplication as Web Forms)
  2. +
  3. During OnAfterRenderAsync, BaseWebFormsComponent calls ClientScript.FlushAsync(IJSRuntime)
  4. +
  5. Scripts execute in order: script blocks first, then startup scripts, then includes
  6. +
  7. The queue clears after each flush cycle
  8. +
+

Before/After Example

+

Web Forms code-behind: +

protected void Page_Load(object sender, EventArgs e)
+{
+    Page.ClientScript.RegisterStartupScript(GetType(), "init", 
+        "console.log('Page loaded');", true);
+}
+

+

Blazor code-behind (with ClientScriptShim — changes circled in red): +

protected override void OnInitialized()
+{
+    ClientScript.RegisterStartupScript(GetType(), "init",
+        "console.log('Page loaded');", true);
+}
+

+

The ClientScript call is identical. Only the lifecycle method name changed.

+
+

Strangler Fig Pattern Context

+

ClientScript migration fits within the broader Strangler Fig migration pattern — the overarching strategy for incrementally moving from Web Forms to Blazor while keeping both systems running side-by-side.

+

In the Strangler Fig approach: +- You migrate one page or feature at a time, not all at once +- The legacy Web Forms app continues running in parallel +- Traffic gradually shifts from Web Forms to Blazor +- ClientScriptShim enables this by letting your JavaScript patterns work unchanged during migration

+

This means you don't need to decide "now or never" on JavaScript refactoring. Use ClientScriptShim to get your migration done quickly (Phase 1), then optionally modernize to IJSRuntime or JS modules later when it makes sense (Phase 2).

+

For a detailed overview of how ClientScript fits into the incremental migration journey, see the Strangler Fig Pattern guide.

+
+

Migration Approaches: A Comparison

+

Choose the approach that fits your migration timeline:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ApproachCode ChangesEffortWhen to Use
ClientScriptShim (recommended)None — keep existing calls⭐ MinimalDefault for most migrations; fastest path to working code
Manual IJSRuntime rewriteRewrite to IJSRuntime.InvokeVoidAsync()⭐⭐ ModerateWhen you want to modernize fully and leverage Blazor patterns
JS Module patternExtract to ES modules, use IJSObjectReference⭐⭐⭐ Full modernizationNew code or heavy JavaScript interaction; long-term maintainability
+

📌 Bottom line: Use ClientScriptShim for the first pass to get your migration done quickly. Refactor to modern patterns in Phase 2 if desired.

+
+

Overview: Why ClientScript Patterns Differ

+

What ClientScript Does in Web Forms

+

In Web Forms, Page.ClientScript (also called ClientScriptManager) enables server-side code to: +- Register startup scripts that run when the page loads +- Include external script files +- Generate postback event references (dynamic __doPostBack calls) +- Manage script deduplication and versioning

+

Web Forms assumed: +- Server-side postback lifecycle (IsPostBack) +- Automatic script injection into the rendered HTML +- Browser-managed form submission via __doPostBack()

+

What Blazor Offers Instead

+

In Blazor, JavaScript interop is explicit, component-scoped, and lifecycle-aware: +- IJSRuntime.InvokeVoidAsync() / InvokeAsync<T>() for calling JavaScript from C# +- Component lifecycle hooks (OnInitializedAsync, OnAfterRenderAsync) for timing +- No postback model — events are direct component method calls +- Prerendering considerations (server-side rendering without browser interactivity)

+

This is fundamentally more explicit — you must choose when to run JavaScript and where it lives (HTML, JavaScript file, or inline in C#). This explicitness is actually safer and easier to reason about than implicit ClientScript injection.

+
+

Quick Reference: ClientScript Patterns → Blazor Equivalents

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web Forms PatternBlazor EquivalentDifficulty
RegisterStartupScript() with inline scriptOnAfterRenderAsync(firstRender) + IJSRuntime.InvokeVoidAsync()⭐ Easy
RegisterClientScriptInclude()<script src=""> in layout or JS module import⭐ Easy
RegisterClientScriptBlock()Inline <script> in component or JS module⭐ Easy
GetPostBackEventReference()@onclick or EventCallback<T>⭐⭐ Medium
Form validation with Page.IsValidEditContext + DataAnnotationsValidator⭐⭐ Medium
IPostBackEventHandler implementationEventCallback<T> parameter⭐⭐ Medium
ScriptManager.SetFocus()@ref element + JS.InvokeVoidAsync("focus", ref)⭐⭐ Medium
ScriptManager.RegisterAsyncPostBackControl()Remove (Blazor uses component binding)⭐⭐⭐ Complex
Dynamic form submission with __doPostBack()Rewrite as component method calls⭐⭐⭐ Complex
+
+

1. Startup Scripts — The Most Common Pattern

+

What It Does

+

RegisterStartupScript() runs a block of JavaScript after the page fully loads. Used for initialization: theme application, jQuery plugins, validation setup, etc.

+

🎯 Easiest Approach: ClientScriptShim

+

For a zero-change migration, use the ClientScriptShim:

+
protected override void OnInitialized()
+{
+    // No code change from Web Forms!
+    ClientScript.RegisterStartupScript(GetType(), "InitializeTheme",
+        "applyTheme('dark');", true);
+}
+
+

See ClientScriptShim (Zero-Rewrite Path) above for full details.

+

Alternative Approach: Modern IJSRuntime Rewrite

+

If you prefer to modernize, use OnAfterRenderAsync() + IJSRuntime. This follows current Blazor best practices but requires code changes.

+

Web Forms

+
protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        Page.ClientScript.RegisterStartupScript(
+            type: this.GetType(),
+            key: "InitializeTheme",
+            script: "$(function() { applyTheme('dark'); });",
+            addScriptTags: true);
+    }
+}
+
+

The if (!IsPostBack) guard ensures the script runs only on first load, not on postbacks.

+

Blazor Equivalent

+

In Blazor, the equivalent is OnAfterRenderAsync(bool firstRender), which fires after the component renders and the DOM is available.

+
@inject IJSRuntime JS
+
+@code {
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            // Script runs only on first render, like !IsPostBack
+            await JS.InvokeVoidAsync("eval", "applyTheme('dark');");
+
+            // Or better: define function in JavaScript and call it
+            // await JS.InvokeVoidAsync("initializeTheme");
+        }
+    }
+}
+
+

Key Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectWeb FormsBlazor
Guardif (!IsPostBack)if (firstRender)
HookPage load (automatic)OnAfterRenderAsync (explicit)
Script locationInjected by serverExternal file or JS module
TimingAfter <body> closesAfter component DOM renders
+

Best Practice: Use JavaScript Modules (If Modernizing)

+

For a modern Blazor approach that's cleaner long-term, define a JavaScript function in a module and call it. This is optional if you're using ClientScriptShim initially.

+

Rather than eval(), define a JavaScript function in a module and call it:

+
+
+
+
export function initializeTheme() {
+    const theme = localStorage.getItem('theme') || 'light';
+    document.documentElement.setAttribute('data-theme', theme);
+}
+
+
+
+
@inject IJSRuntime JS
+
+@code {
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            // Load the module and call the function
+            var module = await JS.InvokeAsync<IJSObjectReference>(
+                "import", "./app.js");
+            await module.InvokeVoidAsync("initializeTheme");
+        }
+    }
+}
+
+
+
+
+

This is cleaner, type-safe (intellisense works), and easier to test.

+
+

2. Script Includes — External JavaScript Files

+

What It Does

+

RegisterClientScriptInclude() references an external .js file, ensuring it loads before dependent scripts.

+

🎯 Easiest Approach: ClientScriptShim

+

For a zero-change migration, use the ClientScriptShim:

+
protected override void OnInitialized()
+{
+    // No code change from Web Forms!
+    ClientScript.RegisterClientScriptInclude(
+        "jquery-ui",
+        "lib/jquery-ui/jquery-ui.min.js");
+}
+
+

The shim dynamically appends <script> tags via IJSRuntime in OnAfterRenderAsync.

+

Alternative Approaches

+ +
<!-- Pages/_Layout.html or index.html -->
+<!DOCTYPE html>
+<html>
+<head>
+    <script src="_framework/lib/jquery/jquery.min.js"></script>
+    <script src="_framework/lib/jquery-ui/jquery-ui.min.js"></script>
+    <script src="app.js"></script>
+</head>
+<body>
+    <!-- Blazor app content -->
+</body>
+</html>
+
+

Option 2: Dynamic import via IJSRuntime (For Conditional Loads)

+

If the script is only needed conditionally (e.g., admin users only):

+
+
+
+
export async function loadAdminTools() {
+    // Dynamically import the admin module
+    const adminModule = await import('./admin-tools.js');
+    adminModule.init();
+}
+
+
+
+
@inject IJSRuntime JS
+@inject AuthenticationStateProvider Auth
+
+@code {
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            var authState = await Auth.GetAuthenticationStateAsync();
+            if (authState.User.IsInRole("Admin"))
+            {
+                var module = await JS.InvokeAsync<IJSObjectReference>(
+                    "import", "./app.js");
+                await module.InvokeVoidAsync("loadAdminTools");
+            }
+        }
+    }
+}
+
+
+
+
+

Key Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectWeb FormsBlazor
InclusionServer-side RegisterClientScriptInclude()HTML <script> tags or JS import()
PathResolveUrl("~/...")Web root paths (no ~ needed)
ConditionalCheck in C# codeCheck in component logic
TimingBefore </body>Before component loads or on-demand
+
+

3. Inline Script Blocks

+

What It Does

+

RegisterClientScriptBlock() injects inline JavaScript code directly into the page, often for utility functions or event handlers.

+

🎯 Easiest Approach: ClientScriptShim

+

For a zero-change migration, use the ClientScriptShim:

+
protected override void OnInitialized()
+{
+    string script = @"
+        function togglePanel(id) {
+            var panel = document.getElementById(id);
+            panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
+        }
+    ";
+
+    // No code change from Web Forms!
+    ClientScript.RegisterClientScriptBlock(
+        this.GetType(),
+        "TogglePanelScript",
+        script,
+        addScriptTags: true);
+}
+
+

Alternative Approaches

+ +
+
+
+
export function togglePanel(id) {
+    const panel = document.getElementById(id);
+    panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
+}
+
+
+
+
@inject IJSRuntime JS
+
+<button @onclick="() => TogglePanel('myPanel')">Toggle</button>
+<div id="myPanel">Content</div>
+
+@code {
+    private IJSObjectReference? module;
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            module = await JS.InvokeAsync<IJSObjectReference>(
+                "import", "./utils.js");
+        }
+    }
+
+    private async Task TogglePanel(string id)
+    {
+        if (module is not null)
+        {
+            await module.InvokeVoidAsync("togglePanel", id);
+        }
+    }
+}
+
+
+
+
+

Alternatively: Inline Script in Layout

+

For truly global scripts, you can inline them in index.html or the layout:

+
<script>
+    function togglePanel(id) {
+        const panel = document.getElementById(id);
+        panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
+    }
+</script>
+
+

But avoid this pattern — it pollutes the global namespace and makes testing harder. Prefer JavaScript modules.

+
+

4. Postback Event References

+

What It Does

+

GetPostBackEventReference() generates a dynamic JavaScript call to trigger a postback event, often used in client-side event handlers that need to notify the server. Phase 2 now includes a working shim for this pattern.

+

🎯 Easiest Approach: ClientScriptShim (Phase 2 — Zero Rewrite)

+

Web Forms: +

public string GetDeleteButtonScript()
+{
+    // Generate: javascript:__doPostBack('btnDelete','clicked')
+    return Page.ClientScript.GetPostBackEventReference(
+        new PostBackOptions(btnDelete, "clicked")
+        {
+            PerformValidation = false
+        });
+}
+
+// Usage in markup:
+// <a href='<%# GetDeleteButtonScript() %>'>Delete</a>
+

+

Blazor with BWFC — Zero rewrite! +

// Same code works! ClientScriptShim returns a working __doPostBack() JS string
+public string GetDeleteButtonScript()
+{
+    return ClientScript.GetPostBackEventReference(
+        new PostBackOptions(btnDelete, "clicked")
+        {
+            PerformValidation = false
+        });
+}
+
+// Usage in markup:
+// <a href="@GetDeleteButtonScript()">Delete</a>
+

+

How It Works (Phase 2)

+
    +
  1. +

    GetPostBackEventReference() returns __doPostBack('controlId', 'arg') — the exact same function name as Web Forms.

    +
  2. +
  3. +

    BWFC ships bwfc-postback.js which defines __doPostBack() as a JavaScript bridge function: +

    window.__doPostBack = async function(eventTarget, eventArgument) {
    +    // Bridge back into Blazor via JS interop
    +    await DotNet.invokeMethodAsync('BlazorWebFormsComponents', 'HandlePostBackFromJs', 
    +        eventTarget, eventArgument);
    +};
    +

    +
  4. +
  5. +

    The page registers itself as a postback target in OnAfterRenderAsync, exposing a .NET callback method via DotNetObjectReference.

    +
  6. +
  7. +

    When JavaScript calls __doPostBack(), it invokes the .NET HandlePostBackFromJs method via JS interop, which fires the page's PostBack event.

    +
  8. +
  9. +

    Your C# code handles the PostBack event, just like Web Forms: +

    @code {
    +    protected override void OnInitialized()
    +    {
    +        PostBack += (sender, args) =>
    +        {
    +            // args.EventTarget — the control that triggered the postback
    +            // args.EventArgument — the argument passed
    +            HandleMyPostBack(args.EventTarget, args.EventArgument);
    +        };
    +    }
    +
    +    private void HandleMyPostBack(string eventTarget, string eventArgument)
    +    {
    +        if (eventTarget == "btnDelete" && eventArgument == "clicked")
    +        {
    +            DeleteItem();
    +        }
    +    }
    +}
    +

    +
  10. +
+

Usage Pattern

+

Use GetPostBackEventReference() in data-bound attributes or JavaScript event handlers that need to trigger server-side actions:

+
@foreach (var item in items)
+{
+    <a href="@ClientScript.GetPostBackEventReference(item, "edit")">
+        Edit
+    </a>
+    <a href="@ClientScript.GetPostBackEventReference(item, "delete")">
+        Delete
+    </a>
+}
+
+@code {
+    protected override void OnInitialized()
+    {
+        PostBack += (sender, args) =>
+        {
+            if (args.EventArgument == "edit")
+            {
+                EditItem(args.EventTarget);
+            }
+            else if (args.EventArgument == "delete")
+            {
+                DeleteItem(args.EventTarget);
+            }
+        };
+    }
+}
+
+

Alternative: Modern Blazor Approach

+

If you prefer to modernize away from postback patterns, use @onclick or EventCallback instead:

+
+
+
+
@foreach (var item in items)
+{
+    <button @onclick="() => EditItem(item.Id)">Edit</button>
+    <button @onclick="() => DeleteItem(item.Id)">Delete</button>
+}
+
+@code {
+    private async Task EditItem(int itemId) { ... }
+    private async Task DeleteItem(int itemId) { ... }
+}
+
+
+
+
<!-- Parent component -->
+@foreach (var item in items)
+{
+    <ChildComponent Item="@item" OnEdit="HandleEdit" OnDelete="HandleDelete" />
+}
+
+@code {
+    private async Task HandleEdit(Item item) { ... }
+    private async Task HandleDelete(Item item) { ... }
+}
+
+<!-- ChildComponent.razor -->
+@code {
+    [Parameter]
+    public Item Item { get; set; }
+
+    [Parameter]
+    public EventCallback<Item> OnEdit { get; set; }
+
+    [Parameter]
+    public EventCallback<Item> OnDelete { get; set; }
+
+    private async Task RaiseEdit() => await OnEdit.InvokeAsync(Item);
+    private async Task RaiseDelete() => await OnDelete.InvokeAsync(Item);
+}
+
+
+
+
+

Key Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectWeb FormsBlazor (Phase 2 with Shim)Blazor (Modern)
Mechanism__doPostBack() → HTTP POST__doPostBack() → JS interop → .NETDirect component method call
Server roundtripFull page reloadBlazor diff sync (no page reload)Instant (no roundtrip)
CompatibilityZero rewriteZero rewriteRequires refactoring
Best forCode migration (Phase 1)Code migration (Phase 2)New development
+
+

5. Callback Event References (Phase 2)

+

What It Does

+

GetCallbackEventReference() generates a JavaScript callback bridge for server callback processing (AJAX-style communication without UpdatePanel). Phase 2 includes a working shim for this pattern.

+

Web Forms

+
protected void Page_Load(object sender, EventArgs e)
+{
+    string callback = Page.ClientScript.GetCallbackEventReference(
+        this, 
+        "arg",           // JavaScript argument to pass
+        "onSuccess",     // JavaScript function to call on success
+        "ctx",           // Context object to pass to callback
+        "onError",       // JavaScript function to call on error
+        true);           // useAsync
+
+    // Inject the callback string into a JavaScript function
+    Page.ClientScript.RegisterStartupScript(this.GetType(), "initCallback",
+        $"var callback = '{callback}'; " +
+        "function myCallback(arg) { callback(arg); }",
+        true);
+}
+
+// In markup:
+// <button onclick="myCallback('someData')">Call Server</button>
+
+// Server-side callback handler:
+public void RaiseCallbackEvent(string eventArgument)
+{
+    // Process eventArgument and prepare return value
+}
+
+public string GetCallbackResult()
+{
+    // Return result to JavaScript
+    return "Server processed: " + eventArgument;
+}
+
+

Blazor with BWFC (Phase 2 — Zero Rewrite)

+
// Same pattern works! ClientScriptShim provides the callback bridge
+protected override void OnInitialized()
+{
+    string callback = ClientScript.GetCallbackEventReference(
+        this, 
+        "arg",
+        "onSuccess",
+        "ctx",
+        "onError",
+        useAsync: true);
+
+    // Register the callback into the page
+    ClientScript.RegisterStartupScript(this.GetType(), "initCallback",
+        $"var callback = '{callback}'; " +
+        "function myCallback(arg) { callback(arg); }",
+        true);
+}
+
+// Handler methods (same as Web Forms):
+public void RaiseCallbackEvent(string eventArgument)
+{
+    // Process eventArgument
+}
+
+public string GetCallbackResult()
+{
+    // Return result to JavaScript
+}
+
+

How It Works (Phase 2)

+
    +
  1. +

    GetCallbackEventReference() returns a JavaScript function call string that bridges back to .NET: +

    // Returned string looks like:
    +"WebForm_DoCallback('controlId',arg,onSuccess,ctx,onError,true)"
    +

    +
  2. +
  3. +

    BWFC ships bwfc-callback.js which defines WebForm_DoCallback() as a bridge: +

    window.WebForm_DoCallback = async function(controlId, arg, onSuccess, ctx, onError, async) {
    +    try {
    +        const result = await DotNet.invokeMethodAsync('BlazorWebFormsComponents', 
    +            'HandleCallbackFromJs', controlId, arg);
    +        if (onSuccess) {
    +            onSuccess(result, ctx);
    +        }
    +    } catch (err) {
    +        if (onError) {
    +            onError(err, ctx);
    +        }
    +    }
    +};
    +

    +
  4. +
  5. +

    Your C# methods handle the callback, just like Web Forms: +

    public void RaiseCallbackEvent(string eventArgument)
    +{
    +    // Process the callback argument
    +    // Set _callbackResult for GetCallbackResult()
    +}
    +
    +public string GetCallbackResult()
    +{
    +    // Return data back to the JavaScript callback
    +    return _callbackResult;
    +}
    +

    +
  6. +
  7. +

    JavaScript receives the result in the onSuccess callback: +

    function onSuccess(result, context) {
    +    console.log('Server returned:', result);
    +    // Update UI with server response
    +}
    +

    +
  8. +
+

Usage Pattern

+

Use callback events for AJAX-style server communication without page reload:

+
@inject IJSRuntime JS
+
+<button @onclick="FetchDataViaCallback">Fetch Data</button>
+<div id="result"></div>
+
+@code {
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            // Define JavaScript callback handlers
+            await JS.InvokeVoidAsync("eval", @"
+                window.onCallbackSuccess = function(result, context) {
+                    document.getElementById('result').innerHTML = result;
+                };
+                window.onCallbackError = function(error, context) {
+                    console.error('Callback error:', error);
+                };
+            ");
+        }
+    }
+
+    private async Task FetchDataViaCallback()
+    {
+        // Get the callback reference
+        string callback = ClientScript.GetCallbackEventReference(
+            this,
+            "\"userQuery\"",  // Argument to pass
+            "onCallbackSuccess",
+            "null",
+            "onCallbackError",
+            useAsync: true);
+
+        // Execute it via JS interop
+        await JS.InvokeVoidAsync("eval", $"{callback};");
+    }
+
+    public void RaiseCallbackEvent(string eventArgument)
+    {
+        // Process the query
+        _callbackResult = $"Data for: {eventArgument}";
+    }
+
+    private string _callbackResult = "";
+
+    public string GetCallbackResult()
+    {
+        return _callbackResult;
+    }
+}
+
+

Alternative: Modern Blazor Approach

+

For new development, use IJSRuntime with direct method calls instead of callbacks:

+
@inject IJSRuntime JS
+@inject HttpClient Http
+
+<button @onclick="FetchData">Fetch Data</button>
+<div id="result">@result</div>
+
+@code {
+    private string result = "";
+
+    private async Task FetchData()
+    {
+        // Direct async call to server
+        result = await Http.GetStringAsync("/api/data");
+    }
+}
+
+

This is cleaner, type-safe, and easier to test than callback-based patterns.

+
+

6. Form Validation Scripts

+

What It Does

+

Web Forms uses Page.IsValid and Page.Validate() to check server-side validators. Client-side validation scripts often run before postback to prevent unnecessary round trips.

+

Web Forms

+
protected void btnSubmit_Click(object sender, EventArgs e)
+{
+    // Validators run server-side
+    if (!Page.IsValid)
+    {
+        // Show error
+        return;
+    }
+
+    // Process form
+    SaveData();
+}
+
+// In markup:
+// <asp:RequiredFieldValidator ControlToValidate="txtName" />
+// <asp:RangeValidator ControlToValidate="txtAge" MinimumValue="0" MaximumValue="120" />
+
+

Blazor Equivalent

+

Use EditForm with EditContext and DataAnnotationsValidator. Validation is declarative (via data annotations) and works on both client and server:

+
@inject HttpClient Http
+
+<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
+    <DataAnnotationsValidator />
+    <ValidationSummary />
+
+    <div class="form-group">
+        <label for="name">Name:</label>
+        <InputText id="name" @bind-Value="model.Name" />
+        <ValidationMessage For="() => model.Name" />
+    </div>
+
+    <div class="form-group">
+        <label for="age">Age:</label>
+        <InputNumber id="age" @bind-Value="model.Age" />
+        <ValidationMessage For="() => model.Age" />
+    </div>
+
+    <button type="submit" class="btn btn-primary">Submit</button>
+</EditForm>
+
+@code {
+    private FormModel model = new();
+
+    private async Task HandleSubmit()
+    {
+        // Only called if validation passes
+        await SaveDataAsync();
+    }
+}
+
+public class FormModel
+{
+    [Required(ErrorMessage = "Name is required")]
+    public string Name { get; set; }
+
+    [Range(0, 120, ErrorMessage = "Age must be between 0 and 120")]
+    public int Age { get; set; }
+}
+
+

Key Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AspectWeb FormsBlazor
DeclarationServer-side validator controlsC# data annotations
Client-side validationRendered JavaScript from validatorsBuilt-in via DataAnnotationsValidator
Validation timingSubmit button click → postbackForm submission or real-time
Custom rulesCustom validators or CustomValidator controlValidationAttribute subclass
+

Custom Validators

+

Web Forms: +

<asp:CustomValidator 
+    OnServerValidate="ValidateDateRange"
+    ErrorMessage="Date must be in the past" />
+

+

Blazor: +

public class DateInPastAttribute : ValidationAttribute
+{
+    protected override ValidationResult IsValid(object value, ValidationContext ctx)
+    {
+        var date = (DateTime?)value;
+        return date < DateTime.Now 
+            ? ValidationResult.Success 
+            : new ValidationResult("Date must be in the past");
+    }
+}
+
+// Usage in model:
+[DateInPast]
+public DateTime EventDate { get; set; }
+

+
+

7. IPostBackEventHandler — Custom Event Binding

+

What It Does

+

IPostBackEventHandler allows controls to raise custom events in response to postback data. Rarely used directly, but common in composite controls.

+

Web Forms

+
public partial class MyCustomControl : UserControl, IPostBackEventHandler
+{
+    public event EventHandler OnCustomAction;
+
+    public void RaisePostBackEvent(string eventArgument)
+    {
+        if (eventArgument == "myaction")
+        {
+            OnCustomAction?.Invoke(this, EventArgs.Empty);
+        }
+    }
+
+    // Markup triggers postback:
+    // <a href='<%# Page.ClientScript.GetPostBackEventReference(this, "myaction") %>'>
+}
+
+

Blazor Equivalent

+

Use EventCallback<T> parameters instead:

+
<!-- MyCustomComponent.razor -->
+@code {
+    [Parameter]
+    public EventCallback OnCustomAction { get; set; }
+
+    private async Task RaiseCustomAction()
+    {
+        await OnCustomAction.InvokeAsync();
+    }
+}
+
+<!-- Usage in parent: -->
+<MyCustomComponent OnCustomAction="HandleCustomAction" />
+
+@code {
+    private async Task HandleCustomAction()
+    {
+        // Handle the event
+    }
+}
+
+

With Arguments

+

If the postback event passes data:

+
+
+
+
public void RaisePostBackEvent(string eventArgument)
+{
+    if (eventArgument.StartsWith("select-"))
+    {
+        string itemId = eventArgument.Replace("select-", "");
+        OnItemSelected?.Invoke(this, new ItemSelectedEventArgs { ItemId = itemId });
+    }
+}
+
+
+
+
@code {
+    [Parameter]
+    public EventCallback<string> OnItemSelected { get; set; }
+
+    private async Task SelectItem(string itemId)
+    {
+        await OnItemSelected.InvokeAsync(itemId);
+    }
+}
+
+
+
+
+
+

8. ScriptManager Code-Behind Patterns

+

SetFocus()

+

Web Forms: +

ScriptManager.SetFocus(txtUserName);
+

+

Blazor: +

@inject IJSRuntime JS
+
+<input @ref="userNameRef" />
+
+@code {
+    private ElementReference userNameRef;
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            await JS.InvokeVoidAsync("focus", userNameRef);
+        }
+    }
+}
+

+

RegisterAsyncPostBackControl()

+

Web Forms: +

ScriptManager.RegisterAsyncPostBackControl(gvData);
+// Enables AJAX partial page updates via UpdatePanel
+

+

Blazor: +RegisterAsyncPostBackControl() has no equivalent in Blazor because Blazor components handle updates natively via parameter binding and EventCallback. Remove this line.

+

Instead, let the component update naturally:

+
@page "/data"
+
+<GridView Data="@items" OnRowSelected="HandleRowSelected" />
+
+@code {
+    private List<Item> items;
+
+    private async Task HandleRowSelected(int itemId)
+    {
+        // Component updates automatically via @bind or parameters
+        var item = await FetchItemAsync(itemId);
+        items = await FetchItemsAsync(); // Re-render with new data
+    }
+}
+
+

RegisterUpdateProgress()

+

Web Forms: +

ScriptManager.RegisterUpdateProgress(updateProgress, masterUpdateProgress);
+// Shows during async postback
+

+

Blazor: +Show a loading indicator using component state:

+
<div class="update-progress" style="@(isLoading ? "display:block" : "display:none")">
+    <p>Loading...</p>
+</div>
+
+<button @onclick="FetchData" disabled="@isLoading">Fetch</button>
+
+@code {
+    private bool isLoading;
+
+    private async Task FetchData()
+    {
+        isLoading = true;
+        await Task.Delay(2000); // Simulate async work
+        isLoading = false;
+    }
+}
+
+ +

Web Forms: +

ScriptManager sm = ScriptManager.GetCurrent(Page);
+sm.RegisterStartupScript(this.GetType(), "init", "initPage();", true);
+sm.SetFocus(txtSearch);
+

+

Blazor with BWFC (Phase 2 — Zero Rewrite): +

// Same pattern works! ScriptManagerShim wraps ClientScriptShim
+ScriptManager sm = ScriptManager.GetCurrent(this);  // 'this' is the component (replaces Page)
+sm.RegisterStartupScript(this.GetType(), "init", "initPage();", true);
+// SetFocus still requires JS interop (see above)
+

+

How It Works (Phase 2)

+
    +
  1. +

    ScriptManager.GetCurrent(page) extracts the ClientScriptShim from the component's dependency injection context.

    +
  2. +
  3. +

    All RegisterStartupScript, RegisterClientScriptBlock, RegisterClientScriptInclude calls delegate to ClientScriptShim, which queues scripts during initialization.

    +
  4. +
  5. +

    Scripts are flushed in OnAfterRenderAsync via IJSRuntime, exactly like Phase 1.

    +
  6. +
  7. +

    Focus and other component methods require JavaScript interop, as documented in the sections above.

    +
  8. +
+

Pattern: RegisterStartupScript via ScriptManager

+

Instead of calling Page.ClientScript directly, you can use ScriptManager.GetCurrent():

+
// Web Forms
+ScriptManager sm = ScriptManager.GetCurrent(Page);
+sm.RegisterStartupScript(this.GetType(), "init", "console.log('loaded');", true);
+
+// Blazor (Phase 2)
+ScriptManager sm = ScriptManager.GetCurrent(this);
+sm.RegisterStartupScript(this.GetType(), "init", "console.log('loaded');", true);
+// Zero code change! Same methods, same behavior
+
+

Key Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodPhase 1Phase 2Notes
RegisterStartupScript()✅ ClientScriptShim✅ Via ScriptManagerBoth work; ScriptManager delegates to ClientScriptShim
RegisterClientScriptBlock()✅ ClientScriptShim✅ Via ScriptManagerBoth work; same delegation
RegisterClientScriptInclude()✅ ClientScriptShim✅ Via ScriptManagerBoth work; same delegation
GetCurrent()❌ Unsupported✅ Phase 2Returns the component's ClientScriptShim
SetFocus()❌ Unsupported❌ Still not supportedUse JS.InvokeVoidAsync("focus", @ref) instead
RegisterAsyncPostBackControl()❌ Unsupported❌ Still not supportedUpdatePanel is not emulated; use component binding
+

When to Use ScriptManager vs ClientScriptShim

+
    +
  • Directly — Both ClientScript property (Phase 1) and ScriptManager.GetCurrent() (Phase 2) work
  • +
  • No functional difference — ScriptManager just wraps ClientScriptShim for API compatibility
  • +
  • Choose based on your Web Forms code — If you used ScriptManager, keep using it; if you used Page.ClientScript, use ClientScript property
  • +
+
+

9. Common Pitfalls and Solutions

+

Pitfall 1: Script Runs Multiple Times Due to Re-renders

+

Problem: +

protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    // ❌ WRONG: Runs every render, not just first
+    await JS.InvokeVoidAsync("applyTheme");
+}
+

+

Solution: +

protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    // ✅ CORRECT: Only on first render
+    if (firstRender)
+    {
+        await JS.InvokeVoidAsync("applyTheme");
+    }
+}
+

+

Pitfall 2: Prerendering Issues

+

In SSR (Server-Side Rendering) or prerendering mode, OnAfterRenderAsync runs on the server without browser interactivity. IJSRuntime calls fail silently.

+

Problem: +

// ❌ WRONG: Fails during prerendering
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        await JS.InvokeVoidAsync("applyTheme"); // No JS in SSR
+    }
+}
+

+

Solution: +

// ✅ CORRECT: Guard with try-catch or check if interactive
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        try
+        {
+            await JS.InvokeVoidAsync("applyTheme");
+        }
+        catch (InvalidOperationException)
+        {
+            // Running in SSR mode; skip JS interop
+        }
+    }
+}
+
+// OR use a runtime check:
+@inject IComponentRenderingContext RenderContext
+
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender && RenderContext.IsInteractive)
+    {
+        await JS.InvokeVoidAsync("applyTheme");
+    }
+}
+

+

Pitfall 3: Script Timing — Waiting for DOM Elements

+

Problem: +

// ❌ WRONG: Element might not exist yet
+document.getElementById("myDiv").classList.add("highlight");
+

+

Solution: +

// ✅ CORRECT: Call from OnAfterRenderAsync, after render
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        await JS.InvokeVoidAsync("highlightElement", "myDiv");
+    }
+}
+

+

JavaScript: +

export function highlightElement(id) {
+    const elem = document.getElementById(id);
+    if (elem) {
+        elem.classList.add("highlight");
+    }
+}
+

+

Pitfall 4: Module Import Caching

+

Problem: +

// ❌ WRONG: Imports module every render
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    var module = await JS.InvokeAsync<IJSObjectReference>("import", "./app.js");
+    await module.InvokeVoidAsync("init");
+}
+

+

Solution: +

// ✅ CORRECT: Cache the module
+private IJSObjectReference? module;
+
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        module = await JS.InvokeAsync<IJSObjectReference>("import", "./app.js");
+        await module.InvokeVoidAsync("init");
+    }
+}
+

+

Pitfall 5: Script Deduplication

+

In Web Forms, RegisterStartupScript with the same key runs only once per page. In Blazor, you must deduplicate manually.

+

Problem: +

// Component rendered multiple times
+foreach (var item in items)
+{
+    <MyComponent />
+}
+
+// ❌ WRONG: Each instance calls applyTheme()
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        await JS.InvokeVoidAsync("applyTheme");
+    }
+}
+

+

Solution: +

<!-- Parent component calls once -->
+@foreach (var item in items)
+{
+    <MyComponent Item="@item" />
+}
+
+@code {
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            await JS.InvokeVoidAsync("applyTheme"); // Once, not per child
+        }
+    }
+}
+

+

Or use a static flag to prevent duplicate initialization:

+
private static bool isAppInitialized;
+
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender && !isAppInitialized)
+    {
+        isAppInitialized = true;
+        await JS.InvokeVoidAsync("applyTheme");
+    }
+}
+
+
+

10. What We Don't Support (And Why)

+

__doPostBack() and Postback Events

+

Why not? +- Web Forms postback is an HTTP POST with form-encoded data and event validation +- Blazor is component-based with direct method calls, not form postbacks +- Emulating __doPostBack() would require replicating the entire Web Forms postback protocol, which defeats the purpose of using Blazor

+

Alternative: +Use @onclick, EventCallback<T>, or form submission with EditForm.

+

UpdatePanel Async Postback Semantics

+

Why not? +- UpdatePanel enables partial-page updates via AJAX postback +- Blazor components handle updates natively via parameter binding +- A compatibility layer would be complex, fragile, and undermine Blazor's design

+

Alternative: +Use Blazor component parameters, @bind, and EventCallback for interactive updates.

+

Automatic Form Validation Conversion

+

Why not? +- Web Forms validators are declarative controls with complex state management +- Blazor validation is based on data annotations, which are independent of the component model +- Conversion would require semantic analysis of validator configurations and cannot be automated reliably

+

Alternative: +Manually rewrite validators as data annotations on your model classes.

+

ScriptManager Full API Surface

+

Why not? +- Only a few ScriptManager methods are commonly used; most are framework internals +- Each method has a different (or no) Blazor equivalent +- A full compatibility wrapper would create maintenance burden with minimal benefit

+

Alternative: +Our Roslyn analyzers (BWFC022, BWFC023, BWFC024) detect problematic patterns and guide you to Blazor equivalents.

+
+

11. Analyzers and CLI Transforms

+

To help automate migration detection, BWFC provides three diagnostic rules:

+

BWFC022: PageClientScript Usage Analyzer

+

Detects Page.ClientScript usage and suggests patterns for each method call.

+

Example: +

// ⚠️ BWFC022: Page.ClientScript is not available in Blazor.
+// Migration path depends on the pattern:
+// - If RegisterStartupScript(): Use OnAfterRenderAsync(IJSRuntime) with firstRender guard.
+// - If RegisterClientScriptInclude(): Add <script> tag to layout or import via JS.InvokeAsync().
+// - If GetPostBackEventReference(): Use @onclick or EventCallback<T> instead.
+
+Page.ClientScript.RegisterStartupScript(this.GetType(), "key", "script");
+

+

See BWFC022 Reference for details.

+

BWFC023: IPostBackEventHandler Usage Analyzer

+

Detects IPostBackEventHandler implementation and suggests EventCallback<T>.

+

Example: +

// ⚠️ BWFC023: IPostBackEventHandler is not available in Blazor.
+// Use EventCallback<T> for event handling instead.
+
+public class MyControl : BaseWebFormsComponent, IPostBackEventHandler
+{
+    public void RaisePostBackEvent(string eventArgument) { }
+}
+

+

See BWFC023 Reference for details.

+

BWFC024: ScriptManager Code-Behind Usage Analyzer

+

Detects ScriptManager.GetCurrent() and method calls like SetFocus(), RegisterAsyncPostBackControl().

+

Example: +

// ⚠️ BWFC024: ScriptManager.GetCurrent() and related methods are not available in Blazor.
+// SetFocus: Use JavaScript interop with element @ref.
+// RegisterAsyncPostBackControl: Blazor does not use UpdatePanel postback model — use component binding instead.
+
+ScriptManager.GetCurrent(Page).SetFocus(txtSearch);
+

+

See BWFC024 Reference for details.

+
+

12. Real-World Examples

+

Example 1: jQuery Plugin Initialization

+

Web Forms: +

protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        Page.ClientScript.RegisterClientScriptInclude(
+            "jqueryui",
+            ResolveUrl("~/lib/jquery-ui/jquery-ui.min.js"));
+
+        Page.ClientScript.RegisterStartupScript(
+            this.GetType(),
+            "initDatepicker",
+            "$(function() { $('#txtDate').datepicker(); });",
+            true);
+    }
+}
+

+

Blazor:

+
+
+
+
export function initializeDatepicker() {
+    $('#txtDate').datepicker();
+}
+
+
+
+
@inject IJSRuntime JS
+
+<input @ref="dateInput" id="txtDate" type="text" />
+
+@code {
+    private ElementReference dateInput;
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            var module = await JS.InvokeAsync<IJSObjectReference>(
+                "import", "./app.js");
+            await module.InvokeVoidAsync("initializeDatepicker");
+        }
+    }
+}
+
+
+
+
+

HTML layout must include jQuery UI: +

<script src="lib/jquery/jquery.min.js"></script>
+<script src="lib/jquery-ui/jquery-ui.min.js"></script>
+

+

Example 2: Dynamic Data Grid with Inline Editing

+

Web Forms: +

protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        // Include script for inline editing
+        Page.ClientScript.RegisterClientScriptInclude(
+            "grideditor",
+            ResolveUrl("~/lib/grid-editor.js"));
+    }
+}
+
+public class GridData
+{
+    public int Id { get; set; }
+    public string Name { get; set; }
+}
+

+

Blazor: +

@page "/data-grid"
+@inject HttpClient Http
+
+<GridView Data="@items" OnRowSelected="HandleRowSelected">
+    <GridViewColumn Binding="@(x => x.Id)" Header="ID" />
+    <GridViewColumn Binding="@(x => x.Name)" Header="Name" />
+</GridView>
+
+<button @onclick="Refresh">Refresh</button>
+
+@code {
+    private List<GridData> items;
+
+    protected override async Task OnInitializedAsync()
+    {
+        items = await Http.GetFromJsonAsync<List<GridData>>("/api/data");
+    }
+
+    private async Task HandleRowSelected(int id)
+    {
+        // Update data directly, no __doPostBack needed
+        var item = items.FirstOrDefault(x => x.Id == id);
+        if (item != null)
+        {
+            item.Name = await PromptForNewName();
+            await UpdateItemAsync(item);
+        }
+    }
+
+    private async Task Refresh()
+    {
+        items = await Http.GetFromJsonAsync<List<GridData>>("/api/data");
+    }
+}
+
+public class GridData
+{
+    public int Id { get; set; }
+    public string Name { get; set; }
+}
+

+

Example 3: Form with Custom Validation and Theme Toggle

+

Web Forms: +

protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        // Validation scripts
+        Page.ClientScript.RegisterStartupScript(
+            this.GetType(),
+            "validate",
+            "window.validateForm = function() { return $('#form').valid(); };",
+            true);
+
+        // Theme toggle
+        Page.ClientScript.RegisterStartupScript(
+            this.GetType(),
+            "theme",
+            "$(function() { applyUserTheme(); });",
+            true);
+    }
+}
+
+protected void btnSubmit_Click(object sender, EventArgs e)
+{
+    if (!Page.IsValid) return;
+
+    // Process
+}
+

+

Blazor: +

@page "/form"
+@inject IJSRuntime JS
+
+<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
+    <DataAnnotationsValidator />
+    <ValidationSummary />
+
+    <div class="form-group">
+        <label>Name:</label>
+        <InputText @bind-Value="model.Name" />
+        <ValidationMessage For="() => model.Name" />
+    </div>
+
+    <div class="form-group">
+        <label>Email:</label>
+        <InputText @bind-Value="model.Email" />
+        <ValidationMessage For="() => model.Email" />
+    </div>
+
+    <button type="submit" class="btn btn-primary">Submit</button>
+    <button type="button" @onclick="ToggleTheme" class="btn btn-secondary">Toggle Theme</button>
+</EditForm>
+
+@code {
+    private FormModel model = new();
+    private IJSObjectReference? module;
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            module = await JS.InvokeAsync<IJSObjectReference>("import", "./app.js");
+            await module.InvokeVoidAsync("applyUserTheme");
+        }
+    }
+
+    private async Task HandleSubmit()
+    {
+        // Only called if validation passes (DataAnnotationsValidator)
+        await SaveFormAsync();
+    }
+
+    private async Task ToggleTheme()
+    {
+        if (module is not null)
+        {
+            await module.InvokeVoidAsync("toggleTheme");
+        }
+    }
+}
+
+public class FormModel
+{
+    [Required(ErrorMessage = "Name is required")]
+    public string Name { get; set; }
+
+    [Required(ErrorMessage = "Email is required")]
+    [EmailAddress(ErrorMessage = "Invalid email format")]
+    public string Email { get; set; }
+}
+

+
+

Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web FormsBlazorLearn More
RegisterStartupScript()OnAfterRenderAsync(IJSRuntime)Section 1
RegisterClientScriptInclude()<script src=""> in layoutSection 2
RegisterClientScriptBlock()JS module + importSection 3
GetPostBackEventReference()@onclick or EventCallback<T>Section 4
Form validation with Page.IsValidEditForm + DataAnnotationsValidatorSection 5
IPostBackEventHandlerEventCallback<T>Section 6
ScriptManager.SetFocus()@ref + JS.InvokeVoidAsync()Section 7
ScriptManager other methodsRemove (Blazor handles natively)Section 7
+

Next Steps: +1. Review the Analyzer Reference Pages to understand diagnostic messages +2. Check the Roslyn Analyzers documentation for CLI integration +3. Explore the Live Samples to see ClientScript patterns in action +4. Review the IJSRuntime Documentation for advanced scenarios

+
+

Last Updated: 2026-07-30
+Status: Complete
+Component: Beast (Technical Writer)

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/ControlCoverage/index.html b/site/Migration/ControlCoverage/index.html new file mode 100644 index 000000000..b3c48d1b0 --- /dev/null +++ b/site/Migration/ControlCoverage/index.html @@ -0,0 +1,7597 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Control Coverage - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ + + + + + + + +

Control Coverage Reference

+

Can I migrate this control? This is the complete reference for all BWFC components and the Web Forms controls that are not covered.

+

For the full control translation rules (attribute mappings, code examples, before/after), see the Copilot migration skill.

+
+

Coverage Summary

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricValue
Primary Web Forms controls58
Supporting components (field columns, styles, infrastructure, helpers)96
Total Razor components shipped154
Migration shims (compile-compatible APIs)14
Web Forms control categories covered9 (Editor, Data, Validation, Navigation, Login, AJAX, Infrastructure, Field Columns, Style Sub-Components)
Enums54
Utility/infrastructure C# classes197+
WingtipToys PoC coverage96.6% (28 of 29 control types used)
Controls with no BWFC equivalentSee Not Supported
+
+

Complexity Rating Guide

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RatingMeaningTypical Effort
TrivialRemove asp: and runat="server". Done.< 1 minute
EasyRemove prefixes + add @bind or adjust one attribute.1–5 minutes
MediumData binding rewiring, template context variables, lifecycle method changes.5–30 minutes
ComplexArchitecture decisions required — data access, auth, state management.30+ minutes
+
+

Editor Controls (28 components)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlBWFC?ComplexityKey ChangesGotchas
AdRotatorEasyRemove asp:, runatConfigure ad data via component properties
BulletedListEasyRemove asp:, runat; bind ItemsDisplayMode and BulletStyle preserved
ButtonTrivialRemove asp:, runatOnClick is now EventCallback — no (sender, e) signature
CalendarEasyRemove asp:, runatSelectionMode is an enum — use CalendarSelectionMode.Day
ChartComplexRemove asp:, runatJS interop for rendering; use <ChartSeries>, <ChartArea>, <ChartLegend> children
CheckBoxEasyRemove asp:, runat; add @bind-CheckedTwo-way binding requires @bind-Checked
CheckBoxListEasyRemove asp:, runat; bind ItemsSame list binding pattern as DropDownList
DropDownListEasyRemove asp:, runat; bind Items + @bind-SelectedValueBind both the items collection and selected value
FileUploadEasyRemove asp:, runatUses Blazor InputFile internally — HasFile, SaveAs() work
HiddenFieldTrivialRemove asp:, runatValue property preserved
HyperLinkTrivialRemove asp:, runat; ~//URL prefix conversion is Layer 1 automated
ImageTrivialRemove asp:, runat; ~//ImageUrl preserved
ImageButtonTrivialRemove asp:, runat; ~//OnClick is EventCallback
ImageMapEasyRemove asp:, runatDefine hotspot regions via component properties
LabelTrivialRemove asp:, runatText, CssClass, AssociatedControlID preserved
LinkButtonTrivialRemove asp:, runatCommandName/CommandArgument preserved
ListBoxEasyRemove asp:, runat; bind ItemsSame binding pattern as DropDownList
LiteralTrivialRemove asp:, runatText and Mode preserved
LocalizeTrivialRemove asp:, runatResource-based text
MultiViewEasyRemove asp:, runatUse with <View> child components
PanelTrivialRemove asp:, runatRenders <div> — same as Web Forms
PlaceHolderTrivialRemove asp:, runatRenders no HTML — structural container only
RadioButtonEasyRemove asp:, runatGroupName preserved
RadioButtonListEasyRemove asp:, runat; bind ItemsSame list binding pattern
SubstitutionEasyRemove asp:, runatUses Func<HttpContext, string> callback; renders output directly
TableEasyRemove asp:, runatUse with <TableRow> and <TableCell> children
TextBoxEasyRemove asp:, runat; add @bind-TextTextMode preserved — note Multiline (not MultiLine)
ViewTrivialRemove asp:, runatUsed inside MultiView
+
+

Data Controls (8 components)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlBWFC?ComplexityKey ChangesGotchas
DataGridMediumItemTypeTItem; SelectMethod → delegate or ItemsBuilt-in numeric pager — does not use PagerSettings
DataListMediumItemTypeTItem; SelectMethod → delegate or ItemsTemplate Context="Item" required
DataPagerMediumRemove asp:, runatWorks with PageableData controls
DetailsViewMediumItemTypeTItem; SelectMethodDataItemSingle-record control — uses DataItem, not Items
FormViewMediumItemTypeTItem; SelectMethodDataItemRenderOuterTable="false" supported; single-record control
GridViewMediumItemTypeTItem; SelectMethod → delegate or ItemsAdd Context="Item" to templates; BoundField/TemplateField preserved
ListViewMediumItemTypeTItem; SelectMethod → delegate or ItemsLayoutTemplate, GroupTemplate, GroupItemCount all supported
RepeaterMediumItemTypeTItem; SelectMethod → delegate or ItemsSeparatorTemplate supported
+

Data Control Migration Pattern

+

All data controls follow the same pattern. When the original Web Forms markup uses SelectMethod, preserve it as a delegate reference (preferred). When it uses DataSource/DataBind(), use Items.

+

Option A — SelectMethod preserved as delegate (preferred when original used SelectMethod):

+
<!-- Web Forms -->
+<asp:GridView ItemType="MyApp.Models.Product" SelectMethod="GetProducts" runat="server">
+
+
<!-- Blazor — SelectMethod as delegate -->
+<GridView TItem="Product" SelectMethod="@productService.GetProducts">
+
+

Option B — Items binding (when original used DataSource/DataBind):

+
<!-- Web Forms -->
+<asp:GridView ItemType="MyApp.Models.Product" runat="server">
+
+
<!-- Blazor -->
+<GridView TItem="Product" Items="products">
+
+

Load data in the code-behind:

+
protected override async Task OnInitializedAsync()
+{
+    products = await ProductService.GetProductsAsync();
+}
+
+
+

⚠️ DO NOT convert SelectMethod to Items= binding when the original Web Forms markup used SelectMethod. BWFC's DataBoundComponent<ItemType> has a native SelectMethod parameter of type SelectHandler<ItemType> that mirrors how Web Forms worked.

+
+
    +
  • Collection controls (GridView, ListView, Repeater, DataList, DataGrid): use SelectMethod delegate or Items parameter
  • +
  • Single-record controls (FormView, DetailsView): use SelectMethod delegate or DataItem parameter
  • +
+
+

Validation Controls (7 components)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlBWFC?ComplexityKey ChangesGotchas
CompareValidatorTrivialRemove asp:, runatControlToCompare, ControlToValidate preserved
CustomValidatorEasyRemove asp:, runatOnServerValidate is EventCallback
ModelErrorMessageTrivialRemove asp:, runatModelStateKey preserved
RangeValidatorTrivialRemove asp:, runatMinimumValue, MaximumValue, Type preserved
RegularExpressionValidatorTrivialRemove asp:, runatValidationExpression preserved
RequiredFieldValidatorTrivialRemove asp:, runatControlToValidate, ErrorMessage preserved
ValidationSummaryTrivialRemove asp:, runatDisplayMode preserved
+

Validation controls are the easiest migration — nearly 1:1 attribute compatibility. Wrap validated forms in <EditForm> for full integration.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlBWFC?ComplexityKey ChangesGotchas
MenuMediumRemove asp:, runatMenuItem structure preserved; dual rendering modes (horizontal/vertical)
SiteMapPathMediumRemove asp:, runatProvide SiteMapNode data programmatically
TreeViewMediumRemove asp:, runatNode expansion state managed by component
+
+

Login Controls (7 components)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlBWFC?ComplexityKey ChangesGotchas
ChangePasswordComplexRemove asp:, runatWire auth provider via service; Orientation/TextLayout enums
CreateUserWizardComplexRemove asp:, runatMulti-step wizard; requires Identity service wiring
LoginComplexRemove asp:, runatWire auth provider via service
LoginNameEasyRemove asp:, runatUses AuthenticationState
LoginStatusEasyRemove asp:, runatUses AuthenticationState
LoginViewEasyRemove asp:, runatUses AuthenticationState for template switching
PasswordRecoveryComplexRemove asp:, runat3-step wizard; requires Identity service wiring
+
+

Important: BWFC provides the UI components, but the underlying authentication system must be migrated separately. ASP.NET Membership → ASP.NET Core Identity is an architecture decision (Layer 3).

+
+
+

AJAX Controls (5 components)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlBWFC?ComplexityKey ChangesGotchas
ScriptManagerTrivialRemove asp:, runatMigration stub — renders nothing. Include during migration, remove when stable.
ScriptManagerProxyTrivialRemove asp:, runatMigration stub — renders nothing. Use IJSRuntime for script registration.
TimerEasyRemove asp:, runatInterval-based tick events; no ScriptManager dependency in Blazor
UpdatePanelTrivialRemove asp:, runatRenders <div> or <span> — Blazor already does partial rendering
UpdateProgressEasyRemove asp:, runatReplace automatic UpdatePanel association with explicit bool IsLoading state
+
+

Note: ScriptManager and ScriptManagerProxy are intentional no-op stubs. They accept Web Forms attributes silently so your markup compiles, but they don't do anything. Blazor's rendering model replaces the need for AJAX partial postback infrastructure.

+
+
+

Infrastructure Controls (7 components)

+

These components support Master Page migration, page metadata, naming scopes, and layout infrastructure.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlBWFC?ComplexityKey ChangesGotchas
ContentEasy<asp:Content><Content>Provides content to ContentPlaceHolder slots in MasterPage
ContentPlaceHolderEasy<asp:ContentPlaceHolder><ContentPlaceHolder>Defines replaceable content regions; renders ChildContent when no Content matches
MasterPageMedium.master → MasterPage.razor layoutUses @layout EmptyLayout; supports Head render fragment for <HeadContent>
WebFormsPageEasyWrap page body in <WebFormsPage>Provides cascading values; renders <PageTitle> and <HeadContent> from IPageService
PageEasyAdd <Page /> to layoutRenders page title and meta tags from IPageService
NamingContainerEasyWrap controls that need naming scopeClientID prefixing with UseCtl00Prefix option for full Web Forms compat
EmptyLayoutTrivialUsed internally by MasterPageMinimal @Body-only layout
+

Master Page Migration Pattern

+
@* MasterPage becomes a Blazor layout *@
+@inherits LayoutComponentBase
+@layout MasterPage
+
+<ContentPlaceHolder ID="MainContent">
+    @Body
+</ContentPlaceHolder>
+
+
+

Field Column Components (4 components)

+

Used inside GridView, DetailsView, and DataGrid for column definitions.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlBWFC?ComplexityKey ChangesGotchas
BoundFieldEasyRemove asp:, runatDataField, HeaderText, DataFormatString preserved
ButtonFieldEasyRemove asp:, runatCommandName, ButtonType preserved
HyperLinkFieldEasyRemove asp:, runatDataNavigateUrlFields, DataTextField preserved
TemplateFieldMediumRemove asp:, runatHeaderTemplate, ItemTemplate, EditItemTemplate all supported
+
+

Style Sub-Components (66 components)

+

BWFC provides declarative style sub-components matching Web Forms' pattern for applying styles to control sub-elements. Use these as child elements inside their parent control.

+

Pattern

+
<GridView TItem="Product" Items="products">
+    <HeaderStyle CssClass="grid-header" BackColor="#336699" ForeColor="White" />
+    <RowStyle CssClass="grid-row" />
+    <AlternatingRowStyle BackColor="#F7F7F7" />
+</GridView>
+
+

Style Components by Parent Control

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParentStyle Sub-Components
Calendar (9)CalendarDayHeaderStyle, CalendarDayStyle, CalendarNextPrevStyle, CalendarOtherMonthDayStyle, CalendarSelectedDayStyle, CalendarSelectorStyle, CalendarTitleStyle, CalendarTodayDayStyle, CalendarWeekendDayStyle
DataGrid (7)DataGridAlternatingItemStyle, DataGridEditItemStyle, DataGridFooterStyle, DataGridHeaderStyle, DataGridItemStyle, DataGridPagerStyle, DataGridSelectedItemStyle
DetailsView (10)DetailsViewAlternatingRowStyle, DetailsViewCommandRowStyle, DetailsViewEditRowStyle, DetailsViewEmptyDataRowStyle, DetailsViewFieldHeaderStyle, DetailsViewFooterStyle, DetailsViewHeaderStyle, DetailsViewInsertRowStyle, DetailsViewPagerStyle, DetailsViewRowStyle
FormView (7)FormViewEditRowStyle, FormViewEmptyDataRowStyle, FormViewFooterStyle, FormViewHeaderStyle, FormViewInsertRowStyle, FormViewPagerStyle, FormViewRowStyle
GridView (8)GridViewAlternatingRowStyle, GridViewEditRowStyle, GridViewEmptyDataRowStyle, GridViewFooterStyle, GridViewHeaderStyle, GridViewPagerStyle, GridViewRowStyle, GridViewSelectedRowStyle
TreeView (6)TreeViewHoverNodeStyle, TreeViewLeafNodeStyle, TreeViewNodeStyle, TreeViewParentNodeStyle, TreeViewRootNodeStyle, TreeViewSelectedNodeStyle
Login Controls (10)CheckBoxStyle, FailureTextStyle, HyperLinkStyle, InstructionTextStyle, LabelStyle, LoginButtonStyle, SuccessTextStyle, TextBoxStyle, TitleTextStyle, ValidatorTextStyle
Shared (6)AlternatingItemStyle, FooterStyle, HeaderStyle, ItemStyle, MenuItemStyle, SeparatorStyle
PagerSettings (3)DetailsViewPagerSettings, FormViewPagerSettings, GridViewPagerSettings
+
+

Utilities & Infrastructure (not Razor components)

+

These C# classes, base classes, and services support the component library. Migration developers should be aware of them.

+

Setup Requirements

+
// Program.cs — REQUIRED for BWFC services
+builder.Services.AddBlazorWebFormsComponents();
+
+
@* _Imports.razor — RECOMMENDED for converted pages *@
+@inherits WebFormsPageBase
+
+

Key Classes

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ClassPurpose
WebFormsPageBaseBase class for converted pages — provides Page.Title, IsPostBack, Request, Response, Server, Session, Cache, ViewState, ClientScript, PostBack event, ResolveUrl(), GetRouteUrl()
ServiceCollectionExtensionsAddBlazorWebFormsComponents() — registers all BWFC services (SessionShim, CacheShim, ServerShim, ClientScriptShim, ScriptManagerShim, IPageService, JS interop)
BaseWebFormsComponentRoot base class — ID, ClientID, Visible, ViewState, lifecycle events (OnInit, OnLoad, OnPreRender, OnUnload), FindControl, theming, CaptureUnmatchedValues
BaseStyledComponentAdds CssClass, BackColor, ForeColor, BorderColor, BorderStyle, Font, Width, Height, ToolTip
DataBinder[Obsolete] Transitional DataBinder.Eval() support — use direct property access instead
ThemeProviderCascades ThemeConfiguration to child components for Web Forms Themes/Skins emulation
ThemeConfigurationRegisters ControlSkin entries by control type + SkinID
+

Helper Components

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentPurpose
BlazorWebFormsScriptsAuto-loads BWFC JavaScript module via dynamic import
BlazorWebFormsHeadAdds BWFC script tag to <head> via HeadContent
EvalData-binding helper for DataBinder.Eval() expressions
WebFormsFormInteractive form component — captures form data via JS interop and feeds FormShim
+
+

Infrastructure & Shim Components

+

BWFC provides a comprehensive shim layer that enables Web Forms API calls to compile and function in Blazor without code changes. All shims are auto-registered by AddBlazorWebFormsComponents() and auto-wired on WebFormsPageBase.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ComponentWeb Forms API PreservedHow It Works
WebFormsPageBasePage.Title, Page.MetaDescription, Page.MetaKeywords, IsPostBackBase class for all pages via @inherits in _Imports.razor
ResponseShimResponse.Redirect("~/path")Auto-strips ~/ prefix and .aspx extension; uses NavigationManager internally
RequestShimRequest.Url, Request.QueryString["key"], Request.CookiesReads current URL and query parameters from NavigationManager
FormShimRequest.Form["key"], Request.Form.AllKeys, Request.Form.CountDual-mode SSR+Interactive; requires <WebFormsForm> wrapper
SessionShimSession["key"], Session.Clear(), Session.Remove()Scoped in-memory dictionary — per-circuit lifetime
CacheShimCache["key"], Cache.Insert(), Cache.Remove()Singleton in-memory cache
ServerShimServer.MapPath("~/path"), Server.HtmlEncode(), Server.UrlEncode()Maps virtual paths; delegates encoding to WebUtility
ClientScriptShimPage.ClientScript.RegisterStartupScript(), RegisterClientScriptBlock()Registers scripts via JS interop
ScriptManagerShimScriptManager.GetCurrent(Page), RegisterStartupScript()Compatibility shim for ScriptManager API
ViewStateDictionaryViewState["key"] dictionary accessIn-memory dictionary (not serialized to page)
PostBack support__doPostBack(), IPostBackEventHandler, PostBackEventArgsPostBack event support with JS interop
ConfigurationManagerConfigurationManager.AppSettings["key"], ConnectionStrings["name"]Bridges to ASP.NET Core IConfiguration; call app.UseConfigurationManagerShim()
BundleConfigBundleTable.Bundles.Add(), ScriptBundle, StyleBundleNo-op compile shim — compiles but does nothing
RouteConfigRouteTable.Routes.MapPageRoute(), Routes.Ignore()No-op compile shim — compiles but does nothing
WebFormsForm<form> POST data captureBlazor component — wraps forms to feed FormShim
+
+

Key insight: With AddBlazorWebFormsComponents() + @inherits WebFormsPageBase, code-behind files using Response.Redirect, Session, Request, IsPostBack, Page.Title, Cache, Server.MapPath, or ClientScript compile and run without modification. This shifts ~20% of previously-manual Layer 2 work into automatic Layer 1 coverage.

+
+

Migration Shims (14)

+

These drop-in shims allow Web Forms API calls to compile and function in Blazor. On WebFormsPageBase, most are auto-wired — no code changes needed.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ShimWeb Forms APIUsage on WebFormsPageBase
RequestShimRequest.Cookies, Request.Form, Request.QueryString, Request.UrlRequest.* — auto-wired
FormShimRequest.Form["key"], Request.Form.AllKeys, Request.Form.CountRequest.Form["key"] — auto-wired via RequestShim
ResponseShimResponse.Redirect(), Response.CookiesResponse.Redirect() — auto-wired
SessionShimSession["key"], Session.Clear(), Session.Remove()Session["key"] — auto-wired
CacheShimCache["key"], Cache.Insert(), Cache.Remove()Cache["key"] — auto-wired
ServerShimServer.MapPath(), Server.HtmlEncode(), Server.UrlEncode()Server.MapPath() — auto-wired
ClientScriptShimPage.ClientScript.RegisterStartupScript(), RegisterClientScriptBlock(), RegisterClientScriptInclude(), GetPostBackEventReference()ClientScript.* — auto-wired
ScriptManagerShimScriptManager.GetCurrent(Page), ScriptManager.RegisterStartupScript()Via ScriptManager.GetCurrent(this)
ConfigurationManagerConfigurationManager.AppSettings["key"], ConfigurationManager.ConnectionStrings["name"]Call app.UseConfigurationManagerShim() in Program.cs
ViewStateDictionaryViewState["key"] dictionary-style accessViewState["key"] — auto-wired
PostBack support__doPostBack(), IPostBackEventHandler, PostBackEventArgsPostBack event — auto-wired with JS interop
BundleConfigBundleTable.Bundles.Add(), ScriptBundle, StyleBundleNo-op shim — compiles but does nothing
RouteConfigRouteTable.Routes.MapPageRoute(), RouteTable.Routes.Ignore()No-op shim — compiles but does nothing
FormSubmitEventArgsN/A (new for Blazor)Used with <WebFormsForm OnSubmit="SetRequestFormData">
+

Custom Control Shims

+

These exist so Web Forms code-behind that references these types can compile:

+ + + + + + + + + + + + + + + + + + + + + +
ShimPurpose
WebControlMinimal compatibility shim for System.Web.UI.WebControls.WebControl
HtmlTextWriterMinimal compatibility shim for System.Web.UI.HtmlTextWriter
CompositeControlMinimal compatibility shim for System.Web.UI.WebControls.CompositeControl
+

Enums (54)

+

All Web Forms enums are faithfully reproduced in Enums/. Key examples: CalendarSelectionMode, TextBoxMode, GridLines, RepeatDirection, ValidationCompareOperator, FormViewMode, DetailsViewMode, ListViewItemType, ButtonType, BorderStyle, HorizontalAlign, VerticalAlign.

+
+

Controls NOT Supported by BWFC

+

These Web Forms controls have no BWFC equivalent. Each requires a different migration approach:

+

DataSource Controls — Replace with Service Injection

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlMigration Approach
SqlDataSourceReplace with an injected service using EF Core or Dapper
ObjectDataSourceReplace with an injected service calling your business layer
EntityDataSourceReplace with an injected DbContext via DI
LinqDataSourceReplace with LINQ queries in an injected service
SiteMapDataSourceBuild navigation data programmatically or from config
XmlDataSourceLoad XML in a service; bind to component properties
+

Pattern: +

// Instead of <asp:SqlDataSource SelectCommand="SELECT * FROM Products">
+// Inject a service:
+@inject IProductService ProductService
+
+@code {
+    private List<Product> products = new();
+
+    protected override async Task OnInitializedAsync()
+    {
+        products = await ProductService.GetProductsAsync();
+    }
+}
+

+

Other Unsupported Controls

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlMigration Approach
WizardImplement as a multi-step Blazor component with state tracking
DynamicData controlsRedesign — Blazor has no DynamicData equivalent
Web Parts (WebPartManager, WebPartZone, etc.)Redesign as Blazor components with drag-and-drop libraries
AJAX Control Toolkit extendersFind Blazor-native replacements (e.g., Radzen, MudBlazor) or build custom components
~~ContentPlaceHolder~~Supported — BWFC provides <ContentPlaceHolder>, <Content>, and <MasterPage> components. See Infrastructure Controls.
+
+

Coverage by Category — Visual Summary

+
Editor Controls (28)          ████████████████████████████████████████ 100% covered
+Data Controls (8)             ████████████████████████████████████████ 100% covered
+Validation Controls (7)       ████████████████████████████████████████ 100% covered
+Navigation Controls (3)       ████████████████████████████████████████ 100% covered
+Login Controls (7)            ████████████████████████████████████████ 100% covered
+AJAX Controls (5)             ████████████████████████████████████████ 100% covered
+Infrastructure Controls (7)   ████████████████████████████████████████ 100% covered
+Field Column Components (4)   ████████████████████████████████████████ 100% covered
+Style Sub-Components (66)     ████████████████████████████████████████ 100% covered
+Utilities & Infrastructure    ████████████████████████████████████████ Shipped
+DataSource Controls (6)       ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░   0% (by design)
+
+

DataSource controls are deliberately not covered. They represent a Web Forms-specific pattern (declarative data access in markup) that has no place in Blazor's service-injection architecture.

+
+

Cross-References

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/CopilotSkills/CoreMigration/index.html b/site/Migration/CopilotSkills/CoreMigration/index.html new file mode 100644 index 000000000..f7a34948e --- /dev/null +++ b/site/Migration/CopilotSkills/CoreMigration/index.html @@ -0,0 +1,6646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Core Migration - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Core Migration Skill

+

The Core Migration Skill handles Web Forms Blazor markup and code-behind transformations. This is the primary skill for Layer 2 migration work the structural transforms that require pattern recognition and semantic understanding.

+
+

When to Use This Skill

+

Use the Core Migration skill when:

+
    +
  • Converting .aspx files to .razor files (after Layer 1 automated transforms)
  • +
  • Updating code-behind lifecycle methods (Page_Load OnInitializedAsync)
  • +
  • Converting data binding patterns (SelectMethod, ItemType, template contexts)
  • +
  • Transforming event handler signatures
  • +
  • Converting Master Pages to Blazor Layouts
  • +
  • Understanding which Web Forms patterns work via BWFC shims
  • +
+
+

What This Skill Covers

+

1. Shim Infrastructure Understanding

+

The skill provides deep knowledge of the BWFC shim infrastructure that preserves Web Forms API patterns in Blazor:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web Forms APIShimStatus
Response.Redirect("~/path")ResponseShimWorks AS-IS
Request.QueryString["key"]RequestShimWorks AS-IS
Request.Form["key"]FormShimWorks with <WebFormsForm> wrapper
Session["key"]SessionShimWorks AS-IS (in-memory per circuit)
Cache["key"]CacheShimWorks AS-IS (IMemoryCache)
Server.MapPath("~/path")ServerShimWorks AS-IS
Page.Title, Page.IsPostBackWebFormsPageBaseWorks AS-IS
ClientScript.RegisterStartupScript()ClientScriptShimWorks AS-IS
ViewState["key"]ViewStateDictionaryWorks AS-IS (in-memory only)
+

Key principle: If the original Web Forms code uses these patterns, keep them unchanged. The shims make them work correctly in Blazor.

+

2. Control Tag Transformations

+

The skill knows all BWFC component mappings and transformations:

+
<!-- Web Forms -->
+<asp:Button ID="btnSubmit" Text="Submit" OnClick="Submit_Click" runat="server" />
+
+<!-- Blazor (after Layer 1 + Layer 2) -->
+<Button Text="Submit" OnClick="Submit_Click" />
+
+

Layer 1 (automated): +- Removes asp: prefix +- Removes ID attribute +- Removes runat="server"

+

Layer 2 (this skill handles): +- Understands that OnClick event handler signature changes from void Submit_Click(object sender, EventArgs e) to void Submit_Click() +- Knows when to add @bind- directives for two-way binding

+

3. Data Binding Patterns

+

The skill guides conversion of Web Forms data binding to Blazor patterns:

+

SelectMethod Items/SelectMethod delegate:

+
<!-- Web Forms -->
+<asp:GridView ItemType="Product" SelectMethod="GetProducts" runat="server">
+    <Columns>
+        <asp:BoundField DataField="Name" HeaderText="Product" />
+    </Columns>
+</asp:GridView>
+
+
<!-- Blazor  preserve SelectMethod as delegate (preferred) -->
+<GridView TItem="Product" SelectMethod="@productService.GetProducts">
+    <Columns>
+        <BoundField DataField="Name" HeaderText="Product" />
+    </Columns>
+</GridView>
+
+

Template Context Variables:

+
<!-- Web Forms -->
+<asp:Repeater ItemType="Product" runat="server">
+    <ItemTemplate>
+        <div><%#: Item.Name %></div>
+    </ItemTemplate>
+</asp:Repeater>
+
+
<!-- Blazor -->
+<Repeater TItem="Product" SelectMethod="@GetProducts">
+    <ItemTemplate Context="Item">
+        <div>@Item.Name</div>
+    </ItemTemplate>
+</Repeater>
+
+

4. Lifecycle Method Conversions

+

Page_Load OnInitializedAsync:

+
// Web Forms
+protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        LoadProducts();
+    }
+}
+
+
// Blazor  signature changes, but IsPostBack works via shim
+protected override async Task OnInitializedAsync()
+{
+    if (!IsPostBack)
+    {
+        await LoadProductsAsync();
+    }
+}
+
+

Event Handler Signatures:

+
// Web Forms
+protected void AddToCart_Click(object sender, EventArgs e)
+{
+    Session["CartId"] = Guid.NewGuid().ToString();
+}
+
+
// Blazor  remove sender/e parameters, Session still works
+protected void AddToCart_Click()
+{
+    Session["CartId"] = Guid.NewGuid().ToString();  // Works via SessionShim
+}
+
+

5. Master Page Layout Conversion

+

Web Forms Master Page:

+
<%@ Master Language="C#" %>
+<html>
+<head runat="server">
+    <title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
+</head>
+<body>
+    <asp:ContentPlaceHolder ID="MainContent" runat="server" />
+</body>
+</html>
+
+

Blazor Layout:

+
@inherits LayoutComponentBase
+
+<html>
+<head>
+    <PageTitle>@Title</PageTitle>
+</head>
+<body>
+    @Body
+</body>
+</html>
+
+

Content Page Conversion:

+
<!-- Web Forms -->
+<%@ Page MasterPageFile="~/Site.Master" %>
+<asp:Content ContentPlaceHolderID="MainContent" runat="server">
+    <h1>Welcome</h1>
+</asp:Content>
+
+
<!-- Blazor -->
+@page "/Welcome"
+@layout MainLayout
+
+<h1>Welcome</h1>
+
+
+

Anti-Patterns This Skill Detects

+

Don't Inject Services the Shims Already Provide

+
// WRONG  NavigationManager for redirects
+@inject NavigationManager Nav
+Nav.NavigateTo("/Products");
+
+// CORRECT  Use ResponseShim
+Response.Redirect("~/Products");
+
+

Don't Use IHttpContextAccessor for Request Data

+
// WRONG  Manual HttpContext access
+@inject IHttpContextAccessor HttpContext
+var id = HttpContext.HttpContext.Request.Query["id"];
+
+// CORRECT  Use RequestShim
+var id = Request.QueryString["id"];
+
+

Don't Create Minimal APIs for Page Actions

+
// WRONG  Fighting Blazor with HTTP endpoints
+app.MapPost("/api/AddToCart", async (int productId) => { ... });
+
+// CORRECT  Keep as Blazor component methods
+private async Task AddToCart_Click() { ... }
+
+

Don't Treat Shims as Temporary Scaffolding

+

Wrong mindset: "I'll use shims to get it compiling, then replace them with 'real' Blazor code."

+

Correct mindset: "Shims ARE the migration strategy. They work correctly. Replacing them with native Blazor patterns is an optional Layer 3 optimization."

+
+

How to Use This Skill

+

Option 1: Reference in Copilot Chat

+

If you've copied the skills to your project:

+
@workspace Use the migration patterns in .github/skills/bwfc-migration/SKILL.md 
+to complete the migration of ProductList.razor. Focus on data binding and event handlers.
+
+

Option 2: File-Specific Guidance

+

When working on a specific file:

+
Apply the BWFC migration skill to convert this Master Page to a Blazor Layout. 
+Preserve the content placeholders as @Body and handle the navigation menu.
+
+

Option 3: Pattern-Specific Questions

+

For specific patterns:

+
Using BWFC migration patterns, how should I convert this SelectMethod binding?
+The original uses SelectMethod="GetProducts" with ItemType="Product".
+
+
+

Migration Decision Tree

+

The skill provides this decision framework:

+
Original code uses Response.Redirect()?
+  → Keep Response.Redirect()  ResponseShim handles it 
+
+Original code uses Session["key"]?
+   Keep Session["key"]  SessionShim handles it 
+
+Original code uses Request.QueryString["key"]?
+   Keep Request.QueryString["key"]  RequestShim handles it 
+
+Original code uses ViewState["key"]?
+   Keep ViewState["key"]  WebFormsPageBase provides it 
+   (Consider refactoring to component fields for clarity)
+
+Original code uses SelectMethod="MethodName"?
+   Convert to SelectMethod="@ServiceMethod" (delegate binding)
+   OR convert to Items="@data" + load in OnInitializedAsync
+
+Original code has Page_Load(sender, e)?
+   Change signature to OnInitializedAsync()
+   IsPostBack checks inside still work via shim 
+
+Original code has event handlers with (sender, e)?
+   Remove sender and e parameters
+   Rest of the code works unchanged
+
+
+

Common Migration Scenarios

+

Scenario 1: Simple Display Page

+

Before (Web Forms): +

<%@ Page Title="Products" MasterPageFile="~/Site.Master" %>
+<asp:Content ContentPlaceHolderID="MainContent" runat="server">
+    <h1>Products</h1>
+    <asp:GridView ItemType="Product" SelectMethod="GetProducts" runat="server">
+        <Columns>
+            <asp:BoundField DataField="Name" HeaderText="Name" />
+            <asp:BoundField DataField="Price" HeaderText="Price" />
+        </Columns>
+    </asp:GridView>
+</asp:Content>
+

+

After (Blazor with BWFC): +

@page "/Products"
+@layout MainLayout
+@inject IProductService ProductService
+
+<h1>Products</h1>
+<GridView TItem="Product" SelectMethod="@ProductService.GetProducts">
+    <Columns>
+        <BoundField DataField="Name" HeaderText="Name" />
+        <BoundField DataField="Price" HeaderText="Price" />
+    </Columns>
+</GridView>
+

+

Scenario 2: Interactive Form

+

Before (Web Forms): +

protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        LoadCategories();
+    }
+}
+
+protected void AddProduct_Click(object sender, EventArgs e)
+{
+    var product = new Product
+    {
+        Name = txtName.Text,
+        CategoryId = int.Parse(ddlCategory.SelectedValue)
+    };
+    _db.Products.Add(product);
+    _db.SaveChanges();
+    Response.Redirect("~/Products.aspx");
+}
+

+

After (Blazor with BWFC): +

protected override async Task OnInitializedAsync()
+{
+    if (!IsPostBack)  // Works via WebFormsPageBase shim
+    {
+        await LoadCategoriesAsync();
+    }
+}
+
+protected async Task AddProduct_Click()  // Signature changed, no sender/e
+{
+    var product = new Product
+    {
+        Name = txtName.Text,
+        CategoryId = int.Parse(ddlCategory.SelectedValue)
+    };
+    _db.Products.Add(product);
+    await _db.SaveChangesAsync();
+    Response.Redirect("~/Products");  // Works via ResponseShim, auto-strips .aspx
+}
+

+
+ + +
+

Skill File Location

+

The full skill file is located at: +

migration-toolkit/skills/bwfc-migration/SKILL.md
+

+

For the complete technical specification and all transformation rules, see the skill file in the BWFC repository.

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/CopilotSkills/DataMigration/index.html b/site/Migration/CopilotSkills/DataMigration/index.html new file mode 100644 index 000000000..160ec77de --- /dev/null +++ b/site/Migration/CopilotSkills/DataMigration/index.html @@ -0,0 +1,6866 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Data & Architecture - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Data & Architecture Migration Skill

+

The Data & Architecture Migration Skill handles Layer 3 architecture decisions and data access pattern migrations from Web Forms to Blazor. This skill covers the decisions that require understanding your application's business logic and technical requirements.

+
+

When to Use This Skill

+

Use this skill when migrating:

+
    +
  • Entity Framework 6 to EF Core
  • +
  • DataSource controls (SqlDataSource, ObjectDataSource) to service injection
  • +
  • Session state patterns (when you need persistence beyond SessionShim)
  • +
  • Global.asax to Program.cs middleware
  • +
  • Web.config to appsettings.json
  • +
  • HTTP handlers (.ashx) and modules to middleware
  • +
  • Third-party integrations and APIs
  • +
+
+

1. Entity Framework 6 EF Core

+

Database Provider Detection

+

** CRITICAL:** Preserve the original database provider. Examine the Web Forms project's Web.config to identify the provider:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web.config IndicatorProviderEF Core Package
System.Data.SqlClient or (LocalDB)SQL ServerMicrosoft.EntityFrameworkCore.SqlServer
System.Data.SQLiteSQLiteMicrosoft.EntityFrameworkCore.Sqlite
Npgsql or Port=5432PostgreSQLNpgsql.EntityFrameworkCore.PostgreSQL
MySql.Data.MySqlClientMySQLPomelo.EntityFrameworkCore.MySql
Oracle.ManagedDataAccess.ClientOracleOracle.EntityFrameworkCore
+

** NEVER default to SQLite.** Most Web Forms apps use SQL Server (often LocalDB for dev).

+

Migration Steps

+
    +
  1. +

    Install the correct EF Core provider: +

    # For SQL Server (most common)
    +dotnet add package Microsoft.EntityFrameworkCore.SqlServer --version 10.0.3
    +

    +
  2. +
  3. +

    Migrate DbContext: +

    // Web Forms (EF6)
    +public class ApplicationDbContext : DbContext
    +{
    +    public ApplicationDbContext() : base("DefaultConnection")
    +    {
    +    }
    +
    +    public DbSet<Product> Products { get; set; }
    +}
    +

    +
  4. +
+
// Blazor (EF Core)
+public class ApplicationDbContext : DbContext
+{
+    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
+        : base(options)
+    {
+    }
+
+    public DbSet<Product> Products { get; set; }
+}
+
+
    +
  1. +

    Register with DI using IDbContextFactory: +

    // Program.cs
    +builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
    +    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
    +

    +
  2. +
  3. +

    Use in components: +

    @inject IDbContextFactory<ApplicationDbContext> DbFactory
    +
    +@code {
    +    protected override async Task OnInitializedAsync()
    +    {
    +        using var db = DbFactory.CreateDbContext();
    +        products = await db.Products.AsNoTracking().ToListAsync();
    +    }
    +}
    +

    +
  4. +
+

Key EF Core Differences

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EF6 PatternEF Core Replacement
db.Products.ToList()await db.Products.ToListAsync()
db.SaveChanges()await db.SaveChangesAsync()
Include("Category")Include(p => p.Category) (strongly-typed)
Direct instantiationUse IDbContextFactory<T> in Blazor
db.Database.Connection.ConnectionStringdb.Database.GetConnectionString()
+
+

2. DataSource Controls Service Injection

+

Web Forms DataSource controls (SqlDataSource, ObjectDataSource, EntityDataSource) have no BWFC equivalent. Replace them with injected services.

+

Migration Pattern

+

Before (Web Forms): +

<asp:SqlDataSource ID="ProductsDataSource" runat="server"
+    ConnectionString="<%$ ConnectionStrings:DefaultConnection %>"
+    SelectCommand="SELECT * FROM Products WHERE CategoryId = @CategoryId">
+    <SelectParameters>
+        <asp:QueryStringParameter Name="CategoryId" QueryStringField="id" />
+    </SelectParameters>
+</asp:SqlDataSource>
+
+<asp:GridView DataSourceID="ProductsDataSource" runat="server" />
+

+

After (Blazor with service):

+
    +
  1. +

    Create a service: +

    public interface IProductService
    +{
    +    Task<List<Product>> GetProductsByCategoryAsync(int categoryId);
    +}
    +
    +public class ProductService : IProductService
    +{
    +    private readonly IDbContextFactory<ApplicationDbContext> _dbFactory;
    +
    +    public ProductService(IDbContextFactory<ApplicationDbContext> dbFactory)
    +    {
    +        _dbFactory = dbFactory;
    +    }
    +
    +    public async Task<List<Product>> GetProductsByCategoryAsync(int categoryId)
    +    {
    +        using var db = _dbFactory.CreateDbContext();
    +        return await db.Products
    +            .Where(p => p.CategoryId == categoryId)
    +            .AsNoTracking()
    +            .ToListAsync();
    +    }
    +}
    +

    +
  2. +
  3. +

    Register service: +

    // Program.cs
    +builder.Services.AddScoped<IProductService, ProductService>();
    +

    +
  4. +
  5. +

    Use in component: +

    @page "/Products"
    +@inject IProductService ProductService
    +
    +<GridView TItem="Product" SelectMethod="@LoadProducts">
    +    <Columns>
    +        <BoundField DataField="Name" HeaderText="Name" />
    +        <BoundField DataField="Price" HeaderText="Price" />
    +    </Columns>
    +</GridView>
    +
    +@code {
    +    [SupplyParameterFromQuery]
    +    public int Id { get; set; }
    +
    +    private async Task<IEnumerable<Product>> LoadProducts()
    +    {
    +        return await ProductService.GetProductsByCategoryAsync(Id);
    +    }
    +}
    +

    +
  6. +
+
+

3. Session State Migration

+

Use SessionShim (Default)

+

For most migration scenarios, keep using Session["key"] the SessionShim makes it work AS-IS:

+
// Web Forms
+Session["CartId"] = Guid.NewGuid().ToString();
+var cartId = Session["CartId"]?.ToString();
+
+// Blazor  IDENTICAL CODE
+Session["CartId"] = Guid.NewGuid().ToString();
+var cartId = Session["CartId"]?.ToString();
+
+

SessionShim works in both SSR and interactive modes: +- SSR: Backed by ASP.NET Core ISession (cookie-based) +- Interactive: In-memory ConcurrentDictionary per circuit

+

When to Upgrade Beyond SessionShim

+

Only consider alternatives when you need:

+

Cross-tab persistence ProtectedBrowserStorage: +

@inject ProtectedSessionStorage SessionStorage
+
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        var result = await SessionStorage.GetAsync<ShoppingCart>("cart");
+        cart = result.Success ? result.Value! : new ShoppingCart();
+    }
+}
+

+

Cross-server persistence Database-backed state: +

public class CartService
+{
+    private readonly IDbContextFactory<AppDbContext> _dbFactory;
+
+    public async Task<Cart> GetCartAsync(string userId)
+    {
+        using var db = _dbFactory.CreateDbContext();
+        return await db.Carts
+            .Include(c => c.Items)
+            .FirstOrDefaultAsync(c => c.UserId == userId) ?? new Cart();
+    }
+}
+

+

Typed state management Scoped services: +

public class WizardStateService
+{
+    public int CurrentStep { get; set; }
+    public FormData Data { get; set; } = new();
+    public bool IsComplete => CurrentStep == 5 && Data.IsValid();
+}
+
+// Program.cs
+builder.Services.AddScoped<WizardStateService>();
+

+
+

4. Global.asax Program.cs

+

Application_Start

+

Before (Web Forms): +

// Global.asax.cs
+protected void Application_Start()
+{
+    RegisterRoutes(RouteTable.Routes);
+    BundleConfig.RegisterBundles(BundleTable.Bundles);
+}
+

+

After (Blazor): +

// Program.cs
+var builder = WebApplication.CreateBuilder(args);
+
+// Equivalent configuration
+builder.Services.AddRazorComponents()
+    .AddInteractiveServerComponents();
+builder.Services.AddBlazorWebFormsComponents();
+
+var app = builder.Build();
+
+app.MapRazorComponents<App>()
+    .AddInteractiveServerRenderMode();
+
+app.Run();
+

+

Application_Error

+

Before (Web Forms): +

protected void Application_Error()
+{
+    var exception = Server.GetLastError();
+    Logger.LogError(exception, "Unhandled exception");
+}
+

+

After (Blazor): +

// Program.cs
+app.UseExceptionHandler("/Error");
+
+// Create Error.razor page
+@page "/Error"
+@inject ILogger<Error> Logger
+
+<h1>Error</h1>
+<p>An error occurred.</p>
+
+@code {
+    [CascadingParameter]
+    public HttpContext? HttpContext { get; set; }
+
+    protected override void OnInitialized()
+    {
+        var feature = HttpContext?.Features.Get<IExceptionHandlerFeature>();
+        if (feature?.Error is { } error)
+        {
+            Logger.LogError(error, "Unhandled exception");
+        }
+    }
+}
+

+
+

5. Web.config appsettings.json

+

Connection Strings

+

Before (Web Forms): +

<!-- Web.config -->
+<connectionStrings>
+    <add name="DefaultConnection" 
+         connectionString="Server=(localdb)\mssqllocaldb;Database=MyDb;Trusted_Connection=True;" 
+         providerName="System.Data.SqlClient" />
+</connectionStrings>
+

+

After (Blazor): +

// appsettings.json
+{
+  "ConnectionStrings": {
+    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MyDb;Trusted_Connection=True;"
+  }
+}
+

+
// Program.cs
+builder.Services.AddDbContextFactory<ApplicationDbContext>(options =>
+    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
+
+

App Settings

+

Before (Web Forms): +

<!-- Web.config -->
+<appSettings>
+    <add key="AdminEmail" value="admin@example.com" />
+    <add key="MaxUploadSize" value="10485760" />
+</appSettings>
+

+
// Web Forms code
+var email = ConfigurationManager.AppSettings["AdminEmail"];
+
+

After (Blazor): +

// appsettings.json
+{
+  "AppSettings": {
+    "AdminEmail": "admin@example.com",
+    "MaxUploadSize": 10485760
+  }
+}
+

+
// Blazor  use IConfiguration
+@inject IConfiguration Configuration
+
+@code {
+    protected override void OnInitialized()
+    {
+        var email = Configuration["AppSettings:AdminEmail"];
+    }
+}
+
+
+

6. HTTP Handlers (.ashx) Middleware

+

Generic Handler Migration

+

Before (Web Forms): +

// DownloadFile.ashx
+public class DownloadFile : IHttpHandler
+{
+    public void ProcessRequest(HttpContext context)
+    {
+        var fileId = context.Request.QueryString["id"];
+        var file = GetFile(fileId);
+        context.Response.ContentType = file.ContentType;
+        context.Response.BinaryWrite(file.Data);
+    }
+
+    public bool IsReusable => true;
+}
+

+

After (Blazor): +

// Program.cs  minimal API endpoint
+app.MapGet("/DownloadFile", async (HttpContext context, IFileService fileService, string id) =>
+{
+    var file = await fileService.GetFileAsync(id);
+    return Results.File(file.Data, file.ContentType, file.FileName);
+});
+

+
+

How to Use This Skill

+

In Copilot Chat

+
@workspace Use the data migration skill to help me convert this 
+SqlDataSource to a service. The original connects to SQL Server LocalDB.
+
+

Pattern-Specific Questions

+
Using BWFC data migration patterns, how should I migrate this EF6 DbContext 
+to EF Core? The original uses SQL Server with lazy loading.
+
+
+

Common Migration Scenarios

+

Scenario: Shopping Cart State

+

Before (Web Forms): +

// Using Session
+Session["CartId"] = Guid.NewGuid().ToString();
+var items = (List<CartItem>)Session["CartItems"];
+

+

After (Blazor) Option 1: Keep SessionShim: +

// Works AS-IS via SessionShim
+Session["CartId"] = Guid.NewGuid().ToString();
+var items = Session.Get<List<CartItem>>("CartItems");
+

+

After (Blazor) Option 2: Scoped Service: +

// CartService.cs
+public class CartService
+{
+    private List<CartItem> _items = new();
+
+    public void AddItem(CartItem item) => _items.Add(item);
+    public List<CartItem> GetItems() => _items;
+}
+
+// Program.cs
+builder.Services.AddScoped<CartService>();
+

+
+ + +
+

Skill File Location

+
migration-toolkit/skills/bwfc-data-migration/SKILL.md
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/CopilotSkills/IdentityMigration/index.html b/site/Migration/CopilotSkills/IdentityMigration/index.html new file mode 100644 index 000000000..dac1ac0bd --- /dev/null +++ b/site/Migration/CopilotSkills/IdentityMigration/index.html @@ -0,0 +1,6646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Identity & Auth - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Identity & Authentication Migration Skill

+

The Identity & Authentication Migration Skill guides the migration of ASP.NET Web Forms authentication and authorization systems to Blazor Server using ASP.NET Core Identity.

+
+

When to Use This Skill

+

Use this skill when migrating:

+
    +
  • Login pages (Login.aspx)
  • +
  • Registration pages (Register.aspx)
  • +
  • Account management pages (password reset, profile)
  • +
  • ASP.NET Identity (OWIN-based)
  • +
  • ASP.NET Membership (pre-2013)
  • +
  • FormsAuthentication patterns
  • +
  • Role-based authorization ([Authorize(Roles = "Admin")])
  • +
  • BWFC login controls (Login, LoginView, ChangePassword, CreateUserWizard)
  • +
+
+

Web Forms Authentication Systems

+

Web Forms applications typically use one of three authentication systems:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
SystemEraDatabase SchemaPassword Hash Compatibility
ASP.NET Identity (OWIN)2013+AspNetUsers, AspNetRolesCompatible with ASP.NET Core Identity
ASP.NET Membership2005-2013aspnet_Users, aspnet_MembershipRequires migration script
FormsAuthentication2002-2005Custom tablesRequires custom migration
+
+ +

** IMPORTANT: When using global interactive server mode (<Routes @rendermode="InteractiveServer" />), HttpContext is NULL** during WebSocket circuits. This means:

+
    +
  • SignInAsync() does not work in component event handlers
  • +
  • SignOutAsync() does not work in component event handlers
  • +
  • Cookies cannot be set from @onclick handlers
  • +
+

Required Pattern: Minimal API Endpoints

+

Cookie-based authentication operations (login, register, logout) must use HTML <form method="post"> that submits to minimal API endpoints:

+
@* Login.razor  form posts to endpoint, NOT a Blazor event handler *@
+<form method="post" action="/Account/LoginHandler">
+    <div>
+        <label>Email</label>
+        <input type="email" name="email" required />
+    </div>
+    <div>
+        <label>Password</label>
+        <input type="password" name="password" required />
+    </div>
+    <button type="submit">Log in</button>
+</form>
+
+
// Program.cs  minimal API endpoint performs SignInAsync
+app.MapPost("/Account/LoginHandler", async (HttpContext context, SignInManager<IdentityUser> signInManager) =>
+{
+    var form = await context.Request.ReadFormAsync();
+    var email = form["email"].ToString();
+    var password = form["password"].ToString();
+
+    var result = await signInManager.PasswordSignInAsync(email, password, isPersistent: false, lockoutOnFailure: false);
+
+    return result.Succeeded
+        ? Results.Redirect("/")
+        : Results.Redirect("/Account/Login?error=Invalid+login+attempt");
+}).DisableAntiforgery();  // Required  Blazor forms don't include antiforgery tokens
+
+

Why .DisableAntiforgery() is required:

+

Blazor's HTML rendering does not automatically include <input type="hidden" name="__RequestVerificationToken" /> in <form> elements. Without disabling antiforgery validation, the POST will fail with a 400 Bad Request error.

+
+

Migration Paths

+

Path 1: ASP.NET Identity (OWIN) ASP.NET Core Identity

+

Best fit when: +- Original app uses Microsoft.AspNet.Identity.EntityFramework +- Web.config has <add key="owin:appStartup" value="Startup" /> +- Database has AspNetUsers, AspNetRoles, AspNetUserClaims tables

+

Steps:

+
    +
  1. +

    Install packages: +

    dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
    +dotnet add package Microsoft.AspNetCore.Identity.UI
    +

    +
  2. +
  3. +

    Create ApplicationUser and DbContext: +

    public class ApplicationUser : IdentityUser
    +{
    +    // Add custom properties from your Web Forms ApplicationUser
    +}
    +
    +public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
    +{
    +    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    +        : base(options) { }
    +}
    +

    +
  4. +
  5. +

    Configure Identity in Program.cs: +

    builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
    +{
    +    options.SignIn.RequireConfirmedAccount = false;
    +    options.Password.RequiredLength = 6;
    +})
    +    .AddEntityFrameworkStores<ApplicationDbContext>()
    +    .AddDefaultTokenProviders();
    +
    +builder.Services.AddCascadingAuthenticationState();
    +
    +// Middleware pipeline (ORDER MATTERS)
    +app.UseAuthentication();
    +app.UseAuthorization();
    +

    +
  6. +
  7. +

    Migrate database schema: +

    dotnet ef migrations add IdentityMigration
    +dotnet ef database update
    +

    +
  8. +
+

Password hash compatibility: ASP.NET Identity v2 (Web Forms) password hashes are compatible with ASP.NET Core Identity. Users can log in with existing passwords.

+

Path 2: ASP.NET Membership ASP.NET Core Identity

+

Best fit when: +- Original app uses System.Web.Security.Membership +- Database has aspnet_Users, aspnet_Membership, aspnet_Roles tables +- No OWIN middleware

+

Steps:

+
    +
  1. +

    Install packages (same as Path 1)

    +
  2. +
  3. +

    Migrate schema using SQL script: +

    -- Example migration from Membership to ASP.NET Core Identity
    +-- (Use Microsoft's migration tool or custom script)
    +INSERT INTO AspNetUsers (Id, UserName, Email, EmailConfirmed, PasswordHash, SecurityStamp)
    +SELECT 
    +    CAST(UserId AS NVARCHAR(450)),
    +    LoweredUserName,
    +    LoweredEmail,
    +    1,
    +    Password,  -- Hash format is NOT compatible
    +    NEWID()
    +FROM aspnet_Membership m
    +JOIN aspnet_Users u ON m.UserId = u.UserId;
    +

    +
  4. +
  5. +

    Force password resets: + Because Membership password hashes are not compatible, users must reset passwords on first login.

    +
  6. +
+ +

Best fit when: +- Original app uses FormsAuthentication.SetAuthCookie() +- Custom user tables (not AspNet* schema) +- Lightweight auth needs

+

Steps:

+
    +
  1. +

    Configure cookie authentication: +

    builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    +    .AddCookie(options =>
    +    {
    +        options.LoginPath = "/Account/Login";
    +        options.LogoutPath = "/Account/Logout";
    +    });
    +

    +
  2. +
  3. +

    Create login minimal API: +

    app.MapPost("/Account/LoginHandler", async (HttpContext context, IUserService userService) =>
    +{
    +    var form = await context.Request.ReadFormAsync();
    +    var username = form["username"].ToString();
    +    var password = form["password"].ToString();
    +
    +    var user = await userService.ValidateCredentialsAsync(username, password);
    +    if (user == null)
    +        return Results.Redirect("/Account/Login?error=Invalid+credentials");
    +
    +    var claims = new List<Claim>
    +    {
    +        new Claim(ClaimTypes.Name, user.Username),
    +        new Claim(ClaimTypes.NameIdentifier, user.Id.ToString())
    +    };
    +
    +    var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
    +    var principal = new ClaimsPrincipal(identity);
    +
    +    await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
    +
    +    return Results.Redirect("/");
    +}).DisableAntiforgery();
    +

    +
  4. +
+
+

BWFC Login Controls

+

BWFC provides Blazor equivalents of Web Forms login controls:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web Forms ControlBWFC ComponentStatus
<asp:Login><Login>Supported
<asp:LoginView><LoginView>Supported
<asp:LoginStatus><LoginStatus>Supported
<asp:LoginName><LoginName>Supported
<asp:ChangePassword><ChangePassword>Supported
<asp:CreateUserWizard><CreateUserWizard>Supported
<asp:PasswordRecovery><PasswordRecovery>Supported
+

Example Migration:

+
<!-- Web Forms -->
+<asp:LoginView runat="server">
+    <AnonymousTemplate>
+        <asp:Login ID="LoginControl" runat="server" />
+    </AnonymousTemplate>
+    <LoggedInTemplate>
+        Welcome, <asp:LoginName runat="server" />!
+        <asp:LoginStatus runat="server" LogoutAction="Redirect" LogoutPageUrl="~/" />
+    </LoggedInTemplate>
+</asp:LoginView>
+
+
<!-- Blazor with BWFC -->
+<LoginView>
+    <NotAuthorized>
+        <Login />
+    </NotAuthorized>
+    <Authorized>
+        Welcome, <LoginName />!
+        <LoginStatus LogoutAction="Redirect" LogoutPageUrl="/" />
+    </Authorized>
+</LoginView>
+
+
+

Role-Based Authorization

+

Web Forms: +

<%@ Page ... %>
+<script runat="server">
+protected void Page_Load(object sender, EventArgs e)
+{
+    if (!User.IsInRole("Admin"))
+    {
+        Response.Redirect("~/AccessDenied.aspx");
+    }
+}
+</script>
+

+

Blazor with BWFC: +

@page "/Admin"
+@attribute [Authorize(Roles = "Admin")]
+
+<h1>Admin Dashboard</h1>
+

+

Or use <AuthorizeView>:

+
<AuthorizeView Roles="Admin">
+    <Authorized>
+        <h1>Admin Dashboard</h1>
+    </Authorized>
+    <NotAuthorized>
+        <p>Access denied.</p>
+    </NotAuthorized>
+</AuthorizeView>
+
+
+

Common Migration Scenarios

+

Scenario 1: Simple Login Page

+

Before (Web Forms): +

<asp:Login ID="LoginControl" 
+           OnAuthenticate="LoginControl_Authenticate" 
+           DestinationPageUrl="~/"
+           runat="server" />
+

+
protected void LoginControl_Authenticate(object sender, AuthenticateEventArgs e)
+{
+    var username = LoginControl.UserName;
+    var password = LoginControl.Password;
+    e.Authenticated = Membership.ValidateUser(username, password);
+}
+
+

After (Blazor with minimal API): +

@page "/Account/Login"
+
+<form method="post" action="/Account/LoginHandler">
+    <div>
+        <label>Username</label>
+        <input type="text" name="username" required />
+    </div>
+    <div>
+        <label>Password</label>
+        <input type="password" name="password" required />
+    </div>
+    <button type="submit">Log in</button>
+</form>
+

+
// Program.cs
+app.MapPost("/Account/LoginHandler", async (HttpContext context, SignInManager<ApplicationUser> signInManager) =>
+{
+    var form = await context.Request.ReadFormAsync();
+    var username = form["username"].ToString();
+    var password = form["password"].ToString();
+
+    var result = await signInManager.PasswordSignInAsync(username, password, isPersistent: false, lockoutOnFailure: false);
+
+    return result.Succeeded
+        ? Results.Redirect("/")
+        : Results.Redirect("/Account/Login?error=1");
+}).DisableAntiforgery();
+
+

Scenario 2: Registration Page

+

Before (Web Forms): +

<asp:CreateUserWizard ID="CreateUserWizard1" runat="server" OnCreatedUser="CreateUserWizard1_CreatedUser">
+    <WizardSteps>
+        <asp:CreateUserWizardStep runat="server" />
+        <asp:CompleteWizardStep runat="server" />
+    </WizardSteps>
+</asp:CreateUserWizard>
+

+

After (Blazor with minimal API): +

@page "/Account/Register"
+
+<form method="post" action="/Account/RegisterHandler">
+    <div>
+        <label>Email</label>
+        <input type="email" name="email" required />
+    </div>
+    <div>
+        <label>Password</label>
+        <input type="password" name="password" required />
+    </div>
+    <div>
+        <label>Confirm Password</label>
+        <input type="password" name="confirmPassword" required />
+    </div>
+    <button type="submit">Register</button>
+</form>
+

+
// Program.cs
+app.MapPost("/Account/RegisterHandler", async (HttpContext context, UserManager<ApplicationUser> userManager, SignInManager<ApplicationUser> signInManager) =>
+{
+    var form = await context.Request.ReadFormAsync();
+    var email = form["email"].ToString();
+    var password = form["password"].ToString();
+    var confirmPassword = form["confirmPassword"].ToString();
+
+    if (password != confirmPassword)
+        return Results.Redirect("/Account/Register?error=passwords");
+
+    var user = new ApplicationUser { UserName = email, Email = email };
+    var result = await userManager.CreateAsync(user, password);
+
+    if (result.Succeeded)
+    {
+        await signInManager.SignInAsync(user, isPersistent: false);
+        return Results.Redirect("/");
+    }
+
+    return Results.Redirect("/Account/Register?error=failed");
+}).DisableAntiforgery();
+
+
+

How to Use This Skill

+

In Copilot Chat

+
@workspace Use the identity migration skill to convert the Login.aspx page 
+to Blazor. The original uses ASP.NET Identity with OWIN.
+
+

Pattern-Specific Questions

+
Using the BWFC identity migration patterns, how should I handle cookie 
+authentication in Interactive Server mode? The original uses FormsAuthentication.
+
+
+ + +
+

Skill File Location

+

The full skill file is located at: +

migration-toolkit/skills/bwfc-identity-migration/SKILL.md
+

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/CopilotSkills/Overview/index.html b/site/Migration/CopilotSkills/Overview/index.html new file mode 100644 index 000000000..41c0eef16 --- /dev/null +++ b/site/Migration/CopilotSkills/Overview/index.html @@ -0,0 +1,6315 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Overview - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Copilot Skills for AI-Assisted Migration

+

BlazorWebFormsComponents provides specialized Copilot Skills that enable AI-assisted migration from Web Forms to Blazor. These skills are frontmatter-based instruction files that give GitHub Copilot deep knowledge of BWFC migration patterns, shim infrastructure, and best practices.

+
+

What Are Copilot Skills?

+

Copilot Skills are markdown files with YAML frontmatter that contain:

+
    +
  • Specialized knowledge about specific migration scenarios
  • +
  • Pattern recognition rules for transforming Web Forms patterns to Blazor
  • +
  • Decision frameworks for architecture choices
  • +
  • Anti-pattern detection to avoid common mistakes
  • +
+

When you reference a skill in a Copilot session, it gains context-specific expertise for that migration area.

+
+

Available Skills

+

BlazorWebFormsComponents provides three specialized migration skills:

+

1. Core Migration Skill

+

File: migration-toolkit/skills/bwfc-migration/SKILL.md

+

Purpose: Handles the core Web Forms Blazor markup and code-behind transformations (Layer 2 work).

+

What it covers: +- Control tag transformations (asp:Button Button) +- Expression conversions (<%: %> @()) +- Data binding patterns (SelectMethod, ItemType, templates) +- Event handler signature updates +- Master Page Layout conversions +- Shim usage patterns (Session, Response, Request, Cache)

+

When to use: During Layer 2 migration when converting .razor files after the automated Layer 1 transforms have run.

+

Read the Core Migration documentation

+
+

2. Identity & Authentication Migration Skill

+

File: migration-toolkit/skills/bwfc-identity-migration/SKILL.md

+

Purpose: Guides migration of authentication and authorization from Web Forms to Blazor.

+

What it covers: +- ASP.NET Identity (OWIN) ASP.NET Core Identity +- ASP.NET Membership ASP.NET Core Identity +- FormsAuthentication migration +- Cookie authentication under Interactive Server mode +- Login control migration (Login, LoginView, ChangePassword) +- Role-based authorization patterns

+

When to use: When migrating login pages, account management, or any authentication-dependent features.

+

Read the Identity Migration documentation

+
+

3. Data & Architecture Migration Skill

+

File: migration-toolkit/skills/bwfc-data-migration/SKILL.md

+

Purpose: Handles Layer 3 architecture decisions and data access pattern migrations.

+

What it covers: +- Entity Framework 6 EF Core migrations +- DataSource controls service injection patterns +- Session state to scoped services (when needed beyond SessionShim) +- Global.asax Program.cs middleware +- Web.config appsettings.json +- HTTP handlers/modules middleware

+

When to use: During Layer 3 work when making architectural decisions about data access, state management, and application structure.

+

Read the Data Migration documentation

+
+

How to Use These Skills

+ +

Copy the skills/ directory from the migration-toolkit into your project:

+
# From your Blazor project root
+mkdir -p .github/skills
+cp -r path/to/bwfc-repo/migration-toolkit/skills/* .github/skills/
+
+

Then reference the skill in Copilot Chat:

+
@workspace Use the migration rules in .github/skills/bwfc-migration/SKILL.md 
+to complete the migration of this file.
+
+

Option 2: Reference Skills from the BWFC Repository

+

If you have the BWFC repository cloned locally or as a submodule, reference skills directly:

+
Use the migration patterns from migration-toolkit/skills/bwfc-migration/SKILL.md 
+to transform this page.
+
+

Option 3: Use the Copilot Instructions Template

+

For team-wide migration projects, use the copilot-instructions-template.md file to create project-specific Copilot instructions that reference all three skills:

+
cp migration-toolkit/copilot-instructions-template.md .github/copilot-instructions.md
+# Edit the template to fill in project-specific details
+
+
+

Skill Selection Guide

+

Use this table to choose the right skill for your current migration task:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Migration TaskUse This Skill
Converting .aspx .razor markupCore Migration
Updating code-behind lifecycle methodsCore Migration
Master Page Layout conversionCore Migration
Data binding and templatesCore Migration
Login page migrationIdentity & Auth
ASP.NET Identity migrationIdentity & Auth
Cookie auth setupIdentity & Auth
EF6 EF Core migrationData & Architecture
SqlDataSource replacementData & Architecture
Session state architectureData & Architecture
Global.asax Program.csData & Architecture
+
+

Best Practices

+

1. Use Skills in Order

+

Follow the three-layer migration pipeline:

+
    +
  1. Layer 1 Run automated transforms (CLI or PowerShell script)
  2. +
  3. Layer 2 Use Core Migration skill for markup/code-behind
  4. +
  5. Layer 3 Use Data & Architecture skill for architectural decisions
  6. +
  7. Use Identity & Auth skill as needed when you encounter authentication
  8. +
+

2. Reference Skills Explicitly

+

When asking Copilot for help, explicitly reference the skill file:

+
Using the patterns from .github/skills/bwfc-migration/SKILL.md, 
+convert this Master Page to a Blazor Layout component.
+
+

3. Review AI Suggestions

+

Always review Copilot's suggestions before accepting them. The skills provide high-quality guidance, but context-specific nuances may require human judgment.

+

4. Combine Skills When Needed

+

Some pages may need multiple skills. For example, a login page needs both Core Migration (for markup) and Identity & Auth (for authentication logic).

+
+

Skill Maintenance

+

The skills are maintained in the BWFC repository and updated as:

+
    +
  • New BWFC components are released
  • +
  • Shim infrastructure evolves
  • +
  • Migration patterns are refined based on community feedback
  • +
+

Check the BWFC repository for the latest versions.

+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/InlineCSharp/index.html b/site/Migration/InlineCSharp/index.html new file mode 100644 index 000000000..36e0b31dd --- /dev/null +++ b/site/Migration/InlineCSharp/index.html @@ -0,0 +1,6719 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Inline C# Expression Migration - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ + + + + + + + +

Inline C# Expression Migration

+

Web Forms uses several inline expression syntaxes in ASPX and ASCX markup. Each syntax serves a different purpose and has direct Blazor/Razor equivalents. This guide covers the migration of all inline expression types from Web Forms to Blazor.

+
+

Expression Overview

+

Web Forms supported five inline expression syntaxes: <%= %>, <%: %>, <%# %>, <% %>, and <%$ %>. Each has a specific role. Understanding these differences is key to successful migration.

+
+
+

Code Render Blocks (<%= ... %>)

+

What It Was

+

Code render blocks output the result of a C# expression directly to the page as raw, unencoded HTML:

+
<td><%= Request.QueryString["id"] %></td>
+<span><%= DateTime.Now.Year %></span>
+<div><%= "<strong>Bold Text</strong>" %></div>
+
+

The result is inserted directly into the HTML output without any encoding.

+

Blazor Equivalent

+

In Blazor/Razor, the @() expression syntax replaces <%= %>. Important: @() performs HTML encoding by default, which is safer than Web Forms' raw output:

+
+
+
+
<td><%= Request.QueryString["id"] %></td>
+<span><%= DateTime.Now.Year %></span>
+<div><%= "<strong>Bold Text</strong>" %></div>
+
+
+
+
<td>@(NavigationManager.Uri.GetQueryParameter("id"))</td>
+<span>@DateTime.Now.Year</span>
+<div>@("<strong>Bold Text</strong>")</div>
+
+
+
+
+
+

XSS Risk and HTML Encoding

+

<%= %> outputs raw unencoded HTML. This is a security risk if the output comes from user input. Blazor's @() is HTML-encoded by default — safe for user data.

+

If you intentionally need raw HTML (e.g., from a trusted source like a database), use @((MarkupString)rawHtml):

+
@((MarkupString)userGeneratedHtml)  <!-- Only use with trusted content! -->
+
+
+

Request Object Access

+

Web Forms' Request object gives access to query strings, form data, and cookies. In Blazor:

+
+
+
+
<!-- Query String -->
+<span><%= Request.QueryString["id"] %></span>
+
+<!-- Form Data -->
+<span><%= Request.Form["username"] %></span>
+
+<!-- Cookies -->
+<span><%= Request.Cookies["sessionid"]?.Value %></span>
+
+
+
+
<!-- Query String with NavigationManager -->
+<span>@(NavigationManager.Uri.Contains("id=") ? NavigationManager.Uri.Split("id=")[1].Split("&")[0] : "")</span>
+
+<!-- Or use [SupplyParameterFromQuery] in code -->
+@code {
+    [SupplyParameterFromQuery]
+    public string? Id { get; set; }
+}
+
+<!-- Form Data: Use @bind with input elements -->
+<input type="text" @bind="username" />
+
+<!-- Cookies with IHttpContextAccessor -->
+@inject IHttpContextAccessor HttpContextAccessor
+
+@{
+    var sessionId = HttpContextAccessor?.HttpContext?.Request.Cookies["sessionid"];
+}
+<span>@sessionId</span>
+
+
+
+
+
+

Prefer Parameter Binding

+

Instead of parsing Request.QueryString manually, use [SupplyParameterFromQuery] attributes on component parameters. This is cleaner, type-safe, and more performant.

+
+
+

HTML-Encoded Output (<%: ... %>)

+

What It Was

+

HTML-encoded output blocks automatically HTML-encode the expression result before rendering:

+
<p><%: userComment %></p>
+<span><%: "3 < 5" %></span>
+
+

This was a safer alternative to <%= %> because it prevented XSS attacks by encoding special characters.

+

Blazor Equivalent

+

In Blazor, @() is HTML-encoded by default. This means you can use @() for the same security benefit:

+
+
+
+
<p><%: userComment %></p>
+<span><%: "3 < 5" %></span>
+<div><%: "<script>alert('xss')</script>" %></div>
+
+
+
+
<p>@userComment</p>
+<span>@("3 < 5")</span>
+<div>@("<script>alert('xss')</script>")</div>
+
+
+
+
+
+

Default Safety

+

Blazor's default behavior (@value) provides the safety of <%: %> without extra syntax. Always use @value for user-generated content and reserve @((MarkupString)value) only for trusted sources.

+
+
+

Data-Binding Expressions (<%# ... %>)

+

What It Was

+

Data-binding expressions were used in templates (ItemTemplate, EditTemplate, etc.) to output data from the current item in a data-bound control:

+
<asp:Repeater DataSource="<%# Products %>">
+  <ItemTemplate>
+    <tr>
+      <td><%# Eval("ProductName") %></td>
+      <td><%# Eval("Price", "{0:C}") %></td>
+      <td><%# Item.StockLevel %></td>
+      <td>
+        <asp:TextBox Text='<%# Bind("ProductName") %>' runat="server" />
+      </td>
+    </tr>
+  </ItemTemplate>
+</asp:Repeater>
+
+

The Eval() method performed one-way data binding (output only), while Bind() performed two-way binding (output + update).

+

Blazor Equivalent: Output Only

+

For repeating controls like <Repeater>, use the implicit @context parameter to access the current item:

+
+
+
+
<asp:Repeater DataSource="<%# Products %>">
+  <ItemTemplate>
+    <tr>
+      <td><%# Eval("ProductName") %></td>
+      <td><%# Eval("Price", "{0:C}") %></td>
+      <td><%# Container.DataItem %></td>
+    </tr>
+  </ItemTemplate>
+</asp:Repeater>
+
+
+
+
<Repeater Items="Products">
+  <ItemTemplate>
+    <tr>
+      <td>@context.ProductName</td>
+      <td>@context.Price.ToString("C")</td>
+      <td>@context</td>
+    </tr>
+  </ItemTemplate>
+</Repeater>
+
+
+
+
+
+

Context Parameter

+

By default, the current item in a template is accessed via the @context variable. You can rename it with Context="Item" if you prefer: <Repeater Context="Item">@Item.ProductName.

+
+

Blazor Equivalent: Two-Way Binding

+

Replace Bind() with the @bind-Value directive:

+
+
+
+
<asp:Repeater DataSource="<%# Products %>">
+  <ItemTemplate>
+    <tr>
+      <td>
+        <asp:TextBox Text='<%# Bind("ProductName") %>' runat="server" />
+      </td>
+    </tr>
+  </ItemTemplate>
+</asp:Repeater>
+
+
+
+
<Repeater Items="Products" Context="Item">
+  <ItemTemplate>
+    <tr>
+      <td>
+        <TextBox @bind-Value="Item.ProductName" />
+      </td>
+    </tr>
+  </ItemTemplate>
+</Repeater>
+
+
+
+
+

Complex Formatting

+

For complex formatting beyond a simple format string, use C# methods:

+
+
+
+
<%# string.Format("{0:D2}/{1:D2}/{2}", 
+    Eval("Month"), Eval("Day"), Eval("Year")) %>
+
+<%# Eval("Price", "{0:C}") %>
+
+
+
+
@($"{context.Month:D2}/{context.Day:D2}/{context.Year}")
+
+@context.Price.ToString("C")
+
+
+
+
+
+

Code Blocks (<% ... %>)

+

What It Was

+

Code blocks executed arbitrary C# code without outputting anything:

+
<% if (User.IsInRole("Admin")) { %>
+  <button>Delete</button>
+<% } %>
+
+<% foreach (var item in Items) { %>
+  <div><%# item.Name %></div>
+<% } %>
+
+

This pattern mixed logic with markup, leading to difficult-to-maintain code.

+

Blazor Equivalent

+

Blazor provides @if, @foreach, and other control flow directives:

+
+
+
+
<% if (User.IsInRole("Admin")) { %>
+  <button>Delete</button>
+<% } %>
+
+<% foreach (var item in Items) { %>
+  <div><%# item.Name %></div>
+<% } %>
+
+<% for (int i = 0; i < 5; i++) { %>
+  <span><%# i %></span>
+<% } %>
+
+
+
+
@if (User?.IsInRole("Admin") == true)
+{
+  <button>Delete</button>
+}
+
+@foreach (var item in Items)
+{
+  <div>@item.Name</div>
+}
+
+@for (int i = 0; i < 5; i++)
+{
+  <span>@i</span>
+}
+
+
+
+
+
+

Code Organization

+

Blazor makes it easy to move complex logic to methods in the @code block instead of embedding it in markup. This improves readability and testability.

+
+

Conditional Rendering

+

Web Forms used code blocks for conditional rendering. Blazor uses @if:

+
+
+
+
<% if (Product.InStock) { %>
+  <button>Add to Cart</button>
+<% } else { %>
+  <p>Out of Stock</p>
+<% } %>
+
+
+
+
@if (Product.InStock)
+{
+  <button>Add to Cart</button>
+}
+else
+{
+  <p>Out of Stock</p>
+}
+
+
+
+
+
+

Expression Builders (<%$ ... %>)

+

What It Was

+

Expression builders accessed application configuration at compile time:

+
<!-- Connection Strings -->
+<%$ ConnectionStrings:DefaultConnection %>
+
+<!-- App Settings -->
+<%$ AppSettings:SiteTitle %>
+
+<!-- Resources (localization) -->
+<%$ Resources:Labels, WelcomeMessage %>
+
+

Blazor Equivalent

+

Blazor uses dependency injection and the IConfiguration service instead:

+
+
+
+
<!-- Connection String -->
+<%$ ConnectionStrings:DefaultConnection %>
+
+<!-- App Setting -->
+<%$ AppSettings:SiteTitle %>
+
+<!-- In code-behind: -->
+string connectionString = ConfigurationManager.ConnectionStrings["DefaultConnection"].ConnectionString;
+
+
+
+
@inject IConfiguration Configuration
+
+<!-- Connection String -->
+@Configuration.GetConnectionString("DefaultConnection")
+
+<!-- App Setting -->
+@Configuration["SiteTitle"]
+
+<!-- In code block: -->
+@code {
+    private string connectionString = "";
+
+    protected override void OnInitialized()
+    {
+        connectionString = Configuration.GetConnectionString("DefaultConnection");
+    }
+}
+
+
+
+
+

Localization (Resources)

+

For localized strings, use IStringLocalizer:

+
+
+
+
<%$ Resources:Labels, WelcomeMessage %>
+
+
+
+
@inject IStringLocalizer<App> Localizer
+
+@Localizer["WelcomeMessage"]
+
+
+
+
+
+

Page Properties and Global Objects

+

Page.Title

+
+
+
+
protected void Page_Load(object sender, EventArgs e)
+{
+    Page.Title = "Product Details";
+}
+
+
+
+
@page "/product/{id}"
+
+<PageTitle>Product Details</PageTitle>
+
+
+
+
+

User Identity

+
+
+
+
<span><%= User.Identity.Name %></span>
+<% if (User.IsInRole("Admin")) { %>
+  <button>Manage</button>
+<% } %>
+
+
+
+
@inject AuthenticationStateProvider AuthenticationStateProvider
+
+@if (authState?.User?.Identity?.IsAuthenticated == true)
+{
+  <span>@authState.User.Identity.Name</span>
+}
+
+@if (authState?.User?.IsInRole("Admin") == true)
+{
+  <button>Manage</button>
+}
+
+@code {
+    private AuthenticationState authState;
+
+    protected override async Task OnInitializedAsync()
+    {
+        authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
+    }
+}
+
+
+
+
+
+

Automated Migration with bwfc-migrate.ps1

+

The automated migration script handles many expression conversions automatically:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ExpressionBeforeAfter
Code render<%= value %>@(value)
HTML-encoded<%: value %>@(value)
Data binding<%# Eval("Prop") %>@context.Prop
Data binding with format<%# Eval("Price", "{0:C}") %>@context.Price.ToString("C")
Code blocks<% if (...) { %>@if (...) {
Bind method<%# Bind("Prop") %>@bind-Value="@context.Prop"
Comments<%-- text --%>@* text *@
+
+

Script Limitations

+

The automated script handles simple, direct expression conversions. Complex expressions, method calls, and custom logic may require manual adjustment. Always review migrated code for correctness.

+
+
+

Common Patterns and Gotchas

+

Ternary Expressions

+
+
+
+
<span><%= Product.InStock ? "Available" : "Out of Stock" %></span>
+<span><%: Product.Price > 100 ? "Premium" : "Standard" %></span>
+
+
+
+
<span>@(Product.InStock ? "Available" : "Out of Stock")</span>
+<span>@(Product.Price > 100 ? "Premium" : "Standard")</span>
+
+
+
+
+

String Concatenation

+
+
+
+
<a href="<%= "/products/detail?id=" + Product.Id %>">
+  <%= Product.Name %>
+</a>
+
+
+
+
<a href="@($"/products/detail?id={Product.Id}")">
+  @Product.Name
+</a>
+
+
+
+
+

Method Calls in Markup

+
+
+
+
<span><%# GetFormattedPrice(container.DataItem) %></span>
+<span><%= CalculateTotal(items) %></span>
+
+
+
+
<span>@GetFormattedPrice(context)</span>
+<span>@CalculateTotal(items)</span>
+
+@code {
+    private string GetFormattedPrice(Product product)
+    {
+        return product.Price.ToString("C");
+    }
+
+    private decimal CalculateTotal(IEnumerable<Product> items)
+    {
+        return items.Sum(i => i.Price);
+    }
+}
+
+
+
+
+

Accessing Container Properties

+
+
+
+
<asp:Repeater DataSource="<%# Items %>">
+  <ItemTemplate>
+    <span><%# Container.ItemIndex %></span>
+    <span><%# Container.DataItem %></span>
+  </ItemTemplate>
+</asp:Repeater>
+
+
+
+
@foreach (var item in Items)
+{
+  <span>@Items.IndexOf(item)</span>
+  <span>@item</span>
+}
+
+<!-- Or with Repeater context: -->
+<Repeater Items="Items">
+  <ItemTemplate>
+    <!-- context is the current item -->
+  </ItemTemplate>
+</Repeater>
+
+
+
+
+
+

What Requires Manual Migration

+

Some patterns cannot be fully automated and require manual attention:

+

Session State in Expressions

+
<!-- Web Forms -->
+<span><%= (string)Session["UserName"] %></span>
+
+<!-- Blazor: Inject a custom service or use distributed caching -->
+@inject ISessionService SessionService
+
+<span>@(await SessionService.GetAsync<string>("UserName"))</span>
+
+

Complex LINQ Queries in Markup

+

Move complex queries to the code block:

+
@code {
+    private List<Product> FilteredProducts => 
+        Products.Where(p => p.InStock && p.Price < 100).ToList();
+}
+
+@foreach (var product in FilteredProducts)
+{
+    <div>@product.Name</div>
+}
+
+

DataSource Controls

+

Web Forms <asp:SqlDataSource> and similar controls have no Blazor equivalent. Replace with injected services:

+
@inject ProductService ProductService
+
+@code {
+    private List<Product> Products = new();
+
+    protected override async Task OnInitializedAsync()
+    {
+        Products = await ProductService.GetProductsAsync();
+    }
+}
+
+

Custom Expression Builders

+

If you created custom expression builders, you'll need to migrate this logic to IConfiguration or custom services.

+
+

See Also

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/Methodology/index.html b/site/Migration/Methodology/index.html new file mode 100644 index 000000000..5204cbb3a --- /dev/null +++ b/site/Migration/Methodology/index.html @@ -0,0 +1,6801 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Three-Layer Methodology - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Migration Methodology: The Three-Layer Pipeline

+

Why three layers, not one? Because migration work falls into three fundamentally different categories — and trying to handle them all with one tool (or one person, or one AI session) is how migrations stall.

+
+

Pipeline Overview

+
┌─────────────────────┐    ┌─────────────────────┐    ┌─────────────────────┐
+│    Layer 1           │    │    Layer 2           │    │    Layer 3           │
+│    AUTOMATED         │───▶│    COPILOT-ASSISTED  │───▶│    ARCHITECTURE      │
+│    + SHIMS           │    │                      │    │                      │
+│  webforms-to-blazor   │    │  Copilot + Skill     │    │  Human + Copilot     │
+│  CLI or PS1 script    │    │                      │    │                      │
+│  ~60% of work        │    │  ~30% of work        │    │  ~10% of work        │
+│  ~30 seconds         │    │  ~1–3 hours          │    │  ~8–12 hours         │
+│  100% accuracy       │    │  High accuracy       │    │  Requires judgment   │
+└─────────────────────┘    └─────────────────────┘    └─────────────────────┘
+         │                          │                          │
+    Mechanical                 Structural                 Semantic
+    transforms                 transforms                 decisions
+
+

Each layer handles a different kind of work, not just a different amount. The boundary between layers is defined by what type of intelligence is required:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LayerIntelligence RequiredToolError Rate
Layer 1None — compiled transforms or regex matchingwebforms-to-blazor CLI or PowerShell script~0% (deterministic)
Layer 2Pattern recognition — knows BWFC control mappingsCopilot with migration skillLow (guided by rules)
Layer 3Judgment — understands your app's architectureHuman + Copilot with data migration skillVaries (depends on decisions)
+
+

Layer 0: Assessment (Before You Start)

+

Before migrating anything, scan your project to understand what you're working with.

+

Tool: scripts/bwfc-scan.ps1

+

Input: Your Web Forms project directory +Output: A readiness report showing: +- File inventory (.aspx, .ascx, .master count) +- Control usage (which asp: controls, how many instances) +- DataSource controls (these need manual replacement) +- Migration readiness score (percentage of controls covered by BWFC)

+

Example: +

.\scripts\bwfc-scan.ps1 -Path .\MyWebFormsApp -OutputFormat Markdown -OutputFile scan-report.md
+

+

The scan report tells you whether BWFC is a good fit before you invest time in migration. If your app is heavy on DataSource controls, Wizard, or Web Parts, you'll know upfront.

+
+

Layer 1: Automated Transforms

+

Primary tool: webforms-to-blazor CLI — 37 compiled C# transforms with 373 unit tests +Alternative: scripts/bwfc-migrate.ps1 — lightweight PowerShell regex transforms (no .NET SDK required)

+

Layer 1 handles every transform that can be applied mechanically. The CLI tool applies compiled, unit-tested transforms with a migration report; the PowerShell script provides simpler regex-based transforms for quick starts.

+

What Layer 1 Does

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TransformCount (WingtipToys)Accuracy
asp: tag prefix removals147+100%
runat="server" attribute removals165+100%
Expression conversions (<%: %>@())~35100%
ItemTypeTItem conversions8100%
Content wrapper removals (<asp:Content>)28100%
URL conversions (~//)All100%
File renaming (.aspx.razor)33100%
Project scaffold (.csproj, Program.cs, _Imports.razor, App.razor)Full
+

The CLI generates _Imports.razor with @inherits BlazorWebFormsComponents.WebFormsPageBase so every page automatically gets Page.Title, Page.MetaDescription, Page.MetaKeywords, IsPostBack, Session, Response, Request, Server, Cache, and ClientScript — with the same API as Web Forms. The layout scaffold includes <BlazorWebFormsComponents.Page /> to render <PageTitle> and <meta> tags.

+

The CLI also generates Program.cs with builder.Services.AddBlazorWebFormsComponents(), which registers all the shim infrastructure (SessionShim, ResponseShim, RequestShim, CacheShim, ServerShim, ClientScriptShim, ViewStateShim) automatically.

+

Shim Infrastructure

+

When AddBlazorWebFormsComponents() is called in Program.cs and pages inherit from WebFormsPageBase (via @inherits in _Imports.razor), the following Web Forms APIs work AS-IS with no code changes:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ShimWhat It Enables
Page.Title / Page.MetaDescription / Page.MetaKeywordsSet metadata from code-behind — auto-rendered by <Page />
IsPostBackReturns false on first render, true on subsequent interactions
Response.Redirect("~/path")Auto-strips ~/ prefix and .aspx extension, uses NavigationManager internally
Request.Url / Request.QueryString["key"]Reads current URL and query parameters
Request.Form["key"]Reads form POST data (wrap form in <WebFormsForm>)
Session["key"]Scoped in-memory dictionary — works like Web Forms Session
Cache["key"]In-memory application cache
Server.MapPath("~/path")Maps virtual paths to physical paths
Page.ClientScript.RegisterStartupScript(...)Registers client-side scripts via JS interop
ViewState["key"]Compile-compatible dictionary (in-memory, not serialized to page)
__doPostBack / IPostBackEventHandlerPostBack event support with JS interop
+

This means that code-behind files referencing Response.Redirect, Session, Request, IsPostBack, Page.Title, Cache, Server.MapPath, or ClientScript compile and run unchanged — no manual conversion required.

+

What Layer 1 Does NOT Do

+
    +
  • Convert SelectMethod to Items binding (requires understanding the data flow)
  • +
  • Convert code-behind lifecycle methods like Page_Load signature (requires semantic understanding)
  • +
  • Replace DataSource controls (requires architecture decisions)
  • +
  • Wire authentication (requires knowing your auth strategy)
  • +
  • Convert Master Pages to layouts (partially — removes directives but doesn't create @Body)
  • +
+

These are intentionally left for Layer 2 and Layer 3.

+

Layer 1 Output

+

After Layer 1, pages fall into three readiness categories:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
StatusTypical %Meaning
✅ Markup-complete~12%Ready to compile and run — no further work needed
⚠️ Needs Layer 2~64%Structural transforms needed — Copilot handles these
❌ Needs Layer 3~24%Architecture decisions required — human judgment needed
+
+

These percentages are from the WingtipToys proof-of-concept. Your mileage will vary based on how much DataSource/auth/session-state your app uses.

+
+
+

Layer 2: Copilot-Assisted Structural Transforms

+

Tool: Copilot migration skill

+

Layer 2 handles transforms that follow consistent patterns but require understanding control semantics. A human could do these mechanically, but it's tedious and error-prone. Copilot with the BWFC migration skill handles them reliably.

+

L2 Principle: Wire up data binding and lifecycle — NOT to replace shims with native patterns.

+

The shims ARE the L2 strategy. If the original Web Forms code says Session["CartId"], the migrated code says Session["CartId"]. The shim makes this work. Don't reinvent what already exists. Layer 2 is about making data flow through the page and connecting event handlers — NOT about converting Response.Redirect() to NavigationManager.NavigateTo(). That's an optional Layer 3 optimization.

+

What Shims Handle Automatically (no Layer 2 work needed)

+

These items were previously Layer 2 manual transforms but are now handled AS-IS by BWFC shims:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PatternStatus
Response.Redirect("~/path")✅ Works AS-IS via ResponseShim — auto-strips ~/ and .aspx
IsPostBack checks in code-behind✅ Works AS-IS via WebFormsPageBase
Session["key"] access✅ Works AS-IS via SessionShim — no longer needs Layer 3 decision
Page.Title, Page.MetaDescription✅ Works AS-IS via WebFormsPageBase + <Page />
Request.QueryString["key"]✅ Works AS-IS via RequestShim
Cache["key"]✅ Works AS-IS via CacheShim
Server.MapPath("~/path")✅ Works AS-IS via ServerShim
+

What Layer 2 Still Handles

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TransformBeforeAfter
Data bindingSelectMethod="GetProducts"Items="products" + OnInitializedAsync
Template context<%#: Item.Name %>@Item.Name with Context="Item"
Lifecycle methodsPage_Load(object sender, EventArgs e) signatureOnInitializedAsync (the IsPostBack inside works AS-IS)
Event handlersvoid Btn_Click(object sender, EventArgs e)void Btn_Click()
Form wrappers<form runat="server">Removed, or <WebFormsForm> for Request.Form, or <EditForm> for validation
Layout conversion<asp:ContentPlaceHolder ID="MainContent">@Body
Query parameters[QueryString] int? id[SupplyParameterFromQuery]
Route parameters[RouteData] int id@page "/path/{id:int}" + [Parameter]
+

How to Use Layer 2

+
    +
  1. Copy the Copilot Skills Overview into your project's .github/copilot-instructions.md
  2. +
  3. Open each migrated .razor file with Copilot
  4. +
  5. Ask Copilot to apply the migration skill to the file
  6. +
  7. Review and accept the transforms
  8. +
+

Or, if using Copilot Chat directly, reference the skill file:

+
@workspace Use the rules in .github/CopilotSkills/CoreMigration.md to complete
+the migration of this file. Look for TODO comments and unresolved patterns.
+
+

Layer 2 Quality

+

Layer 2 is "high accuracy" rather than "100% accuracy" because: +- Data binding patterns vary by application (Copilot needs context about your data layer) +- Some event handler signatures have application-specific parameters +- Navigation routes depend on your URL structure

+

Always review Copilot's changes before committing.

+

Shim Path vs. Native Blazor Path

+
+

You have a choice. BWFC shims get your app compiling and running fast — but they're not the only option long-term.

+ + + + + + + + + + + + + + + + + + + + +
ApproachWhen to UseExample
Shim path (keep AS-IS)Migration speed is the priority; code works correctly; team isn't ready to learn Blazor-native patterns yetResponse.Redirect("~/Products") — works via ResponseShim
Native Blazor path (refactor later)Layer 3 optimization — post-migration polish; team is comfortable with Blazor; want to reduce BWFC dependencyNavigationManager.NavigateTo("/Products") — native Blazor
+

Recommendation: Use shims to get migrated fast (Layer 2), then refactor to native Blazor incrementally in Layer 3 as your team builds Blazor expertise. Both paths produce working code. Replacing shims with native patterns is OPTIONAL, not required.

+
+
+

Layer 3: Architecture Decisions

+

Tool: Data migration skill + your own judgment

+

Layer 3 is the ~15% of migration work that requires understanding your application's architecture. No script or AI can make these decisions for you — but the data migration skill and Copilot can guide you through the options and trade-offs.

+

Important: Replacing shims with native Blazor patterns (e.g., Response.Redirect()NavigationManager.NavigateTo()) is an OPTIONAL Layer 3 optimization, not a migration requirement. Your app works with the shims. Native patterns are for teams that want to reduce BWFC dependency long-term.

+

Common Layer 3 Decisions

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DecisionWeb Forms PatternBlazor Options
Data accessSqlDataSource, inline DbContextEF Core + injected service, Dapper, repository pattern
AuthenticationASP.NET Membership / IdentityASP.NET Core Identity, external provider, cookie auth
Global.asaxApplication_Start, Application_ErrorProgram.cs middleware pipeline
Web.config<connectionStrings>, <appSettings>appsettings.json, user secrets, environment variables
HTTP handlersIHttpHandler, IHttpModuleASP.NET Core middleware
Third-party APIsDirect WebRequest/WebClient callsHttpClient via DI with IHttpClientFactory
+
+

Note: Session state (Session["key"]) is no longer a Layer 3 decision. BWFC's SessionShim provides a scoped in-memory dictionary that works AS-IS. If you need persistent/distributed session state, that's still an architecture decision — but the code compiles and runs without changes.

+
+

Using the Data Migration Skill

+

The data migration skill is designed for interactive Copilot sessions. Point Copilot at your scan report and your partially-migrated files:

+
    +
  1. Share the bwfc-scan.ps1 output
  2. +
  3. Share the bwfc-migrate.ps1 output directory
  4. +
  5. Copilot identifies remaining TODO markers and decision points
  6. +
  7. Walk through each decision interactively
  8. +
+

The skill provides decision frameworks for common architecture patterns — see the full skill reference.

+
+

Layer L3-opt: Performance Optimization Pass (Optional)

+

Tool: L3 performance optimization skill

+

This is an optional fourth step that runs after the app builds and passes verification. It is not part of the core migration pipeline — it is a post-migration polish pass that applies modern .NET 10 performance patterns to already-functional migrated code.

+

What L3-opt Handles

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptimizationBefore (typical migration output)After
Sync lifecyclevoid OnInitialized() with DB callsasync Task OnInitializedAsync()
Sync EF Core.ToList(), .SaveChanges()await .ToListAsync(), await .SaveChangesAsync()
No-tracking readsdb.Products.ToListAsync()db.Products.AsNoTracking().ToListAsync()
@key on loops@foreach (var p in products)@foreach (...) { <C @key="p.ID" ... />}
Query string paramsManual Uri parsing[SupplyParameterFromQuery]
Code-behind extractionInline @code blocks > 50 linesPartial .razor.cs class
+

When to Apply

+

Apply L3-opt after: +1. ✅ App builds without errors +2. ✅ App runs and renders pages correctly +3. ✅ Interactive features (forms, navigation, data) work +4. ✅ Basic verification checklist is complete

+

Do not apply L3-opt to a broken build. Async patterns surface errors that were previously hidden.

+
+

Why This Ordering Matters

+

Layers must run in order: 1 → 2 → 3. Each layer assumes the previous one has completed.

+
    +
  • Layer 1 before Layer 2: Copilot expects files to already have asp: prefixes removed and expressions converted. If Layer 1 hasn't run, Copilot wastes time on mechanical transforms.
  • +
  • Layer 2 before Layer 3: Architecture decisions are easier when the markup is already in Blazor syntax. You can see what's left to wire up instead of mentally translating Web Forms markup.
  • +
  • Layer 3 before L3-opt: Performance optimizations assume functional code. Async migrations and IDbContextFactory patterns require the service layer to already exist.
  • +
+

Don't skip layers. Don't try to do Layer 3 work in Layer 1. The pipeline is designed so that each layer makes the next layer's job easier.

+
+

Time Estimates

+

Based on the WingtipToys proof-of-concept (33 pages, 230+ control instances):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
LayerSolo DeveloperWith Copilot/Agents
Layer 0 (scan)5 minutes5 minutes
Layer 1 (automated + shims)~30 seconds~30 seconds
Layer 2 (structural)6–10 hours1–3 hours
Layer 3 (architecture)10–14 hours8–12 hours
L3-opt (optional)1–2 hours30–60 minutes
Total18–28 hours10–17 hours
+

Layer 3 time varies the most because it depends on your application's complexity. A simple CRUD app with no auth may have almost no Layer 3 work. An enterprise app with custom session state, complex auth, and third-party integrations will spend most of its time in Layer 3.

+
+

Cross-References

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/Phase1-AppStartStubs/index.html b/site/Migration/Phase1-AppStartStubs/index.html new file mode 100644 index 000000000..38caa3c78 --- /dev/null +++ b/site/Migration/Phase1-AppStartStubs/index.html @@ -0,0 +1,6332 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + App_Start Stubs - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

App_Start Compilation Stubs

+

The BundleConfig and RouteConfig stubs allow your migrated App_Start/ directory to compile without modification. These are no-op shims that do nothing at runtime — they exist only to make Web Forms configuration files compile as-is during Phase 1 migrations.

+

Overview

+

What they are: +- No-op (no-operation) stub classes in System.Web.Optimization and System.Web.Routing namespaces +- Emulate the Web Forms bundle and routing APIs just enough to compile +- Located in the BlazorWebFormsComponents library and auto-imported via global usings

+

Why they matter: +When you migrate a Web Forms application, your App_Start/BundleConfig.cs and App_Start/RouteConfig.cs files contain code like:

+
BundleTable.Bundles.Add(new ScriptBundle("~/bundles/jquery")...);
+RouteTable.Routes.MapPageRoute(...);
+
+

Without stubs, this code fails to compile. The stubs allow your App_Start/ directory to compile unchanged in Phase 1, giving you time to plan the Blazor-native alternatives in Phase 2+.

+

Before and After

+
+
+
+
// App_Start/BundleConfig.cs
+using System.Web.Optimization;
+
+public class BundleConfig
+{
+    public static void RegisterBundles(BundleCollection bundles)
+    {
+        bundles.Add(new ScriptBundle("~/bundles/jquery")
+            .Include("~/Scripts/jquery-{version}.js"));
+
+        bundles.Add(new StyleBundle("~/Content/css")
+            .Include("~/Content/site.css"));
+    }
+}
+
+// App_Start/RouteConfig.cs
+using System.Web.Routing;
+
+public class RouteConfig
+{
+    public static void RegisterRoutes(RouteCollection routes)
+    {
+        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
+
+        routes.MapPageRoute("", "{controller}/{action}/{id}", "~/Pages/{controller}/{action}.aspx");
+    }
+}
+
+// Global.asax
+protected void Application_Start()
+{
+    BundleConfig.RegisterBundles(BundleTable.Bundles);
+    RouteConfig.RegisterRoutes(RouteTable.Routes);
+}
+
+
+
+
// App_Start/BundleConfig.cs — compiles unchanged!
+using System.Web.Optimization;
+
+public class BundleConfig
+{
+    public static void RegisterBundles(BundleCollection bundles)
+    {
+        bundles.Add(new ScriptBundle("~/bundles/jquery")
+            .Include("~/Scripts/jquery-{version}.js"));
+
+        bundles.Add(new StyleBundle("~/Content/css")
+            .Include("~/Content/site.css"));
+    }
+}
+
+// App_Start/RouteConfig.cs — compiles unchanged!
+using System.Web.Routing;
+
+public class RouteConfig
+{
+    public static void RegisterRoutes(RouteCollection routes)
+    {
+        routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
+        routes.MapPageRoute("", "{controller}/{action}/{id}", 
+            "~/Pages/{controller}/{action}.aspx");
+    }
+}
+
+// Program.cs (Blazor)
+var builder = WebApplication.CreateBuilder(args);
+builder.Services.AddBlazorWebFormsComponents();
+
+var app = builder.Build();
+app.UseStaticFiles();
+app.UseRouting();
+app.MapBlazorHub();
+app.MapFallbackToPage("/_Host");
+app.Run();
+
+
+
+
+

Key difference: The App_Start/ classes compile but do nothing. Blazor handles bundling and routing completely differently (see below).

+

The Stubs

+

The BWFC library provides minimal implementations of these classes:

+
namespace System.Web.Optimization
+{
+    public class Bundle
+    {
+        public Bundle(string virtualPath) { }
+        public Bundle Include(params string[] virtualPaths) => this;
+    }
+
+    public class ScriptBundle : Bundle { }
+    public class StyleBundle : Bundle { }
+
+    public static class BundleTable
+    {
+        public static BundleCollection Bundles { get; } = new();
+    }
+
+    public class BundleCollection
+    {
+        public void Add(Bundle bundle) { }
+    }
+}
+
+namespace System.Web.Routing
+{
+    public class RouteCollection
+    {
+        public void MapPageRoute(string routeName, string routeUrl, 
+            string physicalFile) { }
+        public void Ignore(string url) { }
+        public void Ignore(string url, object constraints) { }
+    }
+
+    public static class RouteTable
+    {
+        public static RouteCollection Routes { get; } = new();
+    }
+}
+
+

These stub classes do nothing — they exist only to satisfy the compiler.

+

⚠️ Important: These Stubs Are No-Ops

+

The stubs allow your code to compile, but they have zero runtime effect:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web Forms FeatureWhat HappensBlazor Alternative
BundleConfig.RegisterBundles()Called but does nothingCSS/JS Isolation (see below)
Bundle minificationDisabled — stubs ignore itBuild tool or Vite/esbuild
RouteTable.Routes.MapPageRoute()Called but does nothing@page directives in Blazor
Route ignores ({resource}.axd)IgnoredNot needed in Blazor
+

In other words, if your Web Forms app relied on bundling or routing, you must implement Blazor alternatives. The stubs just prevent compilation errors.

+

Blazor Alternatives

+

CSS and JavaScript Bundling

+

Instead of BundleConfig, Blazor uses CSS/JS Isolation:

+
+
+
+
public class BundleConfig
+{
+    public static void RegisterBundles(BundleCollection bundles)
+    {
+        bundles.Add(new StyleBundle("~/Content/css")
+            .Include("~/Content/bootstrap.css", "~/Content/site.css"));
+    }
+}
+
+// In .aspx page:
+// <link href="~/Content/css" rel="stylesheet" />
+
+
+
+
<!-- Components/Layout/MainLayout.razor -->
+@inherits LayoutComponentBase
+
+<link href="bootstrap.css" rel="stylesheet" />
+<link href="site.css" rel="stylesheet" />
+
+@Body
+
+
+
+
+

Or, for component-scoped styles:

+
<!-- MyComponent.razor -->
+<button class="btn">Click me</button>
+
+<!-- MyComponent.razor.css (automatically scoped to component) -->
+button.btn {
+    background-color: blue;
+}
+
+

For JavaScript bundling, use standard npm/webpack tooling:

+
npm install webpack webpack-cli --save-dev
+npm run build  # Bundles and minifies JS
+
+

Routing

+

Instead of RouteConfig.MapPageRoute(), Blazor uses @page directives:

+
+
+
+
public class RouteConfig
+{
+    public static void RegisterRoutes(RouteCollection routes)
+    {
+        routes.MapPageRoute("product_detail", 
+            "products/{id}", 
+            "~/Pages/ProductDetail.aspx");
+    }
+}
+
+<!-- ProductDetail.aspx accessed via /products/123 -->
+
+
+
+
<!-- Pages/ProductDetail.razor -->
+@page "/products/{id}"
+
+@code {
+    [Parameter]
+    public string Id { get; set; }
+
+    protected override void OnInitialized()
+    {
+        // Load product with ID
+    }
+}
+
+
+
+
+

Blazor's routing is declarative (@page) rather than centralized. This is simpler and more composable.

+

Phase 1 → Phase 2+ Migration

+

Here's a suggested timeline:

+

Phase 1 (Now): +- Keep App_Start/BundleConfig.cs and RouteConfig.cs unchanged +- They compile with BWFC stubs +- Focus on UI migration

+

Phase 2 (After initial migration): +1. Delete App_Start/BundleConfig.cs (no longer used) +2. Add CSS/JS imports to Shared/MainLayout.razor +3. Add @page directives to your Blazor components +4. Delete App_Start/RouteConfig.cs

+

Example transition:

+
// Phase 1: Stub-based (works but ignored)
+public class BundleConfig
+{
+    public static void RegisterBundles(BundleCollection bundles)
+    {
+        bundles.Add(new StyleBundle("~/Content/css")
+            .Include("~/Content/bootstrap.css", "~/Content/site.css"));
+    }
+}
+
+// Phase 2: Migrate to Blazor alternatives
+// Delete BundleConfig.cs, add to MainLayout.razor:
+<link href="bootstrap.css" rel="stylesheet" />
+<link href="site.css" rel="stylesheet" />
+
+

Troubleshooting

+

"BundleConfig not found" during compilation

+

Ensure the BlazorWebFormsComponents NuGet package is installed and AddBlazorWebFormsComponents() is called in Program.cs:

+
dotnet add package Fritz.BlazorWebFormsComponents
+
+
// Program.cs
+builder.Services.AddBlazorWebFormsComponents();
+
+

Global usings automatically import the stubs into every file.

+

Bundling or routing not working at runtime

+

This is expected — the stubs do nothing. You must implement Blazor alternatives:

+
    +
  • For CSS/JS: Use Blazor CSS/JS Isolation or standard build tools
  • +
  • For routing: Use @page directives instead of RouteConfig
  • +
+

Summary

+
    +
  • BundleConfig and RouteConfig files compile with BWFC stubs
  • +
  • ✅ No code changes needed in Phase 1
  • +
  • ❌ Stubs do nothing at runtime — bundling and routing are disabled
  • +
  • 🔄 Plan Phase 2 migration to Blazor alternatives (CSS Isolation, @page directives)
  • +
+

See the following for implementation details:

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/Phase1-ConfigurationManager/index.html b/site/Migration/Phase1-ConfigurationManager/index.html new file mode 100644 index 000000000..2101de368 --- /dev/null +++ b/site/Migration/Phase1-ConfigurationManager/index.html @@ -0,0 +1,6307 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ConfigurationManager - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

ConfigurationManager Migration

+

The ConfigurationManager shim allows migrated Web Forms code to access application settings and connection strings without modification. Your existing ConfigurationManager.AppSettings["key"] and ConfigurationManager.ConnectionStrings["name"] calls work unchanged in Blazor.

+

Overview

+

What it does: +- Provides a static ConfigurationManager class in the BlazorWebFormsComponents namespace +- Enables AppSettings["key"] access backed by ASP.NET Core IConfiguration +- Enables ConnectionStrings["name"] access via GetConnectionString() +- Allows migrated Business Logic Layer (BLL) and Data Access Layer (DAL) code to compile and run without code changes

+

Why it matters: +When migrating a Web Forms application, your BLL/DAL code often uses ConfigurationManager to read database connection strings and application settings. The shim eliminates the need to refactor that code during the initial migration phase, letting you focus on UI layer migration first.

+

Before and After

+
+
+
+
// App_Start/ProductRepository.cs - BLL code
+using System.Configuration;
+
+public class ProductRepository
+{
+    public ProductRepository()
+    {
+        // Access connection string from web.config
+        string connStr = ConfigurationManager.ConnectionStrings["DefaultConnection"].ConnectionString;
+        _dbConnection = new SqlConnection(connStr);
+    }
+
+    public void SaveTimeout()
+    {
+        // Access app setting from web.config
+        int timeout = int.Parse(ConfigurationManager.AppSettings["DBTimeout"] ?? "30");
+        _dbConnection.ConnectionTimeout = timeout;
+    }
+}
+
+// web.config
+<configuration>
+  <connectionStrings>
+    <add name="DefaultConnection" connectionString="Server=.;Database=MyApp;Integrated Security=true;" />
+  </connectionStrings>
+  <appSettings>
+    <add key="DBTimeout" value="30" />
+    <add key="ApiKey" value="secret123" />
+  </appSettings>
+</configuration>
+
+
+
+
// Shared/ProductRepository.cs - Same code, no changes!
+using BlazorWebFormsComponents;
+
+public class ProductRepository
+{
+    public ProductRepository()
+    {
+        // Same code as Web Forms — ConfigurationManager just works
+        string connStr = ConfigurationManager.ConnectionStrings["DefaultConnection"].ConnectionString;
+        _dbConnection = new SqlConnection(connStr);
+    }
+
+    public void SaveTimeout()
+    {
+        // Same code as Web Forms
+        int timeout = int.Parse(ConfigurationManager.AppSettings["DBTimeout"] ?? "30");
+        _dbConnection.ConnectionTimeout = timeout;
+    }
+}
+
+// appsettings.json (created by L1 migration script)
+{
+  "ConnectionStrings": {
+    "DefaultConnection": "Server=.;Database=MyApp;Integrated Security=true;"
+  },
+  "AppSettings": {
+    "DBTimeout": "30",
+    "ApiKey": "secret123"
+  }
+}
+
+
+
+
+

Setup: Initialize in Program.cs

+

To enable the shim, call the initialization helper in Program.cs:

+
// Program.cs
+var builder = WebApplication.CreateBuilder(args);
+
+// Register BWFC services and initialize ConfigurationManager shim
+builder.Services.AddBlazorWebFormsComponents();
+
+var app = builder.Build();
+
+// ... configure middleware ...
+
+app.Run();
+
+

The AddBlazorWebFormsComponents() extension automatically initializes the shim with the application's IConfiguration. No additional setup needed.

+

Manual Initialization (Optional)

+

If you prefer manual control, you can initialize directly:

+
// Program.cs
+builder.Services.AddBlazorWebFormsComponents();
+
+var app = builder.Build();
+
+// Manually initialize if needed (e.g., for unit tests)
+BlazorWebFormsComponents.ConfigurationManager.Initialize(app.Services.GetRequiredService<IConfiguration>());
+
+app.Run();
+
+

How It Works

+

The shim reads from ASP.NET Core's IConfiguration in this order:

+

AppSettings Access

+
ConfigurationManager.AppSettings["DBTimeout"]
+
+

Tries these keys in order: +1. AppSettings:DBTimeout (from appsettings.json) +2. DBTimeout (flat key) +3. Returns null if not found

+

This dual-lookup pattern supports both structured config (AppSettings section) and flat keys.

+

ConnectionStrings Access

+
ConfigurationManager.ConnectionStrings["DefaultConnection"]?.ConnectionString
+
+

Returns a ConnectionStringSettings object with: +- Name — the connection string name +- ConnectionString — the actual connection string +- ProviderName — optional ADO.NET provider (default: empty)

+

Reads from the ConnectionStrings section in appsettings.json, accessed via IConfiguration.GetConnectionString().

+

web.config Mapping

+

The L1 migration script (bwfc-migrate.ps1) automatically converts web.config settings to appsettings.json:

+
+
+
+
<configuration>
+  <connectionStrings>
+    <add name="DefaultConnection" 
+         connectionString="Server=myserver;Database=mydb;" />
+    <add name="LegacyDb" 
+         connectionString="Server=oldserver;Database=olddb;" />
+  </connectionStrings>
+  <appSettings>
+    <add key="DBTimeout" value="30" />
+    <add key="MaxRetries" value="3" />
+    <add key="FeatureFlag_NewUI" value="true" />
+  </appSettings>
+</configuration>
+
+
+
+
{
+  "ConnectionStrings": {
+    "DefaultConnection": "Server=myserver;Database=mydb;",
+    "LegacyDb": "Server=oldserver;Database=olddb;"
+  },
+  "AppSettings": {
+    "DBTimeout": "30",
+    "MaxRetries": "3",
+    "FeatureFlag_NewUI": "true"
+  }
+}
+
+
+
+
+

The shim then reads these values exactly as your Web Forms code expects.

+

Configuration Precedence

+

For environment-specific overrides, use the standard ASP.NET Core configuration hierarchy:

+
appsettings.json
+  ↓ (overridden by)
+appsettings.Development.json
+  ↓ (overridden by)
+appsettings.Production.json
+  ↓ (overridden by)
+Environment variables
+  ↓ (overridden by)
+User Secrets (Development only)
+
+

Example:

+
// appsettings.json (default)
+{
+  "AppSettings": { "DBTimeout": "30" }
+}
+
+// appsettings.Production.json (production override)
+{
+  "AppSettings": { "DBTimeout": "60" }
+}
+
+

When running in Production, ConfigurationManager.AppSettings["DBTimeout"] returns "60".

+

Limitations and Known Issues

+

The shim does not support:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureWeb FormsBWFC ShimAlternative
Custom config sections<custom>...</custom>❌ Not supportedUse structured JSON sections in appsettings.json
File referencesconfigSource="settings.xml"❌ Not supportedUse separate JSON files + ASP.NET Core config providers
Encrypted settingsencryptionProvider attribute❌ Not supportedUse Azure Key Vault, AWS Secrets Manager, or .NET User Secrets
Nested app settingsAppSettings.AppSettings["section"]["key"]❌ Not supportedUse structured JSON: "AppSettings": { "Section": { "Key": "value" } }
+

Workaround: Custom Config Sections

+

If you have custom config sections, migrate them to structured JSON:

+
// Web Forms: Custom section
+<customSettings>
+  <database host="localhost" port="5432" />
+</customSettings>
+var dbHost = config.GetSection("customSettings").GetValue<string>("database:host");
+
+// BWFC: Structured JSON
+{
+  "CustomSettings": {
+    "Database": {
+      "Host": "localhost",
+      "Port": 5432
+    }
+  }
+}
+var dbHost = config["CustomSettings:Database:Host"];
+
+

Summary

+

The ConfigurationManager shim: +- ✅ Lets BLL/DAL code use ConfigurationManager unchanged +- ✅ Reads from ASP.NET Core IConfiguration (appsettings.json) +- ✅ Initializes automatically via AddBlazorWebFormsComponents() +- ✅ Supports both flat and nested configuration structures +- ❌ Does not support custom config sections or encryption

+

Use it for Phase 1 ("Just Make It Compile") migrations. Later, consider migrating to dependency injection for configuration access:

+
// Phase 2+: Dependency Injection (Better Practice)
+public class ProductRepository
+{
+    private readonly IConfiguration _config;
+
+    public ProductRepository(IConfiguration config)
+    {
+        _config = config;
+    }
+
+    public void Initialize()
+    {
+        string connStr = _config.GetConnectionString("DefaultConnection");
+        // ...
+    }
+}
+
+

See Service Registration for more on dependency injection patterns in Blazor.

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/Phase2-EventHandlerSignatures/index.html b/site/Migration/Phase2-EventHandlerSignatures/index.html new file mode 100644 index 000000000..85986dc12 --- /dev/null +++ b/site/Migration/Phase2-EventHandlerSignatures/index.html @@ -0,0 +1,6418 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Event Handler Signatures - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Event Handler Signature Migration

+

The migration script automatically transforms Web Forms event handler signatures to Blazor-compatible equivalents. The (object sender, EventArgs e) pattern is stripped or simplified based on the EventArgs type, preserving your handler logic unchanged.

+

Overview

+

What it does: +- Strips (object sender, EventArgs e) parameters from standard event handlers +- Strips only the sender parameter when specialized EventArgs are used (e.g., GridViewCommandEventArgs) +- Preserves all method body logic unchanged

+

Why it matters: +Every Web Forms event handler follows the (object sender, EventArgs e) convention. Blazor event callbacks use different signatures — parameterless for simple actions, or with a single event args parameter for specialized events. The migration script automates this transformation for the common cases, but you should understand the rules for manual review.

+

The Rules

+

The migration follows two simple rules based on the EventArgs type:

+

Rule 1: Standard EventArgs → Strip Both Parameters

+

When the handler uses the base EventArgs class (or no args), remove both parameters — the handler becomes parameterless:

+
// Web Forms
+protected void Button_Click(object sender, EventArgs e)
+{
+    SaveData();
+}
+
+// Blazor (migrated)
+protected void Button_Click()
+{
+    SaveData();
+}
+
+

Rule 2: Specialized EventArgs → Strip Sender Only

+

When the handler uses a specialized EventArgs subclass, remove only sender — keep the event args parameter:

+
// Web Forms
+protected void Grid_RowCommand(object sender, GridViewCommandEventArgs e)
+{
+    if (e.CommandName == "Delete")
+    {
+        int rowIndex = Convert.ToInt32(e.CommandArgument);
+        DeleteRow(rowIndex);
+    }
+}
+
+// Blazor (migrated)
+protected void Grid_RowCommand(GridViewCommandEventArgs e)
+{
+    if (e.CommandName == "Delete")
+    {
+        int rowIndex = Convert.ToInt32(e.CommandArgument);
+        DeleteRow(rowIndex);
+    }
+}
+
+

Common Transformations

+

The following table shows before/after signatures for the most common Web Forms event handlers:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ControlWeb Forms SignatureBlazor SignatureRule
Button ClickButton_Click(object sender, EventArgs e)Button_Click()1 — Standard
LinkButton ClickLink_Click(object sender, EventArgs e)Link_Click()1 — Standard
ImageButton ClickImage_Click(object sender, ImageClickEventArgs e)Image_Click(ImageClickEventArgs e)2 — Specialized
DropDownList ChangedDropDown_Changed(object sender, EventArgs e)DropDown_Changed()1 — Standard
CheckBox ChangedCheck_Changed(object sender, EventArgs e)Check_Changed()1 — Standard
GridView RowCommandGrid_RowCommand(object sender, GridViewCommandEventArgs e)Grid_RowCommand(GridViewCommandEventArgs e)2 — Specialized
GridView RowEditingGrid_RowEditing(object sender, GridViewEditEventArgs e)Grid_RowEditing(GridViewEditEventArgs e)2 — Specialized
GridView RowDeletingGrid_RowDeleting(object sender, GridViewDeleteEventArgs e)Grid_RowDeleting(GridViewDeleteEventArgs e)2 — Specialized
GridView PageIndexChangingGrid_PageChanging(object sender, GridViewPageEventArgs e)Grid_PageChanging(GridViewPageEventArgs e)2 — Specialized
GridView RowDataBoundGrid_RowDataBound(object sender, GridViewRowEventArgs e)Grid_RowDataBound(GridViewRowEventArgs e)2 — Specialized
Repeater ItemCommandRepeater_ItemCommand(object sender, RepeaterCommandEventArgs e)Repeater_ItemCommand(RepeaterCommandEventArgs e)2 — Specialized
ListView ItemCommandList_ItemCommand(object sender, ListViewCommandEventArgs e)List_ItemCommand(ListViewCommandEventArgs e)2 — Specialized
TextBox TextChangedTextBox_Changed(object sender, EventArgs e)TextBox_Changed()1 — Standard
Calendar SelectionChangedCalendar_Changed(object sender, EventArgs e)Calendar_Changed()1 — Standard
Timer TickTimer_Tick(object sender, EventArgs e)Timer_Tick()1 — Standard
+

Before and After: Full Page Example

+
+
+
+
// ProductAdmin.aspx.cs
+public partial class ProductAdmin : System.Web.UI.Page
+{
+    protected void Page_Load(object sender, EventArgs e)
+    {
+        if (!IsPostBack)
+        {
+            BindGrid();
+        }
+    }
+
+    protected void AddButton_Click(object sender, EventArgs e)
+    {
+        Response.Redirect("AddProduct.aspx");
+    }
+
+    protected void ProductGrid_RowCommand(object sender, GridViewCommandEventArgs e)
+    {
+        if (e.CommandName == "Edit")
+        {
+            int index = Convert.ToInt32(e.CommandArgument);
+            Response.Redirect($"EditProduct.aspx?id={index}");
+        }
+    }
+
+    protected void ProductGrid_RowDeleting(object sender, GridViewDeleteEventArgs e)
+    {
+        int productId = (int)ProductGrid.DataKeys[e.RowIndex].Value;
+        DeleteProduct(productId);
+        BindGrid();
+    }
+
+    protected void SearchBox_TextChanged(object sender, EventArgs e)
+    {
+        string query = SearchBox.Text;
+        ProductGrid.DataSource = SearchProducts(query);
+        ProductGrid.DataBind();
+    }
+}
+
+
+
+
// ProductAdmin.razor.cs
+public partial class ProductAdmin : ComponentBase
+{
+    protected override async Task OnInitializedAsync()
+    {
+        // Was Page_Load — lifecycle also transformed
+        if (!IsPostBack)
+        {
+            BindGrid();
+        }
+    }
+
+    protected void AddButton_Click()
+    {
+        // Rule 1: Standard EventArgs → parameterless
+        Response.Redirect("AddProduct.aspx");
+    }
+
+    protected void ProductGrid_RowCommand(GridViewCommandEventArgs e)
+    {
+        // Rule 2: Specialized EventArgs → sender removed, e kept
+        if (e.CommandName == "Edit")
+        {
+            int index = Convert.ToInt32(e.CommandArgument);
+            Response.Redirect($"EditProduct.aspx?id={index}");
+        }
+    }
+
+    protected void ProductGrid_RowDeleting(GridViewDeleteEventArgs e)
+    {
+        // Rule 2: Specialized EventArgs → sender removed, e kept
+        int productId = (int)ProductGrid.DataKeys[e.RowIndex].Value;
+        DeleteProduct(productId);
+        BindGrid();
+    }
+
+    protected void SearchBox_TextChanged()
+    {
+        // Rule 1: Standard EventArgs → parameterless
+        string query = SearchBox.Text;
+        ProductGrid.DataSource = SearchProducts(query);
+        ProductGrid.DataBind();
+    }
+}
+
+
+
+
+

Automated Transformation

+

The migration script (bwfc-migrate.ps1) handles these transformations automatically:

+

What the Script Does

+
    +
  1. Detects EventArgs type — Inspects the second parameter's type to determine which rule to apply
  2. +
  3. Applies Rule 1 or Rule 2 — Strips parameters according to the rules above
  4. +
  5. Preserves method body — All logic inside the handler is left unchanged
  6. +
  7. Updates method visibility — Keeps protected void as-is (no override needed for event handlers)
  8. +
+

Recognized Specialized EventArgs

+

The script recognizes these as specialized EventArgs that trigger Rule 2 (keep the parameter):

+
    +
  • GridViewCommandEventArgs, GridViewEditEventArgs, GridViewDeleteEventArgs, GridViewUpdateEventArgs, GridViewPageEventArgs, GridViewRowEventArgs, GridViewSortEventArgs, GridViewSelectEventArgs
  • +
  • RepeaterCommandEventArgs, RepeaterItemEventArgs
  • +
  • ListViewCommandEventArgs, ListViewEditEventArgs, ListViewDeleteEventArgs, ListViewInsertEventArgs, ListViewUpdateEventArgs
  • +
  • FormViewInsertEventArgs, FormViewUpdateEventArgs, FormViewDeleteEventArgs
  • +
  • DataListCommandEventArgs, DataListItemEventArgs
  • +
  • ImageClickEventArgs
  • +
  • CommandEventArgs
  • +
  • Any type name ending in EventArgs that is not the base System.EventArgs
  • +
+

Manual Review Checklist

+

After the automated migration, review the following:

+

1. Check for sender Usage in Handler Body

+

If the handler body references sender, the automated transform removes the parameter but leaves the reference:

+
// Web Forms — uses sender to identify which button was clicked
+protected void Button_Click(object sender, EventArgs e)
+{
+    var btn = (Button)sender;
+    StatusLabel.Text = $"You clicked {btn.Text}";
+}
+
+// After migration — sender reference breaks
+protected void Button_Click()
+{
+    var btn = (Button)sender;  // ❌ Compile error
+    StatusLabel.Text = $"You clicked {btn.Text}";
+}
+
+

Fix: Replace sender with direct control references or component state:

+
// Option 1: Pass identifying data via CommandArgument
+protected void Button_Click()
+{
+    StatusLabel.Text = "Button clicked";
+}
+
+// Option 2: Use separate handlers per button
+protected void SaveButton_Click()
+{
+    StatusLabel.Text = "You clicked Save";
+}
+
+

2. Check for Dynamic Event Wiring

+

Web Forms allows dynamically wiring events in code:

+
// Web Forms — dynamic wiring
+protected void Page_Init(object sender, EventArgs e)
+{
+    var btn = new Button();
+    btn.Click += DynamicButton_Click;
+    PlaceHolder1.Controls.Add(btn);
+}
+
+

This pattern doesn't have a direct Blazor equivalent. Consider using RenderFragment or conditional rendering instead.

+

3. Custom EventArgs Subclasses

+

If your application defines custom EventArgs subclasses, verify the migration script recognized them:

+
// Custom EventArgs — should trigger Rule 2
+public class ProductEventArgs : EventArgs
+{
+    public int ProductId { get; set; }
+}
+
+protected void Product_Selected(object sender, ProductEventArgs e)
+{
+    LoadProduct(e.ProductId);
+}
+
+// Should migrate to (Rule 2):
+protected void Product_Selected(ProductEventArgs e)
+{
+    LoadProduct(e.ProductId);
+}
+
+

Summary

+
    +
  • ✅ Standard EventArgs → strip both sender and e (parameterless handler)
  • +
  • ✅ Specialized EventArgs → strip sender only, keep e
  • +
  • ✅ Automated by the migration script
  • +
  • ⚠️ Review handler bodies that reference sender — may need refactoring
  • +
  • ⚠️ Dynamic event wiring needs manual conversion to Blazor patterns
  • +
  • ⚠️ Custom EventArgs subclasses should be auto-detected but verify
  • +
+

See Automated Migration Guide for the full list of automated transformations performed by the migration script.

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/Phase2-LifecycleTransforms/index.html b/site/Migration/Phase2-LifecycleTransforms/index.html new file mode 100644 index 000000000..a0dfc21ac --- /dev/null +++ b/site/Migration/Phase2-LifecycleTransforms/index.html @@ -0,0 +1,6318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page Lifecycle - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Page Lifecycle Migration

+

The migration script automatically transforms Web Forms page lifecycle methods (Page_Init, Page_Load, Page_PreRender) to their Blazor equivalents. Your page logic is preserved — only the method signatures change.

+

Overview

+

What it does: +- Renames Web Forms lifecycle methods to Blazor component lifecycle overrides +- Adjusts method signatures (removes sender and EventArgs parameters) +- Preserves method body logic unchanged

+

Why it matters: +Web Forms has a complex page lifecycle with specific events fired in order: Init → Load → PreRender → Render → Unload. Blazor has a different but analogous component lifecycle. The migration script handles the mapping automatically, but understanding the correspondence helps you validate the results and handle edge cases.

+

Lifecycle Mapping

+

The following table shows how Web Forms lifecycle methods map to Blazor:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web FormsBlazorTimingNotes
Page_InitOnInitialized()Sync, runs onceCalled when the component is first initialized
Page_LoadOnInitializedAsync()Async, runs onceUse for data loading; replaces the common if (!IsPostBack) pattern
Page_PreRenderOnAfterRenderAsync(bool firstRender)Async, after renderGuard with if (firstRender) for one-time logic
Page_UnloadDispose() via IDisposableOn component teardownImplement IDisposable on the component
+
+

Note

+

In Web Forms, Page_Load runs on every postback. In Blazor, OnInitializedAsync() runs only once when the component initializes. If your Page_Load contained if (!IsPostBack) guards, the migrated code is correct — Blazor's OnInitializedAsync() is inherently "first load only."

+
+

Before and After

+
+
+
+
// Products.aspx.cs
+public partial class Products : System.Web.UI.Page
+{
+    protected void Page_Init(object sender, EventArgs e)
+    {
+        // Initialize controls
+        CategoryDropDown.DataSource = GetCategories();
+        CategoryDropDown.DataBind();
+    }
+
+    protected void Page_Load(object sender, EventArgs e)
+    {
+        if (!IsPostBack)
+        {
+            // First load — fetch data
+            ProductGrid.DataSource = GetProducts();
+            ProductGrid.DataBind();
+        }
+    }
+
+    protected void Page_PreRender(object sender, EventArgs e)
+    {
+        // Update UI state before rendering
+        ItemCountLabel.Text = $"Showing {ProductGrid.Rows.Count} items";
+    }
+}
+
+
+
+
// Products.razor.cs
+public partial class Products : ComponentBase
+{
+    protected override void OnInitialized()
+    {
+        // Initialize controls (was Page_Init)
+        CategoryDropDown.DataSource = GetCategories();
+        CategoryDropDown.DataBind();
+    }
+
+    protected override async Task OnInitializedAsync()
+    {
+        // First load — fetch data (was Page_Load)
+        // No IsPostBack check needed — runs once
+        ProductGrid.DataSource = GetProducts();
+        ProductGrid.DataBind();
+    }
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        if (firstRender)
+        {
+            // Update UI state after first render (was Page_PreRender)
+            ItemCountLabel.Text = $"Showing {ProductGrid.Rows.Count} items";
+        }
+    }
+}
+
+
+
+
+

Automated Transformation

+

The migration script (bwfc-migrate.ps1) handles these transformations automatically:

+

What the Script Does

+
    +
  1. Renames methodsPage_InitOnInitialized, Page_LoadOnInitializedAsync, Page_PreRenderOnAfterRenderAsync
  2. +
  3. Removes parameters — Strips (object sender, EventArgs e) from the signature
  4. +
  5. Adds override keyword — Changes protected void to protected override void (or async Task)
  6. +
  7. Wraps PreRender body — Adds if (firstRender) { ... } guard to OnAfterRenderAsync
  8. +
  9. Preserves method body — All logic inside the method is left unchanged
  10. +
+

Example Transformation

+
// INPUT: Web Forms
+protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        LoadProducts();
+    }
+}
+
+// OUTPUT: Blazor (automated)
+protected override async Task OnInitializedAsync()
+{
+    if (!IsPostBack)
+    {
+        LoadProducts();
+    }
+}
+
+
+

Tip

+

The IsPostBack property is provided by the BWFC WebFormsPage base class and always returns false in Blazor, making the guard effectively a no-op. You can safely remove it in a later cleanup phase.

+
+

Manual Review Checklist

+

After the automated migration, review the following:

+

1. Check for sender Usage in Method Body

+

If the original method body references the sender parameter, the automated transform will remove the parameter but leave the body reference, causing a compilation error:

+
// Web Forms — uses sender
+protected void Page_Init(object sender, EventArgs e)
+{
+    var page = (Page)sender;  // ← References sender
+    page.Title = "Products";
+}
+
+// After migration — sender is gone, body breaks
+protected override void OnInitialized()
+{
+    var page = (Page)sender;  // ❌ Compile error: 'sender' doesn't exist
+    page.Title = "Products";
+}
+
+

Fix: Replace sender references with direct property access or dependency injection:

+
protected override void OnInitialized()
+{
+    // Use the component's own properties instead
+    PageTitle = "Products";
+}
+
+

2. Check for EventArgs Usage in Method Body

+

Similarly, if the method body uses the e parameter:

+
// Web Forms — uses e
+protected void Page_Load(object sender, EventArgs e)
+{
+    LogEvent(e.ToString());
+}
+
+// Fix: Remove or replace the EventArgs reference
+protected override async Task OnInitializedAsync()
+{
+    LogEvent("Page initialized");  // Simplified
+}
+
+

3. Multiple Page_Load Handlers

+

Web Forms allows wiring multiple handlers to the same event. If your page has this pattern, consolidate into a single Blazor lifecycle method:

+
// Web Forms — multiple handlers (rare but possible)
+this.Load += Page_Load;
+this.Load += Page_LoadAdditional;
+
+// Blazor — combine into one
+protected override async Task OnInitializedAsync()
+{
+    // Logic from Page_Load
+    LoadProducts();
+
+    // Logic from Page_LoadAdditional
+    LoadPromotions();
+}
+
+

4. Async Data Loading

+

If Page_Load calls async methods synchronously (common in Web Forms), the Blazor migration is an opportunity to improve:

+
// Web Forms — sync-over-async (anti-pattern)
+protected void Page_Load(object sender, EventArgs e)
+{
+    var products = GetProductsAsync().Result;  // Blocking call
+}
+
+// Blazor — proper async (recommended cleanup)
+protected override async Task OnInitializedAsync()
+{
+    var products = await GetProductsAsync();  // Non-blocking
+}
+
+

Lifecycle Execution Order

+

For reference, here's how the lifecycle methods execute in each framework:

+
+
+
+
Page_PreInit
+     ↓
+Page_Init          ← Component initialization
+     ↓
+Page_InitComplete
+     ↓
+Page_Load          ← Data loading (every request)
+     ↓
+Page_LoadComplete
+     ↓
+[Event Handlers]   ← Button clicks, grid commands, etc.
+     ↓
+Page_PreRender     ← Final UI adjustments
+     ↓
+Page_Render        ← HTML output
+     ↓
+Page_Unload        ← Cleanup
+
+
+
+
OnInitialized()         ← Sync initialization (once)
+     ↓
+OnInitializedAsync()    ← Async initialization (once)
+     ↓
+OnParametersSet()       ← Parameters received
+     ↓
+OnParametersSetAsync()  ← Async parameter processing
+     ↓
+[Render]                ← HTML output
+     ↓
+OnAfterRender(first)    ← Post-render logic
+     ↓
+OnAfterRenderAsync(first) ← Async post-render
+     ↓
+Dispose()               ← Cleanup (via IDisposable)
+
+
+
+
+

Summary

+
    +
  • Page_InitOnInitialized() — automatic
  • +
  • Page_LoadOnInitializedAsync() — automatic
  • +
  • Page_PreRenderOnAfterRenderAsync(firstRender) — automatic with guard
  • +
  • ⚠️ Review method bodies for sender or e parameter references
  • +
  • ⚠️ IsPostBack checks are safe to leave (always false) but can be removed later
  • +
  • 🔄 Consider converting sync-over-async patterns to proper await calls
  • +
+

See WebFormsPage for details on the IsPostBack shim and other page-level compatibility features.

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/Phase2-SessionShim/index.html b/site/Migration/Phase2-SessionShim/index.html new file mode 100644 index 000000000..7e38fc59f --- /dev/null +++ b/site/Migration/Phase2-SessionShim/index.html @@ -0,0 +1,6368 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Session State - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Session State Migration

+

The SessionShim allows migrated Web Forms code to access Session["key"] without modification. Your existing session access code — shopping carts, user preferences, wizard state — works unchanged in Blazor.

+

Overview

+

What it does: +- Provides a Session indexer (Session["key"]) backed by ASP.NET Core ISession with automatic JSON serialization +- Falls back to an in-memory dictionary in interactive (SignalR) mode where HTTP session isn't available +- Supports type-safe access via Session.Get<T>("key") for explicit deserialization +- Registers automatically via AddBlazorWebFormsComponents()

+

Why it matters: +Web Forms applications use Session["key"] everywhere — shopping carts, user preferences, wizard step tracking, temporary form data. Without a shim, every Session access would need to be rewritten to use Blazor's state management patterns. The SessionShim eliminates that refactoring during migration, letting you focus on UI conversion first.

+

Before and After

+
+
+
+
// ShoppingCart.aspx.cs
+protected void Page_Load(object sender, EventArgs e)
+{
+    // Store cart ID in session
+    if (Session["CartId"] == null)
+    {
+        Session["CartId"] = Guid.NewGuid().ToString();
+    }
+
+    var cartId = (string)Session["CartId"];
+    LoadCart(cartId);
+}
+
+protected void AddToCart_Click(object sender, EventArgs e)
+{
+    // Track item count
+    int count = Session["ItemCount"] != null 
+        ? (int)Session["ItemCount"] : 0;
+    Session["ItemCount"] = count + 1;
+}
+
+protected void SetPreference_Click(object sender, EventArgs e)
+{
+    // Store user preferences
+    Session["Theme"] = "dark";
+    Session["Language"] = "en-US";
+}
+
+
+
+
// ShoppingCart.razor.cs
+protected void Page_Load()
+{
+    // Same session access — SessionShim handles it
+    if (Session["CartId"] == null)
+    {
+        Session["CartId"] = Guid.NewGuid().ToString();
+    }
+
+    var cartId = (string)Session["CartId"];
+    LoadCart(cartId);
+}
+
+protected void AddToCart_Click()
+{
+    // Same code — SessionShim serializes/deserializes automatically
+    int count = Session["ItemCount"] != null 
+        ? (int)Session["ItemCount"] : 0;
+    Session["ItemCount"] = count + 1;
+}
+
+protected void SetPreference_Click()
+{
+    // Same code
+    Session["Theme"] = "dark";
+    Session["Language"] = "en-US";
+}
+
+
+
+
+

Key difference: The Session indexer is provided by SessionShim instead of HttpContext.Session. Your code doesn't need to know the difference.

+

Setup

+

Automatic Registration

+

The SessionShim is registered automatically when you call AddBlazorWebFormsComponents():

+
// Program.cs
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddBlazorWebFormsComponents();
+
+var app = builder.Build();
+app.Run();
+
+

No additional setup needed for interactive (SignalR) mode — the shim uses in-memory storage automatically.

+

SSR Session Persistence

+

If your Blazor app uses Server-Side Rendering (SSR) and you need session data to persist across HTTP requests, add the session middleware:

+
// Program.cs
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddBlazorWebFormsComponents();
+builder.Services.AddSession();  // Enable ASP.NET Core session
+
+var app = builder.Build();
+
+app.UseSession();  // Add session middleware to the pipeline
+app.UseRouting();
+app.MapBlazorHub();
+app.MapFallbackToPage("/_Host");
+
+app.Run();
+
+
+

Note

+

app.UseSession() must be placed before app.UseRouting() in the middleware pipeline.

+
+

How It Works

+

The SessionShim operates in two modes depending on the Blazor hosting model:

+

SSR Mode (HTTP Context Available)

+

When an HttpContext with an active session is available, the shim wraps ASP.NET Core's ISession:

+
Session["CartId"] = "abc-123"
+         │
+         ▼
+SessionShim.SetItem("CartId", "abc-123")
+         │
+         ▼
+JSON.Serialize("abc-123") → ISession.SetString("CartId", json)
+         │
+         ▼
+Stored in ASP.NET Core distributed session (cookies, Redis, SQL, etc.)
+
+

Interactive Mode (SignalR Circuit)

+

When no HTTP session is available (interactive Blazor Server over SignalR), the shim falls back to an in-memory dictionary scoped to the current circuit:

+
Session["CartId"] = "abc-123"
+         │
+         ▼
+SessionShim.SetItem("CartId", "abc-123")
+         │
+         ▼
+Stored in ConcurrentDictionary<string, object> (per-circuit memory)
+
+

JSON Serialization

+

All values are serialized to JSON when stored and deserialized when retrieved. This means:

+
    +
  • Primitive types (string, int, bool) work transparently
  • +
  • Complex objects must be JSON-serializable
  • +
  • The cast syntax (string)Session["key"] works because the shim deserializes back to the original type
  • +
+

Type-Safe Access

+

For explicit type control, use the generic Get<T>() method:

+
// Store a value
+Session["ItemCount"] = 42;
+
+// Retrieve with explicit type (recommended)
+int count = Session.Get<int>("ItemCount");
+
+// Retrieve with cast (also works)
+int count = (int)Session["ItemCount"];
+
+// Complex objects
+Session["UserPrefs"] = new UserPreferences { Theme = "dark", Locale = "en-US" };
+var prefs = Session.Get<UserPreferences>("UserPrefs");
+
+
+

Tip

+

Prefer Session.Get<T>() over casting when the stored type might be ambiguous. JSON deserialization of numeric types can return long instead of int, and Get<T>() handles the conversion correctly.

+
+

Limitations and Known Issues

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureBehaviorNotes
Interactive mode storageIn-memory (per-circuit)Data is lost when the circuit disconnects
Cross-tab sharing❌ Not supported in interactive modeEach SignalR circuit has its own session dictionary
Session timeoutFollows ASP.NET Core session config (SSR) or circuit lifetime (interactive)Configure via builder.Services.AddSession(options => ...)
Non-serializable objects❌ Throws on storeObjects must be JSON-serializable
Session.Abandon()Clears all keysDoes not destroy the underlying ASP.NET Core session in SSR mode
+

Interactive Mode Caveats

+

In interactive Blazor Server mode (SignalR), session state is per-circuit:

+
    +
  • Opening the same page in two browser tabs creates two separate session stores
  • +
  • Refreshing the page creates a new circuit, losing in-memory session data
  • +
  • Session data does not survive server restarts
  • +
+
+

Warning

+

If your Web Forms app relied on session sharing across browser tabs or windows, you will need to migrate to a shared state solution (e.g., database-backed state, ProtectedBrowserStorage, or a distributed cache) in a later phase.

+
+

Troubleshooting

+

Session values are null after page refresh (Interactive mode)

+

This is expected in interactive mode — refreshing the page creates a new SignalR circuit with empty in-memory storage. To persist session data across refreshes:

+
    +
  1. Enable SSR session persistence (see Setup above)
  2. +
  3. Or migrate to ProtectedBrowserStorage for client-side persistence
  4. +
+

"Object not serializable" exception

+

The SessionShim uses JSON serialization. Ensure your stored objects: +- Have parameterless constructors +- Have public properties (not fields) +- Don't contain circular references

+

Summary

+

The SessionShim: +- ✅ Lets existing Session["key"] code work unchanged +- ✅ Wraps ASP.NET Core ISession with JSON serialization in SSR mode +- ✅ Falls back to in-memory dictionary in interactive mode +- ✅ Supports type-safe access via Session.Get<T>("key") +- ✅ Registers automatically via AddBlazorWebFormsComponents() +- ❌ Interactive mode is per-circuit (not shared across tabs) +- ❌ In-memory storage is lost on circuit disconnect or page refresh

+

Use it for Phase 2 migrations when your code uses Session extensively. For long-term state management, consider migrating to Blazor-native patterns like ProtectedBrowserStorage, cascading parameters, or a state management service.

+

See ViewState and PostBack Shim for related state management patterns.

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/QuickStart/index.html b/site/Migration/QuickStart/index.html new file mode 100644 index 000000000..c29276550 --- /dev/null +++ b/site/Migration/QuickStart/index.html @@ -0,0 +1,6383 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Quick Start Guide - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Quickstart: Scan → Migrate → Verify

+

Go from "I have a Web Forms app" to "I have a running Blazor app" in the shortest path.

+

This guide walks you through the linear steps. It doesn't explain why each step exists — see Methodology for the theory behind the pipeline.

+
+

Before You Start

+
    +
  • [ ] .NET 10+ SDK installed (dotnet --version)
  • +
  • [ ] PowerShell 7+ installed (pwsh --version)
  • +
  • [ ] Your Web Forms project compiles and runs on .NET Framework
  • +
  • [ ] Git initialized in your project (you'll want to track changes)
  • +
+
+

Step 1: Install BWFC

+

Create your Blazor project and add the BWFC package:

+
dotnet new blazor -n MyBlazorApp --interactivity Server
+cd MyBlazorApp
+dotnet add package Fritz.BlazorWebFormsComponents
+
+
+

Step 2: Scan Your Web Forms Project

+

Run the scanner against your existing Web Forms project to understand what you're working with:

+
# From the BWFC repo root
+.\scripts\bwfc-scan.ps1 -Path "C:\src\MyWebFormsApp" -OutputFormat Markdown -OutputFile scan-report.md
+
+

The scanner inventories every .aspx, .ascx, and .master file — extracting control usage, data binding patterns, and DataSource controls. Review the report to understand:

+
    +
  • Total page count and complexity distribution
  • +
  • Control coverage — what percentage of your controls BWFC supports
  • +
  • DataSource controls — these need manual replacement (no BWFC equivalent)
  • +
  • Migration readiness score — your starting point
  • +
+
+

📄 Script reference: scripts/bwfc-scan.ps1

+
+
+

Step 3: Run Layer 1 — Automated Transforms

+

Layer 1 applies mechanical transforms deterministically. Use either the CLI tool (recommended) or the PowerShell script:

+

Option A: CLI tool (37 compiled transforms, migration report):

+
dotnet run --project src/BlazorWebFormsComponents.Cli -- migrate -i "C:\src\MyWebFormsApp" -o "C:\src\MyBlazorApp"
+
+

Option B: PowerShell script (lightweight, no build required):

+
.\scripts\bwfc-migrate.ps1 -Path "C:\src\MyWebFormsApp" -Output "C:\src\MyBlazorApp"
+
+

What this does (in ~30 seconds for a typical app):

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TransformExample
Strip asp: prefixes<asp:Button><Button>
Remove runat="server"runat="server"(removed)
Convert expressions<%: Item.Name %>@(Item.Name)
Convert URLs~/Products/Products
Rename filesDefault.aspxDefault.razor
Convert ItemTypeItemType="NS.Product"TItem="Product"
Remove content wrappers<asp:Content>(unwrapped)
Scaffold projectGenerates .csproj, Program.cs, _Imports.razor
+

Dry-run first to preview changes without writing files:

+
# CLI: use the --dry-run flag
+dotnet run --project src/BlazorWebFormsComponents.Cli -- migrate -i "C:\src\MyWebFormsApp" -o "C:\src\MyBlazorApp" --dry-run
+
+# PowerShell alternative:
+.\scripts\bwfc-migrate.ps1 -Path "C:\src\MyWebFormsApp" -Output "C:\src\MyBlazorApp" -WhatIf
+
+
+

📄 Script reference: scripts/bwfc-migrate.ps1

+
+
+

Step 4: Configure BWFC in the Blazor Project

+

After the migration script runs, verify these are in place (the script scaffolds them, but check):

+

_Imports.razor — add BWFC namespaces and page base class: +

@using BlazorWebFormsComponents
+@using BlazorWebFormsComponents.Enums
+@using static Microsoft.AspNetCore.Components.Web.RenderMode
+@inherits BlazorWebFormsComponents.WebFormsPageBase
+

+

This one line gives every page the Web Forms API: +- Page.Title, Page.MetaDescription, Page.MetaKeywords +- IsPostBack (false on first render, true on interactions) +- Session["key"] (scoped in-memory dictionary) +- Response.Redirect("~/path") (auto-strips ~/ and .aspx) +- Request.Url, Request.QueryString["key"], Request.Form["key"] +- Cache["key"] (application-level cache) +- Server.MapPath("~/path") (virtual → physical path) +- ClientScript.RegisterStartupScript(...) (JS interop)

+

Your Web Forms code-behind compiles unchanged. No manual conversion needed.

+

Program.cs — register BWFC services: +

builder.Services.AddBlazorWebFormsComponents();
+

+

What this does: +- Registers SessionShim (scoped in-memory dictionary for Session["key"]) +- Registers ResponseShim (handles Response.Redirect, Response.Write) +- Registers RequestShim (provides Request.QueryString, Request.Form, Request.Url) +- Registers CacheShim (in-memory application cache) +- Registers ServerShim (provides Server.MapPath) +- Registers ClientScriptShim (JS interop for ClientScript.RegisterStartupScript) +- Registers ViewStateShim (compile-compatible dictionary)

+

After this single call, all Web Forms APIs work AS-IS in your migrated code — no manual conversion required.

+

Layout (MainLayout.razor) — add the Page render component: +

<BlazorWebFormsComponents.Page />
+

+

This renders <PageTitle> and <meta> tags. WebFormsPageBase provides the code-behind API, <Page /> does the rendering — both are required.

+

App.razor (or layout head) — add BWFC JavaScript: +

<script src="_content/Fritz.BlazorWebFormsComponents/js/Basepage.js"></script>
+

+
+

Step 5: Set Up Copilot for Layer 2

+

Copy the Copilot instructions template into your project to give Copilot migration-specific context:

+
# From your Blazor project root
+mkdir -p .github
+cp path/to/bwfc-repo/migration-toolkit/copilot-instructions-template.md .github/copilot-instructions.md
+
+

Then open .github/copilot-instructions.md and fill in the <!-- FILL IN --> sections with your project-specific details.

+

Alternatively, point Copilot at the BWFC migration skill directly:

+
+

📄 Skill file: Core Migration Skill

+
+
+

Step 6: Walk Through Layer 2 — Copilot-Assisted Transforms

+

Open each migrated .razor file and work through the structural transforms that the script couldn't handle. These are the patterns Copilot handles well with the migration skill:

+
+

💡 Many Web Forms API calls now compile unchanged thanks to BWFC shims. Response.Redirect, Session["key"], IsPostBack, Page.Title, Request.QueryString, Cache, and Server.MapPath all work AS-IS — no conversion needed. Focus Layer 2 effort on data binding, templates, and event handler signatures.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TransformWhat To Do
SelectMethodItemsReplace SelectMethod="GetProducts" with Items="products", load data in OnInitializedAsync
ItemTypeTItemAlready done by Layer 1, but verify generic type parameter is correct
Template contextAdd Context="Item" to <ItemTemplate>, <EditItemTemplate>, etc.
Code-behind lifecycleConvert Page_Load(object sender, EventArgs e) signature → OnInitializedAsync; IsPostBack inside works AS-IS
Event handlersConvert void Btn_Click(object sender, EventArgs e)void Btn_Click()
Form wrappersRemove <form runat="server">; use <WebFormsForm> if page uses Request.Form, or <EditForm> for validation
Master Page → LayoutConvert to @inherits LayoutComponentBase with @Body
+
+

If your pages use Request.Form, wrap the form content in <WebFormsForm> — this component captures form POST data and feeds the FormShim so Request.Form["key"] works in your code-behind.

+
+

The following are no longer Layer 2 work — they work AS-IS via shims: +- ~~Response.Redirect("~/path")NavigationManager.NavigateTo~~ → works via ResponseShim +- ~~Session["key"] → mark for Layer 3~~ → works via SessionShim +- ~~Page.Title conversion~~ → works via WebFormsPageBase

+

Using Shims (No Conversion Needed)

+

The shims preserve Web Forms API calls AS-IS. Here's what works unchanged:

+
// Session access — works exactly like Web Forms
+Session["CartId"] = cartId;
+var cartId = Session["CartId"];
+
+// Response.Redirect — auto-strips ~/ and .aspx
+Response.Redirect("~/Products");
+Response.Redirect("~/Product.aspx?id=5"); // becomes /Product/5 if routing configured
+
+// Request.QueryString — reads URL parameters
+var productId = Request.QueryString["id"];
+
+// Request.Form — reads form POST data (requires <WebFormsForm> wrapper)
+var username = Request.Form["username"];
+
+// IsPostBack — false on first render, true on interactions
+if (!IsPostBack)
+{
+    LoadInitialData();
+}
+
+// Page properties — auto-rendered by <Page /> component
+Page.Title = "Product Details";
+Page.MetaDescription = "View product details";
+
+// Cache — application-level cache
+Cache["RecentProducts"] = products;
+
+// Server.MapPath — virtual to physical path
+var filePath = Server.MapPath("~/App_Data/config.xml");
+
+

Do NOT inject these services manually: +- ❌ IHttpContextAccessor — use Request property instead +- ❌ NavigationManager (for redirects) — use Response.Redirect() instead +- ❌ IMemoryCache — use Cache property instead +- ❌ IJSRuntime (for startup scripts) — use ClientScript.RegisterStartupScript() instead

+

The shim properties are already available via WebFormsPageBase. Injecting these services and manually converting is extra work that provides no migration benefit.

+

Look for <!-- TODO: BWFC-MIGRATE --> comments left by the migration script — these mark items that need manual attention.

+
+

Step 7: Address Layer 3 — Architecture Decisions

+

These are the decisions that need a human (or a human + the migration agent):

+
    +
  • Data access: Replace SqlDataSource/ObjectDataSource with injected services
  • +
  • Session state: Convert Session["key"] to scoped services or ProtectedSessionStorage (if you need persistence or distributed sessions — basic usage works AS-IS via SessionShim)
  • +
  • Authentication: Migrate ASP.NET Membership/Identity to ASP.NET Core Identity
  • +
  • EF6 → EF Core: Update DbContext, register with DI, adjust LINQ queries
  • +
  • Global.asax → Program.cs: Convert lifecycle hooks to middleware
  • +
  • Third-party integrations: Port to HttpClient pattern
  • +
  • Shim replacement (OPTIONAL): Replace Response.Redirect() with NavigationManager.NavigateTo(), Session with injected state services, etc. — this is a performance/modernization step, NOT a migration requirement
  • +
+
+

📄 For interactive guidance, use the Data & Architecture Migration Skill

+
+

Common Mistakes to Avoid

+

Anti-pattern #1: Manually converting shim-supported APIs

+

Wrong: +

@inject NavigationManager Nav
+@code {
+    void GoToProducts() => Nav.NavigateTo("/Products");
+}
+

+

Correct (use the shim): +

@code {
+    void GoToProducts() => Response.Redirect("~/Products");
+}
+

+

Anti-pattern #2: Injecting services that shims already provide

+

Wrong: +

@inject IHttpContextAccessor HttpContext
+@code {
+    var id = HttpContext.HttpContext.Request.Query["id"];
+}
+

+

Correct (use the shim): +

@code {
+    var id = Request.QueryString["id"];
+}
+

+

Anti-pattern #3: Treating shims as temporary scaffolding

+

Wrong mindset: "I'll use shims to get it compiling, then replace them with 'real' Blazor code."

+

Correct mindset: "Shims are the migration strategy. They work correctly. Replacing them is an optional optimization I can do later if my team wants to reduce BWFC dependency."

+

The shims ARE the solution, not a workaround.

+
+

Step 8: Build and Verify

+
dotnet build
+
+

Fix any compilation errors. Common issues at this stage:

+
    +
  • Missing @using statements for model namespaces
  • +
  • Event handler signature mismatches (Web Forms EventArgs vs. Blazor parameterless)
  • +
  • Unresolved SelectMethod references that should be Items bindings
  • +
+

Once it builds:

+
dotnet run
+
+

Open the app in a browser and compare against your original Web Forms application:

+
    +
  • [ ] Pages render without errors
  • +
  • [ ] Visual layout matches the original
  • +
  • [ ] Interactive features work (buttons, forms, navigation)
  • +
  • [ ] No console errors in browser dev tools
  • +
+
+

Step 9: Iterate

+

Use the per-page checklist to track progress across your application. Migrate pages in priority order:

+
    +
  1. Leaf pages first — simple display pages with no dependencies
  2. +
  3. Shared layouts — Master Page → MainLayout.razor
  4. +
  5. Data-bound pages — pages with GridView, ListView, FormView
  6. +
  7. Auth-dependent pages — login, account management
  8. +
  9. Integration pages — checkout, payment, external APIs
  10. +
+
+

What Comes Next

+ + + + + + + + + + + + + + + + + + + + + + + + + +
If you need...Go to...
Understand the pipeline theoryMethodology
Check if a specific control is supportedControl Coverage
Track per-page migration progressMigration Checklist
Set up Copilot instructions for your teamCopilot Skills Overview
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/Migration/StranglerFigPattern/index.html b/site/Migration/StranglerFigPattern/index.html new file mode 100644 index 000000000..5632d188d --- /dev/null +++ b/site/Migration/StranglerFigPattern/index.html @@ -0,0 +1,6520 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Strangler Fig Pattern - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Strangler Fig Migration Pattern

+

What Is the Strangler Fig Pattern?

+

The Strangler Fig pattern is named after a real-world biological process: strangler fig trees grow around the trunk of a host tree, gradually replacing it until the original tree is completely enclosed. In software architecture, the pattern applies the same philosophy: incrementally replace parts of a legacy system while keeping it running, rather than attempting a complete rewrite all at once.

+

In Practice: Legacy System + New System Running in Parallel

+

Instead of: +- Big bang rewrite — Stop old system, rewrite everything, hope it all works +- Extended downtime — Teams lock down the old app, everyone migrates, weeks of risk

+

The Strangler Fig approach: +- Keep both running — Legacy Web Forms app continues in production +- Route requests — Some traffic goes to new Blazor components, some stays in Web Forms +- Migrate incrementally — Move one page, control, or feature at a time +- Zero downtime — Users see no outage; changes are invisible +- Rollback easily — If something breaks, switch traffic back instantly

+

How BWFC Enables Strangler Fig Migration

+

BWFC provides three layers of support designed specifically for incremental, side-by-side migration.

+

Step 1: Instrument the Legacy App with BWFC

+

Add the BWFC NuGet package to your .NET 10 Blazor target project:

+
dotnet add package Fritz.BlazorWebFormsComponents
+
+

This unlocks Roslyn analyzers that run at compile time in your Blazor project:

+ + + + + + + + + + + + + + + + + + + + + + + + + +
AnalyzerWhat It DetectsExample
BWFC022Page.ClientScript access patternsPage.ClientScript.RegisterStartupScript(...)
BWFC023ViewState read/write accessViewState["key"] assignments
BWFC024Server control event handler patternsevt_Click(sender, e) method signatures
+

Key fact: These analyzers are purely syntactic — they match patterns in the Roslyn syntax tree (e.g., IsClientScriptAccess() in the analyzer source) without requiring System.Web type resolution. This means they work in any .NET project, whether or not System.Web is available.

+

The analyzers highlight every Web Forms pattern that needs attention, guiding you toward migration opportunities one diagnostic at a time.

+
+

Step 2: Strangle Incrementally with CLI + Analyzers

+

Move files from the legacy Web Forms project to the Blazor project one at a time.

+

L1 Automated Transforms

+

The CLI tool (webforms-to-blazor) handles mechanical L1 transforms:

+
    +
  • @Page directives → @page
  • +
  • <asp: tags → <Button>, <GridView>, etc.
  • +
  • Page_LoadOnInitialized()
  • +
  • IsPostBack guards → removed or rewritten
  • +
  • web.configappsettings.json entries
  • +
+

After L1 transforms, your code compiles and runs.

+

Analyzers Show What's Left

+

The BWFC analyzers then light up the remaining patterns: +- Page.ClientScript.RegisterStartupScript() → use ClientScriptShim or IJSRuntime +- ViewState["key"] → use field/property or ViewStateDictionary +- Event handler signatures → add EventCallback<> return types

+

No guessing — every diagnostic is actionable.

+
+

Step 3: Zero-Rewrite Compatibility with Shims

+

Here's what makes BWFC unique: you don't have to rewrite your code to make it work. BWFC provides runtime shims that accept the exact same API calls as Web Forms, but run on Blazor's modern foundation.

+

ClientScriptShim

+
// Web Forms code-behind (no changes needed)
+protected void Page_Load(object sender, EventArgs e)
+{
+    Page.ClientScript.RegisterStartupScript(
+        GetType(), "init", 
+        "console.log('Page loaded');", true);
+}
+
+
// Blazor code-behind (inject shim, change lifecycle only)
+@inject ClientScriptShim ClientScript
+
+@code {
+    protected override void OnInitialized()
+    {
+        ClientScript.RegisterStartupScript(
+            GetType(), "init",
+            "console.log('Page loaded');", true);  // ← Identical call
+    }
+}
+
+

How it works: +1. RegisterStartupScript() queues the script in memory +2. During OnAfterRenderAsync, scripts execute via IJSRuntime.InvokeVoidAsync("eval", script) +3. Queue clears after each cycle +4. Deduplication works exactly like Web Forms (by Type + key)

+

Other Shims

+

The same philosophy applies to other patterns:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ShimWeb FormsBlazorMigration Path
ClientScriptShimPage.ClientScript.RegisterStartupScript()Same call via injected shimZero-rewrite
SessionShimSession["key"] = valueSame call, modern storageZero-rewrite
CacheShimCache.Insert(key, obj)Same call via IMemoryCacheZero-rewrite
ServerShimServer.UrlEncode()Same call via utilityZero-rewrite
+

All shims follow the same pattern: same API surface, modern implementation underneath.

+
+

Step 4: Modernize at Your Own Pace (Optional)

+

Once your code is running in Blazor, you can optionally refactor shim calls to native Blazor patterns — but there's no deadline.

+
// Phase 1: Use shim for speed (zero-rewrite)
+ClientScript.RegisterStartupScript(GetType(), "init", "alert('hi');", true);
+
+// Phase 2: Optional refactor to native Blazor
+@inject IJSRuntime JS
+
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+        await JS.InvokeVoidAsync("eval", "alert('hi');");
+}
+
+

Or better yet, migrate to JS modules for production code:

+
@inject IJSRuntime JS
+
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+    {
+        var module = await JS.InvokeAsync<IJSObjectReference>(
+            "import", "./Components/AlertModule.js");
+        await module.InvokeVoidAsync("showAlert");
+    }
+}
+
+

The key: Shims are production-ready from day one. You modernize when it makes sense, not because you have to.

+
+

Visual Progression: From Web Forms to Blazor

+

Here's what the migration journey looks like:

+
graph TD
+    subgraph "Phase 1: Legacy App"
+        WF1["🟥 Web Forms App<br/>100% Web Forms<br/>All traffic here"]
+    end
+
+    WF1 -->|"Add BWFC NuGet +<br/>Run CLI transforms"| Router
+
+    subgraph "Phase 2: Strangling (Side-by-Side)"
+        Router["Router / Load Balancer"]
+        Router --> WF2["🟥 Web Forms<br/>(Legacy)<br/>Home, Reports, Search"]
+        Router --> BZ1["🟦 Blazor App<br/>1-2 pages migrated<br/>ClientScriptShim<br/>SessionShim"]
+    end
+
+    BZ1 -->|"Traffic gradually shifts"| BZ2
+
+    subgraph "Phase 3: Blazor Dominant"
+        BZ2["🟦 Blazor App (Primary)<br/>30+ pages migrated<br/>Shims handle compatibility<br/>Old patterns still work"]
+        BZ2 -.->|"Fallback for<br/>unmigrated pages"| WF3["🟥 Web Forms<br/>(Remaining)"]
+    end
+
+    BZ2 -->|"Optional modernization"| BZ3
+
+    subgraph "Phase 4: Modernized (Optional)"
+        BZ3["🟩 Blazor App (Native)<br/>All pages migrated<br/>IJSRuntime · ISession · IMemoryCache<br/>Full Blazor patterns"]
+    end
+
+    style WF1 fill:#ff6b6b,color:#fff
+    style WF2 fill:#ff6b6b,color:#fff
+    style WF3 fill:#ff9999,color:#333
+    style BZ1 fill:#4dabf7,color:#fff
+    style BZ2 fill:#339af0,color:#fff
+    style BZ3 fill:#51cf66,color:#fff
+    style Router fill:#ffd43b,color:#333
+
+
+

Why This Works: Three Key Advantages

+

1. Zero Downtime

+

Users never experience service interruption. Traffic routes smoothly between systems. If a migrated page has a bug, you instantly route traffic back to the legacy app.

+

2. Parallel Velocity

+

Teams don't have to wait for everything to be ready. Front-end developers can migrate UI pages while back-end developers work on business logic. QA tests pieces incrementally.

+

3. Reversible Migration

+

Unlike "big bang" rewrites, strangler fig migration is reversible at every step. If the Blazor app isn't ready, you have a working fallback.

+
+

Real-World Example: E-Commerce Site

+

Week 1–2: Migrate the Product Search page to Blazor +- No SearchBox component yet? Use plain HTML inputs with @bind +- JavaScript search filtering? Use ClientScriptShim to keep Page.ClientScript.RegisterStartupScript() calls +- Runs in parallel with legacy Search page; router directs traffic

+

Week 3: Migrate the Shopping Cart page +- SessionShim keeps Session["CartItems"] working +- FormView component for edit/display modes +- Shims handle the Web Forms session storage

+

Week 4–5: Migrate Order History and Account Settings +- GridView becomes GridView (BWFC component) +- Master page becomes Layout +- Event handlers stay the same

+

By Week 6: +- Core pages in Blazor, legacy becomes fallback +- Team can now optionally refactor shims to native patterns +- No deadline — shims are production-ready

+
+

Comparison: Migration Strategies

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StrategyTimelineRiskDowntimeReversibility
Big Bang Rewrite6–12 monthsVery HighDays/WeeksNone (all-in)
Strangler Fig (BWFC)Incremental (weeks)LowNoneFull (every step)
Parallel Development3–6 monthsHighModerateRisky (integration required)
+
+

Next Steps

+
    +
  1. Migration Readiness Assessment — Evaluate your app's readiness for incremental migration
  2. +
  3. Strategies — High-level planning for your specific scenario
  4. +
  5. Automated Migration Guide — Use the CLI to kick off L1 transforms
  6. +
  7. Analyzers — Understand what BWFC022, BWFC023, BWFC024 are telling you
  8. +
  9. ClientScript Migration Guide — Deep dive on JS patterns with ClientScriptShim
  10. +
  11. Phase 2 Session Shim — Keep session state working without rewrites
  12. +
  13. Phase 2 Lifecycle Transforms — Page lifecycle event patterns
  14. +
+
+

Summary

+

The Strangler Fig pattern is the philosophical foundation of BWFC:

+
    +
  • Don't rewrite everything. Incrementally replace parts while keeping the system running.
  • +
  • Use analyzers to guide. BWFC's Roslyn analyzers show you exactly what needs attention.
  • +
  • Use shims to bridge. ClientScriptShim, SessionShim, and others let migrated code work unchanged.
  • +
  • Modernize on your schedule. Refactor to native Blazor patterns when it makes sense, not because you have to.
  • +
  • Zero downtime, zero risk. Both systems run in parallel. Every step is reversible.
  • +
+

BWFC was purpose-built for this pattern. The analyzers, CLI, and shims all exist to make incremental, side-by-side migration possible.

+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/UtilityFeatures/CacheShim/index.html b/site/UtilityFeatures/CacheShim/index.html new file mode 100644 index 000000000..1cf5f6fbf --- /dev/null +++ b/site/UtilityFeatures/CacheShim/index.html @@ -0,0 +1,6147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cache - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Cache Shim

+

The CacheShim class provides compatibility with the ASP.NET Web Forms Cache object (System.Web.Caching.Cache). It wraps ASP.NET Core's IMemoryCache so that migrated code-behind using Cache["key"] dictionary-style access compiles and functions correctly without rewriting.

+

Original Microsoft implementation: https://docs.microsoft.com/en-us/dotnet/api/system.web.caching.cache?view=netframework-4.8

+

Background

+

In ASP.NET Web Forms, the Cache object was available on every Page and through HttpRuntime.Cache:

+
// Web Forms code-behind
+Cache["products"] = GetProductList();
+var products = (List<Product>)Cache["products"];
+Cache.Insert("config", configData, null,
+    DateTime.Now.AddHours(1), Cache.NoSlidingExpiration);
+Cache.Remove("products");
+
+

The Cache provided application-wide in-memory storage with optional expiration policies.

+

Blazor Implementation

+

In Blazor, application caching is handled by IMemoryCache from Microsoft.Extensions.Caching.Memory. The CacheShim bridges the gap by:

+
    +
  1. Dictionary-style accessCache["key"] get/set works like Web Forms
  2. +
  3. Insert() overloads — Supports no-expiry, absolute expiry, and sliding expiry
  4. +
  5. Get<T>(key) — Type-safe retrieval (bonus over Web Forms)
  6. +
  7. Remove(key) — Removes and returns the cached value, matching Web Forms behavior
  8. +
+

Availability

+

CacheShim is automatically registered when you call AddBlazorWebFormsComponents() in Program.cs. It requires IMemoryCache:

+
// Program.cs — both are handled by AddBlazorWebFormsComponents()
+builder.Services.AddMemoryCache();
+builder.Services.AddScoped<CacheShim>();
+
+

Access it through WebFormsPageBase:

+
@inherits WebFormsPageBase
+
+@code {
+    protected override void OnInitialized()
+    {
+        base.OnInitialized();
+        Cache["greeting"] = "Hello, World!";
+        var greeting = (string)Cache["greeting"];
+    }
+}
+
+

Web Forms Usage

+
// Simple get/set
+Cache["products"] = GetProductList();
+var products = (List<Product>)Cache["products"];
+
+// Insert with absolute expiration
+Cache.Insert("config", configData, null,
+    DateTime.Now.AddHours(1), Cache.NoSlidingExpiration);
+
+// Insert with sliding expiration
+Cache.Insert("session-data", sessionData, null,
+    Cache.NoAbsoluteExpiration, TimeSpan.FromMinutes(20));
+
+// Remove
+object removed = Cache.Remove("products");
+
+

Blazor Usage

+
@inherits WebFormsPageBase
+
+@code {
+    private List<Product> _products = new();
+
+    protected override void OnInitialized()
+    {
+        base.OnInitialized();
+
+        // Dictionary-style set
+        Cache["products"] = GetProductList();
+
+        // Dictionary-style get (returns object?)
+        _products = (List<Product>)Cache["products"];
+
+        // Type-safe get (returns T? — no cast needed)
+        _products = Cache.Get<List<Product>>("products");
+
+        // Insert with absolute expiration
+        Cache.Insert("config", LoadConfig(),
+            DateTimeOffset.Now.AddHours(1));
+
+        // Insert with sliding expiration
+        Cache.Insert("session-data", GetSessionData(),
+            TimeSpan.FromMinutes(20));
+
+        // Remove and get the removed value
+        object? removed = Cache.Remove("products");
+    }
+
+    private void RefreshProducts()
+    {
+        // Setting null removes the item
+        Cache["products"] = null;
+
+        // Re-populate
+        Cache["products"] = GetProductList();
+    }
+}
+
+

API Reference

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodSignatureDescription
Indexer getCache["key"]object?Gets item or null
Indexer setCache["key"] = valueSets item; null removes it
Get<T>Cache.Get<T>(key)T?Type-safe retrieval
InsertCache.Insert(key, value)No expiration
InsertCache.Insert(key, value, DateTimeOffset)Absolute expiration
InsertCache.Insert(key, value, TimeSpan)Sliding expiration
RemoveCache.Remove(key)object?Removes and returns item
+

Migration Path

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web FormsBWFC ShimNative Blazor
HttpRuntime.Cache["key"]Cache["key"]IMemoryCache.TryGetValue()
Cache["key"] = valueCache["key"] = valuecache.Set(key, value)
Cache.Insert(key, val, null, expiry, noSliding)Cache.Insert(key, val, expiry)cache.Set(key, val, opts)
Cache.Remove(key)Cache.Remove(key)cache.Remove(key)
+

Moving On

+

CacheShim is a migration bridge. As you refactor:

+
    +
  1. Replace with IMemoryCache — Inject IMemoryCache directly for full control over cache entry options
  2. +
  3. Consider IDistributedCache — For multi-server deployments, switch to Redis or SQL Server distributed caching
  4. +
  5. Use typed wrappers — Create service classes that encapsulate caching logic instead of sprinkling Cache["key"] throughout pages
  6. +
+
@* Before (migration shim) *@
+@inherits WebFormsPageBase
+@code {
+    var products = (List<Product>)Cache["products"];
+    Cache.Insert("products", newList, DateTimeOffset.Now.AddHours(1));
+}
+
+@* After (native Blazor) *@
+@inject IMemoryCache MemoryCache
+@code {
+    MemoryCache.TryGetValue("products", out List<Product> products);
+    MemoryCache.Set("products", newList, DateTimeOffset.Now.AddHours(1));
+}
+
+

See Also

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/UtilityFeatures/RequestShim/index.html b/site/UtilityFeatures/RequestShim/index.html new file mode 100644 index 000000000..1dd1abebe --- /dev/null +++ b/site/UtilityFeatures/RequestShim/index.html @@ -0,0 +1,6389 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Request - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Request Shim

+

The RequestShim class provides compatibility with the ASP.NET Web Forms Request object (HttpRequest). It wraps ASP.NET Core's HttpContext.Request and NavigationManager so that migrated code-behind using Request.QueryString["id"], Request.Cookies["name"], and Request.Url compiles and works correctly — with graceful degradation when HttpContext is unavailable during interactive rendering.

+

Original Microsoft implementation: https://docs.microsoft.com/en-us/dotnet/api/system.web.httprequest?view=netframework-4.8

+

Background

+

In ASP.NET Web Forms, the Request object was available on every Page and UserControl:

+
// Web Forms code-behind
+string id = Request.QueryString["id"];
+string sessionCookie = Request.Cookies["session"].Value;
+Uri currentUrl = Request.Url;
+
+

These properties provided direct access to the incoming HTTP request data.

+

Blazor Implementation

+

In Blazor, HTTP request data is only available during server-side rendering (SSR) via HttpContext. During interactive WebSocket rendering, there is no HTTP request. The RequestShim bridges this gap by:

+
    +
  1. QueryString["key"] — Reads from HttpContext.Request.Query in SSR; falls back to parsing NavigationManager.Uri in interactive mode
  2. +
  3. Cookies["key"] — Reads from HttpContext.Request.Cookies in SSR; returns an empty collection in interactive mode (with a logged warning)
  4. +
  5. Url — Reads from HttpContext.Request in SSR; falls back to NavigationManager.Uri in interactive mode
  6. +
+

Availability

+

RequestShim is automatically available when your page inherits from WebFormsPageBase:

+
@inherits WebFormsPageBase
+
+@code {
+    private string _productId = "";
+
+    protected override void OnInitialized()
+    {
+        base.OnInitialized();
+        _productId = Request.QueryString["id"];
+    }
+}
+
+

Request.Form

+

The FormShim provides NameValueCollection-compatible access to HTTP form POST data in server-side rendering (SSR) mode. This allows code-behind using Request.Form["fieldName"] to compile and work with Blazor forms.

+
// Web Forms
+string username = Request.Form["username"];
+string[] selectedColors = Request.Form.GetValues("colors");
+if (Request.Form.Count > 0) { /* process form */ }
+
+

Supported Members

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MemberReturnsDescription
Form["key"]string?First value for the field, or null
Form.GetValues("key")string[]?All values (multi-select, checkboxes)
Form.AllKeysstring[]Names of all submitted fields
Form.CountintNumber of form fields
Form.ContainsKey("key")boolWhether a field was submitted
+

Blazor Usage

+
@inherits WebFormsPageBase
+
+@code {
+    private string _username = "";
+    private string[] _selectedColors = Array.Empty<string>();
+    private int _fieldCount = 0;
+
+    protected override void OnInitialized()
+    {
+        base.OnInitialized();
+
+        if (Request.Form.Count > 0)
+        {
+            _username = Request.Form["username"] ?? "";
+            _selectedColors = Request.Form.GetValues("colors") ?? Array.Empty<string>();
+            _fieldCount = Request.Form.Count;
+        }
+    }
+}
+
+

Rendering Mode Behavior

+ + + + + + + + + + + + + + + + + + + + + +
ModeBehavior
SSR (Static Server Rendering)Full access — wraps HttpContext.Request.Form
Interactive Blazor ServerUse <WebFormsForm> component to enable access (see below)
Non-form requests (JSON, etc.)Returns empty — exceptions caught gracefully
+

Interactive Mode: Using WebFormsForm

+

In interactive Blazor Server mode (WebSocket-based), HTTP requests do not occur, so Request.Form is initially empty. To enable form submissions and Request.Form access in interactive pages, use the <WebFormsForm> component:

+
@inherits WebFormsPageBase
+
+<WebFormsForm OnSubmit="HandleSubmit">
+    <input type="text" name="username" />
+    <input type="password" name="password" />
+    <button type="submit">Login</button>
+</WebFormsForm>
+
+@code {
+    private void HandleSubmit(FormSubmitEventArgs e)
+    {
+        // Inject form data into Request.Form shim
+        SetRequestFormData(e);
+
+        // Now Request.Form is populated with submitted values
+        string username = Request.Form["username"] ?? "";
+        string password = Request.Form["password"] ?? "";
+    }
+}
+
+

The <WebFormsForm> component captures form data via JavaScript interop and injects it into the Request.Form shim, allowing migrated code-behind to work unchanged. See WebFormsForm for detailed documentation and examples.

+

Graceful Degradation

+

Blazor Server components can run in two modes:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModeHttpContextQueryStringCookiesUrlForm
SSR / Pre-render✅ AvailableFrom HTTP requestFrom HTTP requestFrom HTTP requestFrom HTTP request
Interactive (WebSocket)❌ UnavailableParsed from NavigationManager.UriEmpty collection (warning logged)From NavigationManager.UriEmpty collection (warning logged)
+

Use the IsHttpContextAvailable guard when cookie access is critical:

+
@inherits WebFormsPageBase
+
+@code {
+    private string _theme = "default";
+
+    protected override void OnInitialized()
+    {
+        base.OnInitialized();
+
+        if (IsHttpContextAvailable)
+        {
+            // Safe — HttpContext is present
+            _theme = Request.Cookies["theme"] ?? "default";
+        }
+
+        // QueryString works in both modes (no guard needed)
+        var page = Request.QueryString["page"];
+    }
+}
+
+

Web Forms Usage

+
// Query string access
+string productId = Request.QueryString["id"];
+string category = Request.QueryString["cat"];
+
+// Cookie access
+string session = Request.Cookies["session"].Value;
+string theme = Request.Cookies["theme"]?.Value ?? "default";
+
+// URL access
+Uri currentUrl = Request.Url;
+string fullPath = Request.Url.AbsolutePath;
+
+

Blazor Usage

+
@inherits WebFormsPageBase
+
+<p>Product: @_productId</p>
+<p>URL: @_currentUrl</p>
+
+@code {
+    private string _productId = "";
+    private Uri? _currentUrl;
+    private string _sessionId = "";
+
+    protected override void OnInitialized()
+    {
+        base.OnInitialized();
+
+        // Query strings — works in both SSR and interactive modes
+        _productId = Request.QueryString["id"] ?? "";
+
+        // URL — works in both modes
+        _currentUrl = Request.Url;
+
+        // Cookies — only reliable in SSR mode
+        if (IsHttpContextAvailable)
+        {
+            _sessionId = Request.Cookies["session"] ?? "";
+        }
+    }
+}
+
+

Migration Path

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web FormsBWFC ShimNative Blazor
Request.QueryString["id"]Request.QueryString["id"]NavigationManager.Uri + parse, or [SupplyParameterFromQuery]
Request.Cookies["name"]Request.Cookies["name"]IHttpContextAccessor (SSR only)
Request.Form["field"]Request.Form["field"]EditForm with @bind
Request.Form.GetValues("field")Request.Form.GetValues("field")EditForm with multi-value binding
Request.UrlRequest.UrlNavigationManager.ToAbsoluteUri(Nav.Uri)
Request.Url.AbsolutePathRequest.Url.AbsolutePathnew Uri(Nav.Uri).AbsolutePath
+

Moving On

+

RequestShim is a migration bridge. As you refactor:

+
    +
  1. Replace QueryString with [SupplyParameterFromQuery] — Blazor's built-in attribute binds query parameters directly to component properties
  2. +
  3. Replace Form with EditForm and @bind — Blazor's form data binding replaces the traditional POST form pattern
  4. +
  5. Replace Url with NavigationManager — Inject NavigationManager and use Uri or ToAbsoluteUri()
  6. +
  7. Replace Cookies with proper state management — Use cascading parameters, ProtectedSessionStorage, or server-side services instead of cookies
  8. +
+
@* Before (migration shim) *@
+@inherits WebFormsPageBase
+@code {
+    string id = Request.QueryString["id"];
+    string username = Request.Form["username"];
+    Uri url = Request.Url;
+}
+
+@* After (native Blazor) *@
+@inject NavigationManager Nav
+
+<EditForm Model="@_model" OnSubmit="@HandleSubmit">
+    <InputText @bind-Value="_model.Username" />
+    <button type="submit">Submit</button>
+</EditForm>
+
+@code {
+    [SupplyParameterFromQuery] public string Id { get; set; }
+    Uri url => Nav.ToAbsoluteUri(Nav.Uri);
+
+    private FormModel _model = new();
+
+    private void HandleSubmit()
+    {
+        // Process _model directly — no Request.Form needed
+    }
+}
+
+

See Also

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/UtilityFeatures/ServerShim/index.html b/site/UtilityFeatures/ServerShim/index.html new file mode 100644 index 000000000..f734da95c --- /dev/null +++ b/site/UtilityFeatures/ServerShim/index.html @@ -0,0 +1,6186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Server & Path Resolution - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Server & Path Resolution Shim

+

The ServerShim class provides compatibility with the ASP.NET Web Forms Server object (HttpServerUtility). It wraps ASP.NET Core's IWebHostEnvironment and System.Net.WebUtility so that migrated code-behind using Server.MapPath(), Server.HtmlEncode(), and Server.UrlEncode() compiles and works correctly. The ResolveUrl() and ResolveClientUrl() methods on WebFormsPageBase handle virtual path and .aspx extension stripping.

+

Original Microsoft implementation: https://docs.microsoft.com/en-us/dotnet/api/system.web.httpserverutility?view=netframework-4.8

+

Background

+

In ASP.NET Web Forms, the Server object was available on every Page and UserControl:

+
// Web Forms code-behind
+string uploadDir = Server.MapPath("~/uploads");
+string safe = Server.HtmlEncode(userInput);
+string encoded = Server.UrlEncode(searchTerm);
+string url = ResolveUrl("~/Products.aspx");
+
+

These utilities handled virtual-to-physical path mapping, HTML/URL encoding, and URL resolution — all common operations in Web Forms applications.

+

Blazor Implementation

+

In Blazor, these concerns are split across several APIs. The ServerShim bridges the gap by:

+
    +
  1. MapPath("~/path") — Resolves ~/ to IWebHostEnvironment.WebRootPath (wwwroot) and other paths to ContentRootPath
  2. +
  3. HtmlEncode() / HtmlDecode() — Delegates to System.Net.WebUtility
  4. +
  5. UrlEncode() / UrlDecode() — Delegates to System.Net.WebUtility
  6. +
+

The ResolveUrl() and ResolveClientUrl() methods live on WebFormsPageBase and:

+
    +
  1. Strip ~/ prefix — Converts ~/path to /path
  2. +
  3. Strip .aspx extension — Converts /path.aspx to /path
  4. +
+

Availability

+

ServerShim is automatically available when your page inherits from WebFormsPageBase. The IWebHostEnvironment dependency is injected automatically:

+
@inherits WebFormsPageBase
+
+@code {
+    private void Example()
+    {
+        // All work exactly like Web Forms
+        string path = Server.MapPath("~/uploads");
+        string safe = Server.HtmlEncode("<script>alert('xss')</script>");
+        string url = ResolveUrl("~/Products.aspx");
+    }
+}
+
+

Web Forms Usage

+
// Path resolution
+string uploadDir = Server.MapPath("~/uploads");
+string configPath = Server.MapPath("/App_Data/config.xml");
+
+// HTML encoding
+string safe = Server.HtmlEncode(userInput);
+string decoded = Server.HtmlDecode(encodedHtml);
+
+// URL encoding
+string param = Server.UrlEncode(searchTerm);
+string original = Server.UrlDecode(encodedParam);
+
+// URL resolution
+string productUrl = ResolveUrl("~/Products.aspx");
+string imageUrl = ResolveClientUrl("~/images/logo.png");
+
+

Blazor Usage

+
@inherits WebFormsPageBase
+
+<img src="@ResolveUrl("~/images/logo.png")" alt="Logo" />
+<a href="@ResolveUrl("~/Products.aspx")">Products</a>
+
+@code {
+    private string _uploadDir = "";
+    private string _safeHtml = "";
+
+    protected override void OnInitialized()
+    {
+        base.OnInitialized();
+
+        // ~/uploads → {WebRootPath}/uploads
+        _uploadDir = Server.MapPath("~/uploads");
+
+        // HTML encoding for safe output
+        _safeHtml = Server.HtmlEncode("<script>alert('xss')</script>");
+    }
+
+    private void BuildSearchUrl()
+    {
+        string term = Server.UrlEncode("blazor web forms");
+        // ~/Products.aspx → /Products
+        string url = ResolveUrl($"~/Search.aspx?q={term}");
+        Response.Redirect(url);
+    }
+}
+
+

Path Resolution

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
InputMapPath ResultNotes
"~/uploads"{WebRootPath}/uploads~/ maps to wwwroot
"~/images/logo.png"{WebRootPath}/images/logo.pngFile within wwwroot
"/App_Data/config.xml"{ContentRootPath}/App_Data/config.xmlNon-tilde paths use content root
"" or null{ContentRootPath}Empty returns content root
+

URL Transformations

+ + + + + + + + + + + + + + + + + + + + + + + + + +
InputResolveUrl ResultNotes
"~/Products.aspx""/Products"~/ stripped, .aspx removed
"~/images/logo.png""/images/logo.png"Non-aspx extensions preserved
"/Products""/Products"Already-clean URLs pass through
+

Migration Path

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Web FormsBWFC ShimNative Blazor
Server.MapPath("~/img")Server.MapPath("~/img")Inject IWebHostEnvironment
Server.HtmlEncode(text)Server.HtmlEncode(text)WebUtility.HtmlEncode(text)
Server.HtmlDecode(text)Server.HtmlDecode(text)WebUtility.HtmlDecode(text)
Server.UrlEncode(text)Server.UrlEncode(text)WebUtility.UrlEncode(text)
Server.UrlDecode(text)Server.UrlDecode(text)WebUtility.UrlDecode(text)
ResolveUrl("~/page.aspx")ResolveUrl("~/page.aspx")Use NavigationManager.ToAbsoluteUri()
ResolveClientUrl("~/page.aspx")ResolveClientUrl("~/page.aspx")Use NavigationManager.ToAbsoluteUri()
+

Moving On

+

ServerShim is a migration bridge. As you refactor:

+
    +
  1. Replace MapPath — Inject IWebHostEnvironment directly and use env.WebRootPath or env.ContentRootPath
  2. +
  3. Replace encoding helpers — Use System.Net.WebUtility.HtmlEncode() and UrlEncode() directly
  4. +
  5. Replace ResolveUrl — Use NavigationManager.ToAbsoluteUri() and remove .aspx references from your routes
  6. +
+
@* Before (migration shim) *@
+@inherits WebFormsPageBase
+@code {
+    string path = Server.MapPath("~/uploads");
+    string url = ResolveUrl("~/Products.aspx");
+}
+
+@* After (native Blazor) *@
+@inject IWebHostEnvironment Env
+@inject NavigationManager Nav
+@code {
+    string path = Path.Combine(Env.WebRootPath, "uploads");
+    string url = Nav.ToAbsoluteUri("/Products").ToString();
+}
+
+

See Also

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/UtilityFeatures/WebFormsForm/index.html b/site/UtilityFeatures/WebFormsForm/index.html new file mode 100644 index 000000000..3ad29c7b9 --- /dev/null +++ b/site/UtilityFeatures/WebFormsForm/index.html @@ -0,0 +1,6295 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WebFormsForm - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

WebFormsForm

+

The WebFormsForm component wraps HTML form elements and provides Request.Form support in interactive Blazor Server mode. It bridges the gap between traditional Web Forms form submission (HTTP POST with Request.Form) and modern interactive Blazor (WebSocket-based with no HTTP POST).

+

Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.htmlcontrols.htmlform?view=netframework-4.8

+

Background

+

In ASP.NET Web Forms, form submissions were HTTP POST requests, and Request.Form was populated from the POST body:

+
// Web Forms code-behind
+protected void btnSubmit_Click(object sender, EventArgs e)
+{
+    string username = Request.Form["username"];
+    string[] colors = Request.Form.GetValues("colors");
+}
+
+

In interactive Blazor (WebSocket-based), there is no HTTP POST — all communication is bidirectional WebSocket. This means Request.Form would be empty. The <WebFormsForm> component captures form data via JavaScript interop and injects it into the Request.Form shim, enabling migrated code-behind to work unchanged.

+

Use Cases

+
    +
  • Interactive Blazor Server pages using <WebFormsForm> to enable Request.Form access
  • +
  • SSR pages that can continue using standard <form> (native HTTP POST, Request.Form works natively)
  • +
  • Gradual migration from Web Forms forms to Blazor EditForm components
  • +
+

Rendering Mode Behavior

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ModeApproachRequest.Form Support
SSR (Static Server Rendering)Use standard <form> with [ExcludeFromInteractiveRouting]✅ Native — HTTP POST populates Request.Form directly
Interactive Blazor ServerUse <WebFormsForm> component✅ JS interop captures form data, injected into Request.Form shim
Target state (modern Blazor)Use EditForm + @bind✅ Typed binding — no need for Request.Form
+

The component auto-detects the rendering mode and behaves appropriately.

+

Parameters

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDefaultDescription
MethodFormMethodPostHTTP method (Get or Post)
Actionstring?nullForm action URL; null submits to the current page
OnSubmitEventCallback<FormSubmitEventArgs>Callback fired when the form is submitted; receives FormSubmitEventArgs containing FormData (name-value pairs)
ChildContentRenderFragment?Form content (input fields, buttons, etc.)
(unmatched)Any additional HTML attributes pass through to the <form> element (e.g., enctype, data-*)
+

Syntax Comparison

+
+
+
+
<form runat="server">
+    <asp:TextBox ID="txtUsername" runat="server" />
+    <asp:TextBox ID="txtPassword" TextMode="Password" runat="server" />
+    <asp:CheckBox ID="chkRemember" runat="server" />
+    <asp:Button ID="btnSubmit" runat="server" OnClick="btnSubmit_Click" Text="Login" />
+</form>
+
+
// Code-behind
+protected void btnSubmit_Click(object sender, EventArgs e)
+{
+    string username = Request.Form["txtUsername"];
+    string password = Request.Form["txtPassword"];
+    string remember = Request.Form["chkRemember"];
+
+    // Process login...
+}
+
+
+
+
<WebFormsForm OnSubmit="HandleSubmit">
+    <input type="text" name="txtUsername" />
+    <input type="password" name="txtPassword" />
+    <input type="checkbox" name="chkRemember" />
+    <button type="submit">Login</button>
+</WebFormsForm>
+
+@code {
+    private void HandleSubmit(FormSubmitEventArgs e)
+    {
+        SetRequestFormData(e);
+
+        string username = Request.Form["txtUsername"];
+        string password = Request.Form["txtPassword"];
+        string remember = Request.Form["chkRemember"];
+
+        // Process login...
+    }
+}
+
+
+
+
+

Migration Path

+
    +
  1. Phase 1 — SSR with standard forms (quickest path)
  2. +
  3. Keep existing <form> elements
  4. +
  5. Mark page with [ExcludeFromInteractiveRouting] to stay in SSR mode
  6. +
  7. Request.Form works natively via HTTP POST
  8. +
  9. +

    No code changes needed

    +
  10. +
  11. +

    Phase 2 — Interactive with WebFormsForm (gradual adoption)

    +
  12. +
  13. Remove [ExcludeFromInteractiveRouting]
  14. +
  15. Replace <form> with <WebFormsForm>
  16. +
  17. Add OnSubmit callback
  18. +
  19. Existing code-behind logic using Request.Form continues to work
  20. +
  21. +

    Minimal rewrite

    +
  22. +
  23. +

    Phase 3 — Modern Blazor (target state)

    +
  24. +
  25. Replace <WebFormsForm> with <EditForm>
  26. +
  27. Use @bind for two-way data binding
  28. +
  29. Eliminate Request.Form shim entirely
  30. +
  31. Full type safety and Blazor best practices
  32. +
+

Example: Login Form

+
+
+
+
<!-- LoginPage.aspx -->
+<form runat="server">
+    <div>
+        <label for="username">Username:</label>
+        <asp:TextBox ID="username" runat="server" />
+    </div>
+    <div>
+        <label for="password">Password:</label>
+        <asp:TextBox ID="password" TextMode="Password" runat="server" />
+    </div>
+    <div>
+        <asp:CheckBox ID="rememberMe" runat="server" Text="Remember me" />
+    </div>
+    <asp:Button ID="btnLogin" runat="server" OnClick="LoginClick" Text="Login" />
+    <asp:Label ID="lblError" runat="server" ForeColor="Red" />
+</form>
+
+
// LoginPage.aspx.cs
+public partial class LoginPage : Page
+{
+    protected void LoginClick(object sender, EventArgs e)
+    {
+        string username = Request.Form["username"];
+        string password = Request.Form["password"];
+        string remember = Request.Form["rememberMe"];
+
+        if (ValidateCredentials(username, password))
+        {
+            FormsAuthentication.SetAuthCookie(username, remember == "on");
+            Response.Redirect("~/Default.aspx");
+        }
+        else
+        {
+            lblError.Text = "Invalid credentials";
+        }
+    }
+
+    private bool ValidateCredentials(string username, string password)
+    {
+        // Validation logic...
+        return true;
+    }
+}
+
+
+
+
<!-- Login.razor -->
+@page "/login"
+@inherits WebFormsPageBase
+@inject NavigationManager Nav
+@inject AuthenticationStateProvider AuthStateProvider
+
+<div class="login-form">
+    <h2>Login</h2>
+
+    <WebFormsForm OnSubmit="LoginClick">
+        <div>
+            <label for="username">Username:</label>
+            <input type="text" id="username" name="username" required />
+        </div>
+        <div>
+            <label for="password">Password:</label>
+            <input type="password" id="password" name="password" required />
+        </div>
+        <div>
+            <input type="checkbox" id="rememberMe" name="rememberMe" />
+            <label for="rememberMe">Remember me</label>
+        </div>
+        <button type="submit">Login</button>
+    </WebFormsForm>
+
+    @if (!string.IsNullOrEmpty(_errorMessage))
+    {
+        <p style="color: red;">@_errorMessage</p>
+    }
+</div>
+
+@code {
+    private string _errorMessage = "";
+
+    private async Task LoginClick(FormSubmitEventArgs e)
+    {
+        SetRequestFormData(e);
+
+        string username = Request.Form["username"];
+        string password = Request.Form["password"];
+        string remember = Request.Form["rememberMe"];
+
+        if (ValidateCredentials(username, password))
+        {
+            // Sign in user...
+            Nav.NavigateTo("/", forceLoad: true);
+        }
+        else
+        {
+            _errorMessage = "Invalid credentials";
+        }
+    }
+
+    private bool ValidateCredentials(string username, string password)
+    {
+        // Validation logic...
+        return true;
+    }
+}
+
+
+
+
+

How It Works

+
    +
  1. Form Submission — User submits the form via HTML submit button or programmatic submit
  2. +
  3. JavaScript Interop<WebFormsForm> captures form data (all input fields) via FormData API
  4. +
  5. OnSubmit Callback — Captured data is passed to the OnSubmit event callback as FormSubmitEventArgs
  6. +
  7. SetRequestFormData — Call SetRequestFormData(e) to inject the captured data into the Request.Form shim
  8. +
  9. Access via Request.Form — Code-behind logic using Request.Form["fieldName"] works as expected
  10. +
+

Dual-Mode Support

+

SSR Pages: +

@page "/form-page"
+@attribute [ExcludeFromInteractiveRouting]
+
+<form method="post" action="/form-page">
+    <input type="text" name="username" />
+    <button type="submit">Submit</button>
+</form>
+

+

In SSR mode, the form submits via HTTP POST and Request.Form is automatically populated. No <WebFormsForm> or JavaScript interop needed.

+

Interactive Pages: +

@page "/form-page"
+
+<WebFormsForm OnSubmit="HandleSubmit">
+    <input type="text" name="username" />
+    <button type="submit">Submit</button>
+</WebFormsForm>
+

+

In interactive mode, <WebFormsForm> captures data and injects it into Request.Form.

+

Notes

+
    +
  • HTML Attributes — Unmatched HTML attributes (like enctype, data-*, autocomplete) are passed through to the rendered <form> element
  • +
  • File Uploads — When using enctype="multipart/form-data", ensure file inputs have name attributes so they are captured
  • +
  • Default Action — If Action is null, the form submits to the current page (no full-page reload in interactive mode)
  • +
  • Event Bubbling — The form does not automatically validate child components; add validation as needed in the OnSubmit callback
  • +
  • Accessibility — Use standard form semantics (<label>, for attributes, required, aria-*) for accessibility
  • +
+ +
    +
  • Request Shim — Details on Request.QueryString, Request.Cookies, and Request.Form access
  • +
  • WebFormsPage — Base page class providing Request, Response, and other Web Forms compatibility features
  • +
  • EditForm — Modern Blazor form component (target state)
  • +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/cli/index.html b/site/cli/index.html new file mode 100644 index 000000000..8b377d82c --- /dev/null +++ b/site/cli/index.html @@ -0,0 +1,6279 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Overview - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

WebForms to Blazor CLI Tool

+

The webforms-to-blazor CLI is a powerful command-line tool that automates the first phase of your Web Forms to Blazor migration. It performs deterministic, pattern-based transformations on your Web Forms markup and code-behind to produce Blazor-ready code.

+

What It Does

+

This tool reduces manual migration effort by:

+
    +
  • Removing boilerplate Web Forms directives and syntax
  • +
  • Converting ASP.NET server controls to BWFC components
  • +
  • Replacing Web Forms expressions with Blazor syntax
  • +
  • Extracting code patterns and flagging them with TODO comments for Copilot L2 automation
  • +
  • Scaffolding a new Blazor project structure with shims and services
  • +
+

The tool processes .aspx, .ascx, and .master files in a fixed sequence, ensuring each transformation builds on the previous one correctly.

+

Installation

+

As a Global Tool

+
dotnet tool install --global Fritz.WebFormsToBlazor
+
+

From Source

+
cd src/BlazorWebFormsComponents.Cli
+dotnet pack
+dotnet tool install --global --add-source ./bin/Release Fritz.WebFormsToBlazor
+
+

Verify Installation

+
webforms-to-blazor --help
+
+

Quick Start

+

Convert a Single File

+
webforms-to-blazor migrate --input ProductCard.ascx --output ./BlazorComponents
+
+

Convert a Whole Project

+
webforms-to-blazor migrate --input ./MyWebFormsProject --output ./MyBlazorProject
+
+

The tool will: +1. Scan all .aspx, .ascx, and .master files +2. Apply 33 transforms in sequence +3. Generate a migration report +4. Scaffold supporting files (Program.cs, shims, handlers)

+

Two Commands

+

migrate — Full Project Migration

+

Transforms an entire Web Forms project to Blazor with scaffolding.

+
webforms-to-blazor migrate \
+  --input ./MyWebFormsProject \
+  --output ./MyBlazorProject \
+  --database SqlServer \
+  --scaffold
+
+

Key Options:

+
    +
  • --input <path> — Web Forms project root (required)
  • +
  • --output <path> — Blazor output directory (required)
  • +
  • --database <provider> — SqlServer, Sqlite, Postgres, Oracle (scaffolds appropriate connection setup)
  • +
  • --scaffold — Generate Program.cs, _Imports.razor, App.razor, and shims
  • +
  • --dry-run — Preview changes without writing files
  • +
+

Output: +- Converted .razor files +- Converted .razor.cs code-behind +- Generated Program.cs with shim registration +- Migration report (migration-report.json)

+

convert — File-Level Transformation

+

Converts individual files without scaffolding. Useful for incremental migrations.

+
webforms-to-blazor convert \
+  --input ./Controls/MyControl.ascx \
+  --output ./Components/MyControl.razor
+
+

Key Options:

+
    +
  • --input <path> — Single .ascx or .aspx file (required)
  • +
  • --output <path> — Output file path
  • +
  • --dry-run — Preview transformation
  • +
+

Transform Categories

+

The tool applies 33 transforms organized in three groups:

+
    +
  1. Directives (5) — Page, Master, Control, Register, Import directives
  2. +
  3. Markup (19) — Controls, expressions, templates, data binding
  4. +
  5. Code-Behind (9) — Using statements, base classes, lifecycle, event handlers
  6. +
+

See Transform Reference for complete details on each transform, including before/after examples.

+

TODO Comments and L2 Automation

+

The tool inserts TODO comments with standardized category slugs so Copilot L2 skills can automatically follow up on migration work:

+
// TODO(bwfc-lifecycle): Page_Load → OnInitializedAsync
+// TODO(bwfc-ispostback): Review IsPostBack guard for Blazor patterns
+// TODO(bwfc-session-state): SessionShim auto-wired via [Inject]
+
+

See TODO Categories for the complete list of 13 categories and how L2 automation uses them.

+

Migration Report

+

After migration, the tool generates a migration-report.json with:

+
    +
  • File-by-file transformation summary
  • +
  • Manual work items flagged by category
  • +
  • Severity levels (Info, Warning, Error)
  • +
  • Precise file locations and line numbers
  • +
+

See Report Format for schema and examples.

+

Limitations & Next Steps

+

This tool handles Level 1 transformations only:

+
    +
  • ✅ Markup and directive conversion
  • +
  • ✅ Pattern detection and guidance
  • +
  • ✅ Boilerplate removal
  • +
  • ❌ Logic rewriting (use Copilot L2 skills for this)
  • +
+

After running the CLI:

+
    +
  1. Review TODO comments — each one points to a specific migration pattern
  2. +
  3. Run Copilot L2 skills — automated follow-up transforms for complex patterns
  4. +
  5. Build and test — verify your Blazor project compiles and runs
  6. +
  7. Manual tweaks — business logic, styling, third-party integrations
  8. +
+

Example: Full Migration Workflow

+
# 1. Scan and transform
+webforms-to-blazor migrate \
+  --input ./MyApp.Web \
+  --output ./MyApp.Blazor \
+  --database SqlServer \
+  --scaffold
+
+# 2. Review migration report
+cat MyApp.Blazor/migration-report.json | jq '.manualItems[] | select(.severity == "Error")'
+
+# 3. Build and identify missing pieces
+cd MyApp.Blazor
+dotnet build
+
+# 4. Use Copilot CLI for L2 automation
+copilot /webforms-migration
+
+

Next Steps

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/cli/report/index.html b/site/cli/report/index.html new file mode 100644 index 000000000..fd64599da --- /dev/null +++ b/site/cli/report/index.html @@ -0,0 +1,6929 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Report Format - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

Migration Report Format

+

After running the CLI tool, a migration-report.json file is generated with detailed information about the transformation. This document describes the report schema and how to interpret it.

+

Report Structure

+

The report is a JSON document with the following top-level fields:

+
{
+  "migrationDate": "2026-04-02T15:30:45Z",
+  "sourceProject": "/home/user/MyApp.Web",
+  "outputProject": "/home/user/MyApp.Blazor",
+  "projectName": "MyApp.Blazor",
+  "projectFramework": "net8.0",
+  "toolVersion": "1.0.0",
+  "summary": {
+    "totalFiles": 45,
+    "filesProcessed": 42,
+    "filesFailed": 0,
+    "filesSkipped": 3,
+    "totalTransforms": 1247,
+    "totalManualItems": 156,
+    "criticalIssues": 5,
+    "warnings": 18,
+    "infos": 133
+  },
+  "files": [ /* array of file results */ ],
+  "manualItems": [ /* array of manual work items */ ],
+  "categories": [ /* grouped manual items by TODO category */ ]
+}
+
+
+

Summary Section

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
migrationDatestring (ISO 8601)When the migration was run
sourceProjectstringPath to original Web Forms project
outputProjectstringPath to new Blazor project
projectNamestringName of the generated Blazor project
projectFrameworkstringTarget framework (e.g., net8.0)
toolVersionstringCLI tool version
summary.totalFilesintegerTotal .aspx, .ascx, .master files found
summary.filesProcessedintegerFiles successfully converted
summary.filesFailedintegerFiles with conversion errors
summary.filesSkippedintegerFiles not processed (e.g., excluded)
summary.totalTransformsintegerTotal transforms applied across all files
summary.totalManualItemsintegerNumber of TODO comments inserted
summary.criticalIssuesintegerCount of severity="Error" items
summary.warningsintegerCount of severity="Warning" items
summary.infosintegerCount of severity="Info" items
+
+

Files Array

+

Each file in the files array represents a processed source file:

+
{
+  "sourceFile": "Pages/Product.aspx",
+  "outputFile": "Pages/Product.razor",
+  "fileType": "Page",
+  "status": "Success",
+  "linesAdded": 12,
+  "linesRemoved": 8,
+  "transformsApplied": [
+    {
+      "name": "PageDirective",
+      "count": 1,
+      "description": "Converted <%@ Page %> to @page"
+    },
+    {
+      "name": "ExpressionTransform",
+      "count": 5,
+      "description": "Converted <%: %> expressions to @()"
+    }
+  ],
+  "manualItems": [
+    {
+      "line": 45,
+      "category": "bwfc-datasource",
+      "severity": "Warning",
+      "message": "DataSourceID='ProductSource' → wire Items binding and data service"
+    }
+  ]
+}
+
+

File Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
sourceFilestringRelative path in Web Forms project
outputFilestringRelative path in Blazor project
fileTypestring"Page", "Control", "Master"
statusstring"Success", "Warning", "Error", "Skipped"
linesAddedintegerNew lines in output (includes TODO comments)
linesRemovedintegerLines removed during conversion
transformsAppliedarrayList of transforms run on this file
manualItemsarrayTODO comments with line numbers
+
+

ManualItem Schema

+

Each manualItem represents a TODO comment that requires manual follow-up:

+
{
+  "file": "Pages/Product.aspx",
+  "line": 45,
+  "column": 0,
+  "category": "bwfc-datasource",
+  "severity": "Warning",
+  "message": "DataSourceID attribute detected — implement IProductDataService and wire Items binding",
+  "suggestion": "// TODO(bwfc-datasource): Replace DataSourceID with Items=\"@ProductList\" and create data service",
+  "relatedTransforms": ["DataSourceIdTransform", "SelectMethodTransform"]
+}
+
+

ManualItem Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
filestringRelative file path
lineintegerLine number in output file
columnintegerColumn number (0-based)
categorystringTODO category slug (e.g., bwfc-lifecycle)
severitystring"Info", "Warning", "Error"
messagestringHuman-readable description
suggestionstringCode suggestion or TODO template
relatedTransformsarrayTransforms that created this item
+

Severity Levels

+ + + + + + + + + + + + + + + + + + + + + + + + + +
SeverityMeaningAction
ErrorBlocking issue — project likely won't compileFix immediately
WarningCode will compile but behavior may differReview before building
InfoGuidance comment — migrate at your paceReference during L2 automation
+
+

Categories Summary

+

The categories section groups manual items by TODO category:

+
{
+  "categories": [
+    {
+      "category": "bwfc-lifecycle",
+      "count": 8,
+      "severity": "Warning",
+      "files": [
+        "Pages/Product.aspx",
+        "Pages/Checkout.aspx"
+      ],
+      "description": "Page lifecycle methods need conversion to Blazor component lifecycle"
+    },
+    {
+      "category": "bwfc-datasource",
+      "count": 12,
+      "severity": "Info",
+      "files": [
+        "Pages/Product.aspx",
+        "Pages/Search.aspx",
+        "Controls/ProductCard.ascx"
+      ],
+      "description": "Data binding patterns need to be replaced with component data services"
+    }
+  ]
+}
+
+

Category Summary Fields

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldTypeDescription
categorystringTODO category slug
countintegerNumber of items in this category
severitystringHighest severity in this category
filesarrayFiles affected (unique list)
descriptionstringWhat needs to be done
+
+

Example Report Output

+

Minimal Success Report

+
{
+  "migrationDate": "2026-04-02T14:30:00Z",
+  "sourceProject": "./MyApp.Web",
+  "outputProject": "./MyApp.Blazor",
+  "projectName": "MyApp.Blazor",
+  "toolVersion": "1.0.0",
+  "summary": {
+    "totalFiles": 5,
+    "filesProcessed": 5,
+    "filesFailed": 0,
+    "filesSkipped": 0,
+    "totalTransforms": 87,
+    "totalManualItems": 12,
+    "criticalIssues": 0,
+    "warnings": 3,
+    "infos": 9
+  },
+  "files": [
+    {
+      "sourceFile": "Default.aspx",
+      "outputFile": "Pages/Index.razor",
+      "fileType": "Page",
+      "status": "Success",
+      "linesAdded": 3,
+      "linesRemoved": 2,
+      "transformsApplied": [
+        {"name": "PageDirective", "count": 1},
+        {"name": "ExpressionTransform", "count": 6},
+        {"name": "AspPrefixTransform", "count": 4}
+      ],
+      "manualItems": [
+        {
+          "line": 12,
+          "category": "bwfc-datasource",
+          "severity": "Info",
+          "message": "Review data binding pattern"
+        }
+      ]
+    }
+  ],
+  "categories": [
+    {
+      "category": "bwfc-datasource",
+      "count": 4,
+      "severity": "Info",
+      "files": ["Default.aspx"]
+    }
+  ]
+}
+
+

Report with Errors

+
{
+  "summary": {
+    "totalFiles": 10,
+    "filesProcessed": 8,
+    "filesFailed": 2,
+    "filesSkipped": 0,
+    "totalManualItems": 45,
+    "criticalIssues": 3,
+    "warnings": 15,
+    "infos": 27
+  },
+  "files": [
+    {
+      "sourceFile": "Controls/CustomControl.ascx",
+      "outputFile": "Components/CustomControl.razor",
+      "fileType": "Control",
+      "status": "Error",
+      "statusMessage": "Parser error on line 34: unmatched closing tag",
+      "manualItems": [
+        {
+          "line": 34,
+          "severity": "Error",
+          "category": "bwfc-general",
+          "message": "Malformed markup — review original file for syntax errors"
+        }
+      ]
+    }
+  ]
+}
+
+
+

How to Use the Report

+

1. Review Critical Issues First

+
# Filter for errors (blocks compilation)
+jq '.manualItems[] | select(.severity == "Error")' migration-report.json
+
+# Count by category
+jq 'group_by(.category) | map({category: .[0].category, count: length})' \
+  <(jq '.manualItems[] | select(.severity == "Error")' migration-report.json)
+
+

2. Check Summary Statistics

+
# Print summary
+jq '.summary' migration-report.json
+
+

3. Find Work by Category

+
# All lifecycle TODOs
+jq '.manualItems[] | select(.category == "bwfc-lifecycle")' migration-report.json
+
+# All warnings grouped by category
+jq 'group_by(.category) | map({category: .[0].category, count: length})' \
+  <(jq '.manualItems[] | select(.severity == "Warning")' migration-report.json)
+
+

4. Build a Worklist

+
# Export TODOs for a specific file
+jq '.manualItems[] | select(.file == "Product.aspx")' migration-report.json | \
+  jq -s 'sort_by(.line)' | \
+  jq '.[] | "\(.line): [\(.severity)] \(.category) - \(.message)"'
+
+

5. Track Automation Progress

+
# Before L2 automation
+jq '.summary' migration-report-before.json
+
+# After L2 automation (lifecycle conversion)
+jq '.summary' migration-report-after-lifecycle.json
+
+# Calculate improvement
+
+
+

Report Interpretation Guide

+

What Errors Mean

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ErrorCauseResolution
Parser error: unmatched closing tagMalformed markup in sourceReview original .aspx/.ascx file for syntax errors
File not foundReferenced include or code-behind missingCheck project file references
Unsupported directiveWeb Forms-specific directive without Blazor equivalentManual conversion needed
+

What Warnings Mean

+ + + + + + + + + + + + + + + + + + + + + + + + + +
WarningMeaningAction
DataSourceID detectedNeeds data service wiringImplement L2 datasource automation
ViewState usage foundState pattern conversion neededCreate component fields or parameters
Complex IsPostBack guardHard to unwrap automaticallyReview unwrapped code, may need manual adjustment
+

What Infos Mean

+ + + + + + + + + + + + + + + + + + + + +
InfoMeaningAction
TODO comment insertedGuidance for developerReview alongside code during L2 automation
Pattern detectedRecognized Web Forms patternL2 skill can automate this
+
+

Example Workflow: Using the Report to Plan L2 Automation

+
#!/bin/bash
+
+# 1. Run initial migration
+webforms-to-blazor migrate --input ./MyApp.Web --output ./MyApp.Blazor
+
+# 2. Check what needs manual work
+echo "=== CRITICAL ISSUES ==="
+jq '.summary.criticalIssues' MyApp.Blazor/migration-report.json
+
+# 3. Fix errors first
+if [ $(jq '.summary.criticalIssues' MyApp.Blazor/migration-report.json) -gt 0 ]; then
+  echo "Fixing critical issues..."
+  jq '.manualItems[] | select(.severity == "Error")' MyApp.Blazor/migration-report.json
+fi
+
+# 4. Plan L2 passes by category count
+echo "=== WORK BY CATEGORY ==="
+jq '.categories | sort_by(-(.count)) | .[] | "\(.count) x \(.category)"' MyApp.Blazor/migration-report.json
+
+# 5. Run L2 for high-impact categories
+# (Use Copilot L2 skills for each category)
+copilot /webforms-migration --focus bwfc-lifecycle
+copilot /webforms-migration --focus bwfc-datasource
+copilot /webforms-migration --focus bwfc-validation
+
+# 6. Generate updated report
+webforms-to-blazor migrate --input ./MyApp.Web --output ./MyApp.Blazor --dry-run > migration-report-updated.json
+
+
+

Next Steps

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/cli/todo-conventions/index.html b/site/cli/todo-conventions/index.html new file mode 100644 index 000000000..e63637dda --- /dev/null +++ b/site/cli/todo-conventions/index.html @@ -0,0 +1,6427 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + TODO Categories - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + + + + + +
+ +
+ + + + + + + + +

TODO Categories & L2 Automation

+

The CLI tool inserts standardized TODO comments throughout your code. Each TODO has a category slug in the format TODO(bwfc-category). Copilot L2 skills use these slugs to automatically locate and convert patterns.

+

Category Reference

+

1. bwfc-general

+

Scope: Miscellaneous Web Forms patterns without a more specific category
+Usage: Catch-all for general migration guidance
+Example: +

// TODO(bwfc-general): Event handlers (Button_Click, etc.) → convert to Blazor event callbacks
+protected void SubmitButton_Click(object sender, EventArgs e)
+{
+    // handle click
+}
+

+

L2 Automation: +- Convert event handler signatures from (object, EventArgs) to parameterless or typed callbacks +- Add EventCallback<T> bindings where appropriate

+
+

2. bwfc-lifecycle

+

Scope: Page lifecycle methods and initialization patterns
+Usage: Marks Page_Load, Page_Init, Page_PreRender, etc.
+Example: +

// TODO(bwfc-lifecycle): Page_Load / Page_Init → OnInitializedAsync / OnParametersSetAsync
+protected void Page_Load(object sender, EventArgs e)
+{
+    LoadProductList();
+}
+
+// TODO(bwfc-lifecycle): Page_PreRender → OnAfterRenderAsync
+protected void Page_PreRender(object sender, EventArgs e)
+{
+    UpdateStatus();
+}
+

+

L2 Automation: +- Convert Page_Load to OnInitializedAsync +- Convert Page_Init to component constructor or OnInitializedAsync +- Convert Page_PreRender to OnAfterRenderAsync +- Wrap async work in try/catch for error handling +- Add StateHasChanged() calls where needed

+
+

3. bwfc-ispostback

+

Scope: IsPostBack detection and guard patterns
+Usage: Marks if (!IsPostBack) or if (IsPostBack == false) guards
+Example: +

// TODO(bwfc-ispostback): IsPostBack guard — review for Blazor
+if (!IsPostBack)
+{
+    LoadInitialData();
+}
+

+

L2 Automation: +- Remove unnecessary IsPostBack checks (Blazor components initialize once) +- Extract postback-only logic into separate methods +- Convert event-driven patterns to component state management

+
+

4. bwfc-viewstate

+

Scope: ViewState dictionary access
+Usage: Marks ViewState["key"] patterns
+Example: +

// TODO(bwfc-viewstate): ViewState usage → component [Parameter] or private fields
+ViewState["CurrentPage"] = 1;
+int page = (int)(ViewState["CurrentPage"] ?? 0);
+

+

L2 Automation: +- Replace ViewState["key"] with private component fields +- Convert persisted state to component [Parameter] or cascading parameters +- For complex state, suggest Scoped services or ProtectedSessionStorage

+
+

5. bwfc-session-state

+

Scope: Session and Cache dictionary access
+Usage: Marks Session["key"] and Cache["key"] patterns
+Example: +

// --- Session State Migration ---
+// TODO(bwfc-session-state): SessionShim auto-wired via [Inject] — Session["CartId"] calls compile against the shim's indexer.
+// Session keys found: CartId
+// Options:
+//   (1) ProtectedSessionStorage
+//   (2) Scoped service via DI
+//   (3) Cascading parameter from root-level state provider
+string cartId = Session["CartId"];
+

+

L2 Automation: +- Map identified Session keys to scoped services +- Create session state provider classes with typed properties +- Wire up ProtectedSessionStorage for critical session data +- Add guidance on distributed caching alternatives for Cache patterns

+
+

6. bwfc-navigation

+

Scope: Navigation and redirection
+Usage: Marks Response.Redirect(), Server.Transfer(), URL patterns
+Example: +

// TODO(bwfc-navigation): Response.Redirect → NavigationManager.NavigateTo
+Response.Redirect("~/checkout");
+

+

L2 Automation: +- Convert Response.Redirect() to NavigationManager.NavigateTo() +- Convert Server.Transfer() to component navigation or redirect +- Clean up ~/ paths to absolute routes

+
+

7. bwfc-datasource

+

Scope: Data binding and data source controls
+Usage: Marks DataBind(), DataSourceID, DataSource properties, and data source controls
+Example: +

// TODO(bwfc-datasource): Data binding (DataBind, DataSource) → component parameters or OnInitialized
+// SQL/LINQ data source patterns → implement IDataService and wire Items binding
+ProductGrid.DataBind();
+
+// TODO(bwfc-datasource): Implement IProductsDataService to replace SqlDataSource
+<GridView Items="@ProductsData" />
+

+

L2 Automation: +- Remove DataBind() calls (Blazor re-renders automatically) +- Create IProductsDataService or similar DI service +- Replace DataSourceID with Items="@ProductList" binding +- Add OnInitializedAsync data loading logic

+
+

8. bwfc-identity

+

Scope: Identity, authentication, and role-based access
+Usage: Marks LoginView, RoleGroups, Roles attributes
+Example: +

<!-- Input -->
+<asp:LoginView runat="server">
+  <RoleGroups>
+    <asp:RoleGroup Roles="Admin">
+      <LoggedInTemplate>Admin panel</LoggedInTemplate>
+    </asp:RoleGroup>
+  </RoleGroups>
+</asp:LoginView>
+
+<!-- Output -->
+@* TODO(bwfc-identity): Convert RoleGroups to policy-based AuthorizeView *@
+<AuthorizeView Policy="IsAdmin">
+  <Authorized>Admin panel</Authorized>
+</AuthorizeView>
+

+

L2 Automation: +- Map role names to authorization policies +- Create AuthorizationHandler<T> implementations for custom policies +- Generate policy registration code in Program.cs

+
+

9. bwfc-master-page

+

Scope: Master page structure and layout
+Usage: Marks master page conversions and head content extraction
+Example: +

@inherits LayoutComponentBase
+@* TODO(bwfc-master-page): Review head content extraction for App.razor *@
+<head>
+  <title>@PageTitle</title>
+</head>
+<div class="container">
+  @Body
+</div>
+

+

L2 Automation: +- Extract head content (meta tags, stylesheets) to App.razor +- Verify @Body placement in layout +- Check for complex script/style blocks requiring special handling

+
+

10. bwfc-routing

+

Scope: URL routing and page routes
+Usage: Marks GetRouteUrl(), route attribute patterns
+Example: +

// TODO(bwfc-routing): Page.GetRouteUrl() → Use NavigationManager.GetUriByPage() or Router.TryResolveRoute()
+string url = Page.GetRouteUrl("ProductRoute", new { id = 123 });
+

+

L2 Automation: +- Replace GetRouteUrl() with NavigationManager routes +- Create strongly-typed route builders if needed +- Verify @page directives match Web Forms routing

+
+

11. bwfc-validation

+

Scope: ASP.NET validation controls and patterns
+Usage: Marks RequiredFieldValidator, RegularExpressionValidator, etc.
+Example: +

@* TODO(bwfc-validation): ASP.NET validators → DataAnnotations + <ValidationMessage> *@
+<RequiredFieldValidator ControlToValidate="txtEmail" runat="server" />
+

+

L2 Automation: +- Convert validator declarations to [Required], [RegularExpression], etc. attributes +- Generate <ValidationMessage For="@(() => Model.Email)" /> for display +- Add <DataAnnotationsValidator /> to form

+
+

12. bwfc-ajax

+

Scope: ASP.NET AJAX UpdatePanel, ScriptManager, AJAX extenders
+Usage: Marks UpdatePanel/ScriptManager patterns
+Example: +

@* TODO(bwfc-ajax): UpdatePanel preserved as markup — remove code-behind UpdatePanel API calls; use StateHasChanged() instead *@
+<UpdatePanel>
+  <ContentTemplate>
+    <GridView Items="@Products" />
+  </ContentTemplate>
+</UpdatePanel>
+

+

L2 Automation: +- Remove UpdatePanel.Update() calls (replace with StateHasChanged()) +- Remove ScriptManager references and script injection code +- Convert AJAX extenders to Blazor components (tooltips, modals, etc.)

+
+

13. bwfc-custom-control

+

Scope: Custom Web Forms controls and user control conversion
+Usage: Marks unrecognized or custom control conversions
+Example: +

@* TODO(bwfc-custom-control): Custom control <my:ProductCard> — map to Blazor component *@
+<ProductCard ProductId="@SelectedProductId" />
+

+

L2 Automation: +- Scan for custom control declarations in Register directives +- Create Blazor component wrapper classes +- Migrate custom control markup and code-behind to Razor components

+
+

How L2 Automation Uses Categories

+

Copilot L2 skills scan your migrated code for TODO categories and:

+
    +
  1. Group by category — Find all bwfc-datasource patterns, all bwfc-lifecycle patterns, etc.
  2. +
  3. Analyze context — Use line numbers and surrounding code to understand each pattern
  4. +
  5. Generate transforms — Create targeted code changes for each category
  6. +
  7. Apply iteratively — Run multiple L2 passes for complex migrations (lifecycle → datasource → validation)
  8. +
+

Example Workflow:

+
# After CLI migration
+webforms-to-blazor migrate --input MyApp.Web --output MyApp.Blazor
+
+# Review TODO categories in generated code
+grep -r "TODO(bwfc-" MyApp.Blazor
+
+# Use Copilot L2 for automated follow-up
+copilot /webforms-migration --focus bwfc-lifecycle  # Convert lifecycle methods
+copilot /webforms-migration --focus bwfc-datasource  # Wire data services
+copilot /webforms-migration --focus bwfc-validation  # Add DataAnnotations
+
+

Best Practices

+

During Migration

+
    +
  • Don't remove TODO comments — They guide L2 automation
  • +
  • Preserve category slugs — If you move code, keep the TODO with it
  • +
  • Group related work — TODO comments on consecutive lines can be batch-converted
  • +
+

After Migration

+
    +
  1. Review errors first — Sort migration report by severity
  2. +
  3. Fix blocking issues — Master pages, routing, basic compilation
  4. +
  5. Run L2 passes — One category at a time (lifecycle, then datasource, etc.)
  6. +
  7. Test between passes — Ensure each automation step improves stability
  8. +
+

Next Steps

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/cli/transforms/index.html b/site/cli/transforms/index.html new file mode 100644 index 000000000..974baa645 --- /dev/null +++ b/site/cli/transforms/index.html @@ -0,0 +1,7520 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Transform Reference - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ + + + + + + + +

Transform Reference

+

This page documents all 33 transforms applied by the webforms-to-blazor CLI tool. Transforms are applied in a fixed sequence to ensure correct output.

+

Transform Execution Order

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OrderNameTypeCategoryPurpose
10TodoHeaderCode-BehindMetaInject TODO guidance header
20PageDirectiveMarkupDirectiveConvert <%@ Page %>@page
30MasterDirectiveMarkupDirectiveConvert <%@ Master %> → Blazor layout
40ControlDirectiveMarkupDirectiveConvert <%@ Control %>@inherits
50RegisterDirectiveMarkupDirectiveHandle <%@ Register %> for custom controls
60ImportDirectiveMarkupDirectiveConvert <%@ Import %>@using
250MasterPageTransformMarkupMarkupConvert <asp:ContentPlaceHolder>@Body
300ContentWrapperTransformMarkupMarkupWrap loose content in <div> if needed
310FormWrapperTransformMarkupMarkupConvert <form runat="server"> to Blazor form
400ExpressionTransformMarkupMarkupConvert <%: %>, <%= %> to @()
510LoginViewTransformMarkupMarkupConvert <asp:LoginView><AuthorizeView>
520SelectMethodTransformMarkupMarkupFlag SelectMethod/InsertMethod/etc.
610AjaxToolkitPrefixTransformMarkupMarkupRemove ajaxToolkit: prefixes
620AspPrefixTransformMarkupMarkupRemove asp: prefixes from controls
700AttributeStripTransformMarkupMarkupRemove runat="server", AutoEventWireup
750EventWiringTransformMarkupMarkupConvert OnClick="X"OnClick="@X"
780UrlReferenceTransformMarkupMarkupConvert ~/ paths to /
800TemplatePlaceholderTransformMarkupMarkupConvert Itemcontext in templates
810AttributeNormalizeTransformMarkupMarkupNormalize attribute values (booleans, enums)
820DataSourceIdTransformMarkupMarkupReplace DataSourceID with Items binding
30GetRouteUrlTransformCode-BehindCode-BehindFlag Page.GetRouteUrl() calls
50GetRouteUrlTransformMarkupMarkupFlag <%: Page.GetRouteUrl() %> expressions
400SessionDetectTransformCode-BehindCode-BehindDetect Session/Cache, inject shim references
410ViewStateDetectTransformCode-BehindCode-BehindDetect ViewState usage, flag migration
500IsPostBackTransformCode-BehindCode-BehindUnwrap if (!IsPostBack) guards
510PageLifecycleTransformCode-BehindCode-BehindConvert Page_Load, Page_Init → Blazor lifecycle
520EventHandlerSignatureTransformCode-BehindCode-BehindAdapt event handler signatures
30BaseClassStripTransformCode-BehindCode-BehindRemove System.Web.UI.Page base class
20UsingStripTransformCode-BehindCode-BehindRemove Web Forms and ASP.NET using statements
25ResponseRedirectTransformCode-BehindCode-BehindConvert Response.Redirect()NavigationManager.NavigateTo()
40DataBindTransformCode-BehindCode-BehindFlag DataBind() calls
50UrlCleanupTransformCode-BehindCode-BehindClean URL literals in code
+
+

Directive Transforms

+

1. PageDirective (Order: 20)

+

Converts ASP.NET Page directives to Blazor routes.

+

Web Forms: +

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="Product.aspx.cs" Inherits="MyApp.Product" %>
+<h1>Products</h1>
+

+

Output: +

@page "/product"
+@inherits MyApp.Product
+
+<h1>Products</h1>
+

+

What It Does: +- Extracts Inherits attribute → @inherits +- Infers route from filename or Url attribute +- Removes boilerplate attributes (Language, AutoEventWireup, CodeBehind) +- Adds TODO if custom routing logic is detected

+
+

2. MasterDirective (Order: 30)

+

Converts Master Page directives to Blazor layout components.

+

Web Forms: +

<%@ Master Language="C#" CodeBehind="Site.master.cs" Inherits="MyApp.Site" %>
+

+

Output: +

@inherits LayoutComponentBase
+<div>
+  @Body
+</div>
+

+

What It Does: +- Replaces <%@ Master %> with @inherits LayoutComponentBase +- Converts <asp:ContentPlaceHolder> to @Body +- Strips runat="server" from head/form tags +- Adds TODO for complex head content extraction

+
+

3. ControlDirective (Order: 40)

+

Converts User Control directives to Blazor component inheritance.

+

Web Forms: +

<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="MenuBar.ascx.cs" Inherits="MyApp.MenuBar" %>
+

+

Output: +

@inherits MyApp.MenuBar
+
+<!-- component markup -->
+

+

What It Does: +- Extracts Inherits attribute → @inherits +- Removes boilerplate attributes +- Preserves component markup

+
+

4. RegisterDirective (Order: 50)

+

Handles custom control registration.

+

Web Forms: +

<%@ Register Namespace="MyCompany.Controls" Assembly="MyCompany.Web" TagPrefix="my" %>
+<my:CustomControl ID="ctrl1" runat="server" />
+

+

Output: +

@* TODO(bwfc-general): Custom control <my:CustomControl> — reference as Blazor component *@
+<CustomControl ID="ctrl1" />
+

+

What It Does: +- Removes Register directive (Blazor uses @using) +- Flags custom controls with TODO +- Allows developer to map to appropriate Blazor component

+
+

5. ImportDirective (Order: 60)

+

Converts Import directives to Blazor usings.

+

Web Forms: +

<%@ Import Namespace="System.Collections.Generic" %>
+<%@ Import Namespace="MyApp.Services" %>
+

+

Output: +

@using System.Collections.Generic
+@using MyApp.Services
+

+

What It Does: +- Direct conversion to @using +- Preserves namespace imports +- Placed at top of component

+
+

Markup Transforms

+

6. MasterPageTransform (Order: 250)

+

Converts master page layout elements to Blazor.

+

Details: +- Replaces <asp:ContentPlaceHolder> blocks with @Body +- Strips runat="server" from <head> and <form> tags +- Injects @inherits LayoutComponentBase +- Adds TODO comment for head content review

+

Example: +

<!-- Before -->
+<head runat="server">
+  <title>Site Master</title>
+</head>
+<form runat="server">
+  <asp:ContentPlaceHolder ID="ContentPlaceHolder1" runat="server">
+    Default content
+  </asp:ContentPlaceHolder>
+</form>
+
+<!-- After -->
+@inherits LayoutComponentBase
+@* TODO(bwfc-master-page): Review head content extraction for App.razor *@
+
+<head>
+  <title>Site Master</title>
+</head>
+<form>
+  @Body
+</form>
+

+
+

7. ContentWrapperTransform (Order: 300)

+

Wraps loose content in a div if necessary.

+

Purpose: Blazor requires a single root element. Wraps text nodes and mixed content.

+
+

8. FormWrapperTransform (Order: 310)

+

Converts Web Forms form tags to Blazor EditForm or plain HTML form.

+

Web Forms: +

<form runat="server">
+  <asp:TextBox ID="txtName" runat="server" />
+  <asp:Button Text="Submit" OnClick="Submit_Click" runat="server" />
+</form>
+

+

Output (with EditContext): +

<EditForm Model="@Model" OnValidSubmit="Submit_Click">
+  <DataAnnotationsValidator />
+  <TextBox @bind-Value="@Model.Name" />
+  <Button Text="Submit" />
+</EditForm>
+

+
+

9. ExpressionTransform (Order: 400)

+

Converts Web Forms expression syntax to Blazor.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PatternWeb FormsBlazor
Output<%: expression %>@(expression)
Code<%= expression %>@expression
Data-Bind<%# Bind("Property") %>@bind="Model.Property"
Data-Eval<%# Eval("Property") %>@Item.Property
+

Example: +

<!-- Before -->
+<h1><%: Model.Title %></h1>
+<p><%= GetDescription() %></p>
+<input value="<%# Bind("Email") %>" />
+
+<!-- After -->
+<h1>@(Model.Title)</h1>
+<p>@GetDescription()</p>
+<input @bind="Model.Email" />
+

+
+

10. LoginViewTransform (Order: 510)

+

Converts LoginView to Blazor AuthorizeView.

+

Web Forms: +

<asp:LoginView runat="server">
+  <AnonymousTemplate>
+    <p><asp:LoginStatus runat="server" /></p>
+  </AnonymousTemplate>
+  <LoggedInTemplate>
+    <p>Welcome, <asp:LoginName runat="server" />!</p>
+  </LoggedInTemplate>
+</asp:LoginView>
+

+

Output: +

<AuthorizeView>
+  <NotAuthorized>
+    <p><LoginStatus /></p>
+  </NotAuthorized>
+  <Authorized>
+    <p>Welcome, <LoginName />!</p>
+  </Authorized>
+</AuthorizeView>
+

+

Complex RoleGroups are flagged with TODO: +

<!-- Input -->
+<asp:LoginView runat="server">
+  <RoleGroups>
+    <asp:RoleGroup Roles="Admin">
+      <LoggedInTemplate>Admin panel</LoggedInTemplate>
+    </asp:RoleGroup>
+  </RoleGroups>
+</asp:LoginView>
+
+<!-- Output -->
+@* TODO(bwfc-identity): Convert RoleGroups to policy-based AuthorizeView *@
+

+
+

11. SelectMethodTransform (Order: 520)

+

Flags data source method attributes for conversion.

+

Purpose: Detects SelectMethod, InsertMethod, UpdateMethod, DeleteMethod attributes and adds TODO comments.

+

Example: +

<!-- Input -->
+<GridView DataSource='<%# Products %>' AllowPaging="true" runat="server">
+
+<!-- Output -->
+@* TODO(bwfc-datasource): Wire Items binding and implement IProductsDataService *@
+<GridView Items="@Products" AllowPaging="true">
+

+
+

12. AjaxToolkitPrefixTransform (Order: 610)

+

Removes AJAX Control Toolkit prefixes.

+

Web Forms: +

<ajaxToolkit:TabContainer runat="server">
+  <ajaxToolkit:TabPanel HeaderText="Tab 1" runat="server">
+    Content
+  </ajaxToolkit:TabPanel>
+</ajaxToolkit:TabContainer>
+

+

Output: +

<TabContainer>
+  <TabPanel HeaderText="Tab 1">
+    Content
+  </TabPanel>
+</TabContainer>
+

+
+

13. AspPrefixTransform (Order: 620)

+

Removes asp: prefix from all ASP.NET server controls.

+

Web Forms: +

<div>
+  <asp:Button ID="btnSubmit" Text="Submit" CssClass="btn-primary" runat="server" />
+  <asp:TextBox ID="txtName" placeholder="Enter name" runat="server" />
+  <asp:Label ID="lblStatus" runat="server" />
+</div>
+

+

Output: +

<div>
+  <Button ID="btnSubmit" Text="Submit" CssClass="btn-primary" />
+  <TextBox ID="txtName" placeholder="Enter name" />
+  <Label ID="lblStatus" />
+</div>
+

+
+

14. AttributeStripTransform (Order: 700)

+

Removes Web Forms-specific attributes.

+

Removes: +- runat="server" +- AutoEventWireup="true|false" +- EnableEventValidation="true|false" +- ViewStateMode="Enabled|Disabled|Inherit"

+

Example: +

<!-- Before -->
+<asp:TextBox ID="txt1" runat="server" AutoEventWireup="true" ViewStateMode="Disabled" />
+
+<!-- After -->
+<TextBox ID="txt1" />
+

+
+

15. EventWiringTransform (Order: 750)

+

Converts Web Forms event handler syntax to Blazor.

+

Web Forms: +

<asp:Button ID="btnSubmit" Text="Submit" OnClick="Submit_Click" runat="server" />
+<asp:TextBox ID="txtEmail" OnTextChanged="Email_Changed" AutoPostBack="true" runat="server" />
+

+

Output: +

<Button ID="btnSubmit" Text="Submit" OnClick="@Submit_Click" />
+<TextBox ID="txtEmail" OnInput="@Email_Changed" />
+

+
+

16. UrlReferenceTransform (Order: 780)

+

Converts ASP.NET virtual paths to absolute URLs.

+

Handles 8 URL attributes:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttributeExample
NavigateUrl~/products/list/products/list
ImageUrl~/images/logo.png/images/logo.png
PostBackUrl~/checkout/checkout
ToolTip~/help in URL context → /help
HRef~/page/page
Src~/scripts/app.js/scripts/app.js
DataSourceID (partial)Handled by DataSourceIdTransform
onclick (URLs)JavaScript URLs updated
+

Example: +

<!-- Before -->
+<asp:HyperLink NavigateUrl="~/products/list" Text="Products" runat="server" />
+<asp:Image ImageUrl="~/images/logo.png" runat="server" />
+<script src="~/scripts/app.js"></script>
+
+<!-- After -->
+<HyperLink NavigateUrl="/products/list" Text="Products" />
+<Image ImageUrl="/images/logo.png" />
+<script src="/scripts/app.js"></script>
+

+
+

17. TemplatePlaceholderTransform (Order: 800)

+

Converts Item placeholder to context in Blazor templates.

+

Blazor uses context to reference the template context variable. Web Forms uses Item.

+

Example: +

<!-- Before (GridView template) -->
+<ItemTemplate>
+  <td><%# Item.Name %></td>
+  <td><%# Item.Price %></td>
+</ItemTemplate>
+
+<!-- After -->
+<ItemTemplate>
+  <td>@context.Name</td>
+  <td>@context.Price</td>
+</ItemTemplate>
+

+
+

18. AttributeNormalizeTransform (Order: 810)

+

Normalizes attribute values to Blazor syntax.

+

Converts: +- Visible="true" → no attribute (show by default) +- Visible="false"style="display:none" +- Boolean true/falsetrue/false +- Enum strings → proper C# enum syntax

+

Example: +

<!-- Before -->
+<asp:Panel Visible="<%# ShowPanel %>" BackColor="Red" ForeColor="White" runat="server">
+
+<!-- After -->
+<Panel style="@(ShowPanel ? "" : "display:none")" style="background-color: red; color: white;">
+

+
+

19. DataSourceIdTransform (Order: 820)

+

Replaces DataSourceID with Items binding and scaffolds data properties.

+

Web Forms: +

<asp:GridView DataSourceID="SqlDataSource1" runat="server" />
+<asp:SqlDataSource ID="SqlDataSource1" SelectCommand="SELECT * FROM Products" runat="server" />
+

+

Output: +

@* TODO(bwfc-datasource): Implement IProductsDataService to replace SqlDataSource *@
+<GridView Items="@ProductsData" />
+
+@code {
+    private List<Product> ProductsData { get; set; }
+
+    protected override async Task OnInitializedAsync()
+    {
+        // TODO(bwfc-datasource): Load ProductsData from service
+    }
+}
+

+
+

Code-Behind Transforms

+

20. TodoHeaderTransform (Order: 10)

+

Injects migration guidance header at the top of code-behind files.

+

Output: (injected at file top) +

// =============================================================================
+// 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 → inject IHttpContextAccessor or use DI
+//   TODO(bwfc-navigation): Response.Redirect → NavigationManager.NavigateTo
+//   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 → remove (Blazor handles updates)
+//   TODO(bwfc-general): UpdatePanel markup preserved by BWFC (ContentTemplate supported) — remove only code-behind API calls
+//   TODO(bwfc-general): User controls → Blazor component references
+// =============================================================================
+

+
+

21. UsingStripTransform (Order: 20)

+

Removes Web Forms and ASP.NET using statements.

+

Removes: +- System.Web.* +- System.Web.UI.* +- Microsoft.AspNet.* +- AJAX Control Toolkit namespaces

+

Example: +

// Before
+using System;
+using System.Web;
+using System.Web.UI;
+using System.Web.UI.WebControls;
+using Microsoft.AspNet.Identity;
+
+// After
+using System;
+// (Web Forms usings removed — use BWFC and ASP.NET Core namespaces)
+

+
+

22. BaseClassStripTransform (Order: 30)

+

Removes Web Forms base classes and replaces with Blazor ComponentBase.

+

Example: +

// Before
+public partial class Product : System.Web.UI.Page
+{
+    // ...
+}
+
+// After
+public partial class Product : ComponentBase
+{
+    // ...
+}
+

+
+

23. ResponseRedirectTransform (Order: 25)

+

Converts Response.Redirect() to NavigationManager.NavigateTo().

+

Web Forms: +

private void CheckoutButton_Click(object sender, EventArgs e)
+{
+    Response.Redirect("~/checkout");
+}
+

+

Output: +

private void CheckoutButton_Click(object sender, EventArgs e)
+{
+    NavigationManager.NavigateTo("/checkout");
+}
+

+
+

24. GetRouteUrlTransform (Order: 30/50)

+

Flags Page.GetRouteUrl() calls for conversion guidance.

+

Example: +

// Before
+string url = Page.GetRouteUrl("ProductRoute", new { id = 123 });
+
+// After
+@* TODO(bwfc-routing): Page.GetRouteUrl()  Use NavigationManager.GetUriByPage() or Router.TryResolveRoute() *@
+string url = Page.GetRouteUrl("ProductRoute", new { id = 123 });
+

+
+

25. SessionDetectTransform (Order: 400)

+

Detects Session/Cache usage and auto-wires SessionShim/CacheShim.

+

Web Forms: +

public class CheckoutPage : Page
+{
+    private void LoadCart()
+    {
+        string cartId = (string)Session["CartId"];
+        var items = (List<CartItem>)Cache["ProductList"];
+    }
+}
+

+

Output: +

// --- Session State Migration ---
+// TODO(bwfc-session-state): SessionShim auto-wired via [Inject] — Session["CartId"] calls compile against the shim's indexer.
+// Session keys found: CartId
+// 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
+
+// --- Cache Migration ---
+// TODO(bwfc-session-state): CacheShim auto-wired via [Inject] — Cache["ProductList"] calls compile against the shim's indexer.
+// Cache keys found: ProductList
+// CacheShim wraps IMemoryCache — items are per-server, not distributed.
+// For distributed caching, consider IDistributedCache.
+
+public class CheckoutPage : ComponentBase
+{
+    [Inject] private SessionShim Session { get; set; }
+    [Inject] private CacheShim Cache { get; set; }
+
+    private void LoadCart()
+    {
+        string cartId = (string)Session["CartId"];
+        var items = (List<CartItem>)Cache["ProductList"];
+    }
+}
+

+
+

26. ViewStateDetectTransform (Order: 410)

+

Detects ViewState usage and flags migration strategy.

+

Example: +

// Before
+ViewState["CurrentPage"] = 1;
+int page = (int)(ViewState["CurrentPage"] ?? 0);
+
+// After
+@* TODO(bwfc-viewstate): ViewState["CurrentPage"]  migrate to component private field or [Parameter] *@
+// Options:
+//   (1) Private field (simple state within component):
+//       private int CurrentPage { get; set; } = 1;
+//   (2) [Parameter] (state passed from parent):
+//       [Parameter] public int CurrentPage { get; set; }
+//   (3) Cascading parameter (shared across hierarchy):
+//       [CascadingParameter] private int CurrentPage { get; set; }
+ViewState["CurrentPage"] = 1;
+int page = (int)(ViewState["CurrentPage"] ?? 0);
+

+
+

27. IsPostBackTransform (Order: 500)

+

Unwraps IsPostBack guards and extracts postback logic.

+

Web Forms: +

protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        LoadInitialData();
+    }
+    else
+    {
+        HandlePostBack();
+    }
+}
+

+

Output: +

protected async Task OnInitializedAsync()
+{
+    LoadInitialData();  // Guard unwrapped — runs on first load only
+}
+
+private void HandlePostBack()
+{
+    // Postback logic extracted
+}
+

+
+

28. PageLifecycleTransform (Order: 510)

+

Converts Web Forms lifecycle methods to Blazor.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Web FormsBlazor
Page_LoadOnInitializedAsync
Page_InitOnInitializedAsync / Constructor
Page_PreRenderOnAfterRenderAsync
Page_UnloadDispose
+

Example: +

// Before
+protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+        LoadProducts();
+}
+
+protected void Page_PreRender(object sender, EventArgs e)
+{
+    UpdateStatus();
+}
+
+// After
+protected override async Task OnInitializedAsync()
+{
+    await LoadProducts();
+}
+
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+    if (firstRender)
+        UpdateStatus();
+}
+

+
+

29. EventHandlerSignatureTransform (Order: 520)

+

Adapts Web Forms event handler signatures to Blazor.

+

Web Forms: +

protected void SubmitButton_Click(object sender, EventArgs e)
+{
+    // handle click
+}
+
+protected void GridView_RowCommand(object sender, GridViewCommandEventArgs e)
+{
+    // handle row command
+}
+

+

Output: +

// Event callback wired in markup: OnClick="@SubmitButton_Click"
+private Task SubmitButton_Click()
+{
+    // handle click (no sender/e parameters)
+    return Task.CompletedTask;
+}
+
+// For GridView row commands: OnRowCommand="@GridView_RowCommand"
+private Task GridView_RowCommand(GridViewCommandEventArgs e)
+{
+    // e is preserved for complex data grid scenarios
+    return Task.CompletedTask;
+}
+

+
+

30. DataBindTransform (Order: 40)

+

Flags DataBind() calls for conversion.

+

Example: +

// Before
+protected void Page_Load(object sender, EventArgs e)
+{
+    if (!IsPostBack)
+    {
+        ProductGrid.DataBind();
+    }
+}
+
+// After
+@* TODO(bwfc-datasource): DataBind()  Remove (Blazor renders via @binding) or use StateHasChanged() after data load *@
+protected override async Task OnInitializedAsync()
+{
+    // Load data and StateHasChanged() will re-render
+    Products = await LoadProductsAsync();
+}
+

+
+

31. UrlCleanupTransform (Order: 50)

+

Cleans URL string literals in code.

+

Example: +

// Before
+string redirectUrl = "~/products/list";
+string imageUrl = "~/images/logo.png";
+
+// After
+string redirectUrl = "/products/list";
+string imageUrl = "/images/logo.png";
+

+
+

How to Use This Reference

+
    +
  1. During migration: Open this page alongside your migrated code to understand what changed
  2. +
  3. After migration: Use "Transform Reference" links in TODO comments to jump to specific transform details
  4. +
  5. For troubleshooting: If output doesn't match expectations, check the transform order — earlier transforms affect later ones
  6. +
+

Next Steps

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/images/analyzers/bwfc002-info-squiggle.png b/site/images/analyzers/bwfc002-info-squiggle.png new file mode 100644 index 000000000..d5ad0ec9e Binary files /dev/null and b/site/images/analyzers/bwfc002-info-squiggle.png differ diff --git a/site/images/analyzers/bwfc003-info-squiggle.png b/site/images/analyzers/bwfc003-info-squiggle.png new file mode 100644 index 000000000..bd86f5000 Binary files /dev/null and b/site/images/analyzers/bwfc003-info-squiggle.png differ diff --git a/site/images/analyzers/index.html b/site/images/analyzers/index.html new file mode 100644 index 000000000..248817e47 --- /dev/null +++ b/site/images/analyzers/index.html @@ -0,0 +1,5737 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + Analyzer Screenshots - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ + + + + + + + +

Analyzer Screenshots

+

This directory should contain Visual Studio screenshots showing the BWFC Roslyn analyzer experience.

+

Required Screenshots

+ + + + + + + + + + + + + + + + + + + + + +
FileDescription
bwfc002-info-squiggle.pngBWFC002 — Green info squiggle on ViewState["key"] usage, showing tooltip message
bwfc003-info-squiggle.pngBWFC003 — Green info squiggle on IsPostBack check, showing tooltip message
bwfc025-warning-squiggle.pngBWFC025 — Yellow warning squiggle on ViewState["key"] = new DataTable(), showing tooltip message
+

How to Capture

+
    +
  1. Open a C# file with the relevant pattern in Visual Studio 2022
  2. +
  3. Hover over the squiggle to show the tooltip
  4. +
  5. Use Windows Snipping Tool (Win+Shift+S) to capture the editor area
  6. +
  7. Save as PNG, approximately 800x200 pixels
  8. +
  9. Place in this directory with the filename from the table above
  10. +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/site/themes-and-skins/index.html b/site/themes-and-skins/index.html new file mode 100644 index 000000000..550591b51 --- /dev/null +++ b/site/themes-and-skins/index.html @@ -0,0 +1,7104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Themes and Skins - BlazorWebFormsComponents + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ + +
+ +
+ + + + + + +
+
+ + + +
+
+
+ + + + + +
+
+
+ + + +
+
+
+ + + +
+
+
+ + + +
+ +
+ + + + + + + + +

Themes and Skins

+

The Themes and Skins feature enables you to centrally define and apply consistent styling to all BlazorWebFormsComponents throughout your application—just as you would in ASP.NET Web Forms. This guide explains how to use themes, apply them at runtime, and migrate from Web Forms theme files.

+
+

Feature Status

+

Themes and Skins are implemented in the BlazorWebFormsComponents.Theming namespace and fully available for use.

+
+
+

Overview

+

What Themes and Skins Provide

+

Themes and Skins solve a common problem: maintaining consistent, centralized styling across your entire application without duplicating style definitions on every control.

+

In Web Forms, you would create .skin files containing control definitions with appearance properties:

+
<!-- App_Themes/Professional/controls.skin -->
+<asp:Button runat="server"
+    BackColor="#507CD1"
+    ForeColor="White"
+    Font-Bold="true" />
+
+

Then apply the theme to your page—and every Button would automatically adopt that styling.

+

With BlazorWebFormsComponents, you achieve the same result using a ThemeConfiguration object and the ThemeProvider wrapper component.

+

Key Concepts

+
    +
  • ThemeConfiguration — A builder object that defines styling rules for controls
  • +
  • ThemeProvider — A wrapper component that applies a theme to all child controls
  • +
  • ControlSkin — The styling definition for a single control type (e.g., all Buttons)
  • +
  • SubStyle — Named style definitions for parts of complex controls (e.g., GridView headers, rows)
  • +
  • SkinID — A named skin variant for a control (optional; enables multiple skins per control)
  • +
  • EnableTheming — A control-level property to opt-out of theming
  • +
  • ThemeMode — Either StyleSheetTheme (defaults) or Theme (overrides)
  • +
+
+

Quick Start

+

1. Define Your Theme

+

Create a ThemeConfiguration object that describes how your components should look:

+
// In App.razor, _Layout.razor, or a dedicated service
+var myTheme = new ThemeConfiguration()
+    .ForControl("Button", skin => skin
+        .Set(s => s.BackColor, WebColor.FromHtml("#507CD1"))
+        .Set(s => s.ForeColor, WebColor.FromHtml("#FFFFFF"))
+        .Set(s => s.Font.Bold, true))
+    .ForControl("Label", skin => skin
+        .Set(s => s.ForeColor, WebColor.FromHtml("#333333"))
+        .Set(s => s.Font.Names, "Arial, sans-serif"));
+
+

2. Apply the Theme

+

Wrap your content with <ThemeProvider> to apply the theme:

+
<ThemeProvider Theme="myTheme">
+    <Button Text="Themed Button" />
+    <Label Text="Themed Label" />
+</ThemeProvider>
+
+

3. Result

+

Every Button and Label inside the ThemeProvider automatically adopts the theme styling. No additional markup changes needed.

+
+

Theme Modes

+

Themes support two modes that control how styling precedence works.

+

Mode 1: StyleSheetTheme (Default)

+

Behavior: Theme acts as defaults. Explicit control properties override the theme.

+

Use when: You want components to respect their own property values when specified, but fall back to the theme for unspecified properties.

+

Example:

+
var theme = new ThemeConfiguration()
+    .ForControl("Button", skin => skin
+        .Set(s => s.BackColor, WebColor.FromHtml("#507CD1"))
+        .Set(s => s.Font.Bold, true));
+
+// In your page
+<ThemeProvider Theme="theme" ThemeMode="ThemeMode.StyleSheetTheme">
+    <!-- Uses theme: BackColor=#507CD1, Bold=true -->
+    <Button Text="Themed Button" />
+
+    <!-- Overrides theme BackColor; keeps theme Bold -->
+    <Button Text="Custom" BackColor="Red" />
+</ThemeProvider>
+
+

Mode 2: Theme

+

Behavior: Theme overrides all control properties. Explicit control settings are ignored.

+

Use when: You want to enforce consistent appearance across your entire application (no exceptions).

+

Example:

+
<ThemeProvider Theme="theme" ThemeMode="ThemeMode.Theme">
+    <!-- Uses theme: BackColor=#507CD1, Bold=true -->
+    <Button Text="Themed Button" />
+
+    <!-- Theme still overrides; BackColor stays #507CD1 -->
+    <Button Text="Custom" BackColor="Red" />
+</ThemeProvider>
+
+

Comparison Table

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FeatureStyleSheetTheme (Default)Theme
BehaviorSets defaults; explicit values winOverrides all values
Web Forms equivalentPage.StyleSheetThemePage.Theme
Use whenYou want components to override themeYou want consistent theming
PriorityComponent property > ThemeTheme > Component property
FlexibilityHigh—exceptions per controlLow—uniform across app
+
+

Sub-Component Styles

+

Complex controls like GridView, DetailsView, and FormView have sub-components (HeaderStyle, RowStyle, etc.) that need their own styling rules.

+

Use the SubStyle API to define styles for these sub-components:

+
var theme = new ThemeConfiguration()
+    .ForControl("GridView", skin => skin
+        .SubStyle("HeaderStyle", style => {
+            style.BackColor = WebColor.FromHtml("#507CD1");
+            style.ForeColor = WebColor.FromHtml("#FFFFFF");
+            style.Font.Bold = true;
+        })
+        .SubStyle("RowStyle", style => {
+            style.BackColor = WebColor.FromHtml("#EFF3FB");
+        })
+        .SubStyle("AlternatingRowStyle", style => {
+            style.BackColor = WebColor.FromHtml("#F7F6F3");
+        })
+        .SubStyle("FooterStyle", style => {
+            style.BackColor = WebColor.FromHtml("#507CD1");
+            style.ForeColor = WebColor.FromHtml("#FFFFFF");
+        }));
+
+

When you render the GridView, all HeaderStyle, RowStyle, AlternatingRowStyle, and FooterStyle elements automatically adopt the theme styling.

+

Supported Sub-Styles by Control

+

GridView (8 sub-styles): +- HeaderStyle, FooterStyle, RowStyle, AlternatingRowStyle, SelectedRowStyle, EditRowStyle, PagerStyle, SortedHeaderStyle

+

DetailsView (10 sub-styles): +- HeaderStyle, FooterStyle, RowStyle, AlternatingRowStyle, SelectedRowStyle, EditRowStyle, InsertRowStyle, CommandRowStyle, PagerStyle, EmptyDataRowStyle

+

FormView (7 sub-styles): +- HeaderStyle, FooterStyle, RowStyle, AlternatingRowStyle, EditRowStyle, InsertRowStyle, PagerStyle

+

DataGrid (7 sub-styles): +- HeaderStyle, FooterStyle, ItemStyle, AlternatingItemStyle, SelectedItemStyle, EditItemStyle, PagerStyle

+

DataList (5 sub-styles): +- HeaderStyle, FooterStyle, ItemStyle, AlternatingItemStyle, SelectedItemStyle

+
+

Migration Guide: Web Forms to Blazor

+

Converting .skin Files to ThemeConfiguration

+

Web Forms (.skin File)

+

In Web Forms, you would create a .skin file in your App_Themes folder:

+
<!-- App_Themes/Professional/controls.skin -->
+<asp:Button runat="server" 
+    BackColor="#507CD1" 
+    ForeColor="White" 
+    Font-Bold="true" />
+
+<asp:Label runat="server" 
+    ForeColor="#333333" />
+
+<asp:GridView runat="server">
+    <HeaderStyle BackColor="#507CD1" ForeColor="White" Font-Bold="True" />
+    <RowStyle BackColor="#EFF3FB" />
+    <AlternatingRowStyle BackColor="#F7F6F3" />
+</asp:GridView>
+
+

Blazor Equivalent

+

In Blazor with BlazorWebFormsComponents, create a ThemeConfiguration:

+
public static class Themes
+{
+    public static ThemeConfiguration CreateProfessionalTheme()
+    {
+        return new ThemeConfiguration()
+            .ForControl("Button", skin => skin
+                .Set(s => s.BackColor, WebColor.FromHtml("#507CD1"))
+                .Set(s => s.ForeColor, WebColor.FromHtml("#FFFFFF"))
+                .Set(s => s.Font.Bold, true))
+            .ForControl("Label", skin => skin
+                .Set(s => s.ForeColor, WebColor.FromHtml("#333333")))
+            .ForControl("GridView", skin => skin
+                .SubStyle("HeaderStyle", s => {
+                    s.BackColor = WebColor.FromHtml("#507CD1");
+                    s.ForeColor = WebColor.FromHtml("#FFFFFF");
+                    s.Font.Bold = true;
+                })
+                .SubStyle("RowStyle", s => {
+                    s.BackColor = WebColor.FromHtml("#EFF3FB");
+                })
+                .SubStyle("AlternatingRowStyle", s => {
+                    s.BackColor = WebColor.FromHtml("#F7F6F3");
+                }));
+    }
+}
+
+

Then use it in your pages:

+
@page "/"
+@inject NavigationManager nav
+
+<ThemeProvider Theme="@theme">
+    <Button Text="Themed Button" />
+    <Label Text="Themed Label" />
+    <GridView ItemsSource="@data">
+        <!-- GridView header, rows, etc. all inherit theme -->
+    </GridView>
+</ThemeProvider>
+
+@code {
+    private ThemeConfiguration theme = null!;
+
+    protected override void OnInitialized()
+    {
+        theme = Themes.CreateProfessionalTheme();
+    }
+}
+
+
+

EnableTheming and Opt-Out

+

By default, all controls participate in theming when wrapped in a <ThemeProvider>. You can opt-out at the control level using the EnableTheming parameter.

+

Control-Level Opt-Out

+
<ThemeProvider Theme="myTheme">
+    <!-- This Button uses the theme -->
+    <Button Text="Themed" />
+
+    <!-- This Button does NOT use the theme -->
+    <Button Text="Custom" EnableTheming="false" />
+</ThemeProvider>
+
+

Container Propagation

+

When you set EnableTheming="false" on a container (like a Panel), it disables theming for all child controls:

+
<ThemeProvider Theme="myTheme">
+    <Button Text="Themed" />
+
+    <Panel EnableTheming="false">
+        <!-- All children are NOT themed -->
+        <Button Text="Not Themed" />
+        <Label Text="Not Themed" />
+    </Panel>
+</ThemeProvider>
+
+
+

Named Skins with SkinID

+

The SkinID parameter allows you to define multiple theme variants for the same control type.

+

Defining Multiple Skins

+
var theme = new ThemeConfiguration()
+    // Default skin for all Buttons (no SkinID required)
+    .ForControl("Button", skin => skin
+        .Set(s => s.BackColor, WebColor.FromHtml("#507CD1"))
+        .Set(s => s.Font.Bold, true))
+    // Named skin: "Danger" for destructive actions
+    .ForControl("Button", "Danger", skin => skin
+        .Set(s => s.BackColor, WebColor.FromHtml("#DC3545"))
+        .Set(s => s.ForeColor, WebColor.FromHtml("#FFFFFF")))
+    // Named skin: "Success" for positive actions
+    .ForControl("Button", "Success", skin => skin
+        .Set(s => s.BackColor, WebColor.FromHtml("#28A745"))
+        .Set(s => s.ForeColor, WebColor.FromHtml("#FFFFFF")));
+
+

Using Named Skins

+
<ThemeProvider Theme="theme">
+    <!-- Uses default Button skin -->
+    <Button Text="Save" />
+
+    <!-- Uses "Danger" skin -->
+    <Button Text="Delete" SkinID="Danger" />
+
+    <!-- Uses "Success" skin -->
+    <Button Text="Approve" SkinID="Success" />
+</ThemeProvider>
+
+
+

Runtime Theme Switching

+

You can switch themes at runtime by assigning a new ThemeConfiguration instance to the ThemeProvider.

+

Example: Theme Switcher

+
@page "/"
+
+<div>
+    <button @onclick="() => SwitchTheme(Themes.CreateProfessionalTheme())">
+        Professional Theme
+    </button>
+    <button @onclick="() => SwitchTheme(Themes.CreateModernTheme())">
+        Modern Theme
+    </button>
+</div>
+
+<ThemeProvider Theme="@currentTheme">
+    <Button Text="Click me" />
+    <Label Text="Current theme applied" />
+</ThemeProvider>
+
+@code {
+    private ThemeConfiguration currentTheme = null!;
+
+    protected override void OnInitialized()
+    {
+        currentTheme = Themes.CreateProfessionalTheme();
+    }
+
+    private void SwitchTheme(ThemeConfiguration newTheme)
+    {
+        currentTheme = newTheme;
+    }
+}
+
+

When you assign a new theme, all child controls automatically re-render with the new styling.

+
+

API Reference

+

ThemeConfiguration

+

The builder class for defining themes.

+ + + + + + + + + + + + + + + + + +
MethodDescription
ForControl(string name, Action<ControlSkin> skin)Define the default skin for a control type
ForControl(string name, string skinId, Action<ControlSkin> skin)Define a named skin variant for a control type
+

ControlSkin

+

Defines styling for a single control.

+ + + + + + + + + + + + + + + + + +
MethodDescription
Set<T>(Expression<Func<Style, T>> property, T value)Set a style property
SubStyle(string name, Action<Style> style)Define a sub-component style (for complex controls)
+

ThemeProvider (Component)

+

Applies a theme to child controls.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDescription
ThemeThemeConfigurationThe theme to apply
ThemeModeThemeModeEither StyleSheetTheme (default) or Theme
ChildContentRenderFragmentChild controls to theme
+

Style Properties (Common)

+

All style objects support these properties:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyTypeDescription
BackColorWebColor?Background color
ForeColorWebColor?Text color
BorderColorWebColor?Border color
BorderStyleBorderStyle?Border style (Solid, Dotted, etc.)
BorderWidthUnit?Border width
WidthUnit?Element width
HeightUnit?Element height
CssClassstring?CSS class names
FontFontInfoFont properties (Bold, Italic, Names, Size, etc.)
HorizontalAlignHorizontalAlign?Horizontal alignment (Left, Center, Right)
VerticalAlignVerticalAlign?Vertical alignment (Top, Middle, Bottom)
Wrapbool?Text wrapping (default: true)
+

ThemeMode (Enum)

+ + + + + + + + + + + + + + + + + +
ValueDescription
StyleSheetThemeTheme acts as defaults; component properties override
ThemeTheme overrides all component properties
+
+

Best Practices

+
    +
  1. Centralize Theme Definitions: Create a dedicated Themes.cs static class containing all theme configurations
  2. +
  3. Name Your Skins Meaningfully: Use semantic names like "Danger", "Success", "Warning" rather than "Skin1", "Skin2"
  4. +
  5. Test Theme Application: Verify themes render correctly across all affected control types
  6. +
  7. Document Theme Intent: Add comments explaining the purpose of each theme variant
  8. +
  9. Use StyleSheetTheme for Flexibility: Default to StyleSheetTheme mode unless you need strict consistency
  10. +
  11. Avoid Deep Nesting: Don't nest multiple ThemeProvider components—apply a single theme at the page or layout level
  12. +
+
+

Troubleshooting

+

Q: Theme not applying to a control
+A: Verify the control is wrapped in <ThemeProvider> and the control type name matches exactly (e.g., "Button", not "btn").

+

Q: Control property overriding the theme when I don't want it to
+A: Switch to ThemeMode.Theme to make the theme override all control properties.

+

Q: Sub-style not applying to GridView rows
+A: Ensure the sub-style name matches exactly (e.g., "RowStyle", not "Row" or "Rows"). Verify the GridView is inside a <ThemeProvider>.

+

Q: EnableTheming="false" not working on a child control
+A: Ensure the container (Panel, etc.) where you set EnableTheming="false" is inside a <ThemeProvider>.

+
+

See Also

+ + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+ + + +
+
+
+
+ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/BlazorWebFormsComponents.Cli/Config/EdmxConverterBridge.cs b/src/BlazorWebFormsComponents.Cli/Config/EdmxConverterBridge.cs new file mode 100644 index 000000000..11fad4a8b --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Config/EdmxConverterBridge.cs @@ -0,0 +1,63 @@ +using BlazorWebFormsComponents.Cli.Interop; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Config; + +/// +/// Invokes the existing EDMX-to-EF Core converter through the CLI pipeline until the converter is fully ported to C#. +/// +public class EdmxConverterBridge +{ + private readonly PowerShellScriptRunner _scriptRunner; + + public EdmxConverterBridge(PowerShellScriptRunner scriptRunner) + { + _scriptRunner = scriptRunner; + } + + public async Task> ConvertAsync(string sourcePath, string outputPath, string projectName, bool dryRun, MigrationReport report) + { + var excludedSourceFiles = new HashSet(StringComparer.OrdinalIgnoreCase); + var edmxFiles = Directory.EnumerateFiles(sourcePath, "*.edmx", SearchOption.AllDirectories) + .Where(file => Path.GetDirectoryName(file)?.EndsWith("Models", StringComparison.OrdinalIgnoreCase) == true) + .ToList(); + + if (edmxFiles.Count == 0) + return excludedSourceFiles; + + if (dryRun) + { + report.Warnings.Add("EDMX conversion is skipped in --dry-run mode."); + return excludedSourceFiles; + } + + var scriptPath = RepoPathResolver.FindFileFromRepoRoot(Path.Combine("migration-toolkit", "scripts", "Convert-EdmxToEfCore.ps1")); + + foreach (var edmxFile in edmxFiles) + { + var relativeDir = Path.GetDirectoryName(Path.GetRelativePath(sourcePath, edmxFile)) ?? string.Empty; + var outputDir = Path.Combine(outputPath, relativeDir); + Directory.CreateDirectory(outputDir); + + var args = new List + { + "-EdmxPath", edmxFile, + "-OutputPath", outputDir, + "-Namespace", $"{projectName}.Models" + }; + + var result = await _scriptRunner.RunAsync(scriptPath, args); + if (result.ExitCode != 0) + { + report.Warnings.Add($"EDMX conversion failed for {Path.GetFileName(edmxFile)}: {result.StandardError.Trim()}"); + continue; + } + + var stem = Path.GetFileNameWithoutExtension(edmxFile); + excludedSourceFiles.Add(Path.Combine(Path.GetDirectoryName(edmxFile)!, $"{stem}.cs")); + report.AddManualItem(Path.GetRelativePath(sourcePath, edmxFile), 0, "EDMX", "EDMX converted to EF Core entities and DbContext — verify generated relationships and configuration."); + } + + return excludedSourceFiles; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Config/NuGetStaticAssetExtractor.cs b/src/BlazorWebFormsComponents.Cli/Config/NuGetStaticAssetExtractor.cs new file mode 100644 index 000000000..67932a18b --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Config/NuGetStaticAssetExtractor.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using BlazorWebFormsComponents.Cli.Interop; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Config; + +/// +/// Invokes the legacy NuGet static asset extractor through the CLI pipeline until the extractor is fully ported to C#. +/// +public class NuGetStaticAssetExtractor +{ + private readonly PowerShellScriptRunner _scriptRunner; + + public NuGetStaticAssetExtractor(PowerShellScriptRunner scriptRunner) + { + _scriptRunner = scriptRunner; + } + + public async Task ExtractAsync(string sourcePath, string outputPath, bool dryRun, MigrationReport report) + { + var packagesConfig = Path.Combine(sourcePath, "packages.config"); + if (!File.Exists(packagesConfig)) + return null; + + if (dryRun) + { + report.Warnings.Add("NuGet static asset extraction is skipped in --dry-run mode."); + return null; + } + + var scriptPath = RepoPathResolver.FindFileFromRepoRoot(Path.Combine("migration-toolkit", "scripts", "Migrate-NugetStaticAssets.ps1")); + var args = new List + { + "-SourcePath", sourcePath, + "-OutputPath", outputPath + }; + + var result = await _scriptRunner.RunAsync(scriptPath, args); + if (result.ExitCode != 0) + { + report.Warnings.Add($"NuGet static asset extraction failed: {result.StandardError.Trim()}"); + return null; + } + + var manifestPath = Path.Combine(outputPath, "asset-manifest.json"); + if (!File.Exists(manifestPath)) + return null; + + await using var stream = File.OpenRead(manifestPath); + var manifest = await JsonSerializer.DeserializeAsync(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + return manifest is null + ? null + : new NuGetAssetExtractionResult(manifest.PackagesWithAssets, manifest.TotalFilesExtracted); + } + + private sealed class NuGetAssetManifest + { + public int PackagesWithAssets { get; set; } + public int TotalFilesExtracted { get; set; } + } +} + +public sealed record NuGetAssetExtractionResult(int PackagesWithAssets, int TotalFilesExtracted); diff --git a/src/BlazorWebFormsComponents.Cli/Config/PrescanAnalyzer.cs b/src/BlazorWebFormsComponents.Cli/Config/PrescanAnalyzer.cs new file mode 100644 index 000000000..47349053e --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Config/PrescanAnalyzer.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace BlazorWebFormsComponents.Cli.Config; + +/// +/// Scans source files for common Web Forms migration patterns and emits a JSON summary. +/// Ported from the PowerShell -Prescan workflow in bwfc-migrate.ps1. +/// +public class PrescanAnalyzer +{ + private static readonly IReadOnlyList s_rules = + [ + new("BWFC001", "Missing [Parameter]", @"public\s+\w+\s+\w+\s*\{\s*get\s*;\s*set\s*;\s*\}", "Public properties that may need [Parameter] attribute"), + new("BWFC002", "ViewState Usage", @"ViewState\s*\[", "ViewState dictionary access"), + new("BWFC003", "IsPostBack", @"(Page\.)?IsPostBack", "IsPostBack checks"), + new("BWFC004", "Response.Redirect", @"Response\.Redirect\s*\(", "Response.Redirect calls"), + new("BWFC005", "Session Usage", @"Session\s*\[|HttpContext\.Current\.Session", "Session state access"), + new("BWFC011", "Event Handler Signatures", @"\(\s*object\s+\w+\s*,\s*EventArgs", "Web Forms event handler signatures"), + new("BWFC012", "runat=\"server\"", @"runat\s*=\s*""server""", "runat=\"server\" artifacts in strings/comments"), + new("BWFC013", "Response Object", @"Response\.(Write|WriteFile|Clear|Flush|End)\s*\(", "Response object method calls"), + new("BWFC014", "Request Object", @"Request\.(Form|Cookies|Headers|Files|QueryString|ServerVariables)\s*[\[\.]", "Request object property access"), + new("BWFC015", "Server Utility", @"Server\.(MapPath|HtmlEncode|HtmlDecode|UrlEncode|UrlDecode)\s*\(", "Server utility calls — use ServerShim on WebFormsPageBase"), + new("BWFC016", "ConfigurationManager", @"ConfigurationManager\.(AppSettings|ConnectionStrings)\s*\[", "ConfigurationManager access — BWFC provides shim"), + new("BWFC017", "ClientScript", @"(Page\.)?ClientScript\.(RegisterStartupScript|RegisterClientScriptBlock|RegisterClientScriptInclude|GetPostBackEventReference)\s*\(", "ClientScript calls — use ClientScriptShim"), + new("BWFC018", "Cache Access", @"\bCache\s*\[", "Cache dictionary access — use CacheShim on WebFormsPageBase") + ]; + + public PrescanResult Analyze(string sourcePath) + { + var result = new PrescanResult + { + ScanDate = DateTimeOffset.UtcNow, + SourcePath = sourcePath + }; + + if (!Directory.Exists(sourcePath)) + return result; + + var csFiles = Directory.EnumerateFiles(sourcePath, "*.cs", SearchOption.AllDirectories).ToList(); + result.TotalFiles = csFiles.Count; + + foreach (var file in csFiles) + { + string content; + try + { + content = File.ReadAllText(file); + } + catch + { + continue; + } + + var fileMatches = new List(); + foreach (var rule in s_rules) + { + var matches = rule.Regex.Matches(content); + if (matches.Count == 0) + continue; + + var lines = matches.Select(m => GetLineNumber(content, m.Index)).ToList(); + fileMatches.Add(new PrescanFileMatch(rule.Id, rule.Name, matches.Count, lines)); + + if (!result.Summary.TryGetValue(rule.Id, out var summary)) + { + summary = new PrescanSummary(rule.Name, rule.Description, 0, 0); + } + + result.Summary[rule.Id] = summary with + { + TotalHits = summary.TotalHits + matches.Count, + FileCount = summary.FileCount + 1 + }; + + result.TotalMatches += matches.Count; + } + + if (fileMatches.Count == 0) + continue; + + var relativePath = Path.GetRelativePath(sourcePath, file); + result.Files.Add(new PrescanFileResult(relativePath, fileMatches)); + } + + result.FilesWithMatches = result.Files.Count; + return result; + } + + public static string ToJson(PrescanResult result) + { + return JsonSerializer.Serialize(result, new JsonSerializerOptions + { + WriteIndented = true + }); + } + + private static int GetLineNumber(string content, int index) + { + var line = 1; + for (var i = 0; i < index; i++) + { + if (content[i] == '\n') + line++; + } + + return line; + } + + private sealed record PrescanRule(string Id, string Name, string Pattern, string Description) + { + public Regex Regex { get; } = new(Pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } +} + +public class PrescanResult +{ + public DateTimeOffset ScanDate { get; set; } + public string SourcePath { get; set; } = string.Empty; + public Dictionary Summary { get; } = []; + public List Files { get; } = []; + public int TotalFiles { get; set; } + public int FilesWithMatches { get; set; } + public int TotalMatches { get; set; } +} + +public sealed record PrescanSummary(string Name, string Description, int TotalHits, int FileCount); +public sealed record PrescanFileResult(string Path, IReadOnlyList Matches); +public sealed record PrescanFileMatch(string Rule, string Name, int Count, IReadOnlyList Lines); diff --git a/src/BlazorWebFormsComponents.Cli/Interop/PowerShellScriptRunner.cs b/src/BlazorWebFormsComponents.Cli/Interop/PowerShellScriptRunner.cs new file mode 100644 index 000000000..3936fe539 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Interop/PowerShellScriptRunner.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; + +namespace BlazorWebFormsComponents.Cli.Interop; + +public class PowerShellScriptRunner +{ + public async Task RunAsync(string scriptPath, IReadOnlyList arguments) + { + var shell = OperatingSystem.IsWindows() ? "powershell" : "pwsh"; + var quotedArguments = string.Join(" ", arguments.Select(QuoteArgument)); + var psi = new ProcessStartInfo + { + FileName = shell, + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File {QuoteArgument(scriptPath)} {quotedArguments}", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var process = new Process { StartInfo = psi }; + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new PowerShellScriptResult( + process.ExitCode, + await stdoutTask, + await stderrTask); + } + + private static string QuoteArgument(string value) + { + if (string.IsNullOrEmpty(value)) + return "\"\""; + + if (!value.Contains(' ') && !value.Contains('"')) + return value; + + return $"\"{value.Replace("\"", "\\\"")}\""; + } +} + +public sealed record PowerShellScriptResult(int ExitCode, string StandardOutput, string StandardError); diff --git a/src/BlazorWebFormsComponents.Cli/Interop/RepoPathResolver.cs b/src/BlazorWebFormsComponents.Cli/Interop/RepoPathResolver.cs new file mode 100644 index 000000000..85dc06f8c --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Interop/RepoPathResolver.cs @@ -0,0 +1,20 @@ +namespace BlazorWebFormsComponents.Cli.Interop; + +internal static class RepoPathResolver +{ + public static string FindFileFromRepoRoot(string relativePath) + { + var current = new DirectoryInfo(AppContext.BaseDirectory); + + while (current is not null) + { + var candidate = Path.Combine(current.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + + current = current.Parent; + } + + throw new FileNotFoundException($"Could not locate '{relativePath}' from '{AppContext.BaseDirectory}'."); + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Io/AppStartCopier.cs b/src/BlazorWebFormsComponents.Cli/Io/AppStartCopier.cs new file mode 100644 index 000000000..de6b8139f --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Io/AppStartCopier.cs @@ -0,0 +1,99 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Io; + +/// +/// Copies App_Start files into the Blazor project root with Web Forms cleanup. +/// +public class AppStartCopier +{ + private readonly OutputWriter _outputWriter; + + public AppStartCopier(OutputWriter outputWriter) + { + _outputWriter = outputWriter; + } + + public async Task CopyAsync(string sourcePath, string outputPath, MigrationReport report) + { + if (!Directory.Exists(sourcePath)) + return 0; + + var appStartDirs = Directory.EnumerateDirectories(sourcePath, "App_Start", SearchOption.AllDirectories).ToList(); + if (appStartDirs.Count == 0) + return 0; + + var copied = 0; + foreach (var appStartDir in appStartDirs) + { + foreach (var file in Directory.EnumerateFiles(appStartDir, "*.cs", SearchOption.TopDirectoryOnly)) + { + var relativePath = Path.GetRelativePath(sourcePath, file); + var destFile = Path.Combine( + outputPath, + "migration-artifacts", + "App_Start", + Path.GetFileName(file) + ".txt"); + var content = await File.ReadAllTextAsync(file); + content = TransformContent(content, Path.GetFileName(file), report, relativePath); + + await _outputWriter.WriteFileAsync(destFile, content, $"Manual App_Start artifact: {relativePath}"); + copied++; + } + } + + if (copied > 0) + Console.WriteLine($" App_Start files quarantined: {copied}"); + + return copied; + } + + private static string TransformContent(string content, string fileName, MigrationReport report, string relativePath) + { + var transformed = "// TODO: Review — auto-copied from App_Start. Blazor has no App_Start convention.\n" + + "// TODO: Move relevant configuration to Program.cs or appropriate service registration.\n\n" + + content; + + transformed = Regex.Replace(transformed, @"(?m)^\s*\[assembly:\s*[^\]]*\]\s*\r?\n?", ""); + transformed = Regex.Replace(transformed, @"using\s+System\.Web\.UI(\.\w+)*;\s*\r?\n?", ""); + transformed = Regex.Replace(transformed, @"using\s+System\.Web\.Security;\s*\r?\n?", ""); + + if (Regex.IsMatch(transformed, @"\b(Bundle|BundleTable|BundleCollection)\b")) + { + transformed = Regex.Replace( + transformed, + @"using\s+System\.Web\.Optimization;\s*\r?\n?", + "// using System.Web.Optimization; // BWFC: BundleConfig stubs available via BlazorWebFormsComponents namespace\n"); + } + else + { + transformed = Regex.Replace(transformed, @"using\s+System\.Web\.Optimization;\s*\r?\n?", ""); + } + + if (Regex.IsMatch(transformed, @"\b(Route|RouteTable|RouteCollection)\b")) + { + transformed = Regex.Replace( + transformed, + @"using\s+System\.Web\.Routing;\s*\r?\n?", + "// using System.Web.Routing; // BWFC: RouteConfig stubs available via BlazorWebFormsComponents namespace\n"); + } + else + { + transformed = Regex.Replace(transformed, @"using\s+System\.Web\.Routing;\s*\r?\n?", ""); + } + + transformed = Regex.Replace(transformed, @"using\s+System\.Web(\.\w+)*;\s*\r?\n?", ""); + transformed = Regex.Replace(transformed, @"using\s+Microsoft\.AspNet(\.\w+)*;\s*\r?\n?", ""); + transformed = Regex.Replace(transformed, @"using\s+Microsoft\.Owin(\.\w+)*;\s*\r?\n?", ""); + transformed = Regex.Replace(transformed, @"using\s+Owin;\s*\r?\n?", ""); + + if (fileName.Equals("WebApiConfig.cs", StringComparison.OrdinalIgnoreCase)) + { + report.AddManualItem(relativePath, 0, "AppStart", "WebApiConfig detected — migrate to minimal API endpoints in Program.cs"); + transformed = "// TODO: BWFC — Web API configuration should be migrated to minimal API endpoints in Program.cs\n" + transformed; + } + + return transformed; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Io/SourceFileCopier.cs b/src/BlazorWebFormsComponents.Cli/Io/SourceFileCopier.cs index d15b7e56f..d2c66f6e7 100644 --- a/src/BlazorWebFormsComponents.Cli/Io/SourceFileCopier.cs +++ b/src/BlazorWebFormsComponents.Cli/Io/SourceFileCopier.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using BlazorWebFormsComponents.Cli.Pipeline; using BlazorWebFormsComponents.Cli.Transforms; @@ -13,16 +14,24 @@ public class SourceFileCopier private static readonly HashSet ExcludedDirs = new(StringComparer.OrdinalIgnoreCase) { "bin", "obj", "packages", "node_modules", ".vs", ".git", - "Properties", "App_Start", "App_Data", "Migrations" + "Properties", "App_Start", "App_Data" }; - private static readonly HashSet ExcludedFiles = new(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet QuarantinedFileNames = new(StringComparer.OrdinalIgnoreCase) { "AssemblyInfo.cs", "Global.asax.cs", "Startup.cs", "Startup.Auth.cs", "BundleConfig.cs", "FilterConfig.cs", "RouteConfig.cs", "WebApiConfig.cs", "IdentityConfig.cs" }; + private static readonly (Regex Pattern, string Reason)[] QuarantineRules = + [ + (new Regex(@"\b(Microsoft\.Owin|Owin|IAppBuilder|GetOwinContext|DefaultAuthenticationTypes)\b", RegexOptions.Compiled), "OWIN/bootstrap wiring does not belong in the generated Blazor SSR compile surface."), + (new Regex(@"\b(ApplicationUserManager|ApplicationSignInManager|IdentityFactoryOptions|IIdentityMessageService|IdentityDbContext)\b", RegexOptions.Compiled), "ASP.NET Identity bootstrap code needs app-specific migration and should not be compiled as-is."), + (new Regex(@"\b(HttpContext\.Current|System\.Web\.HttpContext)\b", RegexOptions.Compiled), "Legacy HttpContext access indicates runtime assumptions that are unsafe in the generated SSR compile surface."), + (new Regex(@"\b(DropCreateDatabaseAlways|DropCreateDatabaseIfModelChanges|CreateDatabaseIfNotExists|DbMigrationsConfiguration|DbMigration)\b", RegexOptions.Compiled), "Legacy EF initializer or migrations bootstrap should be reviewed manually instead of compiled directly.") + ]; + private readonly OutputWriter _outputWriter; private readonly IReadOnlyList _transforms; @@ -43,7 +52,9 @@ public async Task CopySourceFilesAsync( string sourcePath, string outputPath, IReadOnlyList pageFiles, - bool verbose) + bool verbose, + MigrationReport report, + ISet? additionalExcludedFiles = null) { if (!Directory.Exists(sourcePath)) return 0; @@ -56,7 +67,8 @@ public async Task CopySourceFilesAsync( codeBehindPaths.Add(Path.GetFullPath(pf.CodeBehindPath)); } - var count = 0; + var copiedCount = 0; + var quarantinedCount = 0; foreach (var file in Directory.EnumerateFiles(sourcePath, "*.cs", SearchOption.AllDirectories)) { @@ -66,6 +78,9 @@ public async Task CopySourceFilesAsync( if (codeBehindPaths.Contains(fullPath)) continue; + if (additionalExcludedFiles is not null && additionalExcludedFiles.Contains(fullPath)) + continue; + var relativePath = Path.GetRelativePath(sourcePath, file); var fileName = Path.GetFileName(file); @@ -74,16 +89,24 @@ public async Task CopySourceFilesAsync( if (ExcludedDirs.Contains(topDir)) continue; - // Skip excluded files - if (ExcludedFiles.Contains(fileName)) - continue; - // Skip designer files if (fileName.EndsWith(".designer.cs", StringComparison.OrdinalIgnoreCase)) continue; - // Read and apply transforms var content = await File.ReadAllTextAsync(file); + + var decision = Classify(relativePath, fileName, topDir, content); + if (decision.ShouldQuarantine) + { + var artifactPath = Path.Combine(outputPath, "migration-artifacts", "compile-surface", relativePath + ".txt"); + var quarantinedContent = BuildQuarantineArtifact(relativePath, decision.Reason!, content); + await _outputWriter.WriteFileAsync(artifactPath, quarantinedContent, $"Compile-surface artifact: {relativePath}"); + report.AddManualItem(relativePath, 0, "bwfc-compile-surface", decision.Reason!); + quarantinedCount++; + continue; + } + + // Read and apply transforms var metadata = new FileMetadata { SourceFilePath = file, @@ -99,12 +122,48 @@ public async Task CopySourceFilesAsync( var destPath = Path.Combine(outputPath, relativePath); await _outputWriter.WriteFileAsync(destPath, content, $"Source: {relativePath}"); - count++; + copiedCount++; + } + + if (verbose || copiedCount > 0) + Console.WriteLine($" Source files copied: {copiedCount} (with namespace transforms)"); + if (verbose || quarantinedCount > 0) + Console.WriteLine($" Compile-surface artifacts quarantined: {quarantinedCount}"); + + return copiedCount + quarantinedCount; + } + + private static CompileSurfaceDecision Classify(string relativePath, string fileName, string topDir, string content) + { + if (QuarantinedFileNames.Contains(fileName)) + { + return new CompileSurfaceDecision(true, $"Legacy bootstrap file '{fileName}' was quarantined from the generated compile surface."); } - if (verbose || count > 0) - Console.WriteLine($" Source files copied: {count} (with namespace transforms)"); + if (topDir.Equals("Migrations", StringComparison.OrdinalIgnoreCase)) + { + return new CompileSurfaceDecision(true, $"Legacy migrations source '{relativePath}' was quarantined from the generated compile surface."); + } - return count; + foreach (var (pattern, reason) in QuarantineRules) + { + if (pattern.IsMatch(content)) + { + return new CompileSurfaceDecision(true, reason); + } + } + + return new CompileSurfaceDecision(false, null); } + + private static string BuildQuarantineArtifact(string relativePath, string reason, string content) + { + return + $"// TODO: Review — '{relativePath}' was quarantined from the generated Blazor SSR compile surface.{Environment.NewLine}" + + $"// TODO: Reason — {reason}{Environment.NewLine}" + + $"// TODO: Move or rewrite only the relevant pieces into Program.cs, services, middleware, or application code.{Environment.NewLine}{Environment.NewLine}" + + content; + } + + private sealed record CompileSurfaceDecision(bool ShouldQuarantine, string? Reason); } diff --git a/src/BlazorWebFormsComponents.Cli/Io/SourceRootResolver.cs b/src/BlazorWebFormsComponents.Cli/Io/SourceRootResolver.cs new file mode 100644 index 000000000..c07a67ec1 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Io/SourceRootResolver.cs @@ -0,0 +1,55 @@ +namespace BlazorWebFormsComponents.Cli.Io; + +/// +/// Resolves the effective Web Forms app root for migration input. +/// Some solutions wrap the real app in a child folder with the same name +/// as the solution root (for example samples\WingtipToys\WingtipToys\). +/// +public class SourceRootResolver +{ + private static readonly HashSet MarkupExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".aspx", ".ascx", ".master" + }; + + public string Resolve(string inputPath) + { + if (string.IsNullOrWhiteSpace(inputPath) || File.Exists(inputPath) || !Directory.Exists(inputPath)) + return inputPath; + + var inputDirectory = new DirectoryInfo(inputPath); + var candidatePath = Path.Combine(inputDirectory.FullName, inputDirectory.Name); + if (!Directory.Exists(candidatePath)) + return inputPath; + + if (!ContainsMarkup(candidatePath)) + return inputPath; + + var candidateRoot = EnsureTrailingSeparator(Path.GetFullPath(candidatePath)); + var markupFiles = Directory.EnumerateFiles(inputDirectory.FullName, "*.*", SearchOption.AllDirectories) + .Where(file => MarkupExtensions.Contains(Path.GetExtension(file))); + + foreach (var markupFile in markupFiles) + { + var fullPath = Path.GetFullPath(markupFile); + if (!fullPath.StartsWith(candidateRoot, StringComparison.OrdinalIgnoreCase)) + return inputPath; + } + + return candidatePath; + } + + private static bool ContainsMarkup(string directory) + { + return Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories) + .Any(file => MarkupExtensions.Contains(Path.GetExtension(file))); + } + + private static string EnsureTrailingSeparator(string path) + { + if (path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar)) + return path; + + return path + Path.DirectorySeparatorChar; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/FileMetadata.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/FileMetadata.cs index ca1fde3ba..68c89bd33 100644 --- a/src/BlazorWebFormsComponents.Cli/Pipeline/FileMetadata.cs +++ b/src/BlazorWebFormsComponents.Cli/Pipeline/FileMetadata.cs @@ -9,6 +9,8 @@ public class FileMetadata public required string OutputFilePath { get; init; } public required FileType FileType { get; init; } public required string OriginalContent { get; init; } + public string? OutputRootPath { get; init; } + public string? ProjectNamespace { get; init; } public string? CodeBehindContent { get; set; } public Dictionary DataBindMap { get; set; } = new(); diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs index a4e52135a..150a2cb5c 100644 --- a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs +++ b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationPipeline.cs @@ -1,7 +1,9 @@ using BlazorWebFormsComponents.Cli.Config; using BlazorWebFormsComponents.Cli.Io; using BlazorWebFormsComponents.Cli.Scaffolding; +using BlazorWebFormsComponents.Cli.SemanticPatterns; using BlazorWebFormsComponents.Cli.Transforms; +using Microsoft.Extensions.DependencyInjection; namespace BlazorWebFormsComponents.Cli.Pipeline; @@ -19,23 +21,37 @@ public class MigrationPipeline private readonly OutputWriter _outputWriter; private readonly StaticFileCopier? _staticFileCopier; private readonly SourceFileCopier? _sourceFileCopier; + private readonly AppStartCopier? _appStartCopier; + private readonly AppAssetInjector? _appAssetInjector; + private readonly NuGetStaticAssetExtractor? _nuGetStaticAssetExtractor; + private readonly EdmxConverterBridge? _edmxConverterBridge; + private readonly RedirectHandlerAnnotator? _redirectHandlerAnnotator; + private readonly SemanticPatternCatalog _semanticPatternCatalog; /// /// Full constructor for DI — used by the CLI commands. /// + [ActivatorUtilitiesConstructor] public MigrationPipeline( IEnumerable markupTransforms, IEnumerable codeBehindTransforms, + SemanticPatternCatalog semanticPatternCatalog, ProjectScaffolder scaffolder, GlobalUsingsGenerator globalUsings, ShimGenerator shimGenerator, WebConfigTransformer webConfigTransformer, OutputWriter outputWriter, StaticFileCopier staticFileCopier, - SourceFileCopier sourceFileCopier) + SourceFileCopier sourceFileCopier, + AppStartCopier appStartCopier, + AppAssetInjector appAssetInjector, + NuGetStaticAssetExtractor nuGetStaticAssetExtractor, + EdmxConverterBridge edmxConverterBridge, + RedirectHandlerAnnotator redirectHandlerAnnotator) { _markupTransforms = markupTransforms.OrderBy(t => t.Order).ToList(); _codeBehindTransforms = codeBehindTransforms.OrderBy(t => t.Order).ToList(); + _semanticPatternCatalog = semanticPatternCatalog; _scaffolder = scaffolder; _globalUsings = globalUsings; _shimGenerator = shimGenerator; @@ -43,6 +59,11 @@ public MigrationPipeline( _outputWriter = outputWriter; _staticFileCopier = staticFileCopier; _sourceFileCopier = sourceFileCopier; + _appStartCopier = appStartCopier; + _appAssetInjector = appAssetInjector; + _nuGetStaticAssetExtractor = nuGetStaticAssetExtractor; + _edmxConverterBridge = edmxConverterBridge; + _redirectHandlerAnnotator = redirectHandlerAnnotator; } /// @@ -50,15 +71,22 @@ public MigrationPipeline( /// public MigrationPipeline( IEnumerable markupTransforms, - IEnumerable codeBehindTransforms) + IEnumerable codeBehindTransforms, + IEnumerable? semanticPatterns = null) { _markupTransforms = markupTransforms.OrderBy(t => t.Order).ToList(); _codeBehindTransforms = codeBehindTransforms.OrderBy(t => t.Order).ToList(); + _semanticPatternCatalog = new SemanticPatternCatalog(semanticPatterns ?? []); _scaffolder = null!; _globalUsings = null!; _shimGenerator = null!; _webConfigTransformer = null!; _outputWriter = null!; + _appStartCopier = null; + _appAssetInjector = null; + _nuGetStaticAssetExtractor = null; + _edmxConverterBridge = null; + _redirectHandlerAnnotator = null; } /// @@ -101,15 +129,58 @@ public async Task ExecuteAsync(MigrationContext context) context.SourcePath, context.OutputPath, context.Options.Verbose); } - // Step 5: Copy non-page source files (Models, Logic, etc.) with namespace transforms + var projectName = GetProjectName(context.SourcePath); + + // Step 5: Generate EF Core output from EDMX before source copy so original T4 artifacts can be skipped. + ISet? excludedSourceFiles = null; + if (_edmxConverterBridge != null) + { + excludedSourceFiles = await _edmxConverterBridge.ConvertAsync( + context.SourcePath, + context.OutputPath, + projectName, + context.Options.DryRun, + report); + } + + // Step 6: Copy non-page source files (Models, Logic, etc.) with namespace transforms if (_sourceFileCopier != null) { var sourceCount = await _sourceFileCopier.CopySourceFilesAsync( - context.SourcePath, context.OutputPath, context.SourceFiles, context.Options.Verbose); + context.SourcePath, context.OutputPath, context.SourceFiles, context.Options.Verbose, report, excludedSourceFiles); report.FilesWritten += sourceCount; } - // Step 6: Generate report + // Step 7: Copy App_Start artifacts to the project root + if (_appStartCopier != null) + { + var appStartCount = await _appStartCopier.CopyAsync(context.SourcePath, context.OutputPath, report); + report.FilesWritten += appStartCount; + } + + // Step 8: Extract package static assets + if (_nuGetStaticAssetExtractor != null) + { + await _nuGetStaticAssetExtractor.ExtractAsync( + context.SourcePath, + context.OutputPath, + context.Options.DryRun, + report); + } + + // Step 9: Inject CSS/JS references into App.razor + if (_appAssetInjector != null && !context.Options.SkipScaffold) + { + await _appAssetInjector.InjectAsync(context.SourcePath, context.OutputPath); + } + + // Step 10: Annotate Program.cs for redirect-only pages + if (_redirectHandlerAnnotator != null) + { + await _redirectHandlerAnnotator.AnnotateAsync(context, report); + } + + // Step 11: Generate report report.GeneratedFiles.AddRange(_outputWriter.WrittenFiles); report.FilesWritten = _outputWriter.WrittenFiles.Count; @@ -123,9 +194,7 @@ public async Task ExecuteAsync(MigrationContext context) private async Task ScaffoldProjectAsync(MigrationContext context, MigrationReport report) { - var projectName = Path.GetFileName(context.SourcePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - if (string.IsNullOrEmpty(projectName)) - projectName = "MigratedApp"; + var projectName = GetProjectName(context.SourcePath); Console.WriteLine($"Scaffolding project: {projectName}"); @@ -182,12 +251,16 @@ await _outputWriter.WriteFileAsync(appSettingsPath, configResult.JsonContent, private async Task ProcessSourceFileAsync(SourceFile sourceFile, MigrationContext context, MigrationReport report) { var markupContent = await File.ReadAllTextAsync(sourceFile.MarkupPath); + var projectName = GetProjectName(context.SourcePath); + var metadata = new FileMetadata { SourceFilePath = sourceFile.MarkupPath, OutputFilePath = sourceFile.OutputPath, FileType = sourceFile.FileType, - OriginalContent = markupContent + OriginalContent = markupContent, + OutputRootPath = context.OutputPath, + ProjectNamespace = projectName }; // Read code-behind if present @@ -218,7 +291,16 @@ private async Task ProcessSourceFileAsync(SourceFile sourceFile, MigrationContex } // Use potentially modified markup content from code-behind transforms - var finalMarkup = metadata.MarkupContent ?? markup; + var semanticResult = _semanticPatternCatalog.Apply( + context, + sourceFile, + metadata, + metadata.MarkupContent ?? markup, + codeBehind, + report); + + var finalMarkup = semanticResult.Markup; + codeBehind = semanticResult.CodeBehind; // Write markup output await _outputWriter.WriteFileAsync(sourceFile.OutputPath, finalMarkup, @@ -227,9 +309,14 @@ await _outputWriter.WriteFileAsync(sourceFile.OutputPath, finalMarkup, // Write code-behind output if (codeBehind != null) { - var codeOutputPath = sourceFile.OutputPath + ".cs"; + 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, - $"Code-behind for {Path.GetFileName(sourceFile.MarkupPath)}"); + $"Manual code-behind artifact for {Path.GetFileName(sourceFile.MarkupPath)}"); } report.FilesProcessed++; @@ -258,4 +345,10 @@ public string TransformCodeBehind(string content, FileMetadata metadata) } return content; } + + private static string GetProjectName(string sourcePath) + { + var projectName = Path.GetFileName(sourcePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + return string.IsNullOrEmpty(projectName) ? "MigratedApp" : projectName; + } } diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationReport.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationReport.cs index a5fc6aef9..fe2aea5bc 100644 --- a/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationReport.cs +++ b/src/BlazorWebFormsComponents.Cli/Pipeline/MigrationReport.cs @@ -18,6 +18,7 @@ public class MigrationReport public int FilesProcessed { get; set; } public int FilesWritten { get; set; } public int TransformsApplied { get; set; } + public int SemanticPatternsApplied { get; set; } public int ScaffoldFilesGenerated { get; set; } public int StaticFilesCopied { get; set; } public List Errors { get; } = []; @@ -43,6 +44,7 @@ public string ToJson() FilesProcessed, FilesWritten, TransformsApplied, + SemanticPatternsApplied, ScaffoldFilesGenerated, StaticFilesCopied, ErrorCount = Errors.Count, @@ -67,6 +69,7 @@ public void PrintSummary() Console.WriteLine($" Files processed: {FilesProcessed}"); Console.WriteLine($" Files written: {FilesWritten}"); Console.WriteLine($" Transforms applied: {TransformsApplied}"); + Console.WriteLine($" Semantic patterns: {SemanticPatternsApplied}"); Console.WriteLine($" Scaffold files: {ScaffoldFilesGenerated}"); Console.WriteLine($" Static files: {StaticFilesCopied}"); diff --git a/src/BlazorWebFormsComponents.Cli/Pipeline/RedirectHandlerAnnotator.cs b/src/BlazorWebFormsComponents.Cli/Pipeline/RedirectHandlerAnnotator.cs new file mode 100644 index 000000000..f752468cc --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Pipeline/RedirectHandlerAnnotator.cs @@ -0,0 +1,168 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Io; +using BlazorWebFormsComponents.Cli.SemanticPatterns; + +namespace BlazorWebFormsComponents.Cli.Pipeline; + +/// +/// Detects redirect-only pages and annotates Program.cs with minimal API migration TODOs. +/// +public class RedirectHandlerAnnotator +{ + private static readonly Regex QueryStringAccessRegex = new( + @"Request\.QueryString\[""(?[^""]+)""\]", + RegexOptions.Compiled); + private static readonly Regex RedirectLiteralRegex = new( + @"Response\.Redirect\(\s*""(?[^""]+)""", + RegexOptions.Compiled); + private static readonly Regex PageAndTitleRegex = new( + @"(?is)^\s*@page\s+""[^""]+""\s*|.*?|<%@\s*\w+[^%]*%>", + RegexOptions.Compiled); + private static readonly Regex WrapperTagRegex = new( + @"(?is)]*)?>", + RegexOptions.Compiled); + + private readonly OutputWriter _outputWriter; + + public RedirectHandlerAnnotator(OutputWriter outputWriter) + { + _outputWriter = outputWriter; + } + + public async Task AnnotateAsync(MigrationContext context, MigrationReport report) + { + if (context.Options.SkipScaffold) + return 0; + + var handlerBlocks = new List(); + foreach (var sourceFile in context.SourceFiles.Where(f => f.FileType == FileType.Page && f.HasCodeBehind)) + { + var markupContent = await File.ReadAllTextAsync(sourceFile.MarkupPath); + if (!TryBuildActionHandlerStub(sourceFile, markupContent, out var handlerBlock, out var manualDescription)) + continue; + + var pageName = Path.GetFileNameWithoutExtension(sourceFile.MarkupPath); + handlerBlocks.Add(handlerBlock); + report.AddManualItem( + Path.GetRelativePath(context.SourcePath, sourceFile.MarkupPath), + 0, + "RedirectHandler", + manualDescription); + } + + if (context.SourceFiles.Any(IsLoginPage)) + { + handlerBlocks.Add(BuildLoginHandlerBlock()); + report.AddManualItem("Account/Login.aspx", 0, "bwfc-identity", "Login.razor submits to /Account/PerformLogin — replace the generated stub with your real authentication endpoint.", "high"); + } + + if (context.SourceFiles.Any(IsRegisterPage)) + { + handlerBlocks.Add(BuildRegisterHandlerBlock()); + report.AddManualItem("Account/Register.aspx", 0, "bwfc-identity", "Register.razor submits to /Account/PerformRegister — replace the generated stub with your real registration endpoint.", "high"); + } + + if (handlerBlocks.Count == 0) + return 0; + + var programPath = Path.Combine(context.OutputPath, "Program.cs"); + if (!File.Exists(programPath)) + return handlerBlocks.Count; + + var programContent = await File.ReadAllTextAsync(programPath); + if (programContent.Contains("// --- BWFC generated handler stubs ---", StringComparison.Ordinal)) + return handlerBlocks.Count; + + var insertion = $"// --- BWFC generated handler stubs ---{Environment.NewLine}{string.Join($"{Environment.NewLine}{Environment.NewLine}", handlerBlocks)}{Environment.NewLine}{Environment.NewLine}"; + if (programContent.Contains("app.MapRazorComponents<", StringComparison.Ordinal)) + { + programContent = programContent.Replace("app.MapRazorComponents<", insertion + "app.MapRazorComponents<", StringComparison.Ordinal); + await _outputWriter.WriteFileAsync(programPath, programContent, "Annotate Program.cs with generated handler stubs"); + } + + return handlerBlocks.Count; + } + + private static bool TryBuildActionHandlerStub(SourceFile sourceFile, string markupContent, out string handlerBlock, out string manualDescription) + { + handlerBlock = string.Empty; + manualDescription = string.Empty; + + var codeBehindPath = sourceFile.CodeBehindPath; + if (string.IsNullOrEmpty(codeBehindPath) || !File.Exists(codeBehindPath)) + return false; + + var codeBehind = File.ReadAllText(codeBehindPath); + if (!codeBehind.Contains("Response.Redirect", StringComparison.Ordinal)) + return false; + + var stripped = PageAndTitleRegex.Replace(markupContent, string.Empty); + stripped = WrapperTagRegex.Replace(stripped, string.Empty); + stripped = Regex.Replace(stripped, @"\s+| ", string.Empty, RegexOptions.IgnoreCase); + if (!string.IsNullOrEmpty(stripped)) + return false; + + var pageName = Path.GetFileNameWithoutExtension(sourceFile.MarkupPath); + var redirectTarget = RedirectLiteralRegex.Match(codeBehind); + var normalizedTarget = redirectTarget.Success + ? SemanticPatternUtilities.NormalizeRoute(redirectTarget.Groups["target"].Value) + : "/"; + var queryKeys = QueryStringAccessRegex.Matches(codeBehind) + .Select(static match => match.Groups["name"].Value) + .Distinct(StringComparer.Ordinal) + .ToArray(); + var endpointRoute = ActionPagesSemanticPattern.GetEndpointRoute(pageName); + var builder = new System.Text.StringBuilder(); + builder.AppendLine($"app.MapPost(\"{endpointRoute}\", async (HttpContext context) =>"); + builder.AppendLine("{"); + builder.AppendLine(" var form = await context.Request.ReadFormAsync();"); + foreach (var queryKey in queryKeys) + { + builder.AppendLine($" var {SemanticPatternUtilities.ToPropertyName(queryKey)} = form[\"{queryKey}\"].ToString();"); + } + builder.AppendLine($" // TODO(bwfc-action-pages): move the original {pageName} side effect into this HTTP handler or a scoped service."); + builder.AppendLine($" return Results.Redirect(\"{normalizedTarget}\");"); + builder.AppendLine("}).DisableAntiforgery();"); + + handlerBlock = builder.ToString().TrimEnd(); + manualDescription = $"{pageName} was a redirect handler (Response.Redirect in code-behind) — generated POST stub {endpointRoute} redirects to {normalizedTarget} until the side effect is migrated."; + return true; + } + + private static bool IsLoginPage(SourceFile sourceFile) => + sourceFile.MarkupPath.Contains($"{Path.DirectorySeparatorChar}Account{Path.DirectorySeparatorChar}Login.aspx", StringComparison.OrdinalIgnoreCase) + || sourceFile.MarkupPath.EndsWith($"{Path.DirectorySeparatorChar}Login.aspx", StringComparison.OrdinalIgnoreCase); + + private static bool IsRegisterPage(SourceFile sourceFile) => + sourceFile.MarkupPath.Contains($"{Path.DirectorySeparatorChar}Account{Path.DirectorySeparatorChar}Register.aspx", StringComparison.OrdinalIgnoreCase) + || sourceFile.MarkupPath.EndsWith($"{Path.DirectorySeparatorChar}Register.aspx", StringComparison.OrdinalIgnoreCase); + + private static string BuildLoginHandlerBlock() => + """ +app.MapGet("/Account/PerformLogin", (string? email, string? password, string? returnUrl) => +{ + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) + return Results.Redirect("/Account/Login?error=Email%20and%20password%20are%20required"); + + // TODO(bwfc-identity): Replace this stub with a real authentication endpoint that issues cookies. + return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) + ? "/Account/Login?error=Authentication%20is%20not%20wired%20yet" + : $"/Account/Login?error=Authentication%20is%20not%20wired%20yet&returnUrl={Uri.EscapeDataString(returnUrl)}"); +}); +"""; + + private static string BuildRegisterHandlerBlock() => + """ +app.MapGet("/Account/PerformRegister", (string? email, string? password, string? confirmPassword) => +{ + if (string.IsNullOrWhiteSpace(email) || string.IsNullOrWhiteSpace(password)) + return Results.Redirect("/Account/Register?error=Email%20and%20password%20are%20required"); + + if (!string.Equals(password, confirmPassword, StringComparison.Ordinal)) + return Results.Redirect("/Account/Register?error=Passwords%20do%20not%20match"); + + // TODO(bwfc-identity): Replace this stub with a real registration endpoint that creates a user record. + return Results.Redirect("/Account/Login?registered=1"); +}); +"""; +} diff --git a/src/BlazorWebFormsComponents.Cli/Program.cs b/src/BlazorWebFormsComponents.Cli/Program.cs index b3115458d..3ae925d68 100644 --- a/src/BlazorWebFormsComponents.Cli/Program.cs +++ b/src/BlazorWebFormsComponents.Cli/Program.cs @@ -1,8 +1,10 @@ using System.CommandLine; using BlazorWebFormsComponents.Cli.Config; +using BlazorWebFormsComponents.Cli.Interop; using BlazorWebFormsComponents.Cli.Io; using BlazorWebFormsComponents.Cli.Pipeline; using BlazorWebFormsComponents.Cli.Scaffolding; +using BlazorWebFormsComponents.Cli.SemanticPatterns; using BlazorWebFormsComponents.Cli.Transforms; using BlazorWebFormsComponents.Cli.Transforms.CodeBehind; using BlazorWebFormsComponents.Cli.Transforms.Directives; @@ -22,6 +24,7 @@ static async Task Main(string[] args) rootCommand.AddCommand(BuildMigrateCommand()); rootCommand.AddCommand(BuildConvertCommand()); + rootCommand.AddCommand(BuildPrescanCommand()); return await rootCommand.InvokeAsync(args); } @@ -60,6 +63,7 @@ private static ServiceProvider BuildServiceProvider() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -79,26 +83,55 @@ private static ServiceProvider BuildServiceProvider() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Config services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // I/O services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - - // Pipeline - services.AddSingleton(); + services.AddSingleton(); + + // Pipeline + services.AddSingleton(); + // Registration order is significant when patterns share the same Order value. + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => new MigrationPipeline( + sp.GetServices(), + sp.GetServices(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); return services.BuildServiceProvider(); } private static Command BuildMigrateCommand() { - var migrateCommand = new Command("migrate", "Full project migration from Web Forms to Blazor"); + var migrateCommand = new Command("migrate", "Full project migration from Web Forms to Blazor SSR on .NET 10"); var inputOption = new Option( aliases: ["--input", "-i"], @@ -107,12 +140,12 @@ private static Command BuildMigrateCommand() var outputOption = new Option( aliases: ["--output", "-o"], - description: "Output Blazor project directory (required)") + description: "Output .NET 10 Blazor SSR project directory (required)") { IsRequired = true }; var skipScaffoldOption = new Option( name: "--skip-scaffold", - description: "Skip .csproj, Program.cs, _Imports.razor generation", + description: "Skip .NET 10 Blazor SSR scaffold generation (.csproj, Program.cs, _Imports.razor, App.razor, Routes.razor)", getDefaultValue: () => false); var dryRunOption = new Option( @@ -147,12 +180,14 @@ private static Command BuildMigrateCommand() try { using var sp = BuildServiceProvider(); + var sourceRootResolver = sp.GetRequiredService(); var scanner = sp.GetRequiredService(); var pipeline = sp.GetRequiredService(); + var effectiveInput = sourceRootResolver.Resolve(input); var context = new MigrationContext { - SourcePath = input, + SourcePath = effectiveInput, OutputPath = output, Options = new MigrationOptions { @@ -164,9 +199,11 @@ private static Command BuildMigrateCommand() } }; - context.SourceFiles = scanner.Scan(input, output); + context.SourceFiles = scanner.Scan(effectiveInput, output); Console.WriteLine($"Found {context.SourceFiles.Count} Web Forms file(s) to migrate..."); + if (verbose && !string.Equals(input, effectiveInput, StringComparison.OrdinalIgnoreCase)) + Console.WriteLine($"Resolved source root: {effectiveInput}"); if (dryRun) Console.WriteLine("(dry-run mode — no files will be written)"); @@ -191,7 +228,7 @@ private static Command BuildMigrateCommand() private static Command BuildConvertCommand() { - var convertCommand = new Command("convert", "Single file conversion from Web Forms to Blazor"); + var convertCommand = new Command("convert", "Single file conversion from Web Forms to a Blazor SSR-compatible Razor file"); var inputOption = new Option( aliases: ["--input", "-i"], @@ -285,5 +322,53 @@ private static Command BuildConvertCommand() return convertCommand; } + + private static Command BuildPrescanCommand() + { + var prescanCommand = new Command("prescan", "Scan source files for common Web Forms migration patterns and emit a JSON summary"); + + var inputOption = new Option( + aliases: ["--input", "-i"], + description: "Source Web Forms project root (required)") + { IsRequired = true }; + + var reportOption = new Option( + name: "--report", + description: "Write prescan JSON output to file"); + + prescanCommand.AddOption(inputOption); + prescanCommand.AddOption(reportOption); + + prescanCommand.SetHandler(async (input, report) => + { + try + { + using var sp = BuildServiceProvider(); + var analyzer = sp.GetRequiredService(); + var result = analyzer.Analyze(input); + var json = PrescanAnalyzer.ToJson(result); + + if (!string.IsNullOrEmpty(report)) + { + var directory = Path.GetDirectoryName(report); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + await File.WriteAllTextAsync(report, json); + } + + Console.WriteLine(json); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine($"Error: {ex.Message}"); + Console.ResetColor(); + Environment.Exit(1); + } + }, inputOption, reportOption); + + return prescanCommand; + } } diff --git a/src/BlazorWebFormsComponents.Cli/Scaffolding/AppAssetInjector.cs b/src/BlazorWebFormsComponents.Cli/Scaffolding/AppAssetInjector.cs new file mode 100644 index 000000000..4296aeba8 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Scaffolding/AppAssetInjector.cs @@ -0,0 +1,166 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Io; + +namespace BlazorWebFormsComponents.Cli.Scaffolding; + +/// +/// Injects detected CSS and JavaScript references into Components/App.razor. +/// +public class AppAssetInjector +{ + private readonly OutputWriter _outputWriter; + + public AppAssetInjector(OutputWriter outputWriter) + { + _outputWriter = outputWriter; + } + + public async Task InjectAsync(string sourcePath, string outputPath) + { + var appRazorPath = Path.Combine(outputPath, "Components", "App.razor"); + if (!File.Exists(appRazorPath)) + return new AssetInjectionResult(); + + var appContent = await File.ReadAllTextAsync(appRazorPath); + var cssRefs = GetCssReferences(sourcePath, outputPath) + .Where(reference => !appContent.Contains(reference.Trim(), StringComparison.OrdinalIgnoreCase)) + .ToList(); + var scriptRefs = GetScriptReferences(sourcePath, outputPath) + .Where(reference => !appContent.Contains(reference.Trim(), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (cssRefs.Count == 0 && scriptRefs.Count == 0) + return new AssetInjectionResult(); + + if (cssRefs.Count > 0 && appContent.Contains(")").Replace(appContent, Environment.NewLine + cssBlock + "$1", 1); + } + + if (scriptRefs.Count > 0 && appContent.Contains("", StringComparison.OrdinalIgnoreCase)) + { + var scriptBlock = string.Join(Environment.NewLine, scriptRefs) + Environment.NewLine; + appContent = new Regex(@"\s*", RegexOptions.IgnoreCase).Replace(appContent, Environment.NewLine + scriptBlock + "", 1); + } + + await _outputWriter.WriteFileAsync(appRazorPath, appContent, "Post-process Components/App.razor asset references"); + return new AssetInjectionResult(cssRefs.Count, scriptRefs.Count); + } + + private static List GetCssReferences(string sourcePath, string outputPath) + { + var references = new List(); + references.AddRange(GetCdnHeadReferences(sourcePath)); + references.AddRange(GetLocalCssReferences(outputPath)); + references.AddRange(GetNuGetAssetReferences(outputPath, isCss: true)); + return references.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static List GetScriptReferences(string sourcePath, string outputPath) + { + var references = new List(); + references.AddRange(GetLocalScriptReferences(outputPath)); + references.AddRange(GetNuGetAssetReferences(outputPath, isCss: false)); + return references.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static IEnumerable GetCdnHeadReferences(string sourcePath) + { + var masterFile = Directory.EnumerateFiles(sourcePath, "Site.Master", SearchOption.AllDirectories).FirstOrDefault(); + if (masterFile is null) + return []; + + var masterContent = File.ReadAllText(masterFile); + var refs = new List(); + + var cdnLinkRegex = new Regex(@"]*href\s*=\s*""(https?://[^""]*(?:cdn\.|cloudflare|bootstrapcdn|googleapis|jsdelivr|unpkg|cdnjs)[^""]*)""[^>]*>", RegexOptions.IgnoreCase); + refs.AddRange(cdnLinkRegex.Matches(masterContent).Select(m => " " + m.Value.Trim())); + + var cdnScriptRegex = new Regex(@"]*src\s*=\s*""(https?://[^""]*(?:cdn\.|cloudflare|bootstrapcdn|googleapis|jsdelivr|unpkg|cdnjs|jquery)[^""]*)""[^>]*>\s*", RegexOptions.IgnoreCase); + refs.AddRange(cdnScriptRegex.Matches(masterContent).Select(m => " " + m.Value.Trim())); + + return refs; + } + + private static IEnumerable GetLocalCssReferences(string outputPath) + { + var wwwroot = Path.Combine(outputPath, "wwwroot"); + if (!Directory.Exists(wwwroot)) + return []; + + var refs = new List(); + refs.AddRange(GetCssFiles(Path.Combine(wwwroot, "Content"), wwwroot)); + + if (refs.Count == 0) + refs.AddRange(GetCssFiles(Path.Combine(wwwroot, "css"), wwwroot)); + + refs.AddRange(Directory.EnumerateFiles(wwwroot, "*.css", SearchOption.TopDirectoryOnly) + .Select(file => $" ")); + + return refs; + } + + private static IEnumerable GetCssFiles(string directory, string wwwroot) + { + if (!Directory.Exists(directory)) + return []; + + return Directory.EnumerateFiles(directory, "*.css", SearchOption.AllDirectories) + .Select(file => $" "); + } + + private static IEnumerable GetLocalScriptReferences(string outputPath) + { + var scriptsDir = Path.Combine(outputPath, "wwwroot", "Scripts"); + if (!Directory.Exists(scriptsDir)) + return []; + + var files = Directory.EnumerateFiles(scriptsDir, "*.js", SearchOption.TopDirectoryOnly) + .Where(file => + { + var fileName = Path.GetFileName(file); + return !fileName.Contains("intellisense", StringComparison.OrdinalIgnoreCase) + && !fileName.Equals("_references.js", StringComparison.OrdinalIgnoreCase); + }) + .Select(Path.GetFileName) + .Where(static fileName => fileName is not null) + .Cast() + .ToList(); + + var ordered = new List(); + AddFirstMatch(files, ordered, name => Regex.IsMatch(name, @"^jquery.*\.min\.js$", RegexOptions.IgnoreCase)); + AddFirstMatch(files, ordered, name => Regex.IsMatch(name, @"^jquery-[\d.]+\.js$", RegexOptions.IgnoreCase)); + AddFirstMatch(files, ordered, name => Regex.IsMatch(name, @"^modernizr.*\.js$", RegexOptions.IgnoreCase)); + AddFirstMatch(files, ordered, name => Regex.IsMatch(name, @"^respond.*\.min\.js$", RegexOptions.IgnoreCase)); + AddFirstMatch(files, ordered, name => Regex.IsMatch(name, @"^respond.*\.js$", RegexOptions.IgnoreCase)); + AddFirstMatch(files, ordered, name => Regex.IsMatch(name, @"^bootstrap.*\.min\.js$", RegexOptions.IgnoreCase)); + AddFirstMatch(files, ordered, name => Regex.IsMatch(name, @"^bootstrap.*\.js$", RegexOptions.IgnoreCase)); + + ordered.AddRange(files.Where(file => !ordered.Contains(file, StringComparer.OrdinalIgnoreCase)).OrderBy(file => file, StringComparer.OrdinalIgnoreCase)); + + return ordered.Select(file => $" "); + } + + private static void AddFirstMatch(IEnumerable files, ICollection ordered, Func predicate) + { + var match = files.FirstOrDefault(predicate); + if (!string.IsNullOrEmpty(match) && !ordered.Contains(match, StringComparer.OrdinalIgnoreCase)) + ordered.Add(match); + } + + private static IEnumerable GetNuGetAssetReferences(string outputPath, bool isCss) + { + var snippetPath = Path.Combine(outputPath, "AssetReferences.html"); + if (!File.Exists(snippetPath)) + return []; + + var pattern = isCss ? " line.TrimEnd()) + .Where(line => line.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + .Select(line => " " + line.Trim()); + } +} + +public sealed record AssetInjectionResult(int CssReferencesInjected = 0, int ScriptReferencesInjected = 0); diff --git a/src/BlazorWebFormsComponents.Cli/Scaffolding/ProjectScaffolder.cs b/src/BlazorWebFormsComponents.Cli/Scaffolding/ProjectScaffolder.cs index ddf1d8daa..245b7b49a 100644 --- a/src/BlazorWebFormsComponents.Cli/Scaffolding/ProjectScaffolder.cs +++ b/src/BlazorWebFormsComponents.Cli/Scaffolding/ProjectScaffolder.cs @@ -34,7 +34,7 @@ public ScaffoldResult Scaffold(string sourcePath, string outputRoot, string proj result.Files["csproj"] = new ScaffoldFile { RelativePath = $"{projectName}.csproj", - Content = GenerateCsproj(projectName, hasModels, hasIdentity, dbProvider) + Content = GenerateCsproj(projectName, outputRoot, hasModels, hasIdentity, dbProvider) }; result.Files["program"] = new ScaffoldFile @@ -46,7 +46,7 @@ public ScaffoldResult Scaffold(string sourcePath, string outputRoot, string proj result.Files["imports"] = new ScaffoldFile { RelativePath = "_Imports.razor", - Content = GenerateImportsRazor(projectName) + Content = GenerateImportsRazor(projectName, hasModels) }; result.Files["app"] = new ScaffoldFile @@ -61,6 +61,12 @@ public ScaffoldResult Scaffold(string sourcePath, string outputRoot, string proj Content = GenerateRoutesRazor() }; + result.Files["layout"] = new ScaffoldFile + { + RelativePath = Path.Combine("Components", "Layout", "MainLayout.razor"), + Content = GenerateMainLayoutRazor() + }; + result.Files["launchSettings"] = new ScaffoldFile { RelativePath = Path.Combine("Properties", "launchSettings.json"), @@ -89,7 +95,7 @@ private static bool DetectIdentity(string sourcePath) File.Exists(Path.Combine(sourcePath, "Register.aspx")); } - private static string GenerateCsproj(string projectName, bool hasModels, bool hasIdentity, DatabaseProviderInfo dbProvider) + private static string GenerateCsproj(string projectName, string outputRoot, bool hasModels, bool hasIdentity, DatabaseProviderInfo dbProvider) { var additionalPackages = ""; if (hasModels) @@ -104,6 +110,8 @@ private static string GenerateCsproj(string projectName, bool hasModels, bool ha additionalPackages += "\n "; } + var bwfcReference = ResolveBwfcReference(outputRoot); + return $@" @@ -114,13 +122,34 @@ private static string GenerateCsproj(string projectName, bool hasModels, bool ha - {additionalPackages} +{bwfcReference}{additionalPackages} "; } + private static string ResolveBwfcReference(string outputRoot) + { + var outputFullPath = Path.GetFullPath(outputRoot); + var current = new DirectoryInfo(outputFullPath); + + while (current is not null) + { + var candidate = Path.Combine(current.FullName, "src", "BlazorWebFormsComponents", "BlazorWebFormsComponents.csproj"); + if (File.Exists(candidate)) + { + var relativePath = Path.GetRelativePath(outputFullPath, candidate) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + return $@" "; + } + + current = current.Parent; + } + + return @" "; + } + private static string GenerateProgramCs(string projectName, bool hasModels, bool hasIdentity, DatabaseProviderInfo dbProvider) { var dbContextLine = !string.IsNullOrEmpty(dbProvider.ConnectionString) @@ -166,12 +195,12 @@ private static string GenerateProgramCs(string projectName, bool hasModels, bool } return $@"// TODO(bwfc-general): Review and adjust this generated Program.cs for your application needs. +// Generated for .NET 10 Blazor static SSR. Keep interactive render modes opt-in and page-specific. using BlazorWebFormsComponents; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); +builder.Services.AddRazorComponents(); builder.Services.AddBlazorWebFormsComponents(); {identityServiceBlock} @@ -187,16 +216,21 @@ private static string GenerateProgramCs(string projectName, bool hasModels, bool app.MapStaticAssets(); app.UseAntiforgery(); {identityMiddlewareBlock} -app.MapRazorComponents<{projectName}.Components.App>() - .AddInteractiveServerRenderMode(); +app.MapRazorComponents<{projectName}.Components.App>(); app.Run(); "; } - private static string GenerateImportsRazor(string projectName) + private static string GenerateImportsRazor(string projectName, bool hasModels) { - return $@"@using System.Net.Http + var modelsUsing = hasModels + ? $@" +@using global::{projectName}.Models" + : string.Empty; + + return $@"@namespace {projectName} +@using System.Net.Http @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization @using Microsoft.AspNetCore.Components.Forms @@ -204,10 +238,10 @@ @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web @using Microsoft.JSInterop @using BlazorWebFormsComponents +@using BlazorWebFormsComponents.Enums @using BlazorWebFormsComponents.LoginControls -@using static Microsoft.AspNetCore.Components.Web.RenderMode -@using {projectName} -@using {projectName}.Models +@using BlazorWebFormsComponents.Validations +@using global::{projectName}{modelsUsing} @inherits BlazorWebFormsComponents.WebFormsPageBase "; } @@ -224,10 +258,9 @@ private static string GenerateAppRazor() -@* SSR by default — add @rendermode=""InteractiveServer"" to pages that need interactivity *@ +@* Generated for .NET 10 static SSR migration output. Only opt into interactive render modes deliberately and per page. *@ - @@ -245,6 +278,16 @@ private static string GenerateRoutesRazor() "; } + private static string GenerateMainLayoutRazor() + { + return @"@inherits LayoutComponentBase + +
+ @Body +
+"; + } + private static string GenerateLaunchSettings(string projectName) { return $$""" diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/AccountPagesSemanticPattern.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/AccountPagesSemanticPattern.cs new file mode 100644 index 000000000..34b793045 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/AccountPagesSemanticPattern.cs @@ -0,0 +1,415 @@ +using System.Text; +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +public sealed class AccountPagesSemanticPattern : ISemanticPattern +{ + private static readonly Regex LabelRegex = new( + @"]*AssociatedControlID=""(?[^""]+)""[^>]*>(?[\s\S]*?)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex TextBoxRegex = new( + @"]*/>", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex CheckBoxRegex = new( + @"]*/>", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex ButtonRegex = new( + @"]*/>", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex ValidatorRegex = new( + @"<(ValidationSummary|RequiredFieldValidator|CompareValidator|RegularExpressionValidator|RangeValidator|CustomValidator)\b", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex ContentBlockRegex = new( + @"]*>(?[\s\S]*?)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex HyperLinkRegex = new( + @"]*(?:NavigateUrl=""(?[^""]+)"")?[^>]*>(?[\s\S]*?)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex AttributeRegex = new( + @"\b(?[A-Za-z]+)\s*=\s*""(?[^""]*)""", + RegexOptions.Compiled); + + public string Id => "pattern-account-pages"; + public int Order => 200; + + public SemanticPatternMatch Match(SemanticPatternContext context) + { + if (context.Metadata.FileType != FileType.Page) + { + return SemanticPatternMatch.NoMatch(); + } + + var fileName = Path.GetFileNameWithoutExtension(context.SourceFile.MarkupPath); + var isAccountPage = context.SourceFile.MarkupPath.Contains($"{Path.DirectorySeparatorChar}Account{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase) + || context.SourceFile.MarkupPath.Contains("/Account/", StringComparison.OrdinalIgnoreCase) + || IsKnownAccountPage(fileName); + + if (!isAccountPage) + { + return SemanticPatternMatch.NoMatch(); + } + + var hasCredentialControls = TextBoxRegex.Matches(context.Markup).Count >= 2 + && ButtonRegex.IsMatch(context.Markup); + + if (!hasCredentialControls) + { + return SemanticPatternMatch.NoMatch(); + } + + if (ValidatorRegex.IsMatch(context.Markup) || IsKnownAccountPage(fileName)) + { + return SemanticPatternMatch.Match($"Normalized account page '{fileName}' to an SSR-safe auth form stub."); + } + + return SemanticPatternMatch.NoMatch(); + } + + public SemanticPatternResult Apply(SemanticPatternContext context) + { + var fileName = Path.GetFileNameWithoutExtension(context.SourceFile.MarkupPath); + var pageTitle = ToTitle(fileName); + var formMarkup = BuildNormalizedAccountMarkup(context.Markup, fileName, pageTitle); + var rewritten = ReplacePrimaryContent(context.Markup, formMarkup); + rewritten = EnsureAccountQueryParameters(rewritten, fileName); + + return new SemanticPatternResult( + rewritten, + context.CodeBehind, + $"Replaced validator-heavy {pageTitle} markup with an SSR-safe account form stub and auth TODOs."); + } + + private static string BuildNormalizedAccountMarkup(string markup, string fileName, string pageTitle) + { + var contentInner = ExtractPrimaryContent(markup); + var fields = ExtractFields(contentInner); + var formAction = GetFormAction(fileName); + var buttonMatch = ButtonRegex.Match(contentInner); + var buttonAttributes = buttonMatch.Success + ? ParseAttributes(buttonMatch.Value) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + var buttonText = buttonAttributes.TryGetValue("Text", out var buttonTextValue) + ? buttonTextValue + : DefaultButtonText(fileName); + var buttonClass = buttonAttributes.TryGetValue("CssClass", out var buttonClassValue) && !string.IsNullOrWhiteSpace(buttonClassValue) + ? buttonClassValue + : "btn btn-default"; + + var builder = new StringBuilder(); + builder.AppendLine($"

{pageTitle}

"); + builder.AppendLine("@* TODO(bwfc-identity): Wire this account page to ASP.NET Core Identity or your app's authentication service. *@"); + builder.AppendLine("@* TODO(bwfc-identity): Recreate validation and submit handling with EditForm, minimal APIs, or an equivalent SSR-safe endpoint. *@"); + if (fileName.Equals("Login", StringComparison.OrdinalIgnoreCase)) + { + builder.AppendLine("@if (Registered.GetValueOrDefault() != 0)"); + builder.AppendLine("{"); + builder.AppendLine("

Registration succeeded. Please log in.

"); + builder.AppendLine("}"); + } + + builder.AppendLine("@if (!string.IsNullOrWhiteSpace(Error))"); + builder.AppendLine("{"); + builder.AppendLine("

@Error

"); + builder.AppendLine("}"); + builder.AppendLine($"
"); + + if (fileName.Equals("Login", StringComparison.OrdinalIgnoreCase)) + { + builder.AppendLine(" @if (!string.IsNullOrWhiteSpace(ReturnUrl))"); + builder.AppendLine(" {"); + builder.AppendLine(" "); + builder.AppendLine(" }"); + } + + foreach (var field in fields) + { + builder.AppendLine(RenderField(field)); + } + + builder.AppendLine("
"); + builder.AppendLine("
"); + builder.AppendLine($" "); + builder.AppendLine("
"); + builder.AppendLine("
"); + builder.AppendLine("
"); + + foreach (var link in ExtractLinks(contentInner, fileName)) + { + builder.AppendLine(link); + } + + return builder.ToString().TrimEnd(); + } + + private static string ReplacePrimaryContent(string markup, string replacement) + { + var contentBlock = ContentBlockRegex.Match(markup); + if (contentBlock.Success) + { + return markup[..contentBlock.Index] + + contentBlock.Value.Replace(contentBlock.Groups["inner"].Value, $"\n{SemanticPatternMarkupHelpers.Indent(replacement, " ")}\n") + + markup[(contentBlock.Index + contentBlock.Length)..]; + } + + return replacement; + } + + private static string ExtractPrimaryContent(string markup) + { + var contentBlock = ContentBlockRegex.Match(markup); + return contentBlock.Success ? contentBlock.Groups["inner"].Value : markup; + } + + private static List ExtractFields(string content) + { + var labelMap = LabelRegex.Matches(content) + .ToDictionary( + m => m.Groups["id"].Value, + m => Regex.Replace(m.Groups["text"].Value, @"\s+", " ").Trim(), + StringComparer.OrdinalIgnoreCase); + + var fields = new List(); + + foreach (Match match in TextBoxRegex.Matches(content)) + { + var attributes = ParseAttributes(match.Value); + if (!attributes.TryGetValue("ID", out var id)) + { + continue; + } + + fields.Add(new AccountField( + id, + labelMap.GetValueOrDefault(id) ?? ToTitle(id), + ToInputType(attributes.GetValueOrDefault("TextMode") ?? string.Empty, id), + string.IsNullOrWhiteSpace(attributes.GetValueOrDefault("CssClass")) ? "form-control" : attributes["CssClass"], + IsCheckbox: false)); + } + + foreach (Match match in CheckBoxRegex.Matches(content)) + { + var attributes = ParseAttributes(match.Value); + if (!attributes.TryGetValue("ID", out var id)) + { + continue; + } + + fields.Add(new AccountField( + id, + labelMap.GetValueOrDefault(id) ?? ToTitle(id), + "checkbox", + string.IsNullOrWhiteSpace(attributes.GetValueOrDefault("CssClass")) ? string.Empty : attributes["CssClass"], + IsCheckbox: true)); + } + + return fields; + } + + private static IEnumerable ExtractLinks(string content, string fileName) + { + var links = HyperLinkRegex.Matches(content) + .Select(m => + { + var text = Regex.Replace(m.Groups["text"].Value, @"\s+", " ").Trim(); + var url = m.Groups["url"].Value; + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(url)) + { + url = text.Contains("register", StringComparison.OrdinalIgnoreCase) + ? "/Account/Register" + : text.Contains("log in", StringComparison.OrdinalIgnoreCase) || text.Contains("login", StringComparison.OrdinalIgnoreCase) + ? "/Account/Login" + : "#"; + } + + return $"

{text}

"; + }) + .Where(link => link is not null) + .Cast() + .ToList(); + + if (links.Count > 0) + { + return links; + } + + if (fileName.Equals("Login", StringComparison.OrdinalIgnoreCase)) + { + return ["

Register as a new user

"]; + } + + if (fileName.Equals("Register", StringComparison.OrdinalIgnoreCase)) + { + return ["

Already have an account? Sign in

"]; + } + + return []; + } + + private static string RenderField(AccountField field) + { + var elementId = ToKebabCase(field.Id); + if (field.IsCheckbox) + { + return $$""" +
+
+
+ + +
+
+
+"""; + } + + return $$""" +
+ +
+ +
+
+"""; + } + + private static string ToInputType(string textMode, string id) + { + var normalizedTextMode = textMode.Trim().TrimStart('@'); + var enumSeparatorIndex = normalizedTextMode.LastIndexOf('.'); + if (enumSeparatorIndex >= 0 && enumSeparatorIndex < normalizedTextMode.Length - 1) + { + normalizedTextMode = normalizedTextMode[(enumSeparatorIndex + 1)..]; + } + + if (normalizedTextMode.Equals("Password", StringComparison.OrdinalIgnoreCase)) + { + return "password"; + } + + if (normalizedTextMode.Equals("Email", StringComparison.OrdinalIgnoreCase) || id.Contains("email", StringComparison.OrdinalIgnoreCase)) + { + return "email"; + } + + if (normalizedTextMode.Equals("Number", StringComparison.OrdinalIgnoreCase)) + { + return "number"; + } + + return "text"; + } + + private static bool IsKnownAccountPage(string fileName) => + fileName.Equals("Login", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("Register", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("Password", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("Phone", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("TwoFactor", StringComparison.OrdinalIgnoreCase) + || fileName.Contains("Account", StringComparison.OrdinalIgnoreCase); + + private static string DefaultButtonText(string fileName) => + fileName.Equals("Login", StringComparison.OrdinalIgnoreCase) ? "Log in" + : fileName.Equals("Register", StringComparison.OrdinalIgnoreCase) ? "Register" + : "Submit"; + + private static string GetFormAction(string fileName) => + fileName.Equals("Login", StringComparison.OrdinalIgnoreCase) ? "/Account/PerformLogin" + : fileName.Equals("Register", StringComparison.OrdinalIgnoreCase) ? "/Account/PerformRegister" + : $"/Account/{fileName}Handler"; + + private static string GetFormMethod(string fileName) => + fileName.Equals("Login", StringComparison.OrdinalIgnoreCase) + || fileName.Equals("Register", StringComparison.OrdinalIgnoreCase) + ? "get" + : "post"; + + private static string ToTitle(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "Account"; + } + + var spaced = Regex.Replace(value, "([a-z])([A-Z])", "$1 $2"); + return Regex.Replace(spaced, @"\s+", " ").Trim(); + } + + private static string ToKebabCase(string value) + { + var kebab = Regex.Replace(value, "([a-z0-9])([A-Z])", "$1-$2"); + return kebab.Replace("_", "-").ToLowerInvariant(); + } + + private static Dictionary ParseAttributes(string tag) => + AttributeRegex.Matches(tag) + .ToDictionary( + m => m.Groups["name"].Value, + m => m.Groups["value"].Value, + StringComparer.OrdinalIgnoreCase); + + private static string EnsureAccountQueryParameters(string markup, string fileName) + { + var parameterLines = new List(); + AddParameterIfMissing(markup, parameterLines, "Error", "error", "string?"); + + if (fileName.Equals("Login", StringComparison.OrdinalIgnoreCase)) + { + AddParameterIfMissing(markup, parameterLines, "Registered", "registered", "int?"); + AddParameterIfMissing(markup, parameterLines, "ReturnUrl", "returnUrl", "string?"); + } + + if (parameterLines.Count == 0) + { + return markup; + } + + var parameterBlock = string.Join(Environment.NewLine + Environment.NewLine, parameterLines); + var codeIndex = markup.LastIndexOf("@code", StringComparison.Ordinal); + if (codeIndex < 0) + { + return $"{markup.TrimEnd()}{Environment.NewLine}{Environment.NewLine}@code {{{Environment.NewLine}{parameterBlock}{Environment.NewLine}}}"; + } + + var openBraceIndex = markup.IndexOf('{', codeIndex); + var closeBraceIndex = markup.LastIndexOf('}'); + if (openBraceIndex < 0 || closeBraceIndex <= openBraceIndex) + { + return $"{markup.TrimEnd()}{Environment.NewLine}{Environment.NewLine}@code {{{Environment.NewLine}{parameterBlock}{Environment.NewLine}}}"; + } + + var body = markup.Substring(openBraceIndex + 1, closeBraceIndex - openBraceIndex - 1).Trim('\r', '\n'); + var rewrittenBody = string.IsNullOrWhiteSpace(body) + ? parameterBlock + : $"{body}{Environment.NewLine}{Environment.NewLine}{parameterBlock}"; + + return markup[..codeIndex] + + $"@code {{{Environment.NewLine}{rewrittenBody}{Environment.NewLine}}}" + + markup[(closeBraceIndex + 1)..]; + } + + private static void AddParameterIfMissing(string markup, ICollection parameterLines, string propertyName, string queryName, string type) + { + if (Regex.IsMatch(markup, $@"\b{Regex.Escape(propertyName)}\b\s*\{{\s*get\s*;\s*set\s*;\s*\}}", RegexOptions.IgnoreCase)) + { + return; + } + + parameterLines.Add($" [Parameter, SupplyParameterFromQuery(Name = \"{queryName}\")] public {type} {propertyName} {{ get; set; }}"); + } + + private sealed record AccountField(string Id, string Label, string InputType, string CssClass, bool IsCheckbox); +} diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/ActionPagesSemanticPattern.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/ActionPagesSemanticPattern.cs new file mode 100644 index 000000000..7de5a7678 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/ActionPagesSemanticPattern.cs @@ -0,0 +1,194 @@ +using System.Text; +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +/// +/// Rewrites blank redirect/action-only pages into visible SSR handler stubs so the +/// generated project contains actionable migration guidance instead of inert output. +/// +public sealed class ActionPagesSemanticPattern : ISemanticPattern +{ + private const string Marker = "TODO(bwfc-action-pages)"; + private static readonly Regex QueryStringAccessRegex = new( + @"Request\.QueryString\[""(?[^""]+)""\]", + RegexOptions.Compiled); + private static readonly Regex RedirectLiteralRegex = new( + @"Response\.Redirect\(\s*""(?[^""]+)""", + RegexOptions.Compiled); + private static readonly Regex ActionCallRegex = new( + @"\b(?AddToCart|RemoveFromCart|UpdateShoppingCartDatabase|Process\w+)\s*\(", + RegexOptions.Compiled); + private static readonly Regex PageAndTitleRegex = new( + @"(?is)^\s*@page\s+""[^""]+""\s*|.*?", + RegexOptions.Compiled); + private static readonly Regex WrapperTagRegex = new( + @"(?is)]*)?>", + RegexOptions.Compiled); + + public string Id => "pattern-action-pages"; + public int Order => 200; + + public SemanticPatternMatch Match(SemanticPatternContext context) + { + if (context.SourceFile.FileType != FileType.Page || context.CodeBehind is null || context.Markup.Contains(Marker, StringComparison.Ordinal)) + { + return SemanticPatternMatch.NoMatch(); + } + + var candidate = FindCandidate(context); + return candidate is null + ? SemanticPatternMatch.NoMatch() + : SemanticPatternMatch.Match( + $"Detected action-only page '{candidate.PageName}' that redirects to '{candidate.RedirectTarget ?? "manual target"}'."); + } + + public SemanticPatternResult Apply(SemanticPatternContext context) + { + var candidate = FindCandidate(context); + if (candidate is null) + { + return SemanticPatternResult.FromContext(context); + } + + var relativePath = SemanticPatternUtilities.RelativeMarkupPath(context); + context.Report.AddManualItem( + relativePath, + 0, + "bwfc-action-pages", + $"{candidate.PageName} is an action-only page — move the side effect into a scoped service or minimal API endpoint before redirecting.", + "high"); + + var markup = BuildMarkup(context.Markup, candidate); + var codeBehind = context.CodeBehind; + if (!string.IsNullOrEmpty(codeBehind) && !codeBehind.Contains(Marker, StringComparison.Ordinal)) + { + var redirectSummary = candidate.RedirectTarget is null + ? "manual redirect target" + : candidate.RedirectTarget; + codeBehind = $"// {Marker}: {candidate.PageName} now renders a visible SSR handler stub. Preserve the side effect, then redirect to {redirectSummary}.{Environment.NewLine}{codeBehind}"; + } + + return new SemanticPatternResult( + markup, + codeBehind, + $"Converted action-only page {candidate.PageName} into an SSR handler stub."); + } + + private static ActionPageCandidate? FindCandidate(SemanticPatternContext context) + { + if (context.CodeBehind is null || !IsInertMarkup(context.Markup)) + { + return null; + } + + var redirectMatch = RedirectLiteralRegex.Match(context.CodeBehind); + if (!redirectMatch.Success) + { + return null; + } + + var queryKeys = QueryStringAccessRegex.Matches(context.CodeBehind) + .Select(static match => match.Groups["name"].Value) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + var actionCall = ActionCallRegex.Match(context.CodeBehind); + var pageName = Path.GetFileNameWithoutExtension(context.SourceFile.MarkupPath); + return new ActionPageCandidate( + pageName, + queryKeys, + SemanticPatternUtilities.NormalizeRoute(redirectMatch.Groups["target"].Value), + actionCall.Success ? actionCall.Groups["action"].Value : null); + } + + private static bool IsInertMarkup(string markup) + { + var stripped = PageAndTitleRegex.Replace(markup, string.Empty); + stripped = WrapperTagRegex.Replace(stripped, string.Empty); + stripped = Regex.Replace(stripped, @"\s+| ", string.Empty, RegexOptions.IgnoreCase); + return string.IsNullOrEmpty(stripped); + } + + private static string BuildMarkup(string existingMarkup, ActionPageCandidate candidate) + { + var pageDirective = SemanticPatternUtilities.ExtractPageDirective(existingMarkup, candidate.PageName); + var endpointRoute = GetEndpointRoute(candidate.PageName); + var title = candidate.PageName; + var builder = new StringBuilder(); + builder.AppendLine(pageDirective); + builder.AppendLine(); + builder.AppendLine($"{title}"); + builder.AppendLine(); + builder.AppendLine("
"); + builder.AppendLine($"

{candidate.PageName}

"); + builder.AppendLine("

This migrated page preserves the original action-on-navigation contract by posting to a generated HTTP endpoint as soon as the shell renders.

"); + builder.AppendLine($"

{Marker}: move the original side effect into the generated endpoint or a scoped service before removing this helper page.

"); + if (candidate.QueryKeys.Length > 0) + { + builder.AppendLine( + $"

Detected query string inputs: {string.Join(", ", candidate.QueryKeys)}.

"); + } + + if (!string.IsNullOrEmpty(candidate.ActionCall)) + { + builder.AppendLine($"

Detected side effect: {candidate.ActionCall}.

"); + } + + if (!string.IsNullOrEmpty(candidate.RedirectTarget)) + { + builder.AppendLine( + $"

Expected redirect target: {candidate.RedirectTarget}.

"); + } + + builder.AppendLine("
"); + builder.AppendLine(); + builder.AppendLine($"
"); + foreach (var queryKey in candidate.QueryKeys) + { + var propertyName = SemanticPatternUtilities.ToPropertyName(queryKey); + builder.AppendLine($" "); + } + + builder.AppendLine(" "); + builder.AppendLine("
"); + builder.AppendLine(); + builder.AppendLine(""); + + builder.AppendLine(); + builder.AppendLine("@code {"); + foreach (var queryKey in candidate.QueryKeys) + { + var propertyName = SemanticPatternUtilities.ToPropertyName(queryKey); + builder.AppendLine( + $" [Parameter, SupplyParameterFromQuery(Name = \"{queryKey}\")] public string? {propertyName} {{ get; set; }}"); + } + + if (candidate.QueryKeys.Length > 0) + { + builder.AppendLine(); + } + + builder.AppendLine($" private const string HandlerRoute = \"{endpointRoute}\";"); + if (!string.IsNullOrEmpty(candidate.RedirectTarget)) + { + builder.AppendLine($" private const string RedirectTarget = \"{candidate.RedirectTarget}\";"); + } + builder.Append('}'); + return builder.ToString(); + } + + internal static string GetEndpointRoute(string pageName) => $"/__bwfc/actions/{pageName}"; + + private sealed record ActionPageCandidate( + string PageName, + string[] QueryKeys, + string? RedirectTarget, + string? ActionCall); +} diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/ISemanticPattern.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/ISemanticPattern.cs new file mode 100644 index 000000000..d910dcbb8 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/ISemanticPattern.cs @@ -0,0 +1,16 @@ +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +/// +/// Contract for isolated semantic page-pattern rewrites that run after the +/// syntactic markup and code-behind transforms have established a compile-safe shape. +/// +public interface ISemanticPattern +{ + string Id { get; } + int Order { get; } + SemanticPatternMatch Match(SemanticPatternContext context); + SemanticPatternResult Apply(SemanticPatternContext context); +} + diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/MasterContentContractsSemanticPattern.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/MasterContentContractsSemanticPattern.cs new file mode 100644 index 000000000..a3683f1d0 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/MasterContentContractsSemanticPattern.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +public sealed class MasterContentContractsSemanticPattern : ISemanticPattern +{ + private static readonly Regex ContentPlaceHolderRegex = new( + @" "pattern-master-content-contracts"; + public int Order => 100; + + public SemanticPatternMatch Match(SemanticPatternContext context) + { + if (context.Metadata.FileType == FileType.Master + && ContentPlaceHolderRegex.IsMatch(context.Markup) + && !context.Markup.Contains("public RenderFragment? ChildComponents { get; set; }", StringComparison.Ordinal)) + { + return SemanticPatternMatch.Match("Normalized master shell contract for named content sections."); + } + + if (context.Metadata.FileType == FileType.Page + && SemanticPatternMarkupHelpers.TryExtractWrapper(context.Markup, out var wrapper) + && SemanticPatternMarkupHelpers.HasNamedContentBlocks(wrapper.InnerContent) + && !wrapper.InnerContent.Contains("", StringComparison.Ordinal)) + { + return SemanticPatternMatch.Match("Grouped page content sections under ChildComponents."); + } + + return SemanticPatternMatch.NoMatch(); + } + + public SemanticPatternResult Apply(SemanticPatternContext context) + { + if (context.Metadata.FileType == FileType.Master) + { + var rewritten = SemanticPatternMarkupHelpers.EnsureChildComponentsRenderSlot(context.Markup); + rewritten = SemanticPatternMarkupHelpers.EnsureChildComponentsParameter(rewritten); + return new SemanticPatternResult( + rewritten, + context.CodeBehind, + "Added ChildComponents wiring to the generated master shell."); + } + + if (context.Metadata.FileType == FileType.Page + && SemanticPatternMarkupHelpers.TryExtractWrapper(context.Markup, out var wrapper)) + { + var rewrittenInner = SemanticPatternMarkupHelpers.WrapInNamedRegions(wrapper.InnerContent); + var rewritten = SemanticPatternMarkupHelpers.RebuildWrapper(wrapper, rewrittenInner); + return new SemanticPatternResult( + rewritten, + context.CodeBehind, + "Wrapped named Content regions under ChildComponents for the generated page shell."); + } + + return SemanticPatternResult.FromContext(context); + } +} diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/QueryDetailsSemanticPattern.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/QueryDetailsSemanticPattern.cs new file mode 100644 index 000000000..500de8953 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/QueryDetailsSemanticPattern.cs @@ -0,0 +1,223 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +/// +/// Converts common Web Forms SelectMethod + QueryString/RouteData pages into +/// SSR-friendly query-bound component properties plus a compile-safe SelectItems stub. +/// +public sealed class QueryDetailsSemanticPattern : ISemanticPattern +{ + private const string Marker = "TODO(bwfc-query-details)"; + private static readonly Regex SelectMethodRegex = new( + @"SelectMethod=""(?[A-Za-z_][A-Za-z0-9_]*)""", + RegexOptions.Compiled); + private static readonly Regex QueryStringParameterRegex = new( + @"\[QueryString(?:\(\s*""(?[^""]+)""\s*\))?\]\s*(?[^,\r\n]+?)\s+(?[A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.Compiled); + private static readonly Regex RouteDataParameterRegex = new( + @"\[RouteData\]\s*(?[^,\r\n]+?)\s+(?[A-Za-z_][A-Za-z0-9_]*)", + RegexOptions.Compiled); + private static readonly Regex TItemRegex = new( + @"(?:TItem|ItemType)=""(?[^""]+)""", + RegexOptions.Compiled); + private static readonly Regex ReturnItemTypeRegex = new( + @"(?:IQueryable|IEnumerable|List|IList|IReadOnlyList)<(?[^>]+)>", + RegexOptions.Compiled); + + public string Id => "pattern-query-details"; + public int Order => 100; + + public SemanticPatternMatch Match(SemanticPatternContext context) + { + if (context.CodeBehind is null || context.Markup.Contains(Marker, StringComparison.Ordinal)) + { + return SemanticPatternMatch.NoMatch(); + } + + var candidate = FindCandidate(context); + return candidate is null + ? SemanticPatternMatch.NoMatch() + : SemanticPatternMatch.Match( + $"Detected query-bound SelectMethod '{candidate.Method.Name}' on {candidate.ControlType}."); + } + + public SemanticPatternResult Apply(SemanticPatternContext context) + { + var candidate = FindCandidate(context); + if (candidate is null) + { + return SemanticPatternResult.FromContext(context); + } + + var wrapperMethodName = $"{candidate.Method.Name}QueryDetails_SelectItems"; + var rewrittenMarkup = SelectMethodRegex.Replace( + context.Markup, + match => string.Equals(match.Groups["method"].Value, candidate.Method.Name, StringComparison.Ordinal) + ? $@"SelectItems=""{wrapperMethodName}""" + : match.Value, + 1); + + var codeBlock = BuildCodeBlock(candidate, wrapperMethodName); + rewrittenMarkup = $"{rewrittenMarkup.TrimEnd()}\n\n{codeBlock}\n"; + + var relativePath = SemanticPatternUtilities.RelativeMarkupPath(context); + context.Report.AddManualItem( + relativePath, + 0, + "bwfc-query-details", + $"{candidate.Method.Name} was normalized to query-bound SelectItems scaffolding — port the original query into an injected service or DbContext.", + "medium"); + + var codeBehind = context.CodeBehind; + if (!string.IsNullOrEmpty(codeBehind) && !codeBehind.Contains(Marker, StringComparison.Ordinal)) + { + codeBehind = $"// {Marker}: {candidate.Method.Name} now binds through component query properties and a SelectItems stub in the generated .razor file.{Environment.NewLine}{codeBehind}"; + } + + return new SemanticPatternResult( + rewrittenMarkup, + codeBehind, + $"Normalized {candidate.Method.Name} to SelectItems with {candidate.BoundParameters.Count} query/route binding stub(s)."); + } + + private static QueryDetailsCandidate? FindCandidate(SemanticPatternContext context) + { + if (context.CodeBehind is null) + { + return null; + } + + var selectMethodMatches = SelectMethodRegex.Matches(context.Markup); + if (selectMethodMatches.Count != 1) + { + return null; + } + + var methodName = selectMethodMatches[0].Groups["method"].Value; + if (!SemanticPatternUtilities.TryExtractMethod(context.CodeBehind, methodName, out var method)) + { + return null; + } + + var boundParameters = GetBoundParameters(method.Parameters); + if (boundParameters.Count == 0) + { + return null; + } + + var tagStart = context.Markup.LastIndexOf('<', selectMethodMatches[0].Index); + var tagEnd = tagStart >= 0 ? context.Markup.IndexOf('>', tagStart) : -1; + if (tagStart < 0 || tagEnd < 0) + { + return null; + } + + var tagMarkup = context.Markup[tagStart..(tagEnd + 1)]; + var controlType = Regex.Match(tagMarkup, @"<(?[A-Za-z_][A-Za-z0-9_]*)").Groups["name"].Value; + if (string.IsNullOrWhiteSpace(controlType)) + { + return null; + } + + var itemType = ResolveItemType(tagMarkup, method.ReturnType); + if (string.IsNullOrWhiteSpace(itemType) || string.Equals(itemType, "object", StringComparison.Ordinal)) + { + return null; + } + + return new QueryDetailsCandidate(controlType, method, boundParameters, itemType); + } + + private static List GetBoundParameters(string parameters) + { + var boundParameters = new List(); + + foreach (Match match in QueryStringParameterRegex.Matches(parameters)) + { + var name = match.Groups["name"].Value; + boundParameters.Add(new BoundParameter( + "query", + match.Groups["type"].Value.Trim(), + name, + match.Groups["binding"].Success ? match.Groups["binding"].Value : name, + SemanticPatternUtilities.ToPropertyName(name))); + } + + foreach (Match match in RouteDataParameterRegex.Matches(parameters)) + { + var name = match.Groups["name"].Value; + boundParameters.Add(new BoundParameter( + "route", + match.Groups["type"].Value.Trim(), + name, + name, + SemanticPatternUtilities.ToPropertyName(name))); + } + + return boundParameters; + } + + private static string? ResolveItemType(string tagMarkup, string returnType) + { + var typeMatch = TItemRegex.Match(tagMarkup); + if (typeMatch.Success) + { + return typeMatch.Groups["type"].Value.Trim(); + } + + var returnTypeMatch = ReturnItemTypeRegex.Match(returnType); + return returnTypeMatch.Success + ? returnTypeMatch.Groups["type"].Value.Trim() + : null; + } + + private static string BuildCodeBlock(QueryDetailsCandidate candidate, string wrapperMethodName) + { + var builder = new StringBuilder(); + builder.AppendLine("@code {"); + + foreach (var parameter in candidate.BoundParameters) + { + var type = SemanticPatternUtilities.NormalizeBindingType(parameter.Type); + if (string.Equals(parameter.Kind, "query", StringComparison.Ordinal)) + { + builder.AppendLine( + $" [Parameter, SupplyParameterFromQuery(Name = \"{parameter.BindingName}\")] public {type} {parameter.PropertyName} {{ get; set; }}"); + } + else + { + builder.AppendLine($" [Parameter] public {type} {parameter.PropertyName} {{ get; set; }}"); + } + } + + builder.AppendLine(); + builder.AppendLine( + $" private IEnumerable<{candidate.ItemType}> {wrapperMethodName}(int maxRows, int startRowIndex, string sortByExpression)"); + builder.AppendLine(" {"); + builder.AppendLine( + $" // {Marker}: {candidate.ControlType} now binds through component properties instead of Web Forms method-parameter binding."); + builder.AppendLine( + $" // Manual boundary: port {candidate.Method.Name} from the migration-artifacts code-behind file into an injected service or DbContext-backed query."); + builder.AppendLine( + $" // Bound inputs: {string.Join(", ", candidate.BoundParameters.Select(static p => $"{p.BindingName} → {p.PropertyName} ({p.Kind})"))}."); + builder.AppendLine($" return Enumerable.Empty<{candidate.ItemType}>();"); + builder.AppendLine(" }"); + builder.Append('}'); + return builder.ToString(); + } + + private sealed record QueryDetailsCandidate( + string ControlType, + ExtractedMethod Method, + IReadOnlyList BoundParameters, + string ItemType); + + private sealed record BoundParameter( + string Kind, + string Type, + string ParameterName, + string BindingName, + string PropertyName); +} diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternCatalog.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternCatalog.cs new file mode 100644 index 000000000..ac3d9d86e --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternCatalog.cs @@ -0,0 +1,62 @@ +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +/// +/// Ordered registry of semantic migration patterns. This gives the CLI a bounded +/// place to grow recurring page-shape rewrites without turning the main transform +/// list into one monolithic semantic pass. +/// +public sealed class SemanticPatternCatalog +{ + private readonly IReadOnlyList _patterns; + + public SemanticPatternCatalog(IEnumerable patterns) + { + _patterns = patterns.OrderBy(p => p.Order).ToList(); + } + + public SemanticPatternExecutionResult Apply( + MigrationContext migrationContext, + SourceFile sourceFile, + FileMetadata metadata, + string markup, + string? codeBehind, + MigrationReport report) + { + var appliedPatterns = new List(); + + foreach (var pattern in _patterns) + { + var context = new SemanticPatternContext + { + MigrationContext = migrationContext, + SourceFile = sourceFile, + Metadata = metadata, + Report = report, + Markup = markup, + CodeBehind = codeBehind + }; + + var match = pattern.Match(context); + if (!match.IsMatch) + { + continue; + } + + var result = pattern.Apply(context); + markup = result.Markup; + codeBehind = result.CodeBehind; + metadata.MarkupContent = markup; + metadata.CodeBehindContent = codeBehind; + + var detail = result.Detail ?? match.Evidence ?? $"Applied semantic pattern '{pattern.Id}'."; + appliedPatterns.Add(new AppliedSemanticPattern(pattern.Id, detail)); + migrationContext.Log.Add(sourceFile.MarkupPath, pattern.Id, detail); + report.SemanticPatternsApplied++; + } + + return new SemanticPatternExecutionResult(markup, codeBehind, appliedPatterns); + } +} + diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternContext.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternContext.cs new file mode 100644 index 000000000..4d6eeaa36 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternContext.cs @@ -0,0 +1,48 @@ +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +/// +/// Immutable per-file context passed to semantic pattern matchers and applicators. +/// +public sealed class SemanticPatternContext +{ + public required MigrationContext MigrationContext { get; init; } + public required SourceFile SourceFile { get; init; } + public required FileMetadata Metadata { get; init; } + public required MigrationReport Report { get; init; } + public required string Markup { get; init; } + public string? CodeBehind { get; init; } +} + +/// +/// Result of a pattern-matching attempt. +/// +public sealed record SemanticPatternMatch(bool IsMatch, string? Evidence = null) +{ + public static SemanticPatternMatch NoMatch() => new(false); + public static SemanticPatternMatch Match(string? evidence = null) => new(true, evidence); +} + +/// +/// Replacement content emitted by a semantic pattern. +/// +public sealed record SemanticPatternResult(string Markup, string? CodeBehind, string? Detail = null) +{ + public static SemanticPatternResult FromContext(SemanticPatternContext context, string? detail = null) => + new(context.Markup, context.CodeBehind, detail); +} + +/// +/// A single applied semantic pattern entry for diagnostics and tests. +/// +public sealed record AppliedSemanticPattern(string PatternId, string Detail); + +/// +/// Aggregate execution result of the semantic pattern catalog for a single file. +/// +public sealed record SemanticPatternExecutionResult( + string Markup, + string? CodeBehind, + IReadOnlyList AppliedPatterns); + diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternMarkupHelpers.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternMarkupHelpers.cs new file mode 100644 index 000000000..9a4d57df2 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternMarkupHelpers.cs @@ -0,0 +1,154 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +internal static partial class SemanticPatternMarkupHelpers +{ + private static readonly Regex WrapperOpenRegex = new( + @"(?m)^(?\s*)<(?[A-Z][A-Za-z0-9_]*)>\s*$", + RegexOptions.Compiled); + + private static readonly Regex ContentBlockRegex = new( + @"]*>[\s\S]*?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex ChildContentBlockRegex = new( + @"(?[\s\S]*?)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public static bool TryExtractWrapper(string markup, out WrapperMatch wrapper) + { + foreach (Match openMatch in WrapperOpenRegex.Matches(markup)) + { + var name = openMatch.Groups["name"].Value; + var closeTag = $""; + var closeIndex = markup.LastIndexOf(closeTag, StringComparison.Ordinal); + if (closeIndex <= openMatch.Index) + { + continue; + } + + var suffix = markup[(closeIndex + closeTag.Length)..]; + if (!string.IsNullOrWhiteSpace(suffix)) + { + continue; + } + + wrapper = new WrapperMatch( + name, + markup[..openMatch.Index], + markup.Substring(openMatch.Index, openMatch.Length), + markup.Substring(openMatch.Index + openMatch.Length, closeIndex - (openMatch.Index + openMatch.Length)), + closeTag, + suffix); + + return true; + } + + wrapper = default; + return false; + } + + public static string WrapInNamedRegions(string innerContent) + { + var contentMatches = ContentBlockRegex.Matches(innerContent); + if (contentMatches.Count == 0) + { + return innerContent; + } + + var namedSections = string.Join("\n", contentMatches.Select(m => m.Value.Trim())); + var remainder = ContentBlockRegex.Replace(innerContent, string.Empty).Trim(); + var builder = new StringBuilder(); + + if (!string.IsNullOrWhiteSpace(remainder)) + { + builder.AppendLine(""); + builder.AppendLine(Indent(remainder.Trim(), " ")); + builder.AppendLine(""); + } + + builder.AppendLine(""); + builder.AppendLine(Indent(namedSections, " ")); + builder.AppendLine(""); + + return builder.ToString().TrimEnd(); + } + + public static bool HasNamedContentBlocks(string markup) => + ContentBlockRegex.IsMatch(markup); + + public static string EnsureChildComponentsRenderSlot(string markup) + { + if (markup.Contains("@ChildComponents", StringComparison.Ordinal)) + { + return markup; + } + + var match = ChildContentBlockRegex.Match(markup); + if (!match.Success) + { + return markup; + } + + var inner = match.Groups["inner"].Value.Trim('\r', '\n'); + var rewrittenInner = $" @ChildComponents\n{inner}"; + return markup[..match.Index] + + $"\n{rewrittenInner}\n" + + markup[(match.Index + match.Length)..]; + } + + public static string EnsureChildComponentsParameter(string markup) + { + if (markup.Contains("public RenderFragment? ChildComponents { get; set; }", StringComparison.Ordinal)) + { + return markup; + } + + var parameterBlock = """ + [Parameter] + public RenderFragment? ChildComponents { get; set; } +"""; + + var codeIndex = markup.LastIndexOf("@code", StringComparison.Ordinal); + if (codeIndex < 0) + { + return $"{markup.TrimEnd()}\n\n@code {{\n{parameterBlock}\n}}"; + } + + var openBraceIndex = markup.IndexOf('{', codeIndex); + var closeBraceIndex = markup.LastIndexOf('}'); + if (openBraceIndex < 0 || closeBraceIndex <= openBraceIndex) + { + return $"{markup.TrimEnd()}\n\n@code {{\n{parameterBlock}\n}}"; + } + + var body = markup.Substring(openBraceIndex + 1, closeBraceIndex - openBraceIndex - 1).Trim('\r', '\n'); + var rewrittenBody = string.IsNullOrWhiteSpace(body) + ? parameterBlock + : $"{body}\n\n{parameterBlock}"; + + return markup[..codeIndex] + + $"@code {{\n{rewrittenBody}\n}}" + + markup[(closeBraceIndex + 1)..]; + } + + public static string RebuildWrapper(WrapperMatch wrapper, string rewrittenInner) => + $"{wrapper.Prefix}{wrapper.OpenTag}\n{Indent(rewrittenInner.Trim(), " ")}\n{wrapper.CloseTag}{wrapper.Suffix}"; + + public static string Indent(string content, string indent) + { + var normalized = content.Replace("\r\n", "\n"); + var lines = normalized.Split('\n'); + return string.Join("\n", lines.Select(line => string.IsNullOrWhiteSpace(line) ? string.Empty : $"{indent}{line.TrimEnd()}")); + } + + public readonly record struct WrapperMatch( + string Name, + string Prefix, + string OpenTag, + string InnerContent, + string CloseTag, + string Suffix); +} diff --git a/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternUtilities.cs b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternUtilities.cs new file mode 100644 index 000000000..c0c9c6ca9 --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/SemanticPatterns/SemanticPatternUtilities.cs @@ -0,0 +1,169 @@ +using System.Text; +using System.Text.RegularExpressions; + +namespace BlazorWebFormsComponents.Cli.SemanticPatterns; + +internal static class SemanticPatternUtilities +{ + public static string RelativeMarkupPath(SemanticPatternContext context) => + Path.GetRelativePath(context.MigrationContext.SourcePath, context.SourceFile.MarkupPath); + + public static string ToPropertyName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "Value"; + } + + var parts = Regex.Matches(value, @"[A-Za-z0-9]+") + .Select(static match => match.Value) + .Where(static part => !string.IsNullOrWhiteSpace(part)) + .ToArray(); + + if (parts.Length == 0) + { + return "Value"; + } + + var builder = new StringBuilder(); + foreach (var part in parts) + { + builder.Append(char.ToUpperInvariant(part[0])); + if (part.Length > 1) + { + builder.Append(part[1..]); + } + } + + return builder.ToString(); + } + + public static string NormalizeBindingType(string type) + { + var trimmed = type.Trim(); + return string.Equals(trimmed, "string", StringComparison.Ordinal) + ? "string?" + : trimmed; + } + + public static string NormalizeRoute(string target) + { + var trimmed = target.Trim(); + if (trimmed.StartsWith("~/", StringComparison.Ordinal)) + { + trimmed = "/" + trimmed[2..]; + } + + if (!trimmed.StartsWith("/", StringComparison.Ordinal)) + { + trimmed = "/" + trimmed; + } + + if (trimmed.EndsWith(".aspx", StringComparison.OrdinalIgnoreCase)) + { + trimmed = trimmed[..^5]; + } + + return trimmed; + } + + public static string ExtractPageDirective(string markup, string pageName) + { + var pageDirective = Regex.Match(markup, @"(?m)^@page\s+""[^""]+""\s*$"); + return pageDirective.Success + ? pageDirective.Value + : $@"@page ""/{pageName}"""; + } + + public static bool TryExtractMethod(string content, string methodName, out ExtractedMethod method) + { + method = default!; + + var signatureRegex = new Regex( + $@"(?s)(?(?:public|protected|internal|private)\s+(?:static\s+)?(?:async\s+)?(?.+?)\s+{Regex.Escape(methodName)}\s*\((?.*?)\))\s*\{{", + RegexOptions.Compiled); + + var match = signatureRegex.Match(content); + if (!match.Success) + { + return false; + } + + var openBraceIndex = match.Index + match.Length - 1; + var closeBraceIndex = FindMatchingBrace(content, openBraceIndex); + if (closeBraceIndex < 0) + { + return false; + } + + method = new ExtractedMethod( + methodName, + match.Groups["returnType"].Value.Trim(), + match.Groups["parameters"].Value, + content[match.Index..(closeBraceIndex + 1)]); + return true; + } + + public static int FindMatchingBrace(string content, int openBraceIndex) + { + var depth = 0; + var inString = false; + var stringDelimiter = '\0'; + var escapeNext = false; + + for (var index = openBraceIndex; index < content.Length; index++) + { + var current = content[index]; + + if (inString) + { + if (escapeNext) + { + escapeNext = false; + continue; + } + + if (current == '\\') + { + escapeNext = true; + continue; + } + + if (current == stringDelimiter) + { + inString = false; + } + + continue; + } + + if (current is '"' or '\'') + { + inString = true; + stringDelimiter = current; + continue; + } + + if (current == '{') + { + depth++; + } + else if (current == '}') + { + depth--; + if (depth == 0) + { + return index; + } + } + } + + return -1; + } +} + +internal sealed record ExtractedMethod( + string Name, + string ReturnType, + string Parameters, + string FullText); diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/NamespaceAlignTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/NamespaceAlignTransform.cs new file mode 100644 index 000000000..c2f0129bb --- /dev/null +++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/NamespaceAlignTransform.cs @@ -0,0 +1,72 @@ +using System.Text.RegularExpressions; +using BlazorWebFormsComponents.Cli.Pipeline; + +namespace BlazorWebFormsComponents.Cli.Transforms.CodeBehind; + +/// +/// Aligns the code-behind namespace with the generated Razor namespace derived +/// from the output path under the migration project root. +/// +public class NamespaceAlignTransform : ICodeBehindTransform +{ + public string Name => "NamespaceAlign"; + public int Order => 212; // After ClassNameAlignTransform (210) + + private static readonly Regex FileScopedNamespaceRegex = new( + @"(?m)^\s*namespace\s+([A-Za-z_][A-Za-z0-9_\.]*)\s*;\s*$", + RegexOptions.Compiled); + + private static readonly Regex BlockScopedNamespaceRegex = new( + @"(?m)^(\s*namespace\s+)([A-Za-z_][A-Za-z0-9_\.]*)(\s*\{)", + RegexOptions.Compiled); + + public string Apply(string content, FileMetadata metadata) + { + var expectedNamespace = GetExpectedNamespace(metadata); + if (string.IsNullOrEmpty(expectedNamespace)) + return content; + + if (FileScopedNamespaceRegex.IsMatch(content)) + { + return FileScopedNamespaceRegex.Replace(content, $"namespace {expectedNamespace};", 1); + } + + if (BlockScopedNamespaceRegex.IsMatch(content)) + { + return BlockScopedNamespaceRegex.Replace(content, $"$1{expectedNamespace}$3", 1); + } + + return content; + } + + private static string? GetExpectedNamespace(FileMetadata metadata) + { + if (string.IsNullOrWhiteSpace(metadata.ProjectNamespace) || + string.IsNullOrWhiteSpace(metadata.OutputRootPath)) + { + return null; + } + + var relativePath = Path.GetRelativePath(metadata.OutputRootPath, metadata.OutputFilePath); + var relativeDirectory = Path.GetDirectoryName(relativePath); + + if (string.IsNullOrWhiteSpace(relativeDirectory) || relativeDirectory == ".") + return metadata.ProjectNamespace; + + var namespaceSegments = relativeDirectory + .Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .Where(static segment => !string.IsNullOrWhiteSpace(segment)) + .Select(SanitizeNamespaceSegment); + + return $"{metadata.ProjectNamespace}.{string.Join(".", namespaceSegments)}"; + } + + private static string SanitizeNamespaceSegment(string segment) + { + var sanitized = segment.Replace('.', '_').Replace('-', '_'); + if (sanitized.Length > 0 && char.IsDigit(sanitized[0])) + sanitized = "_" + sanitized; + + return sanitized; + } +} diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs index 839ebcf81..b017ef02b 100644 --- a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/ContentWrapperTransform.cs @@ -4,31 +4,62 @@ namespace BlazorWebFormsComponents.Cli.Transforms.Markup; /// -/// Removes <asp:Content> wrapper tags, preserving inner content. -/// Handles HeadContent placeholders and TitleContent extraction. +/// Converts <asp:Content> wrappers to BWFC <Content ContentPlaceHolderID="X"> components +/// and wraps all Content elements in the master-page component (e.g. <Site>...</Site>). +/// Reads MasterPageFile from metadata.OriginalContent (before PageDirectiveTransform stripped it). /// public class ContentWrapperTransform : IMarkupTransform { public string Name => "ContentWrapper"; public int Order => 300; - // Open tags for any ContentPlaceHolderID — strip entirely, keeping content + // Captures ContentPlaceHolderID value from the open tag private static readonly Regex ContentOpenRegex = new( - @"]*ContentPlaceHolderID\s*=\s*""[^""]*""[^>]*>[ \t]*\r?\n?", - RegexOptions.Compiled); + @"]*ContentPlaceHolderID\s*=\s*""([^""]*)""[^>]*>", + RegexOptions.Compiled | RegexOptions.IgnoreCase); - // Closing tags private static readonly Regex ContentCloseRegex = new( - @"\s*\r?\n?", - RegexOptions.Compiled); + @"", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex MasterPageFileRegex = new( + @"MasterPageFile\s*=\s*""([^""]*)""", + RegexOptions.Compiled | RegexOptions.IgnoreCase); public string Apply(string content, FileMetadata metadata) { - // Strip opening wrappers - content = ContentOpenRegex.Replace(content, ""); + if (metadata.FileType == FileType.Master) + return content; + + // Convert + content = ContentOpenRegex.Replace(content, m => + $""); + + // Convert → + content = ContentCloseRegex.Replace(content, ""); + + // Determine master page component name from original content (before directive was stripped) + var masterMatch = MasterPageFileRegex.Match(metadata.OriginalContent); + if (!masterMatch.Success) + return content; + + var masterFile = masterMatch.Groups[1].Value; + var componentName = Path.GetFileNameWithoutExtension(masterFile); + + // Wrap all Content elements inside the master component + var firstContentIndex = content.IndexOf("", StringComparison.OrdinalIgnoreCase); + + if (firstContentIndex < 0 || lastContentCloseIndex < 0) + return content; + + var endOfLastClose = lastContentCloseIndex + "".Length; - // Strip closing tags - content = ContentCloseRegex.Replace(content, ""); + content = content.Substring(0, firstContentIndex) + + $"<{componentName}>\n" + + content.Substring(firstContentIndex, endOfLastClose - firstContentIndex) + + $"\n" + + content.Substring(endOfLastClose); return content; } diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/MasterPageTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/MasterPageTransform.cs index 4770ee25c..fa26ada2b 100644 --- a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/MasterPageTransform.cs +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/MasterPageTransform.cs @@ -1,26 +1,34 @@ +using System.Text; using System.Text.RegularExpressions; using BlazorWebFormsComponents.Cli.Pipeline; namespace BlazorWebFormsComponents.Cli.Transforms.Markup; /// -/// Converts master page layout elements to Blazor layout syntax. -/// Replaces <asp:ContentPlaceHolder> with @Body, adds @inherits LayoutComponentBase, -/// and strips runat="server" from head and form tags. +/// Converts .master files to BWFC MasterPage component syntax. +/// Preserves named ContentPlaceHolder relationships using BWFC <ContentPlaceHolder ID="X">, +/// emits a runnable shell contract with a ChildContent parameter, preserves +/// head content inside BWFC <Head>, and strips the outer HTML document +/// scaffold plus server-form wrappers. /// public class MasterPageTransform : IMarkupTransform { public string Name => "MasterPage"; public int Order => 250; - // Block: ... + // Block: ... private static readonly Regex ContentPlaceHolderBlockRegex = new( - @"]*>[\s\S]*?[ \t]*\r?\n?", + @"]*)>([\s\S]*?)[ \t]*\r?\n?", RegexOptions.Compiled | RegexOptions.IgnoreCase); - // Self-closing: + // Self-closing: private static readonly Regex ContentPlaceHolderSelfClosingRegex = new( - @"]*?/>[ \t]*\r?\n?", + @"]*?)/>[ \t]*\r?\n?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // Extract ID="..." from a tag attribute string + private static readonly Regex TagIdRegex = new( + @"\bID\s*=\s*""([^""]*)""", RegexOptions.Compiled | RegexOptions.IgnoreCase); // runat="server" on tags @@ -28,37 +36,169 @@ public class MasterPageTransform : IMarkupTransform @"(]*?)\s+runat\s*=\s*""server""([^>]*>)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - // runat="server" on
tags + // Server form wrappers should not survive into the generated master-page shell. + private static readonly Regex ServerFormBlockRegex = new( + @"[ \t]*]*?)\s+runat\s*=\s*""server""([^>]*)>([\s\S]*?)[ \t]*\r?\n?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex FormRunatRegex = new( @"(]*?)\s+runat\s*=\s*""server""([^>]*>)", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private const string InheritsDirective = "@inherits LayoutComponentBase"; - private const string TodoComment = "@* TODO(bwfc-master-page): Review head content extraction for App.razor *@"; + // Outer HTML document boilerplate + private static readonly Regex DocTypeRegex = new( + @"[ \t]*]*>[ \t]*\r?\n?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex HtmlOpenRegex = new( + @"[ \t]*]*)?>[ \t]*\r?\n?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex HtmlCloseRegex = new( + @"[ \t]*[ \t]*\r?\n?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex BodyOpenRegex = new( + @"[ \t]*]*)?>[ \t]*\r?\n?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex BodyCloseRegex = new( + @"[ \t]*[ \t]*\r?\n?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // ... section (captures inner content) + private static readonly Regex HeadSectionRegex = new( + @"[ \t]*]*>([\s\S]*?)[ \t]*\r?\n?", + RegexOptions.Compiled | RegexOptions.IgnoreCase); public string Apply(string content, FileMetadata metadata) { if (metadata.FileType != FileType.Master) return content; - // Convert ContentPlaceHolder blocks (with default content) → @Body - content = ContentPlaceHolderBlockRegex.Replace(content, "@Body\n"); - - // Convert ContentPlaceHolder self-closing → @Body - content = ContentPlaceHolderSelfClosingRegex.Replace(content, "@Body\n"); + // Normalise line endings for consistent regex behaviour + content = content.Replace("\r\n", "\n"); - // Strip runat="server" from tags + // Step 1: Strip runat="server" from and unwrap server-form shells content = HeadRunatRegex.Replace(content, "$1$2"); - - // Strip runat="server" from
tags + content = ServerFormBlockRegex.Replace(content, m => m.Groups[3].Value.Trim('\n', '\r') + "\n"); content = FormRunatRegex.Replace(content, "$1$2"); - // Add @inherits LayoutComponentBase at the top if not already present - if (!content.Contains(InheritsDirective)) + // Step 2: Convert block ContentPlaceHolder → named BWFC component (preserve default content) + content = ContentPlaceHolderBlockRegex.Replace(content, m => + { + var attrs = m.Groups[1].Value; + var inner = m.Groups[2].Value; + var id = ExtractId(attrs); + return $"{inner}\n"; + }); + + // Step 3: Convert self-closing ContentPlaceHolder → named BWFC component + content = ContentPlaceHolderSelfClosingRegex.Replace(content, m => { - content = InheritsDirective + "\n" + TodoComment + "\n\n" + content; + var attrs = m.Groups[1].Value; + var id = ExtractId(attrs); + return $"\n"; + }); + + // Step 4: Extract section and preserve it in BWFC + string? headContent = null; + + var headMatch = HeadSectionRegex.Match(content); + if (headMatch.Success) + { + var innerHead = headMatch.Groups[1].Value; + var cleanHead = innerHead + .Replace("~/", "/") + .Trim('\n', '\r'); + if (!string.IsNullOrWhiteSpace(cleanHead)) + { + headContent = cleanHead; + } + + content = HeadSectionRegex.Replace(content, ""); + } + + // Step 5: Strip outer HTML boilerplate + content = DocTypeRegex.Replace(content, ""); + content = HtmlOpenRegex.Replace(content, ""); + content = HtmlCloseRegex.Replace(content, ""); + content = BodyOpenRegex.Replace(content, ""); + content = BodyCloseRegex.Replace(content, ""); + + // Step 6: Peel off leading Razor directive lines (e.g. @using) — keep outside + var directives = new StringBuilder(); + var lines = content.Split('\n'); + var bodyStart = 0; + for (var i = 0; i < lines.Length; i++) + { + var trimmed = lines[i].TrimEnd(); + if (trimmed.StartsWith("@") || string.IsNullOrWhiteSpace(trimmed)) + { + if (!string.IsNullOrWhiteSpace(trimmed)) + directives.Append(lines[i].TrimEnd() + "\n"); + bodyStart = i + 1; + } + else + { + break; + } + } + + var bodyContent = string.Join("\n", lines.Skip(bodyStart)).Trim('\n', '\r'); + + // Step 7: Assemble output + var sb = new StringBuilder(); + + if (directives.Length > 0) + sb.Append(directives.ToString()); + + const string todoMsg = + "@* TODO(bwfc-master-page): Review shell scripts, bundle references, and auth/cart chrome for SSR-safe migration. *@"; + + sb.AppendLine(todoMsg); + sb.AppendLine(); + + if (headContent != null) + { + sb.AppendLine(""); + sb.AppendLine(""); + sb.AppendLine(headContent); + sb.AppendLine(""); + sb.AppendLine(""); + if (!string.IsNullOrWhiteSpace(bodyContent)) + { + sb.AppendLine(bodyContent); + } + sb.AppendLine("@ChildContent"); + sb.AppendLine(""); + sb.AppendLine(""); + } + else + { + sb.AppendLine(""); + sb.AppendLine(""); + if (!string.IsNullOrWhiteSpace(bodyContent)) + { + sb.AppendLine(bodyContent); + } + sb.AppendLine("@ChildContent"); + sb.AppendLine(""); + sb.AppendLine(""); } - return content; + sb.AppendLine(); + sb.AppendLine("@code {"); + sb.AppendLine(" [Parameter]"); + sb.AppendLine(" public RenderFragment? ChildContent { get; set; }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static string ExtractId(string attrs) + { + var m = TagIdRegex.Match(attrs); + return m.Success ? m.Groups[1].Value : "Main"; } } diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/SelectMethodTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/SelectMethodTransform.cs index 02093b4f2..fdf8c747a 100644 --- a/src/BlazorWebFormsComponents.Cli/Transforms/Markup/SelectMethodTransform.cs +++ b/src/BlazorWebFormsComponents.Cli/Transforms/Markup/SelectMethodTransform.cs @@ -4,16 +4,17 @@ namespace BlazorWebFormsComponents.Cli.Transforms.Markup; /// -/// Detects SelectMethod, InsertMethod, UpdateMethod, and DeleteMethod attributes on data-bound -/// controls. Preserves the attribute as-is and adds a TODO comment for delegate conversion. +/// Preserves SelectMethod on BWFC data-bound controls because DataBoundComponent already +/// supports delegate-style SelectMethod binding in markup. Insert/Update/Delete methods +/// still need manual review and therefore get TODO comments. /// public class SelectMethodTransform : IMarkupTransform { public string Name => "SelectMethod"; public int Order => 520; - private static readonly Regex MethodAttrRegex = new( - @"(SelectMethod|InsertMethod|UpdateMethod|DeleteMethod)=""([^""]+)""", + private static readonly Regex CrudMethodAttrRegex = new( + @"(InsertMethod|UpdateMethod|DeleteMethod)=""([^""]+)""", RegexOptions.Compiled); public string Apply(string content, FileMetadata metadata) @@ -25,13 +26,13 @@ public string Apply(string content, FileMetadata metadata) { result.Add(line); - var matches = MethodAttrRegex.Matches(line); + var matches = CrudMethodAttrRegex.Matches(line); foreach (Match match in matches) { var attrName = match.Groups[1].Value; var methodName = match.Groups[2].Value; result.Add( - $"@* TODO(bwfc-select-method): Convert {attrName}=\"{methodName}\" to a code-behind method that sets Items property *@"); + $"@* TODO(bwfc-select-method): Review {attrName}=\"{methodName}\" migration for BWFC event/CRUD handling *@"); } } diff --git a/src/BlazorWebFormsComponents.Test/MasterPage/ContentRelationshipTests.razor b/src/BlazorWebFormsComponents.Test/MasterPage/ContentRelationshipTests.razor new file mode 100644 index 000000000..04b858de1 --- /dev/null +++ b/src/BlazorWebFormsComponents.Test/MasterPage/ContentRelationshipTests.razor @@ -0,0 +1,160 @@ +@inherits BlazorWebFormsTestContext + +@code { + [Fact] + public void Content_WithMatchingPlaceholderID_InjectsContent() + { + // Content registered to "MainContent" should replace the placeholder default. + var cut = Render(@ + +

From Content Control

+
+ +

Default content

+
+
); + + cut.Find(".injected").TextContent.ShouldBe("From Content Control"); + cut.Markup.ShouldNotContain("default"); + } + + [Fact] + public void Content_WithNonMatchingPlaceholderID_ShowsDefault() + { + // Content targeting a different ID should not affect MainContent placeholder. + var cut = Render(@ + + Sidebar content + + +

Default main

+
+
); + + cut.Find(".default").TextContent.ShouldBe("Default main"); + cut.Markup.ShouldNotContain("sidebar"); + } + + [Fact] + public void Content_WithEmptyPlaceholderID_DoesNotInject() + { + // Content with no ContentPlaceHolderID must not interfere with any placeholder. + var cut = Render(@ + + Orphan content + + +

Default remains

+
+
); + + cut.Find(".default").TextContent.ShouldBe("Default remains"); + } + + [Fact] + public void MultipleContent_InjectIntoMultiplePlaceholders() + { + // Two Content controls each target distinct ContentPlaceHolder IDs. + var cut = Render(@ + +

My Page Title

+
+ +
Page body text
+
+ +

Default header

+
+ +

Default body

+
+
); + + cut.Find(".page-title").TextContent.ShouldBe("My Page Title"); + cut.Find(".page-body").TextContent.ShouldBe("Page body text"); + } + + [Fact] + public void ContentPlaceHolder_WithoutMasterParent_RendersDefaultContent() + { + // ContentPlaceHolder rendered standalone (no parent MasterPage) should show its default. + var cut = Render(@ +

Standalone default

+
); + + cut.Find(".standalone-default").TextContent.ShouldBe("Standalone default"); + } + + [Fact] + public void MasterPage_ContentPlaceHolders_RegistrationDoesNotThrow() + { + // Registering ContentPlaceHolders in MasterPage.ContentPlaceHolders must not throw. + Should.NotThrow(() => Render(@ + +
A
+
+ +
B
+
+
)); + } + + [Fact] + public void Content_WithMasterPage_RegistersInContentSections() + { + // After rendering, the MasterPage instance must have the content section registered. + var cut = Render(@ + +

Registered

+
+ +

Fallback

+
+
); + + var masterPage = cut.FindComponent().Instance; + masterPage.ContentSections.ContainsKey("Main").ShouldBeTrue(); + } + + [Fact] + public void Content_InChildComponents_InjectsIntoPlaceholderInChildContent() + { + var cut = Render(@ + +
+ +

Default content

+
+
+
+ + +

Injected from ChildComponents

+
+
+
); + + cut.Find(".injected").TextContent.ShouldBe("Injected from ChildComponents"); + cut.Markup.ShouldNotContain("Default content"); + } + + [Fact] + public void ContentPlaceHolder_DefaultContent_RemainsWhenChildComponentsDoNotTargetSlot() + { + var cut = Render(@ + + +

Default main

+
+
+ + + Sidebar content + + +
); + + cut.Find(".default").TextContent.ShouldBe("Default main"); + cut.Markup.ShouldNotContain("sidebar"); + } +} diff --git a/src/BlazorWebFormsComponents/Content.razor.cs b/src/BlazorWebFormsComponents/Content.razor.cs index 860077e9f..36974f4dc 100644 --- a/src/BlazorWebFormsComponents/Content.razor.cs +++ b/src/BlazorWebFormsComponents/Content.razor.cs @@ -1,53 +1,95 @@ using Microsoft.AspNetCore.Components; +using System; using System.Threading.Tasks; namespace BlazorWebFormsComponents { /// /// A component that emulates ASP.NET Web Forms Content control. - /// Provides content for a ContentPlaceHolder in a master page. + /// Provides a named content fragment to the + /// whose matches + /// . /// /// - /// In Web Forms, Content controls are used in child pages to provide content - /// for ContentPlaceHolder controls defined in the master page. - /// - /// In Blazor, this is done by placing content within layout sections or the @Body area. - /// This component provides Web Forms-style syntax for developers migrating to Blazor. - /// - /// Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.webcontrols.content + /// + /// Content renders no visible HTML. It simply registers + /// with the nearest ancestor . + /// + /// + /// This keeps the migration-facing <Content> tag while shifting the + /// implementation toward a layout-like named-section registry. + /// /// public partial class Content : ContentBase { - /// - /// The content to be rendered in the associated ContentPlaceHolder - /// + private string _registeredContentPlaceHolderId; + + /// The content fragment to inject into the matching placeholder. [Parameter] public RenderFragment ChildContent { get; set; } /// - /// The ID of the ContentPlaceHolder this content is for + /// The of the + /// that should receive this content. /// [Parameter] public string ContentPlaceHolderID { get; set; } + /// + /// The shared master-page context cascaded by or + /// . + /// + [CascadingParameter] + private MasterPageContext MasterContext { get; set; } + + /// + /// Direct reference to the parent component. + /// Retained for backward compatibility when older markup only cascades the parent. + /// [CascadingParameter] private MasterPage ParentMasterPage { get; set; } - protected override async Task OnInitializedAsync() + /// + protected override void OnParametersSet() { - // Register this content with the parent MasterPage - if (ParentMasterPage != null && !string.IsNullOrEmpty(ContentPlaceHolderID)) + if (string.IsNullOrWhiteSpace(ContentPlaceHolderID)) + { + return; + } + + var context = MasterContext ?? ParentMasterPage?.Context; + if (string.Equals(_registeredContentPlaceHolderId, ContentPlaceHolderID, StringComparison.OrdinalIgnoreCase)) { - ParentMasterPage.ContentSections[ContentPlaceHolderID] = ChildContent; + return; } - await base.OnInitializedAsync(); + if (!string.IsNullOrWhiteSpace(_registeredContentPlaceHolderId)) + { + context?.SetContent(_registeredContentPlaceHolderId, null); + } + + context?.SetContent(ContentPlaceHolderID, ChildContent); + _registeredContentPlaceHolderId = ContentPlaceHolderID; + } + + /// + /// Clears the registered slot so the falls back + /// to its default content when this component is removed + /// from the render tree. + /// + protected override async ValueTask Dispose(bool disposing) + { + if (disposing && !string.IsNullOrWhiteSpace(_registeredContentPlaceHolderId)) + { + var context = MasterContext ?? ParentMasterPage?.Context; + context?.SetContent(_registeredContentPlaceHolderId, null); + } + + await base.Dispose(disposing); } } - /// - /// Base class for Content component - /// + /// Base class for component. public abstract class ContentBase : BaseWebFormsComponent { } diff --git a/src/BlazorWebFormsComponents/ContentPlaceHolder.razor.cs b/src/BlazorWebFormsComponents/ContentPlaceHolder.razor.cs index 9656f94cb..7542f9f2b 100644 --- a/src/BlazorWebFormsComponents/ContentPlaceHolder.razor.cs +++ b/src/BlazorWebFormsComponents/ContentPlaceHolder.razor.cs @@ -1,56 +1,49 @@ using Microsoft.AspNetCore.Components; -using System.Threading.Tasks; namespace BlazorWebFormsComponents { /// /// A component that emulates ASP.NET Web Forms ContentPlaceHolder control. - /// Defines a region in a master page that can be replaced with content from child pages. + /// Defines a named content slot in a master page that child pages can fill + /// with a control that targets the same + /// . /// /// - /// In Web Forms, ContentPlaceHolder controls are used in master pages to define areas - /// that child pages can customize using Content controls. - /// - /// In Blazor, this is typically done with @Body for the main content area or - /// @RenderSection for named sections. This component bridges the gap by providing - /// Web Forms-style syntax while using Blazor's underlying mechanisms. - /// - /// Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.webcontrols.contentplaceholder + /// + /// ContentPlaceHolder is intentionally a thin reader over the shared + /// registry. The master-page host is responsible + /// for re-rendering when the registry changes, which keeps this control aligned + /// with Blazor layout semantics while preserving the original Web Forms tag name. + /// /// public partial class ContentPlaceHolder : ContentPlaceHolderBase { - /// - /// Default content to display if no Content control provides a replacement - /// + /// Default content shown when no matching is registered. [Parameter] public RenderFragment ChildContent { get; set; } /// - /// Content provided by a child page's Content control + /// The shared master-page context cascaded by or + /// . /// - internal RenderFragment Content { get; set; } + [CascadingParameter] + private MasterPageContext MasterContext { get; set; } + /// + /// Direct reference to the parent component, kept for + /// backward compatibility. + /// [CascadingParameter] private MasterPage ParentMasterPage { get; set; } - protected override async Task OnInitializedAsync() - { - // Register this placeholder with the parent MasterPage - ParentMasterPage?.RegisterContentPlaceHolder(this); - - // Get content from parent MasterPage if available - if (ParentMasterPage != null && !string.IsNullOrEmpty(ID)) - { - Content = ParentMasterPage.GetContentForPlaceHolder(ID); - } - - await base.OnInitializedAsync(); - } + /// Fragment injected by a matching control, if any. + internal RenderFragment Content => + string.IsNullOrWhiteSpace(ID) + ? null + : MasterContext?.GetContent(ID) ?? ParentMasterPage?.GetContentForPlaceHolder(ID); } - /// - /// Base class for ContentPlaceHolder component - /// + /// Base class for component. public abstract class ContentPlaceHolderBase : BaseWebFormsComponent { } diff --git a/src/BlazorWebFormsComponents/MasterPage.razor b/src/BlazorWebFormsComponents/MasterPage.razor index 2808b2fc0..ab94946a6 100644 --- a/src/BlazorWebFormsComponents/MasterPage.razor +++ b/src/BlazorWebFormsComponents/MasterPage.razor @@ -10,5 +10,10 @@ @if (Visible) { - @ChildContent + + + @ChildContent + @ChildComponents + + } diff --git a/src/BlazorWebFormsComponents/MasterPage.razor.cs b/src/BlazorWebFormsComponents/MasterPage.razor.cs index b47b441b8..9db2855f7 100644 --- a/src/BlazorWebFormsComponents/MasterPage.razor.cs +++ b/src/BlazorWebFormsComponents/MasterPage.razor.cs @@ -1,108 +1,238 @@ using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; using System; using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; namespace BlazorWebFormsComponents { /// /// A component that emulates ASP.NET Web Forms MasterPage functionality. - /// In Blazor, MasterPages are replaced by Layout components, but this component - /// provides a familiar API for developers migrating from Web Forms. + /// Wrap your master-page chrome (header, nav, footer) inside this component and use + /// controls to define named content slots. Child + /// pages provide slot content via controls. /// /// - /// Web Forms MasterPages define a template for pages, with ContentPlaceHolder - /// controls that child pages can populate with Content controls. - /// - /// In Blazor, layouts use @Body and @RenderSection to achieve similar functionality. - /// This MasterPage component acts as a bridge, allowing Web Forms-style markup - /// to work in Blazor by internally using Blazor's layout system. - /// - /// The Head parameter allows you to define head content that will be automatically - /// wrapped in a HeadContent component, bridging the gap between Web Forms' - /// <head runat="server"> and Blazor's HeadContent approach. You can include - /// <title> elements directly in the Head content and they will work correctly. - /// - /// Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.masterpage + /// + /// MasterPage cascades a to all descendants so that + /// controls can register named fragments and the host can + /// re-render like a lightweight layout/section shim. + /// then simply reads its named slot from the shared context. + /// + /// + /// For Blazor layout scenarios (migrated master pages used with @layout), + /// inherit in the layout component instead. + /// + /// + /// Original Microsoft documentation: + /// https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.masterpage + /// /// public partial class MasterPage : MasterPageBase { /// - /// The content of the master page template, which contains the layout and ContentPlaceHolder controls + /// The content of the master page template, which typically contains layout + /// structure and controls. /// [Parameter] public RenderFragment ChildContent { get; set; } /// - /// Optional head content that will be automatically wrapped in a HeadContent component. - /// This provides a bridge between Web Forms' <head runat="server"> and Blazor's HeadContent. - /// Content defined here will be rendered in the document's <head> section via HeadOutlet. - /// You can include <title> elements directly in the Head content. + /// Optional head content wrapped in a HeadContent component, bridging + /// Web Forms' <head runat="server"> to Blazor's HeadOutlet. /// - /// - /// <MasterPage> - /// <Head> - /// <title>My Page Title</title> - /// <link href="css/site.css" rel="stylesheet" /> - /// <meta name="description" content="My site" /> - /// </Head> - /// <ChildContent> - /// <!-- Page layout here --> - /// </ChildContent> - /// </MasterPage> - /// [Parameter] public RenderFragment Head { get; set; } /// - /// Collection of ContentPlaceHolder controls defined in this master page + /// Shared section registry consumed by and + /// populated by . /// - internal Dictionary ContentPlaceHolders { get; } = new Dictionary(); + internal MasterPageContext Context { get; } = new(); /// - /// Registers a ContentPlaceHolder with this MasterPage + /// Read-only view of the content sections that have been registered by + /// controls. Keyed by ContentPlaceHolderID. /// - internal void RegisterContentPlaceHolder(ContentPlaceHolder placeholder) + internal IReadOnlyDictionary ContentSections => + _contentSectionsView ??= new MasterPageContentSectionsView(Context); + + private MasterPageContentSectionsView _contentSectionsView; + private bool _renderQueued; + + /// + /// Gets the content registered for , or null. + /// + internal RenderFragment GetContentForPlaceHolder(string placeHolderId) => + Context.GetContent(placeHolderId); + + /// + /// Called by controls to announce their presence. + /// Retained for backward compatibility. + /// + [Obsolete("ContentPlaceHolder controls now resolve content directly from MasterPageContext.")] + internal void RegisterContentPlaceHolder(ContentPlaceHolder placeholder) { } + + /// + protected override void OnInitialized() { - if (!string.IsNullOrEmpty(placeholder.ID)) + base.OnInitialized(); + Context.SectionsChanged += OnSectionsChanged; + } + + private void OnSectionsChanged() + { + if (_renderQueued) { - ContentPlaceHolders[placeholder.ID] = placeholder; + return; } + + _renderQueued = true; + _ = InvokeAsync(() => + { + _renderQueued = false; + StateHasChanged(); + }); } - /// - /// Gets the content for a specific ContentPlaceHolder from child pages - /// - internal RenderFragment GetContentForPlaceHolder(string placeHolderId) + /// + protected override async ValueTask Dispose(bool disposing) + { + if (disposing) + { + Context.SectionsChanged -= OnSectionsChanged; + Context.Dispose(); + } + + await base.Dispose(disposing); + } + } + + /// + /// Read-only dictionary view over a for backward + /// compatibility with code that accesses . + /// + internal sealed class MasterPageContentSectionsView : IReadOnlyDictionary + { + private readonly MasterPageContext _ctx; + + public MasterPageContentSectionsView(MasterPageContext ctx) => _ctx = ctx; + + public RenderFragment this[string key] => _ctx.GetContent(key) ?? throw new KeyNotFoundException(key); + public IEnumerable Keys => _ctx.RegisteredIds; + public IEnumerable Values { - if (ContentSections != null && ContentSections.TryGetValue(placeHolderId, out var contentSection)) + get { - return contentSection; + foreach (var id in _ctx.RegisteredIds) + { + yield return _ctx.GetContent(id); + } } - return null; } - /// - /// Content sections provided by child pages that use this master page - /// - internal Dictionary ContentSections { get; set; } = new Dictionary(); + public int Count => System.Linq.Enumerable.Count(_ctx.RegisteredIds); + public bool ContainsKey(string key) => _ctx.HasContent(key); + + public bool TryGetValue(string key, out RenderFragment value) + { + value = _ctx.GetContent(key); + return value != null; + } + + public IEnumerator> GetEnumerator() + { + foreach (var id in _ctx.RegisteredIds) + { + yield return new KeyValuePair(id, _ctx.GetContent(id)); + } + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); } /// - /// Base class for MasterPage components + /// Base class for the component. Carries the shared + /// obsolete Web Forms properties that migration tooling may emit. /// public abstract class MasterPageBase : BaseWebFormsComponent { /// - /// Gets or sets the title of the page. In Web Forms, this is typically set by child pages - /// and propagated to the master page's title element. + /// The page title. In Blazor use the PageTitle component instead. /// - [Parameter, Obsolete("Use @page directive with @title in Blazor, or set Title via PageTitle component")] + [Parameter, Obsolete("Use PageTitle component or set Title via IPageService instead.")] public string Title { get; set; } /// - /// Gets or sets the path to a parent master page for nested master pages + /// Path to a parent master page (nested master pages). + /// In Blazor, use the @layout directive on the layout component instead. /// - [Parameter, Obsolete("Nested master pages are not commonly used in Blazor. Use nested layouts instead by setting @layout directive")] + [Parameter, Obsolete("Use the @layout directive for nested layouts in Blazor.")] public string MasterPageFile { get; set; } } + + /// + /// Abstract base class for Blazor layout components that emulate ASP.NET Web Forms + /// master pages. Inherit this class in your migrated .razor layout files + /// (those that use @layout from child pages) when you want to preserve named + /// / slot relationships. + /// + public abstract class MasterPageLayoutBase : LayoutComponentBase, IAsyncDisposable + { + private static readonly FieldInfo s_renderFragmentField = + typeof(ComponentBase).GetField("_renderFragment", BindingFlags.NonPublic | BindingFlags.Instance) + ?? throw new InvalidOperationException( + "ComponentBase._renderFragment field not found. " + + "This Blazor version may not be compatible with MasterPageLayoutBase."); + + private readonly RenderFragment _baseRenderFragment; + private bool _renderQueued; + + /// + /// The shared context that coordinates ContentPlaceHolder / Content communication. + /// Automatically cascaded to all descendants by this base class. + /// + protected MasterPageContext Context { get; } = new(); + + /// Initialises the context cascade wrapper. + protected MasterPageLayoutBase() + { + _baseRenderFragment = (RenderFragment)s_renderFragmentField.GetValue(this)!; + s_renderFragmentField.SetValue(this, (RenderFragment)ContextWrappedRenderTree); + Context.SectionsChanged += OnSectionsChanged; + + void ContextWrappedRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>(0); + builder.AddAttribute(1, nameof(CascadingValue.Value), Context); + builder.AddAttribute(2, nameof(CascadingValue.IsFixed), false); + builder.AddAttribute(3, nameof(CascadingValue.ChildContent), _baseRenderFragment); + builder.CloseComponent(); + } + } + + private void OnSectionsChanged() + { + if (_renderQueued) + { + return; + } + + _renderQueued = true; + _ = InvokeAsync(() => + { + _renderQueued = false; + StateHasChanged(); + }); + } + + /// + public ValueTask DisposeAsync() + { + Context.SectionsChanged -= OnSectionsChanged; + Context.Dispose(); + return ValueTask.CompletedTask; + } + } } diff --git a/src/BlazorWebFormsComponents/MasterPageContext.cs b/src/BlazorWebFormsComponents/MasterPageContext.cs new file mode 100644 index 000000000..ca0e2b431 --- /dev/null +++ b/src/BlazorWebFormsComponents/MasterPageContext.cs @@ -0,0 +1,99 @@ +using Microsoft.AspNetCore.Components; +using System; +using System.Collections.Generic; + +namespace BlazorWebFormsComponents +{ + /// + /// Shared context object cascaded by and + /// to coordinate named content-slot + /// communication between controls (which + /// consume slots) and controls (which populate slots). + /// + /// + /// + /// The context behaves like a lightweight section registry layered over Blazor + /// layouts: registers a fragment for a named slot and the + /// owning master-page host re-renders when the registry changes. + /// + /// + /// remains a thin reader over that registry, + /// which keeps the migration-facing tag names while moving the behavior closer + /// to Blazor's normal layout/section rendering model. + /// + /// + public sealed class MasterPageContext : IDisposable + { + private readonly Dictionary _sections = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Raised when the section registry changes and the owning master-page host + /// should re-render. + /// + public event Action SectionsChanged; + + /// + /// Register, update, or remove the content fragment for a named placeholder slot. + /// + public void SetContent(string placeholderId, RenderFragment content) + { + if (string.IsNullOrWhiteSpace(placeholderId)) + { + return; + } + + var hadExisting = _sections.TryGetValue(placeholderId, out var existing); + var changed = false; + + if (content is null) + { + if (hadExisting) + { + _sections.Remove(placeholderId); + changed = true; + } + } + else + { + changed = !hadExisting || !ReferenceEquals(existing, content); + _sections[placeholderId] = content; + } + + if (changed) + { + SectionsChanged?.Invoke(); + } + } + + /// Returns the registered content for , or null. + public RenderFragment GetContent(string placeholderId) + { + if (string.IsNullOrWhiteSpace(placeholderId)) + { + return null; + } + + return _sections.TryGetValue(placeholderId, out var content) ? content : null; + } + + /// + /// Returns true if any control has registered + /// a fragment for . + /// + public bool HasContent(string placeholderId) => + !string.IsNullOrWhiteSpace(placeholderId) && _sections.ContainsKey(placeholderId); + + /// + /// Returns the IDs of all registered content slots (useful for diagnostics). + /// + public IEnumerable RegisteredIds => _sections.Keys; + + /// + public void Dispose() + { + _sections.Clear(); + SectionsChanged = null; + } + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/CliTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/CliTests.cs index c28b3b90b..5ea806240 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/CliTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/CliTests.cs @@ -42,6 +42,11 @@ private static RootCommand BuildRootCommand() convertCommand.AddOption(new Option("--overwrite", "Overwrite existing .razor file")); rootCommand.AddCommand(convertCommand); + var prescanCommand = new Command("prescan", "Prescan migration patterns"); + prescanCommand.AddOption(new Option(new[] { "--input", "-i" }, "Source Web Forms project root") { IsRequired = true }); + prescanCommand.AddOption(new Option("--report", "Output report path")); + rootCommand.AddCommand(prescanCommand); + return rootCommand; } @@ -117,12 +122,11 @@ public void ConvertCommand_AcceptsOptionalFlags(string optionName) } [Fact] - public void AnalyzeCommand_DoesNotExist() + public void PrescanCommand_Exists() { - // Architecture doc says analyze is internal — verify it's NOT exposed as a command var root = BuildRootCommand(); - var analyze = root.Children.OfType().FirstOrDefault(c => c.Name == "analyze"); - Assert.Null(analyze); + var prescan = root.Children.OfType().FirstOrDefault(c => c.Name == "prescan"); + Assert.NotNull(prescan); } [Fact] diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/NamespaceAlignTransformTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/NamespaceAlignTransformTests.cs new file mode 100644 index 000000000..cb33ebb86 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/NamespaceAlignTransformTests.cs @@ -0,0 +1,60 @@ +using BlazorWebFormsComponents.Cli.Pipeline; +using BlazorWebFormsComponents.Cli.Transforms.CodeBehind; + +namespace BlazorWebFormsComponents.Cli.Tests; + +public class NamespaceAlignTransformTests +{ + [Fact] + public void NamespaceAlignTransform_UsesOutputPathRelativeToProjectRoot() + { + var transform = new NamespaceAlignTransform(); + var content = """ +namespace WingtipToys.Account +{ + public partial class Login + { + } +} +"""; + + var metadata = new FileMetadata + { + SourceFilePath = @"D:\source\WingtipToys\Account\Login.aspx", + OutputFilePath = @"D:\output\Account\Login.razor", + OutputRootPath = @"D:\output", + ProjectNamespace = "WingtipToys", + FileType = FileType.Page, + OriginalContent = content + }; + + var result = transform.Apply(content, metadata); + + Assert.Contains("namespace WingtipToys.Account", result); + } + + [Fact] + public void NamespaceAlignTransform_LeavesContentUnchanged_WhenProjectContextMissing() + { + var transform = new NamespaceAlignTransform(); + var content = """ +namespace WingtipToys.Account; + +public partial class Login +{ +} +"""; + + var metadata = new FileMetadata + { + SourceFilePath = @"D:\source\WingtipToys\Account\Login.aspx", + OutputFilePath = @"D:\output\WingtipToys\Account\Login.razor", + FileType = FileType.Page, + OriginalContent = content + }; + + var result = transform.Apply(content, metadata); + + Assert.Equal(content, result); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs index 6a37b385d..0614e7e36 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/PipelineIntegrationTests.cs @@ -1,8 +1,12 @@ +using System.Diagnostics; using System.Reflection; +using System.Text; using BlazorWebFormsComponents.Cli.Config; +using BlazorWebFormsComponents.Cli.Interop; using BlazorWebFormsComponents.Cli.Io; using BlazorWebFormsComponents.Cli.Pipeline; using BlazorWebFormsComponents.Cli.Scaffolding; +using BlazorWebFormsComponents.Cli.SemanticPatterns; namespace BlazorWebFormsComponents.Cli.Tests; @@ -47,13 +51,19 @@ private static MigrationPipeline CreateFullPipeline(OutputWriter? writer = null) return new MigrationPipeline( markupTransforms, codeBehindTransforms, + new SemanticPatternCatalog(TestHelpers.CreateDefaultSemanticPatterns()), new ProjectScaffolder(new DatabaseProviderDetector()), new GlobalUsingsGenerator(), new ShimGenerator(), new WebConfigTransformer(), outputWriter, new StaticFileCopier(outputWriter), - new SourceFileCopier(outputWriter, codeBehindTransforms)); + new SourceFileCopier(outputWriter, codeBehindTransforms), + new AppStartCopier(outputWriter), + new AppAssetInjector(outputWriter), + new NuGetStaticAssetExtractor(new PowerShellScriptRunner()), + new EdmxConverterBridge(new PowerShellScriptRunner()), + new RedirectHandlerAnnotator(outputWriter)); } private (string inputDir, string outputDir) CreateTempProjectDir( @@ -134,6 +144,60 @@ protected void Button1_Click(object sender, EventArgs e) return (inputDir, outputDir); } + private (string inputDir, string outputDir) CreateRepoScopedProjectDir(string suffix) + { + var projectRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..")); + var baseDir = Path.Combine(projectRoot, "obj", $"bwfc-pipeline-{suffix}-{Guid.NewGuid():N}"); + var inputDir = Path.Combine(baseDir, "input"); + var outputDir = Path.Combine(baseDir, "output"); + + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(outputDir); + _tempDirs.Add(baseDir); + + return (inputDir, outputDir); + } + + private static async Task<(int ExitCode, string Output)> RunProcessAsync(string fileName, string arguments, string workingDirectory) + { + var output = new StringBuilder(); + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + } + }; + + process.OutputDataReceived += (_, e) => + { + if (e.Data is not null) + { + output.AppendLine(e.Data); + } + }; + process.ErrorDataReceived += (_, e) => + { + if (e.Data is not null) + { + output.AppendLine(e.Data); + } + }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + await process.WaitForExitAsync(); + + return (process.ExitCode, output.ToString()); + } + // ─────────────────────────────────────────────────────────────── // Full pipeline — markup transforms // ─────────────────────────────────────────────────────────────── @@ -191,7 +255,7 @@ public async Task Pipeline_RazorOutput_ContainsBwfcComponents() } [Fact] - public async Task Pipeline_CreatesCodeBehindFiles() + public async Task Pipeline_QuarantinesCodeBehindFilesAsManualArtifacts() { var (inputDir, outputDir) = CreateTempProjectDir(includeCodeBehind: true); var pipeline = CreateFullPipeline(); @@ -209,9 +273,192 @@ public async Task Pipeline_CreatesCodeBehindFiles() var report = await pipeline.ExecuteAsync(context); Assert.Empty(report.Errors); - // Default.aspx has a code-behind → should produce Default.razor.cs - Assert.True(File.Exists(Path.Combine(outputDir, "Default.razor.cs")), - "Default.razor.cs should be created from code-behind"); + // 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"); + } + + [Fact] + public async Task Pipeline_QueryDetailsPattern_RewritesMarkupWithQueryStub() + { + var (inputDir, outputDir) = CreateTempProjectDir(includeWebConfig: false, includeCodeBehind: false); + File.WriteAllText(Path.Combine(inputDir, "ProductDetails.aspx"), """ + <%@ Page Title="Product Details" Language="C#" AutoEventWireup="true" CodeBehind="ProductDetails.aspx.cs" Inherits="TestApp.ProductDetails" %> + + """); + File.WriteAllText(Path.Combine(inputDir, "ProductDetails.aspx.cs"), """ + using System.Linq; + using TestApp.Models; + + namespace TestApp + { + public partial class ProductDetails + { + public IQueryable GetProduct([QueryString("ProductID")] int? productId, [RouteData] string productName) + { + return Enumerable.Empty().AsQueryable(); + } + } + } + """); + + 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); + + var markup = File.ReadAllText(Path.Combine(outputDir, "ProductDetails.razor")); + Assert.Contains("SelectItems=\"GetProductQueryDetails_SelectItems\"", markup); + Assert.Contains("SupplyParameterFromQuery(Name = \"ProductID\")", markup); + Assert.True(report.SemanticPatternsApplied >= 1, $"Expected at least one semantic rewrite, got {report.SemanticPatternsApplied}"); + Assert.Contains(report.ManualItems, item => item.Category == "bwfc-query-details"); + } + + [Fact] + public async Task Pipeline_ActionPagePattern_ReplacesBlankHandlerOutput() + { + var (inputDir, outputDir) = CreateTempProjectDir(includeWebConfig: false, includeCodeBehind: false); + File.WriteAllText(Path.Combine(inputDir, "AddToCart.aspx"), """ + <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="AddToCart.aspx.cs" Inherits="TestApp.AddToCart" %> + + + + +
+ + + + """); + File.WriteAllText(Path.Combine(inputDir, "AddToCart.aspx.cs"), """ + namespace TestApp + { + public partial class AddToCart + { + protected void Page_Load() + { + var rawId = Request.QueryString["ProductID"]; + usersShoppingCart.AddToCart(Convert.ToInt16(rawId)); + Response.Redirect("ShoppingCart.aspx"); + } + } + } + """); + + 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); + + var markup = File.ReadAllText(Path.Combine(outputDir, "AddToCart.razor")); + Assert.Contains("AddToCart", markup); + Assert.Contains("action=\"/__bwfc/actions/AddToCart\"", markup); + Assert.Contains("document.getElementById('bwfc-action-pages-form')?.submit();", markup); + Assert.Contains("TODO(bwfc-action-pages)", markup); + Assert.True(report.SemanticPatternsApplied >= 1, $"Expected at least one semantic rewrite, got {report.SemanticPatternsApplied}"); + Assert.Contains(report.ManualItems, item => item.Category == "bwfc-action-pages"); + } + + [Fact] + public async Task Pipeline_MasterContentContractPatterns_RewriteDefaultCatalogMasterLayouts() + { + var (inputDir, outputDir) = CreateTempProjectDir(includeWebConfig: false, includeCodeBehind: false); + File.WriteAllText(Path.Combine(inputDir, "Site.Master"), """ + <%@ Master Language="C#" %> + +
+ +
+ """); + + 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); + + var masterMarkup = File.ReadAllText(Path.Combine(outputDir, "Site.razor")); + var defaultMarkup = File.ReadAllText(Path.Combine(outputDir, "Default.razor")); + + Assert.Contains("@ChildComponents", masterMarkup); + Assert.Contains("public RenderFragment? ChildComponents { get; set; }", masterMarkup); + Assert.Contains("", defaultMarkup); + Assert.DoesNotContain("", defaultMarkup); + Assert.True(report.SemanticPatternsApplied >= 3, $"Expected master/content semantic rewrites to run, got {report.SemanticPatternsApplied}"); + } + + [Fact] + public async Task Pipeline_AccountPagePattern_RewritesDefaultCatalogLoginPage() + { + var (inputDir, outputDir) = CreateTempProjectDir(includeWebConfig: false, includeCodeBehind: false, includeAccount: true); + File.WriteAllText(Path.Combine(inputDir, "Account", "Login.aspx"), """ + <%@ Page Title="Log in" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" %> + + + Email + + + Password + + + + Remember me? + + Register as a new user + + """); + + 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); + + var loginMarkup = File.ReadAllText(Path.Combine(outputDir, "Account", "Login.razor")); + + Assert.Contains("TODO(bwfc-identity)", loginMarkup); + Assert.Contains("
", loginMarkup); + Assert.Contains("type=\"email\"", loginMarkup); + Assert.Contains("type=\"password\"", loginMarkup); + Assert.Contains("Register as a new user", loginMarkup); + Assert.Contains("SupplyParameterFromQuery(Name = \"returnUrl\")", loginMarkup); + Assert.DoesNotContain("= 1, "Expected the account semantic pattern to run for Account/Login.aspx"); } // ─────────────────────────────────────────────────────────────── @@ -440,8 +687,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 - Assert.True(File.Exists(Path.Combine(outputDir, "Default.razor.cs")), "Default.razor.cs 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 — no identity shims (no Account folder) Assert.False(File.Exists(Path.Combine(outputDir, "IdentityShims.cs")), @@ -477,6 +725,456 @@ public async Task FullMigration_WithIdentity_GeneratesIdentityShims() Assert.True(File.Exists(Path.Combine(outputDir, "IdentityShims.cs"))); } + [Fact] + public async Task FullMigration_InjectsDetectedCssAndScriptsIntoAppRazor() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + Directory.CreateDirectory(Path.Combine(inputDir, "Content")); + File.WriteAllText(Path.Combine(inputDir, "Content", "site.css"), "body { color: red; }"); + Directory.CreateDirectory(Path.Combine(inputDir, "Scripts")); + File.WriteAllText(Path.Combine(inputDir, "Scripts", "jquery-3.7.1.js"), "window.jqueryLoaded = true;"); + File.WriteAllText(Path.Combine(inputDir, "Site.Master"), """ + <%@ Master Language="C#" %> + + + + + + + + + + """); + + 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 }, + SourceFiles = sourceFiles + }; + + await pipeline.ExecuteAsync(context); + + var appRazor = File.ReadAllText(Path.Combine(outputDir, "Components", "App.razor")); + Assert.Contains("/Content/site.css", appRazor); + Assert.Contains("https://cdn.example.com/site.css", appRazor); + Assert.Contains("/Scripts/jquery-3.7.1.js", appRazor); + } + + [Fact] + public async Task FullMigration_QuarantinesAppStartFilesAsManualArtifacts() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + Directory.CreateDirectory(Path.Combine(inputDir, "App_Start")); + File.WriteAllText(Path.Combine(inputDir, "App_Start", "RouteConfig.cs"), """ + using System.Web.Routing; + + public class RouteConfig + { + } + """); + File.WriteAllText(Path.Combine(inputDir, "App_Start", "WebApiConfig.cs"), """ + public class WebApiConfig + { + } + """); + + 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 }, + SourceFiles = sourceFiles + }; + + var report = await pipeline.ExecuteAsync(context); + + Assert.False(File.Exists(Path.Combine(outputDir, "RouteConfig.cs"))); + Assert.False(File.Exists(Path.Combine(outputDir, "WebApiConfig.cs"))); + Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "App_Start", "RouteConfig.cs.txt"))); + Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "App_Start", "WebApiConfig.cs.txt"))); + Assert.Contains("Blazor has no App_Start convention", File.ReadAllText(Path.Combine(outputDir, "migration-artifacts", "App_Start", "RouteConfig.cs.txt"))); + Assert.Contains(report.ManualItems, item => item.Category == "AppStart"); + } + + [Fact] + public async Task FullMigration_QuarantinesLegacyCompileSurfaceFilesAndKeepsSafeSources() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + Directory.CreateDirectory(Path.Combine(inputDir, "Models")); + + File.WriteAllText(Path.Combine(inputDir, "Startup.Auth.cs"), """ + using Microsoft.Owin; + using Owin; + + public partial class Startup + { + public void ConfigureAuth(IAppBuilder app) + { + } + } + """); + + File.WriteAllText(Path.Combine(inputDir, "CatalogDatabaseInitializer.cs"), """ + using System.Data.Entity; + + public class CatalogDatabaseInitializer : DropCreateDatabaseIfModelChanges + { + } + """); + + File.WriteAllText(Path.Combine(inputDir, "Models", "Product.cs"), """ + namespace TestApp.Models; + + public class Product + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + """); + + 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 }, + SourceFiles = sourceFiles + }; + + var report = await pipeline.ExecuteAsync(context); + + Assert.False(File.Exists(Path.Combine(outputDir, "Startup.Auth.cs"))); + Assert.False(File.Exists(Path.Combine(outputDir, "CatalogDatabaseInitializer.cs"))); + Assert.True(File.Exists(Path.Combine(outputDir, "Models", "Product.cs"))); + + var startupArtifact = Path.Combine(outputDir, "migration-artifacts", "compile-surface", "Startup.Auth.cs.txt"); + var efArtifact = Path.Combine(outputDir, "migration-artifacts", "compile-surface", "CatalogDatabaseInitializer.cs.txt"); + + Assert.True(File.Exists(startupArtifact)); + Assert.True(File.Exists(efArtifact)); + Assert.Contains("quarantined from the generated Blazor SSR compile surface", File.ReadAllText(startupArtifact)); + Assert.Contains(report.ManualItems, item => item.Category == "bwfc-compile-surface" && item.File == "Startup.Auth.cs"); + Assert.Contains(report.ManualItems, item => item.Category == "bwfc-compile-surface" && item.File == "CatalogDatabaseInitializer.cs"); + } + + [Fact] + public async Task FullMigration_BuildsGeneratedApp_WhenLegacyCompileSurfaceFilesAreQuarantined() + { + var (inputDir, outputDir) = CreateRepoScopedProjectDir("build-clean"); + Directory.CreateDirectory(Path.Combine(inputDir, "Account")); + + File.WriteAllText(Path.Combine(inputDir, "Site.Master"), """ + <%@ Master Language="C#" AutoEventWireup="true" CodeBehind="Site.master.cs" Inherits="TestApp.SiteMaster" %> + + + + Wingtip Shell + + + + +
+ +
+ + + + """); + + File.WriteAllText(Path.Combine(inputDir, "Default.aspx"), """ + <%@ Page Title="Home" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" Inherits="TestApp._Default" %> + + + + """); + + File.WriteAllText(Path.Combine(inputDir, "AddToCart.aspx"), """ + <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="AddToCart.aspx.cs" Inherits="TestApp.AddToCart" %> + """); + + File.WriteAllText(Path.Combine(inputDir, "AddToCart.aspx.cs"), """ + using System; + + namespace TestApp + { + public partial class AddToCart + { + protected void Page_Load(object sender, EventArgs e) + { + var rawId = Request.QueryString["ProductID"]; + Response.Redirect("~/ShoppingCart.aspx"); + } + } + } + """); + + File.WriteAllText(Path.Combine(inputDir, "Account", "Login.aspx"), """ + <%@ Page Title="Log in" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" Inherits="TestApp.Account.Login" %> + + Email + + + Password + + + + + """); + + File.WriteAllText(Path.Combine(inputDir, "Account", "Register.aspx"), """ + <%@ Page Title="Register" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" Inherits="TestApp.Account.Register" %> + + Email + + + Password + + + Confirm password + + + + + """); + + File.WriteAllText(Path.Combine(inputDir, "Web.config"), """ + + + + + + + """); + + File.WriteAllText(Path.Combine(inputDir, "Startup.Auth.cs"), """ + using Microsoft.Owin; + using Owin; + + public partial class Startup + { + public void ConfigureAuth(IAppBuilder app) + { + } + } + """); + + File.WriteAllText(Path.Combine(inputDir, "IdentityConfig.cs"), """ + using Microsoft.AspNet.Identity; + + public class IdentityConfig + { + } + """); + + File.WriteAllText(Path.Combine(inputDir, "CatalogDatabaseInitializer.cs"), """ + using System.Data.Entity; + + public class CatalogDatabaseInitializer : DropCreateDatabaseIfModelChanges + { + } + """); + + 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 }, + SourceFiles = sourceFiles + }; + + var report = await pipeline.ExecuteAsync(context); + Assert.Empty(report.Errors); + + Assert.False(File.Exists(Path.Combine(outputDir, "Startup.Auth.cs"))); + Assert.False(File.Exists(Path.Combine(outputDir, "IdentityConfig.cs"))); + Assert.False(File.Exists(Path.Combine(outputDir, "CatalogDatabaseInitializer.cs"))); + + var layoutDir = Path.Combine(outputDir, "Layout"); + Directory.CreateDirectory(layoutDir); + File.WriteAllText(Path.Combine(layoutDir, "MainLayout.razor"), """ + @inherits Microsoft.AspNetCore.Components.LayoutComponentBase + +
+ @Body +
+ """); + + var projectPath = Directory.GetFiles(outputDir, "*.csproj", SearchOption.TopDirectoryOnly).Single(); + var (exitCode, buildOutput) = await RunProcessAsync("dotnet", $"build \"{projectPath}\" -c Release --nologo", outputDir); + + Assert.True(exitCode == 0, buildOutput); + Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "compile-surface", "Startup.Auth.cs.txt"))); + Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "compile-surface", "IdentityConfig.cs.txt"))); + Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "compile-surface", "CatalogDatabaseInitializer.cs.txt"))); + } + + [Fact] + public async Task FullMigration_AnnotatesProgramForRedirectHandlerPages() + { + var (inputDir, outputDir) = CreateTempProjectDir(); + File.WriteAllText(Path.Combine(inputDir, "CheckoutStart.aspx"), """ + <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="CheckoutStart.aspx.cs" Inherits="TestApp.CheckoutStart" %> + """); + File.WriteAllText(Path.Combine(inputDir, "CheckoutStart.aspx.cs"), """ + using System; + + namespace TestApp + { + public partial class CheckoutStart + { + protected void Page_Load(object sender, EventArgs e) + { + Response.Redirect("~/Checkout/Start.aspx"); + } + } + } + """); + + 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 }, + SourceFiles = sourceFiles + }; + + await pipeline.ExecuteAsync(context); + + var program = File.ReadAllText(Path.Combine(outputDir, "Program.cs")); + Assert.Contains("app.MapPost(\"/__bwfc/actions/CheckoutStart\"", program); + Assert.Contains("TODO(bwfc-action-pages)", program); + } + + [Fact] + public async Task FullMigration_GeneratesRoutableWingtipStyleShellArtifacts() + { + var (inputDir, outputDir) = CreateTempProjectDir(includeWebConfig: false, includeAccount: true); + File.WriteAllText(Path.Combine(inputDir, "Site.Master"), """ + <%@ Master Language="C#" %> +
+ + +
+ """); + File.WriteAllText(Path.Combine(inputDir, "Account", "Login.aspx"), """ + <%@ Page Title="Log in" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" %> + + + Email + + Password + + + + """); + File.WriteAllText(Path.Combine(inputDir, "Account", "Register.aspx"), """ + <%@ Page Title="Register" Language="C#" MasterPageFile="~/Site.Master" AutoEventWireup="true" %> + + + Email + + Password + + Confirm password + + + + """); + File.WriteAllText(Path.Combine(inputDir, "AddToCart.aspx"), """ + <%@ Page Language="C#" AutoEventWireup="true" CodeBehind="AddToCart.aspx.cs" Inherits="TestApp.AddToCart" %> + + + +
+
+
+ + + """); + File.WriteAllText(Path.Combine(inputDir, "AddToCart.aspx.cs"), """ + namespace TestApp + { + public partial class AddToCart + { + protected void Page_Load() + { + var rawId = Request.QueryString["ProductID"]; + Response.Redirect("ShoppingCart.aspx"); + } + } + } + """); + File.WriteAllText(Path.Combine(inputDir, "Startup.Auth.cs"), """ + using Microsoft.Owin; + using Owin; + + public partial class Startup + { + public void ConfigureAuth(IAppBuilder app) + { + } + } + """); + + 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 }, + SourceFiles = sourceFiles + }; + + await pipeline.ExecuteAsync(context); + + var siteMarkup = File.ReadAllText(Path.Combine(outputDir, "Site.razor")); + var loginMarkup = File.ReadAllText(Path.Combine(outputDir, "Account", "Login.razor")); + var registerMarkup = File.ReadAllText(Path.Combine(outputDir, "Account", "Register.razor")); + var actionMarkup = File.ReadAllText(Path.Combine(outputDir, "AddToCart.razor")); + var program = File.ReadAllText(Path.Combine(outputDir, "Program.cs")); + + Assert.Contains("@ChildComponents", siteMarkup); + Assert.Contains("ContentPlaceHolder", siteMarkup); + Assert.Contains("MainContent", siteMarkup); + Assert.Contains("method=\"get\"", loginMarkup); + Assert.Contains("action=\"/Account/PerformLogin\"", loginMarkup); + Assert.Contains("SupplyParameterFromQuery(Name = \"returnUrl\")", loginMarkup); + Assert.Contains("method=\"get\"", registerMarkup); + Assert.Contains("action=\"/Account/PerformRegister\"", registerMarkup); + Assert.Contains("action=\"/__bwfc/actions/AddToCart\"", actionMarkup); + Assert.Contains("document.getElementById('bwfc-action-pages-form')?.submit();", actionMarkup); + Assert.Contains("app.MapGet(\"/Account/PerformLogin\"", program); + Assert.Contains("app.MapGet(\"/Account/PerformRegister\"", program); + Assert.Contains("app.MapPost(\"/__bwfc/actions/AddToCart\"", program); + Assert.Contains("DisableAntiforgery()", program); + Assert.False(File.Exists(Path.Combine(outputDir, "Startup.Auth.cs"))); + Assert.True(File.Exists(Path.Combine(outputDir, "migration-artifacts", "compile-surface", "Startup.Auth.cs.txt"))); + } + // ─────────────────────────────────────────────────────────────── // MigrationReport // ─────────────────────────────────────────────────────────────── diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/PrescanAnalyzerTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/PrescanAnalyzerTests.cs new file mode 100644 index 000000000..87bb07795 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/PrescanAnalyzerTests.cs @@ -0,0 +1,64 @@ +using BlazorWebFormsComponents.Cli.Config; + +namespace BlazorWebFormsComponents.Cli.Tests; + +public class PrescanAnalyzerTests +{ + [Fact] + public void PrescanAnalyzer_FindsCommonMigrationPatterns() + { + var dir = TestHelpers.CreateTempProjectDir("prescan"); + try + { + File.WriteAllText(Path.Combine(dir, "Sample.cs"), """ + public class Sample + { + public string Name { get; set; } + + public void Go() + { + if (IsPostBack) + { + Response.Redirect("~/Done.aspx"); + } + + var x = ViewState["key"]; + } + } + """); + + var analyzer = new PrescanAnalyzer(); + var result = analyzer.Analyze(dir); + + Assert.True(result.TotalFiles >= 1); + Assert.True(result.FilesWithMatches >= 1); + Assert.Contains("BWFC002", result.Summary.Keys); + Assert.Contains("BWFC003", result.Summary.Keys); + Assert.Contains("BWFC004", result.Summary.Keys); + } + finally + { + TestHelpers.CleanupTempDir(dir); + } + } + + [Fact] + public void PrescanAnalyzer_ToJson_ProducesExpectedShape() + { + var result = new PrescanResult + { + SourcePath = @"D:\src\LegacyApp", + TotalFiles = 1, + FilesWithMatches = 1, + TotalMatches = 2 + }; + result.Summary["BWFC003"] = new PrescanSummary("IsPostBack", "IsPostBack checks", 2, 1); + result.Files.Add(new PrescanFileResult("Default.aspx.cs", [new PrescanFileMatch("BWFC003", "IsPostBack", 2, [10, 14])])); + + var json = PrescanAnalyzer.ToJson(result); + + Assert.Contains("\"SourcePath\"", json); + Assert.Contains("\"BWFC003\"", json); + Assert.Contains("\"Default.aspx.cs\"", json); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs index db5dd9542..5ccb96590 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/ScaffoldingTests.cs @@ -48,6 +48,24 @@ public void ProjectScaffolder_GeneratesCsproj() Assert.Contains("Microsoft.NET.Sdk.Web", csproj); } + [Fact] + public void ProjectScaffolder_UsesProjectReference_WhenOutputIsInsideRepo() + { + var repoRoot = Path.Combine(_tempDir, "repo"); + var srcDir = Path.Combine(repoRoot, "src", "BlazorWebFormsComponents"); + var outputDir = Path.Combine(repoRoot, "samples", "AfterTestApp"); + + Directory.CreateDirectory(srcDir); + Directory.CreateDirectory(outputDir); + File.WriteAllText(Path.Combine(srcDir, "BlazorWebFormsComponents.csproj"), ""); + + var result = _scaffolder.Scaffold(repoRoot, outputDir, "TestApp"); + var csproj = result.Files["csproj"].Content; + + Assert.Contains(@"", csproj); + Assert.DoesNotContain(@"", csproj); + } + [Fact] public void ProjectScaffolder_CsprojFileName_MatchesProjectName() { @@ -65,7 +83,8 @@ public void ProjectScaffolder_GeneratesProgramCs() Assert.Contains("AddBlazorWebFormsComponents()", program); Assert.Contains("AddRazorComponents()", program); - Assert.Contains("AddInteractiveServerComponents()", program); + Assert.DoesNotContain("AddInteractiveServerComponents()", program); + Assert.Contains("Generated for .NET 10 Blazor static SSR", program); Assert.Contains("using BlazorWebFormsComponents;", program); } @@ -77,7 +96,7 @@ public void ProjectScaffolder_ProgramCs_MapsComponents() var program = result.Files["program"].Content; Assert.Contains("MapRazorComponents()", program); - Assert.Contains("AddInteractiveServerRenderMode()", program); + Assert.DoesNotContain("AddInteractiveServerRenderMode()", program); } [Fact] @@ -88,18 +107,33 @@ public void ProjectScaffolder_GeneratesImportsRazor() var imports = result.Files["imports"].Content; // Standard Blazor usings + Assert.Contains("@namespace TestApp", imports); Assert.Contains("@using Microsoft.AspNetCore.Components.Web", imports); Assert.Contains("@using Microsoft.AspNetCore.Components.Forms", imports); Assert.Contains("@using Microsoft.AspNetCore.Components.Routing", imports); Assert.Contains("@using Microsoft.JSInterop", imports); // BWFC usings Assert.Contains("@using BlazorWebFormsComponents", imports); + Assert.Contains("@using BlazorWebFormsComponents.Enums", imports); + Assert.Contains("@using BlazorWebFormsComponents.Validations", imports); + Assert.DoesNotContain("@using static Microsoft.AspNetCore.Components.Web.RenderMode", imports); // Project namespace - Assert.Contains("@using TestApp", imports); + Assert.Contains("@using global::TestApp", imports); // WebFormsPageBase inherits Assert.Contains("@inherits BlazorWebFormsComponents.WebFormsPageBase", imports); } + [Fact] + public void ProjectScaffolder_GeneratesModelsUsing_WhenModelsExist() + { + Directory.CreateDirectory(Path.Combine(_tempDir, "Models")); + + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + var imports = result.Files["imports"].Content; + + Assert.Contains("@using global::TestApp.Models", imports); + } + [Fact] public void ProjectScaffolder_GeneratesAppRazor() { @@ -110,7 +144,8 @@ public void ProjectScaffolder_GeneratesAppRazor() Assert.Contains("", appRazor); Assert.Contains("", appRazor); Assert.Contains("", appRazor); - Assert.Contains("blazor.web.js", appRazor); + Assert.DoesNotContain("blazor.web.js", appRazor); + Assert.Contains("Generated for .NET 10 static SSR migration output", appRazor); } [Fact] @@ -125,6 +160,17 @@ public void ProjectScaffolder_GeneratesRoutesRazor() Assert.Contains("FocusOnNavigate", routes); } + [Fact] + public void ProjectScaffolder_GeneratesMainLayout() + { + var result = _scaffolder.Scaffold(_tempDir, _tempDir, "TestApp"); + + var layout = result.Files["layout"].Content; + + Assert.Contains("@inherits LayoutComponentBase", layout); + Assert.Contains("@Body", layout); + } + [Fact] public void ProjectScaffolder_GeneratesLaunchSettings() { @@ -189,8 +235,9 @@ public void ProjectScaffolder_GeneratesAllExpectedFileKeys() Assert.Contains("imports", result.Files.Keys); Assert.Contains("app", result.Files.Keys); Assert.Contains("routes", result.Files.Keys); + Assert.Contains("layout", result.Files.Keys); Assert.Contains("launchSettings", result.Files.Keys); - Assert.Equal(6, result.Files.Count); + Assert.Equal(7, result.Files.Count); } // ─────────────────────────────────────────────────────────────── diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternCatalogTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternCatalogTests.cs new file mode 100644 index 000000000..d4183c9b2 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternCatalogTests.cs @@ -0,0 +1,303 @@ +using BlazorWebFormsComponents.Cli.Config; +using BlazorWebFormsComponents.Cli.Interop; +using BlazorWebFormsComponents.Cli.Io; +using BlazorWebFormsComponents.Cli.Pipeline; +using BlazorWebFormsComponents.Cli.Scaffolding; +using BlazorWebFormsComponents.Cli.SemanticPatterns; +using BlazorWebFormsComponents.Cli.Transforms; + +namespace BlazorWebFormsComponents.Cli.Tests; + +public class SemanticPatternCatalogTests : IDisposable +{ + private readonly List _tempDirs = []; + + public void Dispose() + { + foreach (var dir in _tempDirs) + { + if (!Directory.Exists(dir)) + { + continue; + } + + try + { + Directory.Delete(dir, recursive: true); + } + catch + { + // best effort + } + } + } + + [Fact] + public void Catalog_AppliesMatchingPatternsInOrder() + { + var catalog = new SemanticPatternCatalog( + [ + new AppendSemanticPattern("pattern-b", 20, "|b", "|b-code"), + new AppendSemanticPattern("pattern-a", 10, "|a", "|a-code") + ]); + + var migrationContext = new MigrationContext + { + SourcePath = "input", + OutputPath = "output", + Options = new MigrationOptions() + }; + + var sourceFile = new SourceFile + { + MarkupPath = "Default.aspx", + OutputPath = "Default.razor", + FileType = FileType.Page + }; + + var metadata = new FileMetadata + { + SourceFilePath = sourceFile.MarkupPath, + OutputFilePath = sourceFile.OutputPath, + FileType = sourceFile.FileType, + OriginalContent = "markup" + }; + + var report = new MigrationReport(); + var result = catalog.Apply(migrationContext, sourceFile, metadata, "markup", "code", report); + + Assert.Equal("markup|a|b", result.Markup); + Assert.Equal("code|a-code|b-code", result.CodeBehind); + Assert.Equal(["pattern-a", "pattern-b"], result.AppliedPatterns.Select(p => p.PatternId).ToArray()); + Assert.Equal(2, report.SemanticPatternsApplied); + Assert.Equal(2, migrationContext.Log.Entries.Count); + } + + [Fact] + public async Task Pipeline_RunsSemanticPatterns_AfterMarkupAndCodeBehindTransforms() + { + var tempRoot = Path.Combine(Path.GetTempPath(), $"bwfc-semantic-patterns-{Guid.NewGuid():N}"); + var inputDir = Path.Combine(tempRoot, "input"); + var outputDir = Path.Combine(tempRoot, "output"); + Directory.CreateDirectory(inputDir); + Directory.CreateDirectory(outputDir); + _tempDirs.Add(tempRoot); + + File.WriteAllText(Path.Combine(inputDir, "Default.aspx"), ""); + File.WriteAllText(Path.Combine(inputDir, "Default.aspx.cs"), "code"); + + var sourceFiles = new[] + { + new SourceFile + { + MarkupPath = Path.Combine(inputDir, "Default.aspx"), + CodeBehindPath = Path.Combine(inputDir, "Default.aspx.cs"), + OutputPath = Path.Combine(outputDir, "Default.razor"), + FileType = FileType.Page + } + }; + + var outputWriter = new OutputWriter(); + var pipeline = new MigrationPipeline( + [new AppendMarkupTransform()], + [new AppendCodeBehindTransform()], + new SemanticPatternCatalog([new VerifyAndAppendSemanticPattern()]), + new ProjectScaffolder(new DatabaseProviderDetector()), + new GlobalUsingsGenerator(), + new ShimGenerator(), + new WebConfigTransformer(), + outputWriter, + new StaticFileCopier(outputWriter), + new SourceFileCopier(outputWriter, []), + new AppStartCopier(outputWriter), + new AppAssetInjector(outputWriter), + new NuGetStaticAssetExtractor(new PowerShellScriptRunner()), + new EdmxConverterBridge(new PowerShellScriptRunner()), + new RedirectHandlerAnnotator(outputWriter)); + + var context = new MigrationContext + { + SourcePath = inputDir, + OutputPath = outputDir, + Options = new MigrationOptions { SkipScaffold = true }, + SourceFiles = sourceFiles + }; + + var report = await pipeline.ExecuteAsync(context); + + var markupOutput = await File.ReadAllTextAsync(Path.Combine(outputDir, "Default.razor")); + var codeBehindOutput = await File.ReadAllTextAsync(Path.Combine(outputDir, "migration-artifacts", "codebehind", "Default.razor.cs.txt")); + + Assert.Equal("|markup|code-markup|semantic", markupOutput); + Assert.Equal("code|codebehind|semantic", codeBehindOutput); + Assert.Single(context.Log.Entries); + Assert.Equal("verify-semantic-order", context.Log.Entries[0].Transform); + Assert.Equal(1, report.SemanticPatternsApplied); + Assert.Empty(report.Errors); + } + + [Fact] + public void Catalog_QueryDetailsPattern_RewritesSelectMethodToQueryBoundStub() + { + var catalog = new SemanticPatternCatalog([new QueryDetailsSemanticPattern()]); + var migrationContext = new MigrationContext + { + SourcePath = "input", + OutputPath = "output", + Options = new MigrationOptions() + }; + var sourceFile = new SourceFile + { + MarkupPath = Path.Combine("input", "ProductDetails.aspx"), + OutputPath = Path.Combine("output", "ProductDetails.razor"), + FileType = FileType.Page + }; + var metadata = new FileMetadata + { + SourceFilePath = sourceFile.MarkupPath, + OutputFilePath = sourceFile.OutputPath, + FileType = sourceFile.FileType, + OriginalContent = "" + }; + var report = new MigrationReport(); + + var result = catalog.Apply( + migrationContext, + sourceFile, + metadata, + """ + @page "/ProductDetails" + + + """, + """ + using WingtipToys.Models; + + public partial class ProductDetails + { + public IQueryable GetProduct( + [QueryString("ProductID")] int? productId, + [RouteData] string productName) + { + return Enumerable.Empty().AsQueryable(); + } + } + """, + report); + + Assert.Contains("SelectItems=\"GetProductQueryDetails_SelectItems\"", result.Markup); + Assert.Contains("[Parameter, SupplyParameterFromQuery(Name = \"ProductID\")] public int? ProductId { get; set; }", result.Markup); + Assert.Contains("public string? ProductName { get; set; }", result.Markup); + Assert.Contains("TODO(bwfc-query-details)", result.Markup); + Assert.Single(result.AppliedPatterns); + Assert.Equal("pattern-query-details", result.AppliedPatterns[0].PatternId); + Assert.Single(report.ManualItems); + Assert.Equal("bwfc-query-details", report.ManualItems[0].Category); + } + + [Fact] + public void Catalog_ActionPagesPattern_ReplacesInertMarkupWithHandlerStub() + { + var catalog = new SemanticPatternCatalog([new ActionPagesSemanticPattern()]); + var migrationContext = new MigrationContext + { + SourcePath = "input", + OutputPath = "output", + Options = new MigrationOptions() + }; + var sourceFile = new SourceFile + { + MarkupPath = Path.Combine("input", "AddToCart.aspx"), + OutputPath = Path.Combine("output", "AddToCart.razor"), + FileType = FileType.Page + }; + var metadata = new FileMetadata + { + SourceFilePath = sourceFile.MarkupPath, + OutputFilePath = sourceFile.OutputPath, + FileType = sourceFile.FileType, + OriginalContent = "" + }; + var report = new MigrationReport(); + + var result = catalog.Apply( + migrationContext, + sourceFile, + metadata, + """ + @page "/AddToCart" + +
+ """, + """ + public partial class AddToCart + { + protected void Page_Load() + { + var rawId = Request.QueryString["ProductID"]; + usersShoppingCart.AddToCart(Convert.ToInt16(rawId)); + Response.Redirect("ShoppingCart.aspx"); + } + } + """, + report); + + Assert.Contains("AddToCart", result.Markup); + Assert.Contains("TODO(bwfc-action-pages)", result.Markup); + Assert.Contains("action=\"/__bwfc/actions/AddToCart\"", result.Markup); + Assert.Contains("document.getElementById('bwfc-action-pages-form')?.submit();", result.Markup); + Assert.Contains("[Parameter, SupplyParameterFromQuery(Name = \"ProductID\")] public string? ProductID { get; set; }", result.Markup); + Assert.Contains("private const string HandlerRoute = \"/__bwfc/actions/AddToCart\";", result.Markup); + Assert.Single(result.AppliedPatterns); + Assert.Equal("pattern-action-pages", result.AppliedPatterns[0].PatternId); + Assert.Single(report.ManualItems); + Assert.Equal("bwfc-action-pages", report.ManualItems[0].Category); + } + + private sealed class AppendSemanticPattern(string id, int order, string markupSuffix, string codeSuffix) : ISemanticPattern + { + public string Id => id; + public int Order => order; + + public SemanticPatternMatch Match(SemanticPatternContext context) => SemanticPatternMatch.Match($"{Id} matched"); + + public SemanticPatternResult Apply(SemanticPatternContext context) => + new($"{context.Markup}{markupSuffix}", $"{context.CodeBehind}{codeSuffix}", $"{Id} applied"); + } + + private sealed class AppendMarkupTransform : IMarkupTransform + { + public string Name => "append-markup"; + public int Order => 10; + + public string Apply(string content, FileMetadata metadata) => $"{content}|markup"; + } + + private sealed class AppendCodeBehindTransform : ICodeBehindTransform + { + public string Name => "append-codebehind"; + public int Order => 10; + + public string Apply(string content, FileMetadata metadata) + { + metadata.MarkupContent = $"{metadata.MarkupContent}|code-markup"; + return $"{content}|codebehind"; + } + } + + private sealed class VerifyAndAppendSemanticPattern : ISemanticPattern + { + public string Id => "verify-semantic-order"; + public int Order => 10; + + public SemanticPatternMatch Match(SemanticPatternContext context) + { + return context.Markup == "|markup|code-markup" && context.CodeBehind == "code|codebehind" + ? SemanticPatternMatch.Match("semantic pattern saw post-transform state") + : SemanticPatternMatch.NoMatch(); + } + + public SemanticPatternResult Apply(SemanticPatternContext context) => + new($"{context.Markup}|semantic", $"{context.CodeBehind}|semantic", "semantic pattern applied after transforms"); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternConcreteTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternConcreteTests.cs new file mode 100644 index 000000000..15d2594f7 --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/SemanticPatternConcreteTests.cs @@ -0,0 +1,221 @@ +using BlazorWebFormsComponents.Cli.Pipeline; +using BlazorWebFormsComponents.Cli.SemanticPatterns; + +namespace BlazorWebFormsComponents.Cli.Tests; + +public class SemanticPatternConcreteTests +{ + [Fact] + public void DefaultSemanticPatterns_ExposeFirstFourCatalogEntriesInCanonicalOrder() + { + var patterns = TestHelpers.CreateDefaultSemanticPatterns(); + + Assert.Equal( + [ + "pattern-query-details", + "pattern-master-content-contracts", + "pattern-action-pages", + "pattern-account-pages" + ], + patterns.Select(static pattern => pattern.Id).ToArray()); + } + + [Fact] + public void AccountPagesPattern_NormalizesValidatorHeavyLoginMarkup() + { + var result = ApplyPattern( + new AccountPagesSemanticPattern(), + """ + @page "/Account/Login" + Login + + +

Login

+
+ + + + + + + + +
+
+
+ """, + "D:\\input\\Account\\Login.aspx", + FileType.Page); + + Assert.Contains("TODO(bwfc-identity)", result.Markup); + Assert.Contains("
", result.Markup); + Assert.Contains("type=\"email\"", result.Markup); + Assert.Contains("type=\"password\"", result.Markup); + Assert.Contains("type=\"checkbox\"", result.Markup); + Assert.Contains("Register as a new user", result.Markup); + Assert.Contains("SupplyParameterFromQuery(Name = \"returnUrl\")", result.Markup); + Assert.DoesNotContain(" + +
+ + + @ChildContent +
+
+ + + @code { + [Parameter] + public RenderFragment? ChildContent { get; set; } + } + """, + "D:\\input\\Site.master", + FileType.Master); + + Assert.Contains("@ChildComponents", result.Markup); + Assert.Contains("public RenderFragment? ChildComponents { get; set; }", result.Markup); + Assert.Contains("public RenderFragment? ChildContent { get; set; }", result.Markup); + } + + [Fact] + public void MasterContentContractsPattern_GroupsNamedSectionsUnderChildComponents() + { + var result = ApplyPattern( + new MasterContentContractsSemanticPattern(), + """ + @page "/Default" + + + Home + + +

Hello

+
+
+ """, + "D:\\input\\Default.aspx", + FileType.Page); + + Assert.Contains("", result.Markup); + Assert.Contains("", result.Markup); + Assert.Contains("", result.Markup); + Assert.DoesNotContain("", result.Markup); + } + + [Fact] + public void Catalog_AppliesMasterAndAccountPatternsTogether() + { + var catalog = new SemanticPatternCatalog( + [ + new MasterContentContractsSemanticPattern(), + new AccountPagesSemanticPattern() + ]); + + var migrationContext = new MigrationContext + { + SourcePath = "input", + OutputPath = "output", + Options = new MigrationOptions() + }; + + var sourceFile = new SourceFile + { + MarkupPath = "D:\\input\\Account\\Register.aspx", + OutputPath = "D:\\output\\Account\\Register.razor", + FileType = FileType.Page + }; + + var metadata = new FileMetadata + { + SourceFilePath = sourceFile.MarkupPath, + OutputFilePath = sourceFile.OutputPath, + FileType = sourceFile.FileType, + OriginalContent = "original" + }; + + var report = new MigrationReport(); + var result = catalog.Apply( + migrationContext, + sourceFile, + metadata, + """ + @page "/Account/Register" + Register + + + + + + + + + + + + +