Skip to content

Commit 94b959d

Browse files
committed
Fixed case sensitiviy issue in HtmlWriter
1 parent 188d1b6 commit 94b959d

4 files changed

Lines changed: 152 additions & 7 deletions

File tree

src/Framework/Framework/Compilation/ControlTree/ControlResolverBase.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,15 @@ public IControlResolverMetadata ResolveControl(IControlType controlType)
177177
group.PropertyGroup.MarkupOptions.MappingMode.HasFlag(requiredMode))
178178
{
179179
var concreteName = name.Substring(group.Prefix.Length);
180+
181+
// this handles cases when someone sets Class=valueOrBinding & class=valueOrBinding on the same control
182+
if (group.PropertyGroup.MarkupOptions.AttributeValueMerger == typeof(HtmlAttributeValueMerger)
183+
&& (string.Equals(concreteName, "style", StringComparison.OrdinalIgnoreCase)
184+
|| string.Equals(concreteName, "class", StringComparison.OrdinalIgnoreCase)))
185+
{
186+
concreteName = concreteName.ToLowerInvariant();
187+
}
188+
180189
return group.PropertyGroup.GetDotvvmProperty(concreteName);
181190
}
182191
}

src/Framework/Framework/Controls/HtmlWriter.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ internal void Warn(string message, Exception? ex = null)
7777

7878
public static string GetSeparatorForAttribute(string attributeName)
7979
{
80-
return attributeName switch {
81-
"class" => " ",
82-
"data-bind" => ",",
83-
_ => ";"
84-
};
80+
if (string.Equals(attributeName, "class", StringComparison.OrdinalIgnoreCase))
81+
return " ";
82+
if (string.Equals(attributeName, "data-bind", StringComparison.OrdinalIgnoreCase))
83+
return ",";
84+
return ";";
8585
}
8686

8787
public static string? JoinAttributeValues(string attributeName, string? valueA, string? valueB, string? separator = null)
@@ -230,7 +230,7 @@ public void RenderSelfClosingTag(string name)
230230
Warn($"Element {name} is not self-closing but is rendered as so. It may be interpreted as a start tag without an end tag by the browsers.");
231231
}
232232

233-
private Dictionary<string, string?> attributeMergeTable = new Dictionary<string, string?>(23);
233+
private Dictionary<string, string?> attributeMergeTable = new Dictionary<string, string?>(23, StringComparer.OrdinalIgnoreCase);
234234

235235
/// <summary>
236236
/// Renders the begin tag without end char.
@@ -260,7 +260,7 @@ private void RenderBeginTagCore(string name)
260260
// there can't be any name collisions of arguments
261261
WriteAttrWithTransformers(name, aname, aval);
262262
}
263-
else if (attributes.Count == 2 && attributes[0].name != attributes[1].name)
263+
else if (attributes.Count == 2 && !string.Equals(attributes[0].name, attributes[1].name, StringComparison.OrdinalIgnoreCase))
264264
{
265265
// there can't be any name collisions
266266

src/Tests/ControlTests/HtmlGenericControlTests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,63 @@ public void ValueBinding_Style(RenderMode renderMode)
147147
Assert.AreEqual("""<div style=width:123 data-bind="style: { width: Integer }"></div>""", str);
148148
}
149149

150+
[TestMethod]
151+
public async Task ClassAttribute_HtmlCapabilityAndAttributesCollection_StaticValues()
152+
{
153+
var r = await cth.RunPage(typeof(BasicTestViewModel), """
154+
<div Class=from-capability class=from-attributes />
155+
""");
156+
157+
Assert.AreEqual("""
158+
159+
160+
<head></head>
161+
<body>
162+
<div class="from-capability from-attributes"></div>
163+
164+
165+
</body>
166+
""", r.OutputString);
167+
}
168+
169+
[TestMethod]
170+
public async Task ClassAttribute_HtmlCapabilityAndAttributesCollection_ValueBinding()
171+
{
172+
var r = await cth.RunPage(typeof(BasicTestViewModel), """
173+
<div Class=static-class class={value: String} />
174+
""");
175+
176+
Assert.AreEqual("""
177+
178+
179+
<head></head>
180+
<body>
181+
<div class="static-class some-string" data-bind='class: "static-class " + (String() ?? "")'></div>
182+
183+
184+
</body>
185+
""", r.OutputString);
186+
}
187+
188+
[TestMethod]
189+
public async Task ClassAttribute_HtmlCapabilityAndAttributesCollection_BothValueBindings()
190+
{
191+
var r = await cth.RunPage(typeof(BasicTestViewModel), """
192+
<div Class={value: String} class={value: String} />
193+
""");
194+
195+
Assert.AreEqual("""
196+
197+
198+
<head></head>
199+
<body>
200+
<div class="some-string some-string" data-bind='class: (String() ?? "") + " " + (String() ?? "")'></div>
201+
202+
203+
</body>
204+
""", r.OutputString);
205+
}
206+
150207
public class BasicTestViewModel: DotvvmViewModelBase
151208
{
152209
public int Integer { get; set; } = 123;

src/Tests/Runtime/HtmlWriterTests.cs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,84 @@ public void EscapingAmpersandUnallowed()
7575
});
7676
Assert.AreEqual("<img a=\"&amp;amp;\" b=\"a&amp;b\" />", text);
7777
}
78+
79+
[TestMethod]
80+
public void AttributeMerging_SameCasingAppend()
81+
{
82+
// Two "class" attributes (same casing) with append=true should be joined with a space
83+
var text = WriteHtml(a => {
84+
a.AddAttribute("class", "foo", append: true);
85+
a.AddAttribute("class", "bar", append: true);
86+
a.RenderBeginTag("div");
87+
a.RenderEndTag();
88+
});
89+
Assert.AreEqual("<div class=\"foo bar\"></div>", text);
90+
}
91+
92+
[TestMethod]
93+
public void AttributeMerging_DifferentCasingAppend()
94+
{
95+
// "class" and "Class" (different casing) with append=true must be treated as the same attribute and joined
96+
var text = WriteHtml(a => {
97+
a.AddAttribute("class", "foo", append: true);
98+
a.AddAttribute("Class", "bar", append: true);
99+
a.RenderBeginTag("div");
100+
a.RenderEndTag();
101+
});
102+
Assert.AreEqual("<div class=\"foo bar\"></div>", text);
103+
}
104+
105+
[TestMethod]
106+
public void AttributeMerging_DifferentCasingAppend_ThreeVariants()
107+
{
108+
// All three casing variants of "class" must collapse into one joined attribute
109+
var text = WriteHtml(a => {
110+
a.AddAttribute("class", "a", append: true);
111+
a.AddAttribute("CLASS", "b", append: true);
112+
a.AddAttribute("Class", "c", append: true);
113+
a.RenderBeginTag("div");
114+
a.RenderEndTag();
115+
});
116+
Assert.AreEqual("<div class=\"a b c\"></div>", text);
117+
}
118+
119+
[TestMethod]
120+
public void AttributeMerging_DifferentCasingOverwrite()
121+
{
122+
// When append=false (overwrite), the last value for the case-insensitive attribute name wins
123+
var text = WriteHtml(a => {
124+
a.AddAttribute("class", "first", append: false);
125+
a.AddAttribute("Class", "second", append: false);
126+
a.RenderBeginTag("div");
127+
a.RenderEndTag();
128+
});
129+
Assert.AreEqual("<div class=second></div>", text);
130+
}
131+
132+
[TestMethod]
133+
public void AttributeMerging_DifferentCasingStyle()
134+
{
135+
// "style" and "Style" (different casing) with append=true must be joined with ";"
136+
var text = WriteHtml(a => {
137+
a.AddAttribute("style", "color:red", append: true, appendSeparator: ";");
138+
a.AddAttribute("Style", "font-size:12px", append: true, appendSeparator: ";");
139+
a.RenderBeginTag("div");
140+
a.RenderEndTag();
141+
});
142+
Assert.AreEqual("<div style=\"color:red;font-size:12px\"></div>", text);
143+
}
144+
145+
[TestMethod]
146+
public void AttributeMerging_DifferentCasingNonAppendedAndAppended()
147+
{
148+
// mix of overwrite and append with different casing: first sets "foo", second appends "bar"
149+
var text = WriteHtml(a => {
150+
a.AddAttribute("class", "foo", append: false);
151+
a.AddAttribute("Class", "bar", append: true);
152+
a.RenderBeginTag("div");
153+
a.RenderEndTag();
154+
});
155+
Assert.AreEqual("<div class=\"foo bar\"></div>", text);
156+
}
78157
}
79158
}

0 commit comments

Comments
 (0)