Skip to content

Instantly share code, notes, and snippets.

@austins
Last active June 10, 2024 23:00
Show Gist options
  • Save austins/c260c9e233411610f8f32c58ddd9f8ad to your computer and use it in GitHub Desktop.
Save austins/c260c9e233411610f8f32c58ddd9f8ad to your computer and use it in GitHub Desktop.
ASP.NET Core Vite Manifest to Razor TagHelper
<link rel="stylesheet" vite-asset="main.css"/>
<script type="module" vite-asset="main.js"></script>
{
"Vite": {
"AssetsDirectoryName": "Assets",
"AssetPaths": [
"main.css",
"main.js"
],
"ManifestFileName": "manifest.json"
}
}
{
"private": true,
"type": "module",
"scripts": {
"dev": "vite build --watch",
"build": "vite build"
},
"devDependencies":
"is-subdir": "^1.2.0",
"jsonc-parser": "^3.2.1",
"vite": "^5.2.12"
}
}
import { defineConfig } from "vite";
import { parse as parseJSONC } from "jsonc-parser";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import isSubdir from "is-subdir";
const appsettings = parseJSONC(readFileSync("./appsettings.json", "utf8"));
// Validate config.
if (!isSubdir(process.cwd(), resolve(appsettings.Vite.AssetsDirectoryName))
|| appsettings.Vite.ManifestFileName.includes("/")) {
throw new Error("Invalid Vite config.");
}
export default defineConfig({
build: {
outDir: `./wwwroot/${appsettings.Vite.AssetsDirectoryName.toLowerCase()}`,
emptyOutDir: true,
manifest: appsettings.Vite.ManifestFileName,
rollupOptions: {
input: appsettings.Vite.AssetPaths.map(asset => `./${appsettings.Vite.AssetsDirectoryName}/${asset}`),
output: {
assetFileNames: "[name]-[hash][extname]",
chunkFileNames: "[name]-[hash].js",
entryFileNames: "[name]-[hash].js"
}
}
}
});
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;
/// <summary>
/// Tag helper to get hashed asset file name URL from Vite manifest.
/// </summary>
[HtmlTargetElement("link", Attributes = ViteAssetAttributeName, TagStructure = TagStructure.WithoutEndTag)]
[HtmlTargetElement("script", Attributes = ViteAssetAttributeName)]
public sealed class ViteAssetTagHelper : TagHelper
{
private const string ViteAssetAttributeName = "vite-asset";
private const string CacheKey = "vite:assets";
private readonly IMemoryCache _cache;
private readonly IFileProvider _fileProvider;
private readonly string _publicAssetsDirectoryName;
private readonly IOptions<ViteOptions> _viteOptions;
public ViteAssetTagHelper(IMemoryCache cache, IWebHostEnvironment environment, IOptions<ViteOptions> viteOptions)
{
_cache = cache;
_fileProvider = environment.WebRootFileProvider;
_viteOptions = viteOptions;
#pragma warning disable CA1308
_publicAssetsDirectoryName = _viteOptions.Value.AssetsDirectoryName.ToLowerInvariant();
#pragma warning restore CA1308
}
[HtmlAttributeName(ViteAssetAttributeName)]
public required string ViteAsset { get; set; }
[ViewContext]
public required ViewContext ViewContext { get; set; }
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var assets = await _cache.GetOrCreateAsync<IDictionary<string, Asset>>(
CacheKey,
async entry =>
{
var filePath = Path.Combine(_publicAssetsDirectoryName, _viteOptions.Value.ManifestFileName);
var fileInfo = _fileProvider.GetFileInfo(filePath);
if (!fileInfo.Exists || fileInfo.IsDirectory)
{
throw new FileNotFoundException($"Vite manifest file not found at path [{filePath}].");
}
entry.AddExpirationToken(_fileProvider.Watch(filePath));
await using var fileStream = fileInfo.CreateReadStream();
var result = await JsonSerializer.DeserializeAsync<IDictionary<string, Asset>>(
fileStream,
cancellationToken: ViewContext.HttpContext.RequestAborted);
return result!;
});
if (!assets!.TryGetValue($"{_viteOptions.Value.AssetsDirectoryName}/{ViteAsset}", out var asset))
{
throw new InvalidOperationException($"Asset [{ViteAsset}] not found.");
}
var attributeName = output.TagName == "link" ? "href" : "src";
var assetPath = $"/{_publicAssetsDirectoryName}/{asset.FilePath}";
output.Attributes.SetAttribute(attributeName, assetPath);
}
private sealed record Asset(
[property: JsonPropertyName("file")]
string FilePath);
}
using System.ComponentModel.DataAnnotations;
public sealed class ViteOptions : IValidatableObject
{
public const string SectionName = "Vite";
/// <summary>
/// The directory for the raw assets relative to the root of the project.
/// </summary>
[Required]
public required string AssetsDirectoryName { get; init; }
/// <summary>
/// The paths for the raw assets relative to the <see cref="AssetsDirectoryName" />.
/// </summary>
[Required]
[MinLength(1)]
public required ISet<string> AssetPaths { get; init; }
/// <summary>
/// The Vite manifest file name.
/// </summary>
[Required]
public required string ManifestFileName { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (AssetsDirectoryName.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal)
|| AssetsDirectoryName.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
{
yield return new ValidationResult(
$"{nameof(AssetsDirectoryName)} must be a name of a directory in the root of the project.",
[nameof(AssetsDirectoryName)]);
}
if (AssetPaths.Any(x => x.Contains("..", StringComparison.Ordinal)))
{
yield return new ValidationResult(
$"{nameof(AssetPaths)} must not go up to parent directories.",
[nameof(AssetPaths)]);
}
if (ManifestFileName.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal)
|| ManifestFileName.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal))
{
yield return new ValidationResult(
$"{nameof(ManifestFileName)} must be a file name and not be a path.",
[nameof(ManifestFileName)]);
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment