diff --git a/src/Framework/Framework/Compilation/Directives/BaseTypeDirectiveCompiler.cs b/src/Framework/Framework/Compilation/Directives/BaseTypeDirectiveCompiler.cs index 194d4867e2..8f7bb37212 100644 --- a/src/Framework/Framework/Compilation/Directives/BaseTypeDirectiveCompiler.cs +++ b/src/Framework/Framework/Compilation/Directives/BaseTypeDirectiveCompiler.cs @@ -13,6 +13,7 @@ using System.Collections.Immutable; using System.Security.Cryptography; using System.Text; +using DotVVM.Framework.Configuration; namespace DotVVM.Framework.Compilation.Directives { @@ -22,15 +23,17 @@ public class BaseTypeDirectiveCompiler : DirectiveCompiler imports; + private readonly DotvvmConfiguration configuration; public override string DirectiveName => ParserConstants.BaseTypeDirective; public BaseTypeDirectiveCompiler( - IReadOnlyDictionary> directiveNodesByName, IAbstractTreeBuilder treeBuilder, string fileName, ImmutableList imports) + IReadOnlyDictionary> directiveNodesByName, IAbstractTreeBuilder treeBuilder, string fileName, ImmutableList imports, DotvvmConfiguration configuration) : base(directiveNodesByName, treeBuilder) { this.fileName = fileName; this.imports = imports; + this.configuration = configuration; } protected override IAbstractBaseTypeDirective Resolve(DothtmlDirectiveNode directiveNode) @@ -113,7 +116,14 @@ IEnumerable propertyDirectives /// private ITypeDescriptor GetDefaultWrapperType() { - if (fileName.EndsWith(".dotcontrol", StringComparison.Ordinal)) + var isControl = + fileName.EndsWith(".dotcontrol", StringComparison.Ordinal) || + configuration.Markup.Controls.Any(c => fileName.Equals(c.Src, StringComparison.OrdinalIgnoreCase)) || + this.DirectiveNodesByName.ContainsKey("property") || + this.DirectiveNodesByName.ContainsKey("wrapperTag") || + this.DirectiveNodesByName.ContainsKey("noWrapperTag"); + + if (isControl) { return new ResolvedTypeDescriptor(typeof(DotvvmMarkupControl)); } diff --git a/src/Framework/Framework/Compilation/Directives/MarkupDirectiveCompilerPipeline.cs b/src/Framework/Framework/Compilation/Directives/MarkupDirectiveCompilerPipeline.cs index 9c854e3f9b..2b9509485d 100644 --- a/src/Framework/Framework/Compilation/Directives/MarkupDirectiveCompilerPipeline.cs +++ b/src/Framework/Framework/Compilation/Directives/MarkupDirectiveCompilerPipeline.cs @@ -6,6 +6,7 @@ using System; using System.Linq; using System.Collections.Generic; +using DotVVM.Framework.Configuration; namespace DotVVM.Framework.Compilation.Directives { @@ -14,12 +15,14 @@ public class MarkupDirectiveCompilerPipeline : IMarkupDirectiveCompilerPipeline private readonly IAbstractTreeBuilder treeBuilder; private readonly IControlBuilderFactory controlBuilderFactory; private readonly DotvvmResourceRepository resourceRepository; + private readonly DotvvmConfiguration configuration; - public MarkupDirectiveCompilerPipeline(IAbstractTreeBuilder treeBuilder, IControlBuilderFactory controlBuilderFactory, DotvvmResourceRepository resourceRepository) + public MarkupDirectiveCompilerPipeline(IAbstractTreeBuilder treeBuilder, IControlBuilderFactory controlBuilderFactory, DotvvmResourceRepository resourceRepository, DotvvmConfiguration configuration) { this.treeBuilder = treeBuilder; this.controlBuilderFactory = controlBuilderFactory; this.resourceRepository = resourceRepository; + this.configuration = configuration; } public MarkupPageMetadata Compile(DothtmlRootNode dothtmlRoot, string fileName) @@ -50,7 +53,7 @@ public MarkupPageMetadata Compile(DothtmlRootNode dothtmlRoot, string fileName) var injectedServicesResult = serviceCompiler.Compile(); resolvedDirectives.AddIfAny(serviceCompiler.DirectiveName, injectedServicesResult.Directives); - var baseTypeCompiler = new BaseTypeDirectiveCompiler(directivesByName, treeBuilder, fileName, imports); + var baseTypeCompiler = new BaseTypeDirectiveCompiler(directivesByName, treeBuilder, fileName, imports, configuration); var baseTypeResult = baseTypeCompiler.Compile(); var baseType = baseTypeResult.Artefact; resolvedDirectives.AddIfAny(baseTypeCompiler.DirectiveName, baseTypeResult.Directives); diff --git a/src/Framework/Framework/Compilation/ResourceLinksVisitor.cs b/src/Framework/Framework/Compilation/ResourceLinksVisitor.cs new file mode 100644 index 0000000000..ef3f9977e2 --- /dev/null +++ b/src/Framework/Framework/Compilation/ResourceLinksVisitor.cs @@ -0,0 +1,122 @@ +using System; +using System.Collections.Generic; +using System.Text; +using DotVVM.Framework.Compilation.ControlTree.Resolved; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Utils; +using DotVVM.Framework.Binding; +using DotVVM.Framework.Binding.Properties; +using DotVVM.Framework.Binding.Expressions; +using System.Collections.Immutable; +using DotVVM.Framework.Compilation.ControlTree; +using System.Linq; + +namespace DotVVM.Framework.Compilation +{ + /// Inserts and into head or body element + public class ResourceLinksVisitor : ResolvedControlTreeVisitor + { + private readonly IControlResolver controlResolver; + + private bool headLinksFound = false; + private bool bodyLinksFound = false; + + public ResourceLinksVisitor(IControlResolver controlResolver) + { + this.controlResolver = controlResolver; + } + + + public override void VisitPropertyTemplate(ResolvedPropertyTemplate propertyTemplate) + { + // skip + } + + public override void VisitControl(ResolvedControl control) + { + if (typeof(HeadResourceLinks).IsAssignableFrom(control.Metadata.Type)) + { + headLinksFound = true; + } + else if (typeof(BodyResourceLinks).IsAssignableFrom(control.Metadata.Type)) + { + bodyLinksFound = true; + } + else + { + base.VisitControl(control); + } + } + + private ResolvedControl? TryFindElement(ResolvedControl control, string tagName) + { + // BFS to find the outermost element + // in case somebody mistypes element into a , we don't want to insert resources into it + var queue = new Queue(); + queue.Enqueue(control); + + while (queue.Count > 0) + { + var current = queue.Dequeue(); + if (current.Metadata.Type == typeof(HtmlGenericControl) && current.ConstructorParameters?[0] is string controlTagName && tagName.Equals(controlTagName, StringComparison.OrdinalIgnoreCase)) + { + return current; + } + foreach (var child in current.Content) + { + queue.Enqueue(child); + } + } + return null; + } + + private ResolvedControl CreateLinksControl(Type type, DataContextStack dataContext) + { + var metadata = controlResolver.ResolveControl(new ResolvedTypeDescriptor(type)); + var control = new ResolvedControl((ControlResolverMetadata)metadata, null, dataContext); + control.SetProperty(new ResolvedPropertyValue(Internal.UniqueIDProperty, "cAuto" + type.Name)); + return control; + } + + public override void VisitView(ResolvedTreeRoot view) + { + if (view.MasterPage is {}) + { + // if there is a masterpage, this visitor has already inserted the links into it + return; + } + if (!typeof(Controls.Infrastructure.DotvvmView).IsAssignableFrom(view.ControlBuilderDescriptor.ControlType)) + { + // markup controls + return; + } + if (!headLinksFound) + { + var headElement = TryFindElement(view, "head"); + if (headElement is {}) + { + headElement.Content.Add(CreateLinksControl(typeof(HeadResourceLinks), headElement.DataContextTypeStack)); + } + else + { + // no head element found, and no masterpage -> insert it at the document start + view.Content.Insert(0, CreateLinksControl(typeof(HeadResourceLinks), view.DataContextTypeStack)); + } + } + if (!bodyLinksFound) + { + var bodyElement = TryFindElement(view, "body"); + if (bodyElement is {}) + { + bodyElement.Content.Add(CreateLinksControl(typeof(BodyResourceLinks), bodyElement.DataContextTypeStack)); + } + else + { + // no body element found, and no masterpage -> insert it at the document end + view.Content.Add(CreateLinksControl(typeof(BodyResourceLinks), view.DataContextTypeStack)); + } + } + base.VisitView(view); + } + } +} diff --git a/src/Framework/Framework/Compilation/ViewCompiler/ViewCompilingVisitor.cs b/src/Framework/Framework/Compilation/ViewCompiler/ViewCompilingVisitor.cs index 5619925eb6..21a4aee29b 100644 --- a/src/Framework/Framework/Compilation/ViewCompiler/ViewCompilingVisitor.cs +++ b/src/Framework/Framework/Compilation/ViewCompiler/ViewCompilingVisitor.cs @@ -20,6 +20,7 @@ public class ViewCompilingVisitor : ResolvedControlTreeVisitor protected int currentTemplateIndex; protected string? controlName; + int controlIdIndex = 0; public Func BuildCompiledView { get; set; } @@ -247,8 +248,13 @@ protected string CreateControl(ResolvedControl control) // RawLiterals don't need these helper properties unless in root if (control.Metadata.Type != typeof(RawLiteral) || control.Parent is ResolvedTreeRoot) { - // set unique id - emitter.EmitSetDotvvmProperty(name, Internal.UniqueIDProperty, name); + if (!control.Properties.ContainsKey(Internal.UniqueIDProperty)) + { + var uniqueId = "c" + controlIdIndex; + controlIdIndex++; + // set unique id + emitter.EmitSetDotvvmProperty(name, Internal.UniqueIDProperty, uniqueId); + } if (control.DothtmlNode != null && control.DothtmlNode.Tokens.Count > 0) { diff --git a/src/Framework/Framework/Controls/HtmlGenericControl.cs b/src/Framework/Framework/Controls/HtmlGenericControl.cs index 94dea44e8c..b590935488 100644 --- a/src/Framework/Framework/Controls/HtmlGenericControl.cs +++ b/src/Framework/Framework/Controls/HtmlGenericControl.cs @@ -286,12 +286,6 @@ protected override void RenderBeginTag(IHtmlWriter writer, IDotvvmRequestContext /// protected override void RenderEndTag(IHtmlWriter writer, IDotvvmRequestContext context) { - // render resource link. If the Render is invoked multiple times the resources are rendered on the first invocation. - if (TagName == "head") - new HeadResourceLinks().Render(writer, context); - else if (TagName == "body") - new BodyResourceLinks().Render(writer, context); - if (RendersHtmlTag) { writer.RenderEndTag(); diff --git a/src/Framework/Framework/DependencyInjection/DotVVMServiceCollectionExtensions.cs b/src/Framework/Framework/DependencyInjection/DotVVMServiceCollectionExtensions.cs index 2b3b97d0ac..f4b1004e9e 100644 --- a/src/Framework/Framework/DependencyInjection/DotVVMServiceCollectionExtensions.cs +++ b/src/Framework/Framework/DependencyInjection/DotVVMServiceCollectionExtensions.cs @@ -113,6 +113,7 @@ public static IServiceCollection RegisterDotVVMServices(IServiceCollection servi var requiredResourceControl = controlResolver.ResolveControl(new ResolvedTypeDescriptor(typeof(RequiredResource))); o.TreeVisitors.Add(() => new StyleTreeShufflingVisitor(controlResolver)); o.TreeVisitors.Add(() => new ControlPrecompilationVisitor(s)); + o.TreeVisitors.Add(() => new ResourceLinksVisitor(controlResolver)); o.TreeVisitors.Add(() => new LiteralOptimizationVisitor()); o.TreeVisitors.Add(() => new BindingRequiredResourceVisitor((ControlResolverMetadata)requiredResourceControl)); var requiredGlobalizeControl = controlResolver.ResolveControl(new ResolvedTypeDescriptor(typeof(GlobalizeResource))); diff --git a/src/Framework/Framework/Testing/TestHttpRequest.cs b/src/Framework/Framework/Testing/TestHttpRequest.cs index c97f484421..2bdb7e8fe6 100644 --- a/src/Framework/Framework/Testing/TestHttpRequest.cs +++ b/src/Framework/Framework/Testing/TestHttpRequest.cs @@ -25,7 +25,7 @@ public TestHttpRequest(IHttpContext context) public string? Path { get; set; } - public string? PathBase { get; set; } + public string? PathBase { get; set; } = ""; IPathString IHttpRequest.Path => new TestPathString(this.Path); IPathString IHttpRequest.PathBase => new TestPathString(this.PathBase); diff --git a/src/Framework/Testing/ControlTestHelper.cs b/src/Framework/Testing/ControlTestHelper.cs index 00572cd942..7d0adc17b9 100644 --- a/src/Framework/Testing/ControlTestHelper.cs +++ b/src/Framework/Testing/ControlTestHelper.cs @@ -147,6 +147,16 @@ public async Task RunPage( markup = "" + markup; } markup = $"@viewModel {viewModel.ToString().Replace("+", ".")}\n{directives}\n\n{markup}"; + return await RunPageRaw(markup, markupFiles, fileName, user, culture); + } + /// Run page with the exact markup, without any modifications + public async Task RunPageRaw( + string markup, + Dictionary? markupFiles = null, + [CallerMemberName] string? fileName = null, + ClaimsPrincipal? user = null, + CultureInfo? culture = null) + { var request = PreparePage(markup, markupFiles, fileName, user, culture); await presenter.ProcessRequest(request); return CreatePageResult(request); diff --git a/src/Framework/Testing/DotvvmTestHelper.cs b/src/Framework/Testing/DotvvmTestHelper.cs index e8f19583cc..a3119a7d9a 100644 --- a/src/Framework/Testing/DotvvmTestHelper.cs +++ b/src/Framework/Testing/DotvvmTestHelper.cs @@ -167,7 +167,10 @@ public static ResolvedTreeRoot ParseResolvedTree(string markup, string fileName if (checkErrors) CheckForErrors(root.DothtmlNode); foreach (var v in visitors) { - v().VisitView(root); + var visitor = v(); + if (visitor is ResourceLinksVisitor) + continue; // not wanted in parser tests + visitor.VisitView(root); } if (checkErrors) validator.VisitAndAssert(root); diff --git a/src/Tests/ControlTests/ResourceLinksTests.cs b/src/Tests/ControlTests/ResourceLinksTests.cs new file mode 100644 index 0000000000..96a11c51e9 --- /dev/null +++ b/src/Tests/ControlTests/ResourceLinksTests.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Globalization; +using System.Threading.Tasks; +using CheckTestOutput; +using DotVVM.Framework.Compilation; +using DotVVM.Framework.Controls; +using DotVVM.Framework.Tests.Binding; +using DotVVM.Framework.ViewModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using DotVVM.Framework.Testing; +using System.Security.Claims; + +namespace DotVVM.Framework.Tests.ControlTests +{ + [TestClass] + public class ResourceLinksTests + { + static readonly ControlTestHelper cth = new ControlTestHelper(config: config => { + }); + OutputChecker check = new OutputChecker("testoutputs"); + + [TestMethod] + public async Task OnlyLiteralInBody() + { + // There was a bug which collapsed the body contents into a `data-bind='text: ...'` binding + var r = await cth.RunPageRaw(""" + @viewModel DotVVM.Framework.Tests.ControlTests.ResourceLinksTests.BasicTestViewModel + + + Id: {{value: Integer}} + + """); + + check.CheckString(r.OutputString, fileExtension: "html"); + } + [TestMethod] + public async Task MultipleBodyElements() + { + // You can put body into the document by accident. In such case, DotVVM should try to select the outermost one + var r = await cth.RunPageRaw(""" + @viewModel DotVVM.Framework.Tests.ControlTests.ResourceLinksTests.BasicTestViewModel + + +
+ {{value: Integer}} +
+ + """); + + check.CheckString(r.OutputString, fileExtension: "html"); + } + [TestMethod] + public async Task NoHeadBodyElements() + { + // No body and head elements, DotVVM should put the resources at the start and end of the document, + // the browser will hopefully correctly infer the head and body elements + var r = await cth.RunPageRaw(""" + @viewModel DotVVM.Framework.Tests.ControlTests.ResourceLinksTests.BasicTestViewModel + +
= 0}>{{value: Integer}}
+ """); + + check.CheckString(r.OutputString, fileExtension: "html"); + } + public class BasicTestViewModel: DotvvmViewModelBase + { + [Bind(Name = "int")] + public int Integer { get; set; } = 10; + } + } +} diff --git a/src/Tests/ControlTests/testoutputs/AutoUITests.FormInRepeater.html b/src/Tests/ControlTests/testoutputs/AutoUITests.FormInRepeater.html index 51c5e1461f..c2c842848a 100644 --- a/src/Tests/ControlTests/testoutputs/AutoUITests.FormInRepeater.html +++ b/src/Tests/ControlTests/testoutputs/AutoUITests.FormInRepeater.html @@ -6,18 +6,18 @@ - + - + - + - + diff --git a/src/Tests/ControlTests/testoutputs/CompositeControlTests.MenuRepeater.html b/src/Tests/ControlTests/testoutputs/CompositeControlTests.MenuRepeater.html index e5bb9146f5..e1fc4a22e0 100644 --- a/src/Tests/ControlTests/testoutputs/CompositeControlTests.MenuRepeater.html +++ b/src/Tests/ControlTests/testoutputs/CompositeControlTests.MenuRepeater.html @@ -7,32 +7,32 @@
-
list-item1
+
list-item1
-
list-item2
+
list-item2
  • - +
-
+
diff --git a/src/Tests/ControlTests/testoutputs/CompositeControlTests.WrappedHierarchyRepeaterControlWithGeneratedIds.html b/src/Tests/ControlTests/testoutputs/CompositeControlTests.WrappedHierarchyRepeaterControlWithGeneratedIds.html index 2156bc59e3..3634a57c36 100644 --- a/src/Tests/ControlTests/testoutputs/CompositeControlTests.WrappedHierarchyRepeaterControlWithGeneratedIds.html +++ b/src/Tests/ControlTests/testoutputs/CompositeControlTests.WrappedHierarchyRepeaterControlWithGeneratedIds.html @@ -5,18 +5,18 @@
    -
  • A
  • +
  • A
  • -
  • B
  • +
  • B
  • -
  • C
  • +
  • C
  • -
  • D
  • +
  • D
  • @@ -25,15 +25,15 @@
      - +
    - -