Skip to content

Commit 08136d1

Browse files
committed
+semver:minor Added static HtmlUtils class
Methods in this class add support for fixing missing target attributes on links and copying simple asset files when creating a temp HTML file to display. Also, prepped CHANGELOG.md for v. 16.1.0 release
1 parent 41a13c9 commit 08136d1

File tree

7 files changed

+602
-52
lines changed

7 files changed

+602
-52
lines changed

CHANGELOG.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,21 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1616

1717
## [Unreleased]
1818

19+
## [16.1.0] - 2025-07-16
20+
1921
### Added
2022

2123
- [SIL.Windows.Forms] Added PortableClipboard.CanGetImage()
2224
- [ClipboardTestApp] Restored this test program and added tests for PortableClipboard.CanGetImage() and GetImageFromClipboard()
23-
- [SIL.Windows.Forms] Check in debug mode to alert developer when using the SILAboutBox to display HTML that has links but has neither a base target in the head element (`<base target=""_blank""> element`) nor explicit `target=""_blank""` attributes for any of the links when they have not handled the Navigating event to customize the navigation behavior. In this situation links will likely open directly in the About browser window and will probably not behave as expected.
25+
- [SIL.Windows.Forms] Check in debug mode to alert developer when using the SILAboutBox to display HTML that has links but has neither a base target in the head element (`<base target="_blank"> element`) nor explicit `target="_blank"` attributes for any of the links when they have not handled the Navigating event to customize the navigation behavior. In this situation links will likely open directly in the About browser window and will probably not behave as expected.
2426
- [SIL.Windows.Forms] Added SILAboutBox.Navigating and SILAboutBox.Navigated events to allow callers to customize how HTML links in the embedded browser are handled.
25-
- [SIL.Windows.Forms] Added SILAboutBox.AllowExternalLinksToOpenInsideAboutBox property to control whether a <base target="_blank" rel="noopener noreferrer"> line is automatically added to the HTML (if missing) to ensure links open in the system browser rather than within the About dialog box.
26-
-
27+
- [SIL.Windows.Forms] Added SILAboutBox.AllowExternalLinksToOpenInsideAboutBox property to control whether a `<base target="_blank" rel="noopener noreferrer">` line is automatically added to the HTML (if missing) to ensure links open in the system browser rather than within the About dialog box.
28+
- [SIL.Core] Added static HtmlUtils class with methods for handling HTML to be displayed in browser controls (e.g., `SILAboutBox`), including support for fixing missing target attributes on links and copying simple asset files when creating a temp HTML file to display.
29+
2730
### Fixed
2831

2932
- [SIL.Windows.Forms] In `CustomDropDown.OnOpening`, fixed check that triggers timer to stop.
30-
- [SIL.Windows.Forms] Fixed HtmlBrowserHandled.OnNewWindow to open external URLs (target="_blank") in the system’s default browser instead of Internet Explorer. This improves behavior in SILAboutBox and other components that use the embedded browser.
33+
- [SIL.Windows.Forms] Fixed HtmlBrowserHandled.OnNewWindow to open external URLs (`target="_blank"`) in the system’s default browser instead of Internet Explorer. This improves behavior in SILAboutBox and other components that use the embedded browser.
3134

3235
### Changed
3336

@@ -594,7 +597,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
594597
- [SIL.NUnit3Compatibility] new project/package that allows to use NUnit3 syntax with NUnit2
595598
projects
596599

597-
[Unreleased]: https://github.com/sillsdev/libpalaso/compare/v16.0.0...master
600+
[Unreleased]: https://github.com/sillsdev/libpalaso/compare/v16.1.0...master
601+
[16.1.0]: https://github.com/sillsdev/libpalaso/compare/v16.0.0...master
598602
[16.0.0]: https://github.com/sillsdev/libpalaso/compare/v15.0.0...v16.0.0
599603
[15.0.0]: https://github.com/sillsdev/libpalaso/compare/v14.1.1...v15.0.0
600604
[14.1.1]: https://github.com/sillsdev/libpalaso/compare/v14.1.0...v14.1.1
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
using NUnit.Framework;
2+
using System.IO;
3+
using SIL.Core;
4+
using SIL.IO;
5+
using static System.IO.Path;
6+
using static System.StringComparison;
7+
8+
namespace SIL.Tests.Html
9+
{
10+
[TestFixture]
11+
public class HtmlUtilsTests
12+
{
13+
private const string htmlWithNoHead = @"<html>
14+
<body>
15+
<h3>Stuff</h3>
16+
<p>Here is something you need to know.</p>
17+
</body>
18+
</html>";
19+
20+
private const string htmlWithSelfClosingEmptyHead = @"<html>
21+
<head/>
22+
<body>
23+
<h3>Stuff</h3>
24+
<p>Here is something you need to know.</p>
25+
</body>
26+
</html>";
27+
28+
private const string htmlWithNonEmptyHeadButNoTarget = @"<html>
29+
<head><meta charset='UTF-8' /></head>
30+
<body>
31+
<h3>Stuff</h3>
32+
<p>Here is something you need to know.</p>
33+
</body>
34+
</html>";
35+
36+
[TestCase(@"<html>
37+
<head><base target=""_blank"" rel=""noopener noreferrer""></head>
38+
<body>
39+
<p>Good</p>
40+
</body>
41+
</html>")]
42+
[TestCase(@"<html>
43+
<head><base target=""_blank""></head>
44+
<body>
45+
<p>Stuff.</p>
46+
</body>
47+
</html>")]
48+
[TestCase(@"<html>
49+
<head><base target=""_self""></head>
50+
<body>
51+
<p>Stuff.</p>
52+
</body>
53+
</html>")]
54+
public void HasBaseTarget_Yes_ReturnsTrue(string html)
55+
{
56+
Assert.That(HtmlUtils.HasBaseTarget(html), Is.True);
57+
}
58+
59+
[TestCase(htmlWithNoHead)]
60+
[TestCase(htmlWithSelfClosingEmptyHead)]
61+
[TestCase(htmlWithNonEmptyHeadButNoTarget)]
62+
public void HasBaseTarget_No_ReturnsFalse(string html)
63+
{
64+
Assert.That(HtmlUtils.HasBaseTarget(html), Is.False);
65+
}
66+
67+
[TestCase("")]
68+
[TestCase(null)]
69+
public void HandleMissingLinkTargets_EmptyHtml_ReturnsNull(string html)
70+
{
71+
var result = HtmlUtils.HandleMissingLinkTargets(html);
72+
Assert.IsNull(result);
73+
}
74+
75+
[TestCase(htmlWithNoHead)]
76+
[TestCase(htmlWithSelfClosingEmptyHead)]
77+
[TestCase(htmlWithNonEmptyHeadButNoTarget)]
78+
public void HandleMissingLinkTargets_HtmlWithNoLinks_ReturnsNull(string html)
79+
{
80+
var result = HtmlUtils.HandleMissingLinkTargets(html);
81+
Assert.IsNull(result);
82+
}
83+
84+
[TestCase(true)]
85+
[TestCase(false)]
86+
public void HandleMissingLinkTargets_ExternalLinkWithoutTarget_AddsBaseTarget(
87+
bool useDoubleQuotes)
88+
{
89+
var html = @"<html>
90+
<head></head>
91+
<body>
92+
<a href='http://example.com'>External</a>
93+
</body>
94+
</html>";
95+
if (useDoubleQuotes)
96+
html = html.Replace("'", @"""");
97+
var origBody = html.Substring(html.IndexOf("<body>", Ordinal));
98+
var result = HtmlUtils.HandleMissingLinkTargets(html);
99+
Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
100+
Assert.That(result.Substring(result.IndexOf("<body>", Ordinal)),
101+
Is.EqualTo(origBody));
102+
}
103+
104+
[TestCase(true)]
105+
[TestCase(false)]
106+
public void HandleMissingLinkTargets_ExternalLinkWithTarget_ReturnsNull(
107+
bool useDoubleQuotes)
108+
{
109+
var html = @"<html>
110+
<head></head>
111+
<body>
112+
<a href='https://example.com' target='_blank'>External</a>
113+
</body>
114+
</html>";
115+
if (useDoubleQuotes)
116+
html = html.Replace("'", @"""");
117+
var result = HtmlUtils.HandleMissingLinkTargets(html);
118+
Assert.That(result, Is.Null, "No need to modify; explicit target already present");
119+
}
120+
121+
[TestCase(true)]
122+
[TestCase(false)]
123+
public void HandleMissingLinkTargets_OnlyInternalLinks_ReturnsNull(
124+
bool useDoubleQuotes)
125+
{
126+
var html = @"<html>
127+
<head></head>
128+
<body>
129+
<a href='#section1'>Internal</a>
130+
<a href='mailto:someone@example.com'>Email</a>
131+
<h4 id='section1'>This is the internal section</h4>
132+
<p>You jumped here using an internal anchor link.</p>
133+
</body>
134+
</html>";
135+
if (useDoubleQuotes)
136+
html = html.Replace("'", @"""");
137+
var result = HtmlUtils.HandleMissingLinkTargets(html);
138+
Assert.That(result, Is.Null, "No need to modify; all links are internal/special");
139+
}
140+
141+
[Test]
142+
public void HandleMissingLinkTargets_InternalAnchor_GetsTargetSelf()
143+
{
144+
var html = @"<html><head></head><body><a href=""#section1"">Jump</a> and
145+
<a href='http://example.com'>Go</a>!</body></html>";
146+
147+
var origTail = html.Substring(html.IndexOf("Jump</a> and", Ordinal));
148+
149+
var result = HtmlUtils.HandleMissingLinkTargets(html);
150+
151+
Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
152+
Assert.That(result, Does.Contain(@"<a href=""#section1"" target=""_self"">Jump</a>"));
153+
Assert.That(result, Does.EndWith(origTail));
154+
}
155+
156+
[Test]
157+
public void HandleMissingLinkTargets_Mailto_GetsTargetSelf()
158+
{
159+
var html = @"<html><head></head><body><a href='http://example.com'>Go</a> here to
160+
<a href='mailto:someone@example.com'>Email</a> someone.</body></html>";
161+
162+
int bodyStart = html.IndexOf("<body>", Ordinal);
163+
var origBodyStart = html.Substring(bodyStart,
164+
html.IndexOf("<a href='mailto", Ordinal) - bodyStart);
165+
166+
var result = HtmlUtils.HandleMissingLinkTargets(html);
167+
168+
Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
169+
Assert.That(result, Does.Contain(origBodyStart));
170+
Assert.That(result, Does.EndWith("<a href='mailto:someone@example.com' " +
171+
@"target=""_self"">Email</a> someone.</body></html>"));
172+
}
173+
174+
[TestCase(@"href=""#internal"" target=""_self""")]
175+
[TestCase(@"target=""_self"" href=""#internal""")]
176+
[TestCase(@"target='_self' href=""#internal""")]
177+
[TestCase(@"href='#internal' target=""_self""")]
178+
public void HandleMissingLinkTargets_InternalLink_WithTargetAlready_OnlyBaseTargetAdded(
179+
string internalLinkAttributes)
180+
{
181+
var html = $"<html><head></head><body><a {internalLinkAttributes}>Stay</a>" +
182+
@"alert as you <a href=""www.example.com"">walk</a>.</body></html>";
183+
184+
var origBody = html.Substring(html.IndexOf("<body>", Ordinal));
185+
var result = HtmlUtils.HandleMissingLinkTargets(html);
186+
Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
187+
Assert.That(result.Substring(result.IndexOf("<body>", Ordinal)),
188+
Is.EqualTo(origBody));
189+
}
190+
191+
[TestCase("www.example.com")]
192+
[TestCase("http://www.example.com")]
193+
[TestCase("https://www.example.com")]
194+
public void IsExternalHref_IsExternal_ReturnsTrue(string href)
195+
{
196+
Assert.That(HtmlUtils.IsExternalHref(href), Is.True);
197+
}
198+
199+
[TestCase("#internal")]
200+
[TestCase("mailto:someone@example.com")]
201+
[TestCase("tel:8008008000")]
202+
[TestCase("")]
203+
[TestCase(null)]
204+
public void IsExternalHref_IsNotExternal_ReturnsFalse(string href)
205+
{
206+
Assert.That(HtmlUtils.IsExternalHref(href), Is.False);
207+
}
208+
209+
[TestCase(htmlWithNoHead)]
210+
[TestCase(htmlWithSelfClosingEmptyHead)]
211+
[TestCase(htmlWithNonEmptyHeadButNoTarget)]
212+
public void InjectBaseTarget_Missing_AddsHeadElementWithBaseTarget(string html)
213+
{
214+
var result = HtmlUtils.InjectBaseTarget(html);
215+
Assert.That(HtmlUtils.HasBaseTarget(result), Is.True);
216+
}
217+
218+
[TestCase("")]
219+
[TestCase(@"<p><a name=""gumby""/></p>")]
220+
[TestCase(@"<p><a href=""https://www.example.com""/></p>")]
221+
public void InjectBaseTarget_AlreadyHasBaseTarget_ReturnsOriginalHtml(string body)
222+
{
223+
var html = $"<html><head><base target='_blank'></head><body>{body}</body></html>";
224+
var result = HtmlUtils.InjectBaseTarget(html);
225+
Assert.That(result, Is.EqualTo(html));
226+
}
227+
}
228+
229+
[TestFixture]
230+
public class HtmlUtilsCreatePatchedTempHtmlFileTests
231+
{
232+
private string _testDir;
233+
private TempFile _modifiedHtml;
234+
private string _htmlPath;
235+
236+
[SetUp]
237+
public void Setup()
238+
{
239+
_modifiedHtml = TempFile.WithFilenameInTempFolder("about.html");
240+
_testDir = GetDirectoryName(_modifiedHtml.Path);
241+
_htmlPath = _modifiedHtml.Path;
242+
}
243+
244+
[TearDown]
245+
public void Teardown()
246+
{
247+
_modifiedHtml.Dispose();
248+
}
249+
250+
[TestCase("")]
251+
[TestCase("./")]
252+
[TestCase(" ")]
253+
public void SimpleCssLink_AssetCopied(string prefix)
254+
{
255+
const string cssName = "style.css";
256+
var cssPath = Combine(_testDir, cssName);
257+
File.WriteAllText(cssPath, "body { background: black; }");
258+
259+
var html = $@"<html><head>
260+
<link rel=""stylesheet"" href=""{prefix}{cssName}""></head><body>hello</body></html>";
261+
File.WriteAllText(_htmlPath, html);
262+
263+
using var tempFile = HtmlUtils.CreatePatchedTempHtmlFile(html, _htmlPath);
264+
265+
var tempDir = GetDirectoryName(tempFile.Path);
266+
Assert.That(tempFile.Path, Does.Exist);
267+
Assert.That(Combine(tempDir, cssName), Does.Exist);
268+
}
269+
270+
[TestCase("")]
271+
[TestCase("./")]
272+
[TestCase(" ")]
273+
[TestCase(" ./")]
274+
public void MultipleSimpleAssets_AllCopied(string prefix)
275+
{
276+
const string cssName = "style.css";
277+
const string jsName = "script.js";
278+
const string logoName = "logo.png";
279+
File.WriteAllText(Combine(_testDir, cssName), "css");
280+
File.WriteAllText(Combine(_testDir, jsName), "js");
281+
File.WriteAllText(Combine(_testDir, logoName), "png");
282+
283+
var html = $@"<html><head>
284+
<link rel=""stylesheet"" href = ""{prefix}{cssName}"">
285+
<script src=""{prefix}{jsName}""></script>
286+
</head><body><img src=""{prefix}{logoName}""></body></html>";
287+
File.WriteAllText(_htmlPath, html);
288+
289+
using var tempFile = HtmlUtils.CreatePatchedTempHtmlFile(html, _htmlPath);
290+
var tempDir = GetDirectoryName(tempFile.Path);
291+
292+
Assert.That(File.Exists(Combine(tempDir, cssName)), Is.True);
293+
Assert.That(File.Exists(Combine(tempDir, jsName)), Is.True);
294+
Assert.That(File.Exists(Combine(tempDir, logoName)), Is.True);
295+
}
296+
297+
[Test]
298+
public void ExternalLinks_Ignored()
299+
{
300+
const string html = @"<html><head>
301+
<link rel=""stylesheet"" href=""https://example.com/style.css"">
302+
</head><body>hello</body></html>";
303+
File.WriteAllText(_htmlPath, html);
304+
305+
using var tempFile = HtmlUtils.CreatePatchedTempHtmlFile(html, _htmlPath);
306+
307+
var tempDir = GetDirectoryName(tempFile.Path);
308+
Assert.That(File.Exists(tempFile.Path), Is.True);
309+
Assert.That(Directory.GetFiles(tempDir).Length, Is.EqualTo(1),
310+
"Should not attempt to copy external resources.");
311+
}
312+
313+
/// <summary>
314+
/// Since we're purposefully trying to keep things simple by ignoring relative/
315+
/// subdirectory assets, this test ensures that we don't attempt to copy them.
316+
/// </summary>
317+
[TestCase(@"\")]
318+
[TestCase("/")]
319+
public void SubdirectoryAsset_NotCopied(string slash)
320+
{
321+
var assetsDir = Combine(_testDir, "assets");
322+
Directory.CreateDirectory(assetsDir);
323+
File.WriteAllText(Combine(assetsDir, "style.css"), "should not copy");
324+
325+
var html = $@"<html><head>
326+
<link rel=""stylesheet"" href=""assets{slash}style.css"">
327+
</head><body>hello</body></html>";
328+
File.WriteAllText(_htmlPath, html);
329+
330+
using var tempFile = HtmlUtils.CreatePatchedTempHtmlFile(html, _htmlPath);
331+
332+
var tempDir = GetDirectoryName(tempFile.Path);
333+
Assert.That(Directory.GetFiles(tempDir).Length, Is.EqualTo(1),
334+
"Subdirectory assets should not be copied.");
335+
}
336+
}
337+
}

0 commit comments

Comments
 (0)