Skip to content

Commit 8e70013

Browse files
authored
Initial support for static assets in Razor Class Libraries (#580)
* Imports static assets from packages containing custom msbuild targets defining a StaticWebAsset item group. * Generates an embeds a manifest into the application assembly that contains a list of paths to the content roots of the assets defined in the packages custom msbuild targets.
1 parent c245deb commit 8e70013

21 files changed

+1226
-11
lines changed
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Text;
8+
using System.Xml;
9+
using System.Xml.Linq;
10+
using Microsoft.Build.Framework;
11+
using Microsoft.Build.Utilities;
12+
13+
namespace Microsoft.AspNetCore.Razor.Tasks
14+
{
15+
public class GenerateStaticWebAssetsManifest : Task
16+
{
17+
private const string ContentRoot = "ContentRoot";
18+
private const string BasePath = "BasePath";
19+
20+
[Required]
21+
public string TargetManifestPath { get; set; }
22+
23+
[Required]
24+
public ITaskItem[] ContentRootDefinitions { get; set; }
25+
26+
public override bool Execute()
27+
{
28+
if (!ValidateArguments())
29+
{
30+
return false;
31+
}
32+
33+
return ExecuteCore();
34+
}
35+
36+
private bool ExecuteCore()
37+
{
38+
var document = new XDocument(new XDeclaration("1.0", "utf-8", "yes"));
39+
var root = new XElement(
40+
"StaticWebAssets",
41+
new XAttribute("Version", "1.0"),
42+
CreateNodes());
43+
44+
document.Add(root);
45+
46+
var settings = new XmlWriterSettings
47+
{
48+
Encoding = Encoding.UTF8,
49+
CloseOutput = true,
50+
OmitXmlDeclaration = true,
51+
Indent = true,
52+
NewLineOnAttributes = false,
53+
Async = true
54+
};
55+
56+
using (var xmlWriter = GetXmlWriter(settings))
57+
{
58+
document.WriteTo(xmlWriter);
59+
}
60+
61+
return !Log.HasLoggedErrors;
62+
}
63+
64+
private IEnumerable<XElement> CreateNodes()
65+
{
66+
var nodes = new List<XElement>();
67+
for (var i = 0; i < ContentRootDefinitions.Length; i++)
68+
{
69+
var contentRootDefinition = ContentRootDefinitions[i];
70+
var basePath = contentRootDefinition.GetMetadata(BasePath);
71+
var contentRoot = contentRootDefinition.GetMetadata(ContentRoot);
72+
73+
// basePath is meant to be a prefix for the files under contentRoot. MSbuild
74+
// normalizes '\' according to the OS, but this is going to be part of the url
75+
// so it needs to always be '/'.
76+
var normalizedBasePath = basePath.Replace("\\", "/");
77+
78+
// At this point we already know that there are no elements with different base paths and same content roots
79+
// or viceversa. Here we simply skip additional items that have the same base path and same content root.
80+
if (!nodes.Exists(e => e.Attribute(BasePath).Value.Equals(normalizedBasePath, StringComparison.OrdinalIgnoreCase)))
81+
{
82+
nodes.Add(new XElement("ContentRoot",
83+
new XAttribute("BasePath", normalizedBasePath),
84+
new XAttribute("Path", contentRoot)));
85+
}
86+
}
87+
88+
return nodes;
89+
}
90+
91+
private XmlWriter GetXmlWriter(XmlWriterSettings settings)
92+
{
93+
var fileStream = new FileStream(TargetManifestPath, FileMode.Create);
94+
return XmlWriter.Create(fileStream, settings);
95+
}
96+
97+
private bool ValidateArguments()
98+
{
99+
for (var i = 0; i < ContentRootDefinitions.Length; i++)
100+
{
101+
var contentRootDefinition = ContentRootDefinitions[i];
102+
if (!EnsureRequiredMetadata(contentRootDefinition, BasePath) ||
103+
!EnsureRequiredMetadata(contentRootDefinition, ContentRoot))
104+
{
105+
return false;
106+
}
107+
}
108+
109+
// We want to validate that there are no different item groups that share either the same base path
110+
// but different content roots or that share the same content root but different base paths.
111+
// We pass in all the static web assets that we discovered to this task without making any distinction for
112+
// duplicates, so here we skip elements for which we are already tracking an element with the same
113+
// content root path and same base path.
114+
115+
// Case-sensitivity depends on the underlying OS so we are not going to do anything to enforce it here.
116+
// Any two items that match base path and content root in a case-insensitive way won't produce an error.
117+
// Any other two items will produce an error even if there is only a casing difference between either the
118+
// base paths or the content roots.
119+
var basePaths = new Dictionary<string, ITaskItem>(StringComparer.OrdinalIgnoreCase);
120+
var contentRootPaths = new Dictionary<string, ITaskItem>(StringComparer.OrdinalIgnoreCase);
121+
122+
for (var i = 0; i < ContentRootDefinitions.Length; i++)
123+
{
124+
var contentRootDefinition = ContentRootDefinitions[i];
125+
var basePath = contentRootDefinition.GetMetadata(BasePath);
126+
var contentRoot = contentRootDefinition.GetMetadata(ContentRoot);
127+
128+
if (basePaths.TryGetValue(basePath, out var existingBasePath))
129+
{
130+
var existingBasePathContentRoot = existingBasePath.GetMetadata(ContentRoot);
131+
if (!string.Equals(contentRoot, existingBasePathContentRoot, StringComparison.OrdinalIgnoreCase))
132+
{
133+
// Case:
134+
// Item1: /_content/Library, /package/aspnetContent1
135+
// Item2: /_content/Library, /package/aspnetContent2
136+
Log.LogError($"Duplicate base paths '{basePath}' for content root paths '{contentRoot}' and '{existingBasePathContentRoot}'. " +
137+
$"('{contentRootDefinition.ItemSpec}', '{existingBasePath.ItemSpec}')");
138+
return false;
139+
}
140+
// It was a duplicate, so we skip it.
141+
// Case:
142+
// Item1: /_content/Library, /package/aspnetContent
143+
// Item2: /_content/Library, /package/aspnetContent
144+
}
145+
else
146+
{
147+
if (contentRootPaths.TryGetValue(contentRoot, out var existingContentRoot))
148+
{
149+
// Case:
150+
// Item1: /_content/Library1, /package/aspnetContent
151+
// Item2: /_content/Library2, /package/aspnetContent
152+
Log.LogError($"Duplicate content root paths '{contentRoot}' for base paths '{basePath}' and '{existingContentRoot.GetMetadata(BasePath)}' " +
153+
$"('{contentRootDefinition.ItemSpec}', '{existingContentRoot.ItemSpec}')");
154+
return false;
155+
}
156+
}
157+
158+
if (!basePaths.ContainsKey(basePath))
159+
{
160+
basePaths.Add(basePath, contentRootDefinition);
161+
}
162+
163+
if (!contentRootPaths.ContainsKey(contentRoot))
164+
{
165+
contentRootPaths.Add(contentRoot, contentRootDefinition);
166+
}
167+
}
168+
169+
return true;
170+
}
171+
172+
private bool EnsureRequiredMetadata(ITaskItem item, string metadataName)
173+
{
174+
var value = item.GetMetadata(metadataName);
175+
if (string.IsNullOrEmpty(value))
176+
{
177+
Log.LogError($"Missing required metadata '{metadataName}' for '{item.ItemSpec}'.");
178+
return false;
179+
}
180+
181+
return true;
182+
}
183+
}
184+
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
<!--
2+
***********************************************************************************************
3+
Microsoft.NET.Sdk.Razor.StaticWebAssets.targets
4+
5+
WARNING: DO NOT MODIFY this file unless you are knowledgeable about MSBuild and have
6+
created a backup copy. Incorrect changes to this file will make it
7+
impossible to load or build your projects from the command-line or the IDE.
8+
9+
Copyright (c) .NET Foundation. All rights reserved.
10+
***********************************************************************************************
11+
-->
12+
13+
<Project ToolsVersion="14.0">
14+
15+
<!-- Targets that support static content scenarios in ASP.NET Core.
16+
The main targets are:
17+
* GenerateStaticWebAssetsManifest: Creates a manifest file to use in development with
18+
the paths to all the references packages and projects content roots.
19+
* ResolveStaticWebAssetsInputs: Collects all the static assets from different sources
20+
* Current project.
21+
* Referenced project.
22+
* Referenced packages.
23+
-->
24+
25+
<UsingTask
26+
TaskName="Microsoft.AspNetCore.Razor.Tasks.GenerateStaticWebAssetsManifest"
27+
AssemblyFile="$(RazorSdkBuildTasksAssembly)"
28+
Condition="'$(RazorSdkBuildTasksAssembly)' != ''" />
29+
30+
<PropertyGroup>
31+
<GenerateStaticWebAssetsManifestDependsOn>
32+
ResolveStaticWebAssetsInputs;
33+
_CreateStaticWebAssetsInputsCacheFile
34+
</GenerateStaticWebAssetsManifestDependsOn>
35+
36+
<AssignTargetPathsDependsOn>
37+
GenerateStaticWebAssetsManifest;
38+
$(AssignTargetPathsDependsOn)
39+
</AssignTargetPathsDependsOn>
40+
41+
</PropertyGroup>
42+
43+
<PropertyGroup>
44+
<_GeneratedStaticWebAssetsInputsCacheFile>$(IntermediateOutputPath)$(TargetName).StaticWebAssets.cache</_GeneratedStaticWebAssetsInputsCacheFile>
45+
<_GeneratedStaticWebAssetsDevelopmentManifest>$(IntermediateOutputPath)$(TargetName).StaticWebAssets.xml</_GeneratedStaticWebAssetsDevelopmentManifest>
46+
</PropertyGroup>
47+
48+
<Target
49+
Name="_CreateStaticWebAssetsInputsCacheFile"
50+
DependsOnTargets="ResolveStaticWebAssetsInputs">
51+
52+
<ItemGroup>
53+
<!--
54+
This is the list of inputs that will be used for generating the manifest used during development.
55+
-->
56+
<_ExternalStaticWebAsset
57+
Include="%(StaticWebAsset.Identity)"
58+
Condition="'%(SourceType)' != ''">
59+
<BasePath>%(StaticWebAsset.BasePath)</BasePath>
60+
<ContentRoot>%(StaticWebAsset.ContentRoot)</ContentRoot>
61+
</_ExternalStaticWebAsset>
62+
</ItemGroup>
63+
64+
<!-- We need a transform here to make sure we hash the metadata -->
65+
<Hash ItemsToHash="@(_ExternalStaticWebAsset->'%(Identity)%(BasePath)%(ContentRoot)')">
66+
<Output TaskParameter="HashResult" PropertyName="_StaticWebAssetsCacheHash" />
67+
</Hash>
68+
69+
<WriteLinesToFile
70+
Lines="$(_StaticWebAssetsCacheHash)"
71+
File="$(_GeneratedStaticWebAssetsInputsCacheFile)"
72+
Overwrite="True"
73+
WriteOnlyWhenDifferent="True" />
74+
75+
<ItemGroup>
76+
<FileWrites Include="$(_GeneratedStaticWebAssetsInputsCacheFile)" />
77+
</ItemGroup>
78+
79+
</Target>
80+
81+
<!--
82+
This target generates a manifest for development time that includes information
83+
about the base path for the referenced package and project static web assets. The
84+
manifest includes the content root and the base path for each of the referenced
85+
packages and projects.
86+
87+
Ideally, each package/project contains a unique base path and a given content
88+
root, but we don't check for duplicates on either of them.
89+
-->
90+
91+
<Target
92+
Name="GenerateStaticWebAssetsManifest"
93+
Inputs="$(_GeneratedStaticWebAssetsInputsCacheFile)"
94+
Outputs="$(_GeneratedStaticWebAssetsDevelopmentManifest)"
95+
DependsOnTargets="$(GenerateStaticWebAssetsManifestDependsOn)">
96+
97+
<GenerateStaticWebAssetsManifest
98+
ContentRootDefinitions="@(_ExternalStaticWebAsset)"
99+
TargetManifestPath="$(_GeneratedStaticWebAssetsDevelopmentManifest)" />
100+
101+
<!-- This is the list of inputs that will be used for generating the manifest used during development. -->
102+
<ItemGroup>
103+
<EmbeddedResource Condition="'@(_ExternalStaticWebAsset->Count())' != '0'"
104+
Include="$(_GeneratedStaticWebAssetsDevelopmentManifest)"
105+
LogicalName="Microsoft.AspNetCore.StaticWebAssets.xml" />
106+
</ItemGroup>
107+
108+
<ItemGroup>
109+
<FileWrites Include="$(_GeneratedStaticWebAssetsDevelopmentManifest)" />
110+
</ItemGroup>
111+
112+
</Target>
113+
114+
<!-- This target collects all the StaticWebAssets from different sources:
115+
* The current project StaticWebAssets that come from wwwroot\** by default.
116+
* Assets from referenced projects that get retrieved invoking an MSBuild target on
117+
the referenced projects.
118+
* Assets from the referenced packages. These will be implicitly included when nuget
119+
restores the package and includes the package props file for the package.
120+
-->
121+
<Target
122+
Name="ResolveStaticWebAssetsInputs">
123+
<PropertyGroup>
124+
<!-- The _SafeBasePath is used as a path segment in the urls that we will
125+
be exposing content from when the library is referenced as a package
126+
or as a project by a web application. Our convention will be to expose
127+
content directly on _content/<<_SafeBasePath>>
128+
129+
We simply remove the dots from the package id so that a package
130+
like Microsoft.AspNetCore.Identity becomes MicrosoftAspNetCoreIdentity
131+
132+
TODO: Investigate if we need to do something more sophisticated here.
133+
-->
134+
<_SafeBasePath>$(PackageId.Replace('.',''))</_SafeBasePath>
135+
</PropertyGroup>
136+
137+
<!-- StaticWebAssets from the current project -->
138+
139+
<ItemGroup>
140+
<!--
141+
Should we promote 'wwwroot\**'' to a property?
142+
We don't want to capture any content outside the content root, that's why we don't do
143+
@(Content) here.
144+
-->
145+
<StaticWebAsset Include="wwwroot\**" Exclude="$(DefaultItemExcludes);$(DefaultExcludesInProjectFolder)">
146+
<!-- (PackageReference,ProjectReference,'' (CurrentProject)) -->
147+
<SourceType></SourceType>
148+
<!-- Identifier describing the source, the package id, the project name, empty for the current project. -->
149+
<SourceId></SourceId>
150+
<!--
151+
Full path to the content root for the item:
152+
* For packages it corresponds to %userprofile%/.nuget/packages/<<PackageId>>/<<PackageVersion>>/razorContent
153+
* For referenced projects it corresponds to <<FullProjectRefPath>>/wwwroot
154+
* For the current projects it corresponds to $(MSBuildThisProjectFileDirectory)wwwroot\
155+
-->
156+
<ContentRoot>$(MSBuildProjectDirectory)\wwwroot\</ContentRoot>
157+
<!-- Subsection (folder) from the url space where content for this library will be served. -->
158+
<BasePath>_content\$(_SafeBasePath)\</BasePath>
159+
<!-- Relative path from the content root for the file. At publish time, we combine the BasePath + Relative
160+
path to determine the final path for the file. -->
161+
<RelativePath>%(RecursiveDir)%(FileName)%(Extension)</RelativePath>
162+
163+
</StaticWebAsset>
164+
</ItemGroup>
165+
166+
<!-- StaticWebAssets from referenced projects. -->
167+
<!-- TODO: Include implementation -->
168+
169+
<!-- StaticWebAssets from packages are already available, so we don't do anything. -->
170+
</Target>
171+
172+
</Project>

src/Razor/src/Microsoft.NET.Sdk.Razor/build/netstandard2.0/Sdk.Razor.CurrentVersion.props

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ Copyright (c) .NET Foundation. All rights reserved.
7474
<None Remove="**\*.razor" />
7575
</ItemGroup>
7676

77+
<PropertyGroup>
78+
<EnableRazorSdkContent Condition=" '$(UsingMicrosoftNETSdkWeb)' == '' ">true</EnableRazorSdkContent>
79+
</PropertyGroup>
80+
7781
<Import
7882
Project="$(MSBuildThisFileDirectory)..\..\Sdk\Sdk.Razor.StaticAssets.ProjectSystem.props"
7983
Condition=" '$(EnableRazorSdkContent)' == 'true' " />

src/Razor/src/Microsoft.NET.Sdk.Razor/build/netstandard2.0/Sdk.Razor.CurrentVersion.targets

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,8 @@ Copyright (c) .NET Foundation. All rights reserved.
338338

339339
<Import Project="Microsoft.NET.Sdk.Razor.Component.targets" Condition="'$(_Targeting30OrNewerRazorLangVersion)' == 'true'" />
340340

341+
<Import Project="Microsoft.NET.Sdk.Razor.StaticWebAssets.targets" Condition="'$(_Targeting30OrNewerRazorLangVersion)' == 'true'" />
342+
341343
<Import Project="Microsoft.NET.Sdk.Razor.GenerateAssemblyInfo.targets" />
342344

343345
<Import Project="Microsoft.NET.Sdk.Razor.MvcApplicationPartsDiscovery.targets" Condition="'$(_TargetingNETCoreApp30OrLater)' == 'true'" />
@@ -480,6 +482,7 @@ Copyright (c) .NET Foundation. All rights reserved.
480482
'$(EnableDefaultRazorGenerateItems)'=='true'">
481483

482484
<Content Condition="'%(Content.Extension)'=='.cshtml'" Pack="$(IncludeRazorContentInPack)" />
485+
<Content Condition="'%(Content.Extension)'=='.razor'" Pack="$(IncludeRazorContentInPack)" />
483486
</ItemGroup>
484487
</Target>
485488

0 commit comments

Comments
 (0)