diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 15bf44ba88..d1ec15d3ef 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -131,6 +131,15 @@ jobs:
# title: Analyzer Tests
# github-token: ${{ secrets.GITHUB_TOKEN }}
# target-framework: net7.0
+ - name: Adapters.WebForms.Tests (net472)
+ uses: ./.github/unittest
+ if: matrix.os == 'windows-2022'
+ with:
+ project: src/Adapters/Tests/WebForms
+ name: webforms-adapters-tests
+ title: WebForms Adapter Tests
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ target-framework: net472
js-tests:
runs-on: ubuntu-latest
diff --git a/ci/scripts/Get-PublicProjects.ps1 b/ci/scripts/Get-PublicProjects.ps1
index 9669670080..f1adf33d67 100644
--- a/ci/scripts/Get-PublicProjects.ps1
+++ b/ci/scripts/Get-PublicProjects.ps1
@@ -108,5 +108,10 @@ return @(
Name = "DotVVM.Tracing.MiniProfiler.Owin";
Path = "src/Tracing/MiniProfiler.Owin";
Type = "standard"
+ },
+ [PSCustomObject]@{
+ Name = "DotVVM.Adapters.WebForms";
+ Path = "src/Adapters/WebForms";
+ Type = "standard"
}
)
diff --git a/src/Adapters/Tests/WebForms/DotVVM.Adapters.WebForms.Tests.csproj b/src/Adapters/Tests/WebForms/DotVVM.Adapters.WebForms.Tests.csproj
new file mode 100644
index 0000000000..08d3a2e33f
--- /dev/null
+++ b/src/Adapters/Tests/WebForms/DotVVM.Adapters.WebForms.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net472
+ false
+
+
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Adapters/Tests/WebForms/HybridRouteLinkTests.cs b/src/Adapters/Tests/WebForms/HybridRouteLinkTests.cs
new file mode 100644
index 0000000000..fe3cc1616d
--- /dev/null
+++ b/src/Adapters/Tests/WebForms/HybridRouteLinkTests.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using System.Web;
+using CheckTestOutput;
+using DotVVM.Framework.Configuration;
+using DotVVM.Framework.Testing;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace DotVVM.Adapters.WebForms.Tests
+{
+ [TestClass]
+ public class HybridRouteLinkTests
+ {
+ private static readonly ControlTestHelper cth = new ControlTestHelper(config: config => config.AddWebFormsAdapters());
+ OutputChecker check = new OutputChecker("testoutputs");
+
+ [ClassInitialize]
+ public static void Init(TestContext testContext)
+ {
+ WebFormsRouteTableInit.EnsureInitialized();
+ }
+
+ [TestMethod]
+ public async Task HybridRouteLink_NoBindings()
+ {
+ HttpContext.Current = new HttpContext(
+ new HttpRequest("", "http://tempuri.org", ""),
+ new HttpResponse(new StringWriter())
+ );
+
+ var r = await cth.RunPage(typeof(ControlTestViewModel), @"
+
+
+
+
+
+
+ ");
+
+ check.CheckString(r.FormattedHtml, fileExtension: "html");
+ }
+
+ [TestMethod]
+ public async Task HybridRouteLink_ValueBinding()
+ {
+ HttpContext.Current = new HttpContext(
+ new HttpRequest("", "http://tempuri.org", ""),
+ new HttpResponse(new StringWriter())
+ );
+
+ var r = await cth.RunPage(typeof(ControlTestViewModel), @"
+
+
+
+ ");
+
+ check.CheckString(r.FormattedHtml, fileExtension: "html");
+ }
+
+ [TestMethod]
+ public async Task HybridRouteLink_SuffixAndQueryString()
+ {
+ HttpContext.Current = new HttpContext(
+ new HttpRequest("", "http://tempuri.org", ""),
+ new HttpResponse(new StringWriter())
+ );
+
+ var r = await cth.RunPage(typeof(ControlTestViewModel), @"
+
+
+
+
+ ");
+
+ check.CheckString(r.FormattedHtml, fileExtension: "html");
+ }
+ }
+
+ class ControlTestViewModel
+ {
+ public int Value { get; set; } = 15;
+
+ public List Items { get; set; } = new()
+ {
+ new ControlTestChildViewModel() { Id = 1, Name = "one" },
+ new ControlTestChildViewModel() { Id = 2, Name = "two" },
+ new ControlTestChildViewModel() { Id = 3, Name = "three" }
+ };
+ }
+
+ class ControlTestChildViewModel
+ {
+ public int Id { get; set; }
+ public string Name { get; set; }
+ }
+}
diff --git a/src/Adapters/Tests/WebForms/WebFormsRouteTableInit.cs b/src/Adapters/Tests/WebForms/WebFormsRouteTableInit.cs
new file mode 100644
index 0000000000..c56508ce8b
--- /dev/null
+++ b/src/Adapters/Tests/WebForms/WebFormsRouteTableInit.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Web;
+using System.Web.Routing;
+
+namespace DotVVM.Adapters.WebForms.Tests
+{
+ public static class WebFormsRouteTableInit
+ {
+
+ static WebFormsRouteTableInit()
+ {
+ RouteTable.Routes.Add("NoParams", new Route("", new EmptyHandler()));
+ RouteTable.Routes.Add("SingleParam", new Route("page/{Index}", new EmptyHandler()));
+ RouteTable.Routes.Add("MultipleOptionalParams", new Route("catalog/{Tag}/{SubTag}", new EmptyHandler()) { Defaults = new RouteValueDictionary(new { Tag = "xx", SubTag = "yy" })});
+ }
+
+ public static void EnsureInitialized()
+ {
+ }
+
+ }
+
+ public class EmptyHandler : IRouteHandler
+ {
+ public IHttpHandler GetHttpHandler(RequestContext requestContext) => throw new NotImplementedException();
+ }
+}
diff --git a/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_NoBindings.html b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_NoBindings.html
new file mode 100644
index 0000000000..8c77847baa
--- /dev/null
+++ b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_NoBindings.html
@@ -0,0 +1,12 @@
+
+
+
+ hello 1
+ hello 2
+ hello 3
+ hello 4
+ hello 5
+ hello 6
+ hello 6
+
+
diff --git a/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_SuffixAndQueryString.html b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_SuffixAndQueryString.html
new file mode 100644
index 0000000000..7d179a3a4a
--- /dev/null
+++ b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_SuffixAndQueryString.html
@@ -0,0 +1,10 @@
+
+
+
+ hello 1
+ hello 2
+ hello 3
+ hello 4
+ hello 5
+
+
diff --git a/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_ValueBinding.html b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_ValueBinding.html
new file mode 100644
index 0000000000..cc45494e0d
--- /dev/null
+++ b/src/Adapters/Tests/WebForms/testoutputs/HybridRouteLinkTests.HybridRouteLink_ValueBinding.html
@@ -0,0 +1,10 @@
+
+
+
+ hello 3
+
+
+
+
+
+
diff --git a/src/Adapters/WebForms/Controls/HybridRouteLink.cs b/src/Adapters/WebForms/Controls/HybridRouteLink.cs
new file mode 100644
index 0000000000..0606f75011
--- /dev/null
+++ b/src/Adapters/WebForms/Controls/HybridRouteLink.cs
@@ -0,0 +1,57 @@
+using System;
+using DotVVM.Framework.Controls;
+using DotVVM.Framework.Hosting;
+
+#if NETFRAMEWORK
+using System.Web.Routing;
+#endif
+
+namespace DotVVM.Adapters.WebForms.Controls
+{
+ ///
+ /// Renders a hyperlink pointing to the specified DotVVM route if such route exists; otherwise it falls back to a Web Forms route with the specified name.
+ ///
+#if !NETFRAMEWORK
+ [Obsolete("This control is used only during the Web Forms migration and is not needed in .NET Core. Use the standard RouteLink control.")]
+#endif
+ public class HybridRouteLink : CompositeControl
+ {
+ private readonly IDotvvmRequestContext context;
+
+ public HybridRouteLink(IDotvvmRequestContext context)
+ {
+ this.context = context;
+ }
+
+ public DotvvmControl GetContents(
+ HtmlCapability htmlCapability,
+ TextOrContentCapability textOrContent,
+ RouteLinkCapability routeLinkCapability
+ )
+ {
+ if (context.Configuration.RouteTable.Contains(routeLinkCapability.RouteName))
+ {
+ return GenerateDotvvmRouteLink(htmlCapability, textOrContent, routeLinkCapability);
+ }
+#if NETFRAMEWORK
+ else if (RouteTable.Routes[routeLinkCapability.RouteName] is Route webFormsRoute)
+ {
+ return WebFormsLinkUtils.BuildWebFormsRouteLink(this, context, htmlCapability, textOrContent, routeLinkCapability, webFormsRoute);
+ }
+#endif
+ else
+ {
+ throw new DotvvmControlException($"Route '{routeLinkCapability.RouteName}' does not exist.");
+ }
+ }
+
+ private static DotvvmControl GenerateDotvvmRouteLink(HtmlCapability htmlCapability, TextOrContentCapability textOrContent, RouteLinkCapability routeLinkCapability)
+ {
+ return new RouteLink()
+ .SetCapability(htmlCapability)
+ .SetCapability(textOrContent)
+ .SetCapability(routeLinkCapability);
+ }
+
+ }
+}
diff --git a/src/Adapters/WebForms/Controls/WebFormsLinkUtils.cs b/src/Adapters/WebForms/Controls/WebFormsLinkUtils.cs
new file mode 100644
index 0000000000..ba730df1d9
--- /dev/null
+++ b/src/Adapters/WebForms/Controls/WebFormsLinkUtils.cs
@@ -0,0 +1,132 @@
+#if NETFRAMEWORK
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+using DotVVM.Framework.Binding;
+using DotVVM.Framework.Controls;
+using DotVVM.Framework.Routing;
+using DotVVM.Framework.Utils;
+using System.Web.Routing;
+using System.Web;
+using DotVVM.Framework.Binding.Expressions;
+using DotVVM.Framework.Hosting;
+
+namespace DotVVM.Adapters.WebForms.Controls
+{
+ public class WebFormsLinkUtils
+ {
+ public static HtmlGenericControl BuildWebFormsRouteLink(DotvvmControl container, IDotvvmRequestContext context, HtmlCapability htmlCapability, TextOrContentCapability textOrContent, RouteLinkCapability routeLinkCapability, Route webFormsRoute)
+ {
+ var link = new HtmlGenericControl("a", textOrContent, htmlCapability);
+
+ var parameters = BuildParameters(context, routeLinkCapability, webFormsRoute);
+ if (routeLinkCapability.UrlSuffix is { HasBinding: true, BindingOrDefault: IValueBinding }
+ || routeLinkCapability.Params.Any(p => p.Value is { HasBinding: true, BindingOrDefault: IValueBinding }))
+ {
+ // bindings are used, we have to generate client-script code
+ var fragments = new List { KnockoutHelper.MakeStringLiteral(context.TranslateVirtualPath("~/")) };
+
+ // generate binding and embed it in the function call
+ var routeUrlExpression = GenerateRouteUrlExpression(container, webFormsRoute, parameters);
+ fragments.Add(routeUrlExpression);
+
+ // generate URL suffix
+ if (GenerateUrlSuffixExpression(container, routeLinkCapability) is string urlSuffix)
+ {
+ fragments.Add(urlSuffix);
+ }
+
+ // render the binding and try to evaluate it on the server
+ link.AddAttribute("data-bind", "attr: { 'href': " + fragments.StringJoin("+") + "}");
+ if (container.DataContext != null)
+ {
+ try
+ {
+ var url = context.TranslateVirtualPath(EvaluateRouteUrl(container, webFormsRoute, parameters, routeLinkCapability));
+ link.SetAttribute("href", url);
+ }
+ catch (Exception ex)
+ {
+ }
+ }
+ }
+ else
+ {
+ // the value can be built on the server
+ var url = context.TranslateVirtualPath(EvaluateRouteUrl(container, webFormsRoute, parameters, routeLinkCapability));
+ link.SetAttribute("href", url);
+ }
+
+ return link;
+ }
+
+ private static IDictionary> BuildParameters(IDotvvmRequestContext context, RouteLinkCapability routeLinkCapability, Route webFormsRoute)
+ {
+ var parameters = webFormsRoute.Defaults?.ToDictionary(t => t.Key, t => ValueOrBinding