Skip to content

Commit

Permalink
Merge pull request #47 from skttl/our-self-host
Browse files Browse the repository at this point in the history
Adds our-self-host
Warren Buckley authored May 12, 2023
2 parents 09b25f5 + c99c6a1 commit 7e23b3a
Showing 10 changed files with 556 additions and 9 deletions.
71 changes: 71 additions & 0 deletions Our.Umbraco.TagHelpers.Tests/SelfHostServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
using Our.Umbraco.TagHelpers.Configuration;
using Our.Umbraco.TagHelpers.Services;
using System;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Logging;

namespace Our.Umbraco.TagHelpers.Tests
{
public class SelfHostServiceTests
{
private Mock<IProfilingLogger> _loggerMock;
private Mock<IAppPolicyCache> _runtimeCacheMock;
private Mock<IWebHostEnvironment> _hostingEnvironmentMock;
private Mock<IOptions<OurUmbracoTagHelpersConfiguration>> _globalSettingsMock;
private SelfHostService _service;

[SetUp]
public void SetUp()
{
_loggerMock = new Mock<IProfilingLogger>();
_runtimeCacheMock = new Mock<IAppPolicyCache>();
_hostingEnvironmentMock = new Mock<IWebHostEnvironment>();
_globalSettingsMock = new Mock<IOptions<OurUmbracoTagHelpersConfiguration>>();
_globalSettingsMock.SetupGet(x => x.Value).Returns(new OurUmbracoTagHelpersConfiguration
{
OurSelfHost = new SelfHostTagHelperConfiguration
{
RootFolder = "/self-hosted/"
}
});
_service = new SelfHostService(
_loggerMock.Object,
_runtimeCacheMock.Object,
_hostingEnvironmentMock.Object,
_globalSettingsMock.Object
);
}

[Test]
public void GetRemoteFolderPath_GivenUriWithMoreThanTwoSegments_ReturnsFolderPath()
{
// Arrange
var uri = new Uri("http://www.example.com/folder/subfolder/file.jpg");
var expectedFolderPath = "/folder/subfolder";

// Act
var result = _service.GetRemoteFolderPath(uri);

// Assert
Assert.AreEqual(expectedFolderPath, result);
}

[Test]
public void GetRemoteFolderPath_GivenUriWithLessThanTwoSegments_ReturnsEmptyString()
{
// Arrange
var uri = new Uri("http://www.example.com/");
var expectedFolderPath = string.Empty;

// Act
var result = _service.GetRemoteFolderPath(uri);

// Assert
Assert.AreEqual(expectedFolderPath, result);
}
}
}
165 changes: 165 additions & 0 deletions Our.Umbraco.TagHelpers.Tests/SelfHostTagHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using Moq;
using NUnit.Framework;
using Our.Umbraco.TagHelpers.Models;
using Our.Umbraco.TagHelpers.Services;
using Our.Umbraco.TagHelpers.Tests.Helpers;
using System.Threading.Tasks;

namespace Our.Umbraco.TagHelpers.Tests
{
[TestFixture]
public class SelfHostTagHelperTests
{
[Test]
public async Task ProcessAsync_SrcAttribute_SetsSrcAttribute()
{
// Arrange
var selfHostServiceMock = new Mock<ISelfHostService>();
selfHostServiceMock.Setup(x => x.SelfHostFile(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SelfHostedFile { Url = "/media/test.jpg" });

var tagHelper = new SelfHostTagHelper(selfHostServiceMock.Object);
tagHelper.SrcAttribute = "https://example.com/test.jpg";
var id = "unique-id";
var tagHelperContext = TestContextHelpers.GetTagHelperContext(id);
var output = new TagHelperOutput("img", new TagHelperAttributeList(), (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));

// Act
await tagHelper.ProcessAsync(tagHelperContext, output);

// Assert
Assert.AreEqual("/media/test.jpg", output.Attributes["src"].Value);
}

[Test]
public async Task ProcessAsync_HrefAttribute_SetsHrefAttribute()
{
// Arrange
var selfHostServiceMock = new Mock<ISelfHostService>();
selfHostServiceMock.Setup(x => x.SelfHostFile(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SelfHostedFile { Url = "/media/test.pdf" });

var tagHelper = new SelfHostTagHelper(selfHostServiceMock.Object);
tagHelper.HrefAttribute = "https://example.com/test.pdf";
var id = "unique-id";
var tagHelperContext = TestContextHelpers.GetTagHelperContext(id);
var output = new TagHelperOutput("a", new TagHelperAttributeList(), (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));

// Act
await tagHelper.ProcessAsync(tagHelperContext, output);

// Assert
Assert.AreEqual("/media/test.pdf", output.Attributes["href"].Value);
}

[Test]
public async Task ProcessAsync_RemovesOurSelfHostAttribute()
{
// Arrange
var selfHostServiceMock = new Mock<ISelfHostService>();
selfHostServiceMock.Setup(x => x.SelfHostFile(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SelfHostedFile { Url = "/media/test.jpg" });

var tagHelper = new SelfHostTagHelper(selfHostServiceMock.Object);
tagHelper.SrcAttribute = "https://example.com/test.jpg";
var id = "unique-id";
var tagHelperContext = TestContextHelpers.GetTagHelperContext(id);
var output = new TagHelperOutput("img", new TagHelperAttributeList(), (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));

// Act
await tagHelper.ProcessAsync(tagHelperContext, output);

// Assert
Assert.IsFalse(output.Attributes.ContainsName("our-self-host"));
}

[Test]
public async Task ProcessAsync_RemovesFolderAttribute()
{
// Arrange
var selfHostServiceMock = new Mock<ISelfHostService>();
selfHostServiceMock.Setup(x => x.SelfHostFile(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SelfHostedFile { Url = "/media/test.jpg" });

var tagHelper = new SelfHostTagHelper(selfHostServiceMock.Object);
tagHelper.SrcAttribute = "https://example.com/test.jpg";
tagHelper.FolderName = "test-folder";
var id = "unique-id";
var tagHelperContext = TestContextHelpers.GetTagHelperContext(id);
var output = new TagHelperOutput("img", new TagHelperAttributeList(), (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));

// Act
await tagHelper.ProcessAsync(tagHelperContext, output);

// Assert
Assert.IsFalse(output.Attributes.ContainsName("folder"));
}

[Test]
public async Task ProcessAsync_RemovesExtAttribute()
{
// Arrange
var selfHostServiceMock = new Mock<ISelfHostService>();
selfHostServiceMock.Setup(x => x.SelfHostFile(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SelfHostedFile { Url = "/media/test.jpg" });

var tagHelper = new SelfHostTagHelper(selfHostServiceMock.Object);
tagHelper.SrcAttribute = "https://example.com/test";
tagHelper.Extension = "jpg";
var id = "unique-id";
var tagHelperContext = TestContextHelpers.GetTagHelperContext(id);
var output = new TagHelperOutput("img", new TagHelperAttributeList(), (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));

// Act
await tagHelper.ProcessAsync(tagHelperContext, output);

// Assert
Assert.IsFalse(output.Attributes.ContainsName("ext"));
}

[Test]
public async Task ProcessAsync_SrcAttribute_EnforcesExtAttribute()
{
// Arrange
var selfHostServiceMock = new Mock<ISelfHostService>();
selfHostServiceMock.Setup(x => x.SelfHostFile(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SelfHostedFile { Url = "/media/test.jpg" });

var tagHelper = new SelfHostTagHelper(selfHostServiceMock.Object);
tagHelper.SrcAttribute = "https://example.com/test";
tagHelper.Extension = "jpg";
var id = "unique-id";
var tagHelperContext = TestContextHelpers.GetTagHelperContext(id);
var output = new TagHelperOutput("img", new TagHelperAttributeList(), (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));

// Act
await tagHelper.ProcessAsync(tagHelperContext, output);

// Assert
Assert.IsTrue(output.Attributes.ContainsName("src") && output.Attributes["src"].Value.ToString().EndsWith(tagHelper.Extension));
}

[Test]
public async Task ProcessAsync_HrefAttribute_EnforcesExtAttribute()
{
// Arrange
var selfHostServiceMock = new Mock<ISelfHostService>();
selfHostServiceMock.Setup(x => x.SelfHostFile(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SelfHostedFile { Url = "/media/test.pdf" });

var tagHelper = new SelfHostTagHelper(selfHostServiceMock.Object);
tagHelper.HrefAttribute = "https://example.com/test";
tagHelper.Extension = "pdf";
var id = "unique-id";
var tagHelperContext = TestContextHelpers.GetTagHelperContext(id);
var output = new TagHelperOutput("a", new TagHelperAttributeList(), (useCachedResult, encoder) => Task.FromResult<TagHelperContent>(new DefaultTagHelperContent()));

// Act
await tagHelper.ProcessAsync(tagHelperContext, output);

// Assert
Assert.IsTrue(output.Attributes.ContainsName("href") && output.Attributes["href"].Value.ToString().EndsWith(tagHelper.Extension));
}
}
}
15 changes: 15 additions & 0 deletions Our.Umbraco.TagHelpers/Composing/SelfHostServiceComposer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using Our.Umbraco.TagHelpers.Services;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;

namespace Our.Umbraco.TagHelpers.Composing
{
public class SelfHostServiceComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.Services.AddSingleton<ISelfHostService, SelfHostService>();
}
}
}
Original file line number Diff line number Diff line change
@@ -5,7 +5,9 @@ namespace Our.Umbraco.TagHelpers.Configuration
public class OurUmbracoTagHelpersConfiguration
{
public InlineSvgTagHelperConfiguration OurSVG { get; set; } = new InlineSvgTagHelperConfiguration();
public ImgTagHelperConfiguration OurImg { get; set; } = new ImgTagHelperConfiguration();
public ImgTagHelperConfiguration OurImg { get; set; } = new ImgTagHelperConfiguration();

public SelfHostTagHelperConfiguration OurSelfHost { get; set; } = new SelfHostTagHelperConfiguration();
}

public class InlineSvgTagHelperConfiguration
@@ -14,33 +16,33 @@ public class InlineSvgTagHelperConfiguration
public bool Cache { get; set; } = false;
public int CacheMinutes { get; set; } = 180;
}
public class ImgTagHelperConfiguration

public class ImgTagHelperConfiguration
{
/// <summary>
/// Define the typical responsive breakpoints (S,M,L,XL,XXL) in which your website uses during screen resize
/// </summary>
public MediaQuerySizes MediaQueries { get; set; } = new MediaQuerySizes();

/// <summary>
/// If true, let the browser handle image lazy loading, otherwise disable to use a 3rd party JavaScript based library
/// </summary>
public bool UseNativeLazyLoading { get; set; } = true;

/// <summary>
/// Applicable if UseNativeLazyLoading is false
/// </summary>
public string LazyLoadCssClass { get; set; } = "lazyload";

/// <summary>
/// Applicable if UseNativeLazyLoading is false
/// </summary>
public ImagePlaceholderType LazyLoadPlaceholder { get; set; } = ImagePlaceholderType.SVG;

/// <summary>
/// Applicable if UseNativeLazyLoading is false & LazyLoadPlaceholder is LowQualityImage
/// </summary>
public int LazyLoadPlaceholderLowQualityImageQuality { get; set; } = 5;
public int LazyLoadPlaceholderLowQualityImageQuality { get; set; } = 5;
public bool ApplyAspectRatio { get; set; } = false;
public bool MobileFirst { get; set; } = true;

@@ -57,4 +59,9 @@ public class MediaQuerySizes
public int ExtraLarge { get; set; } = 1200;
public int ExtraExtraLarge { get; set; } = 1400;
}

public class SelfHostTagHelperConfiguration
{
public string RootFolder { get; set; } = "~/assets";
}
}
48 changes: 48 additions & 0 deletions Our.Umbraco.TagHelpers/Extensions/WebHostEnvironmentExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Hosting;
using System;
using System.IO;
using Umbraco.Cms.Core;

namespace Our.Umbraco.TagHelpers.Extensions
{
[Obsolete("This should be removed, when the package gets upgraded past Umbraco 10")]
public static class WebHostEnvironmentExtensions
{

/// <summary>
/// Maps a virtual path to a physical path to the application's web root
/// </summary>
/// <remarks>
/// Depending on the runtime 'web root', this result can vary. For example in Net Framework the web root and the
/// content root are the same, however
/// in netcore the web root is /wwwroot therefore this will Map to a physical path within wwwroot.
/// </remarks>

[Obsolete("This should be removed, when the package gets upgraded past Umbraco 10")]
public static string MapPathWebRoot(this IWebHostEnvironment webHostEnvironment, string path)
{
var root = webHostEnvironment.WebRootPath;

// Create if missing
if (string.IsNullOrWhiteSpace(root))
{
root = webHostEnvironment.WebRootPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot");
}

var newPath = path.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar);

// TODO: This is a temporary error because we switched from IOHelper.MapPath to HostingEnvironment.MapPathXXX
// IOHelper would check if the path passed in started with the root, and not prepend the root again if it did,
// however if you are requesting a path be mapped, it should always assume the path is relative to the root, not
// absolute in the file system. This error will help us find and fix improper uses, and should be removed once
// all those uses have been found and fixed
if (newPath.StartsWith(root))
{
throw new ArgumentException(
"The path appears to already be fully qualified. Please remove the call to MapPathWebRoot");
}

return Path.Combine(root, newPath.TrimStart(Constants.CharArrays.TildeForwardSlashBackSlash));
}
}
}
15 changes: 15 additions & 0 deletions Our.Umbraco.TagHelpers/Models/SelfHostedFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Our.Umbraco.TagHelpers.Models
{
public class SelfHostedFile
{
public string? ExternalUrl { get; set; }
public string? FileName { get; set; }
public string? FolderPath { get; set; }
public string? Url { get; set; }

public override string? ToString()
{
return Url;
}
}
}
54 changes: 54 additions & 0 deletions Our.Umbraco.TagHelpers/SelfHostTagHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
using Our.Umbraco.TagHelpers.Services;
using System.Threading.Tasks;
using Umbraco.Extensions;

namespace Our.Umbraco.TagHelpers
{
/// <summary>
/// Downloads the specified file (in href or src) to wwwroot and changes the link to local.
/// </summary>
[HtmlTargetElement("*", Attributes = "our-self-host")]
public class SelfHostTagHelper : TagHelper
{
private readonly ISelfHostService _selfHostService;

public SelfHostTagHelper(ISelfHostService selfHostService)
{
_selfHostService = selfHostService;
}

[HtmlAttributeName("folder")]
public string? FolderName { get; set; }
[HtmlAttributeName("src")]
public string? SrcAttribute { get; set; }
[HtmlAttributeName("href")]
public string? HrefAttribute { get; set; }
[HtmlAttributeName("ext")]
public string? Extension { get; set; }
public string Url => SrcAttribute.IfNullOrWhiteSpace(HrefAttribute);

public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var url = (Url.StartsWith("//") ? $"https:{Url}" : Url);
var selfHostedFile = await _selfHostService.SelfHostFile(url, FolderName, Extension);

if (SrcAttribute.IsNullOrWhiteSpace() == false)
{
output.Attributes.SetAttribute("data-original-src", SrcAttribute);
output.Attributes.SetAttribute("src", selfHostedFile.Url);
}
else if (HrefAttribute.IsNullOrWhiteSpace() == false)
{
output.Attributes.SetAttribute("data-original-href", HrefAttribute);
output.Attributes.SetAttribute("href", selfHostedFile.Url);
}

output.Attributes.Remove(new TagHelperAttribute("our-self-host"));
output.Attributes.Remove(new TagHelperAttribute("folder"));
output.Attributes.Remove(new TagHelperAttribute("ext"));
}


}
}
10 changes: 10 additions & 0 deletions Our.Umbraco.TagHelpers/Services/ISelfHostService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Our.Umbraco.TagHelpers.Models;
using System.Threading.Tasks;

namespace Our.Umbraco.TagHelpers.Services
{
public interface ISelfHostService
{
Task<SelfHostedFile> SelfHostFile(string url, string? subfolder = null, string? fileExtension = null);
}
}
133 changes: 133 additions & 0 deletions Our.Umbraco.TagHelpers/Services/SelfHostService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Options;
using Our.Umbraco.TagHelpers.Configuration;
using Our.Umbraco.TagHelpers.Extensions;
using Our.Umbraco.TagHelpers.Models;
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Umbraco.Cms.Core.Cache;
using Umbraco.Cms.Core.Logging;
using Umbraco.Extensions;

namespace Our.Umbraco.TagHelpers.Services
{
public class SelfHostService : ISelfHostService
{
private readonly IProfilingLogger _logger;
private readonly IAppPolicyCache _runtimeCache;
private readonly IWebHostEnvironment _hostingEnvironment;
private readonly OurUmbracoTagHelpersConfiguration _globalSettings;

public SelfHostService(
IProfilingLogger logger,
IAppPolicyCache appPolicyCache,
IWebHostEnvironment hostingEnvironment,
IOptions<OurUmbracoTagHelpersConfiguration> globalSettings
)
{
_logger = logger;
_runtimeCache = appPolicyCache;
_hostingEnvironment = hostingEnvironment;
_globalSettings = globalSettings.Value;
}

public async Task<SelfHostedFile> SelfHostFile(string url, string? subfolder = null, string? fileExtension = null)
{
return await _runtimeCache.GetCacheItem($"Our.Umbraco.TagHelpers.Services.SelfHostService.SelfHostedFile({url}, {subfolder}, {fileExtension})", async () =>
{
using (_logger.TraceDuration<ISelfHostService>($"Start generating SelfHostedFile: {url}", $"Finished generating SelfHostedFile: {url}"))
{
var uri = new Uri(url, UriKind.Absolute);

var selfHostedFile = new SelfHostedFile()
{
ExternalUrl = url,
FileName = uri.Segments.Last() + fileExtension.IfNotNull(ext => ext.EnsureStartsWith(".")),
FolderPath = GetFolderPath(uri, subfolder)
};

selfHostedFile.Url = await GetSelfHostedUrl(selfHostedFile);
return selfHostedFile;
}
});
}

public string GetFolderPath(Uri uri, string? subfolder = null)
{
var folderPath = _globalSettings.OurSelfHost.RootFolder;

if (subfolder.IsNullOrWhiteSpace() == false) folderPath += subfolder.EnsureStartsWith("/");

folderPath += GetRemoteFolderPath(uri);

return folderPath;
}

public string GetRemoteFolderPath(Uri uri)
{
var segments = uri?.Segments;

// if there is more than 2 segments (first segment is the root, last segment is the file)
// we can extract the folderpath
if (segments?.Length > 2)
{
segments = segments.Skip(1).SkipLast(1).ToArray();

// remove trailing slash from segments
segments = segments.Select(x => x.Replace("/", "")).ToArray();

// join segments with slash
return string.Join("/", segments).EnsureStartsWith("/");
}

return string.Empty;
}
private async Task<string> GetSelfHostedUrl(SelfHostedFile file)
{
var filePath = $"{file.FolderPath}/{file.FileName}";
var localPath = _hostingEnvironment.MapPathWebRoot(file.FolderPath);
var localFilePath = _hostingEnvironment.MapPathWebRoot(filePath);

if (!File.Exists(localFilePath) && file.ExternalUrl is not null)
{
using (_logger.TraceDuration<ISelfHostService>($"Start downloading SelfHostedFile: {file.ExternalUrl} to {localFilePath}", $"Finished downloading SelfHostedFile: {file.ExternalUrl} to {localFilePath}"))
{
var content = await GetUrlContent(file.ExternalUrl);
if (content != null)
{
if (!Directory.Exists(localPath)) Directory.CreateDirectory(localPath);
await File.WriteAllBytesAsync(localFilePath, content);
return filePath;
}
else
{
return file.ExternalUrl;
}
}
}

return filePath;
}

private static async Task<byte[]?> GetUrlContent(string url)
{
using (var client = new HttpClient())
{
using (var result = await client.GetAsync(url))
{
if (result is not null && result.IsSuccessStatusCode)
{
return await result.Content.ReadAsByteArrayAsync();
}
else
{
return null;
}
}
}
}
}
}
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -615,10 +615,39 @@ This will produce an `<img>` tag with:
</picture>
```

## `our-self-host`
This is a tag helper attribute that can be applied to any element using a `src` or `href` attribute in the razor template or partial. It will automatically download and self hosting of third party assets, like javascript, css or images. This was written by Soren Kottal for 24Days.in Umbraco Advent calendar 2022 article - https://24days.in/umbraco-cms/2022/static-assets-taghelper/

### Simple Example
```cshtml
<script src="https://unpkg.com/alpinejs@3.10.5/dist/cdn.min.js" our-self-host>
```

This will download the linked file to your local filesystem, and swap out the src attribute with a reference to the now locally hosted file.

### Folder location for downloaded files
By default the files will be saved in `~/assets/`, and keep the folder path of the url. The root folder can be configured in appsettings.json, by adding a value at `Our.Umbraco.TagHelpers:OurSelfHost:RootFolder` specifying the desired rootfolder. The default value is `~/assets/`.

You can further divide the files into folders, by adding a `folder` attribute to the script tag, eg:
```cshtml
<script src="https://unpkg.com/alpinejs@3.10.5/dist/cdn.min.js" our-self-host folder="libraries">
```
This will save the file in `~/assets/libraries/alpinejs@3.10.5/dist/cdn.min.js`.

### Handling extensionless urls to files
In case the url is extensionless, like `https://unpkg.com/alpinejs`, you can add an `ext` attribute, for specifying the extension of the file, eg:
```cshtml
<script src="https://unpkg.com/alpinejs" our-self-host ext="js">
```
This will save the file as `~/assets/alpinejs.js`, enabling eg. MIME types for .js files.

### Caching
The file is saved once, and never updated unless you manually remove the file. The lookup for the local file is cached in the Runtime Cache.

## Video 📺

[![How to create ASP.NET TagHelpers for Umbraco](https://user-images.githubusercontent.com/1389894/138666925-15475216-239f-439d-b989-c67995e5df71.png)](https://www.youtube.com/watch?v=3fkDs0NwIE8)

## Attribution

The logo for Our.Umbraco.TagHelpers is made by [Freepik](https://www.freepik.com) from [flaticon.com](https://www.flaticon.com)
The logo for Our.Umbraco.TagHelpers is made by [Freepik](https://www.freepik.com) from [flaticon.com](https://www.flaticon.com)

0 comments on commit 7e23b3a

Please sign in to comment.