Skip to content

Commit 9349140

Browse files
committed
feat(sdk): auto-inject Content entries into vsixmanifest
Automatically inject <Content><ProjectTemplate/> and <Content><ItemTemplate/> entries into the vsixmanifest when templates are discovered, eliminating the need for manual manifest edits. - Add custom MSBuild task (InjectVsixManifestContentTask) that properly parses and modifies the XML manifest - Write to intermediate manifest (obj folder) to avoid modifying source - Enable by default via AutoInjectVsixTemplateContent property - Skip existing WarnMissingManifestContent warnings when auto-injection is enabled - Add E2E test to verify auto-injection works correctly Closes #40
1 parent 410bde4 commit 9349140

13 files changed

Lines changed: 365 additions & 2 deletions

File tree

.github/workflows/build.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ jobs:
8787
- name: Build E2E.AllFeatures
8888
run: dotnet build tests/e2e/E2E.AllFeatures/E2E.AllFeatures.csproj -c Release
8989

90+
- name: Build E2E.Templates.AutoInject
91+
run: dotnet build tests/e2e/E2E.Templates.AutoInject/E2E.Templates.AutoInject.csproj -c Release
92+
9093
# VSIX Verification - Check that VSIX files contain expected content
9194
- name: Verify E2E.Minimal VSIX
9295
run: |
@@ -141,6 +144,16 @@ jobs:
141144
if ($files -notcontains "E2E.AllFeatures.dll") { throw "Missing E2E.AllFeatures.dll" }
142145
Write-Host "E2E.AllFeatures VSIX verified successfully"
143146
147+
- name: Verify E2E.Templates.AutoInject VSIX
148+
run: |
149+
$vsix = "tests/e2e/E2E.Templates.AutoInject/bin/Release/net472/E2E.Templates.AutoInject.vsix"
150+
if (!(Test-Path $vsix)) { throw "VSIX not found: $vsix" }
151+
Expand-Archive -Path $vsix -DestinationPath "tests/e2e/E2E.Templates.AutoInject/vsix-contents" -Force
152+
$files = Get-ChildItem -Path "tests/e2e/E2E.Templates.AutoInject/vsix-contents" -Recurse | Select-Object -ExpandProperty Name
153+
# Verify template was included (Content was auto-injected)
154+
if ($files -notcontains "TestProject.vstemplate") { throw "Missing ProjectTemplates/TestProject/TestProject.vstemplate" }
155+
Write-Host "E2E.Templates.AutoInject VSIX verified successfully"
156+
144157
- name: Test Template - Install
145158
run: dotnet new install artifacts/packages/CodingWithCalvin.VsixSdk.Templates.1.0.0.nupkg
146159

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net472</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<Nullable>enable</Nullable>
7+
<IsPackable>false</IsPackable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.Build.Framework" Version="17.3.2" ExcludeAssets="runtime" />
12+
<PackageReference Include="Microsoft.Build.Utilities.Core" Version="17.3.2" ExcludeAssets="runtime" />
13+
</ItemGroup>
14+
15+
</Project>
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
using System;
2+
using System.IO;
3+
using System.Xml;
4+
using Microsoft.Build.Framework;
5+
using Microsoft.Build.Utilities;
6+
7+
namespace CodingWithCalvin.VsixSdk.Tasks;
8+
9+
/// <summary>
10+
/// MSBuild task that injects Content entries into a VSIX manifest for discovered templates.
11+
/// Creates an intermediate manifest file without modifying the source.
12+
/// </summary>
13+
public class InjectVsixManifestContentTask : Task
14+
{
15+
private const string VsixNamespace = "http://schemas.microsoft.com/developer/vsx-schema/2011";
16+
17+
/// <summary>
18+
/// Path to the source VSIX manifest file.
19+
/// </summary>
20+
[Required]
21+
public string SourceManifestPath { get; set; } = string.Empty;
22+
23+
/// <summary>
24+
/// Path where the modified manifest will be written.
25+
/// </summary>
26+
[Required]
27+
public string OutputManifestPath { get; set; } = string.Empty;
28+
29+
/// <summary>
30+
/// Whether project templates were discovered.
31+
/// </summary>
32+
public bool HasProjectTemplates { get; set; }
33+
34+
/// <summary>
35+
/// Whether item templates were discovered.
36+
/// </summary>
37+
public bool HasItemTemplates { get; set; }
38+
39+
/// <summary>
40+
/// The folder path for project templates (default: "ProjectTemplates").
41+
/// </summary>
42+
public string ProjectTemplatesPath { get; set; } = "ProjectTemplates";
43+
44+
/// <summary>
45+
/// The folder path for item templates (default: "ItemTemplates").
46+
/// </summary>
47+
public string ItemTemplatesPath { get; set; } = "ItemTemplates";
48+
49+
public override bool Execute()
50+
{
51+
try
52+
{
53+
if (!File.Exists(SourceManifestPath))
54+
{
55+
Log.LogError("VSIXSDK020", null, null, null, 0, 0, 0, 0,
56+
"Source manifest not found: {0}", SourceManifestPath);
57+
return false;
58+
}
59+
60+
var doc = new XmlDocument();
61+
doc.PreserveWhitespace = true;
62+
doc.Load(SourceManifestPath);
63+
64+
var nsmgr = new XmlNamespaceManager(doc.NameTable);
65+
nsmgr.AddNamespace("vsix", VsixNamespace);
66+
67+
var modified = false;
68+
69+
var packageManifest = doc.SelectSingleNode("/vsix:PackageManifest", nsmgr);
70+
if (packageManifest == null)
71+
{
72+
Log.LogError("VSIXSDK021", null, null, SourceManifestPath, 0, 0, 0, 0,
73+
"Invalid manifest: PackageManifest element not found");
74+
return false;
75+
}
76+
77+
var contentElement = doc.SelectSingleNode("/vsix:PackageManifest/vsix:Content", nsmgr);
78+
if (contentElement == null && (HasProjectTemplates || HasItemTemplates))
79+
{
80+
contentElement = doc.CreateElement("Content", VsixNamespace);
81+
packageManifest.AppendChild(contentElement);
82+
modified = true;
83+
Log.LogMessage(MessageImportance.Normal, "Created Content element in manifest");
84+
}
85+
86+
if (contentElement != null)
87+
{
88+
if (HasProjectTemplates)
89+
{
90+
var existingProjectTemplate = contentElement.SelectSingleNode(
91+
"vsix:ProjectTemplate", nsmgr);
92+
if (existingProjectTemplate == null)
93+
{
94+
var projectTemplateElement = doc.CreateElement("ProjectTemplate", VsixNamespace);
95+
projectTemplateElement.SetAttribute("Path", ProjectTemplatesPath);
96+
contentElement.AppendChild(projectTemplateElement);
97+
modified = true;
98+
Log.LogMessage(MessageImportance.Normal,
99+
"Added ProjectTemplate entry with Path='{0}'", ProjectTemplatesPath);
100+
}
101+
else
102+
{
103+
Log.LogMessage(MessageImportance.Low,
104+
"ProjectTemplate entry already exists, skipping injection");
105+
}
106+
}
107+
108+
if (HasItemTemplates)
109+
{
110+
var existingItemTemplate = contentElement.SelectSingleNode(
111+
"vsix:ItemTemplate", nsmgr);
112+
if (existingItemTemplate == null)
113+
{
114+
var itemTemplateElement = doc.CreateElement("ItemTemplate", VsixNamespace);
115+
itemTemplateElement.SetAttribute("Path", ItemTemplatesPath);
116+
contentElement.AppendChild(itemTemplateElement);
117+
modified = true;
118+
Log.LogMessage(MessageImportance.Normal,
119+
"Added ItemTemplate entry with Path='{0}'", ItemTemplatesPath);
120+
}
121+
else
122+
{
123+
Log.LogMessage(MessageImportance.Low,
124+
"ItemTemplate entry already exists, skipping injection");
125+
}
126+
}
127+
}
128+
129+
var outputDir = Path.GetDirectoryName(OutputManifestPath);
130+
if (!string.IsNullOrEmpty(outputDir) && !Directory.Exists(outputDir))
131+
{
132+
Directory.CreateDirectory(outputDir);
133+
}
134+
135+
doc.Save(OutputManifestPath);
136+
137+
if (modified)
138+
{
139+
Log.LogMessage(MessageImportance.High,
140+
"Injected template Content entries into manifest: {0}", OutputManifestPath);
141+
}
142+
else
143+
{
144+
Log.LogMessage(MessageImportance.Normal,
145+
"No template Content injection needed, copied manifest to: {0}", OutputManifestPath);
146+
}
147+
148+
return true;
149+
}
150+
catch (Exception ex)
151+
{
152+
Log.LogErrorFromException(ex, showStackTrace: true);
153+
return false;
154+
}
155+
}
156+
}

src/CodingWithCalvin.VsixSdk/CodingWithCalvin.VsixSdk.csproj

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@
4343
</ProjectReference>
4444
</ItemGroup>
4545

46+
<!-- Reference the tasks project -->
47+
<ItemGroup>
48+
<ProjectReference Include="..\CodingWithCalvin.VsixSdk.Tasks\CodingWithCalvin.VsixSdk.Tasks.csproj">
49+
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
50+
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
51+
<PrivateAssets>all</PrivateAssets>
52+
</ProjectReference>
53+
</ItemGroup>
54+
4655
<!-- Include the SDK files in the package at the correct location -->
4756
<ItemGroup>
4857
<!-- The Sdk folder must be at the root of the package -->
@@ -54,6 +63,12 @@
5463
PackagePath="analyzers\dotnet\cs"
5564
Visible="false" />
5665

66+
<!-- Include the MSBuild tasks -->
67+
<None Include="..\CodingWithCalvin.VsixSdk.Tasks\bin\$(Configuration)\net472\CodingWithCalvin.VsixSdk.Tasks.dll"
68+
Pack="true"
69+
PackagePath="build"
70+
Visible="false" />
71+
5772
<!-- Include README at package root -->
5873
<None Include="..\..\README.md" Pack="true" PackagePath="" />
5974
</ItemGroup>

src/CodingWithCalvin.VsixSdk/Sdk/Sdk.Vsix.Templates.props

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
<!-- Enable/disable automatic template discovery -->
1919
<EnableDefaultVsixTemplateItems Condition="'$(EnableDefaultVsixTemplateItems)' == ''">true</EnableDefaultVsixTemplateItems>
2020

21+
<!-- Enable/disable automatic Content entry injection for discovered templates -->
22+
<AutoInjectVsixTemplateContent Condition="'$(AutoInjectVsixTemplateContent)' == ''">true</AutoInjectVsixTemplateContent>
23+
2124
<!-- Default folders for template discovery -->
2225
<VsixProjectTemplatesFolder Condition="'$(VsixProjectTemplatesFolder)' == ''">ProjectTemplates</VsixProjectTemplatesFolder>
2326
<VsixItemTemplatesFolder Condition="'$(VsixItemTemplatesFolder)' == ''">ItemTemplates</VsixItemTemplatesFolder>

src/CodingWithCalvin.VsixSdk/Sdk/Sdk.Vsix.Templates.targets

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,21 @@
55
Template support for VSIX projects. Handles:
66
- Auto-discovery of template folders for validation
77
- Copying templates from referenced projects (VsixTemplateReference)
8+
- Auto-injection of Content entries into the manifest
89
- Validation warnings for manifest configuration
910
1011
Note: VSSDK handles template packaging when the manifest contains
1112
<Content><ProjectTemplate/></Content> entries. This file provides
12-
discovery, cross-project template support, and validation.
13+
discovery, cross-project template support, auto-injection, and validation.
1314
-->
1415

16+
<!--
17+
Register the custom task for injecting Content entries.
18+
UsingTask must be at the top level, outside of any Target.
19+
-->
20+
<UsingTask TaskName="CodingWithCalvin.VsixSdk.Tasks.InjectVsixManifestContentTask"
21+
AssemblyFile="$(MSBuildThisFileDirectory)..\build\CodingWithCalvin.VsixSdk.Tasks.dll" />
22+
1523
<!--
1624
Auto-discover project and item templates for validation.
1725
VSSDK handles the actual packaging based on manifest Content entries.
@@ -130,6 +138,59 @@
130138
Condition="'@(VsixTemplateReference)' != ''" />
131139
</Target>
132140

141+
<!--
142+
Target: InjectVsixTemplateContent
143+
Injects Content entries for discovered templates into an intermediate manifest.
144+
Runs AFTER template discovery and BEFORE VSSDK processes the manifest.
145+
-->
146+
<Target Name="InjectVsixTemplateContent"
147+
BeforeTargets="GetVsixSourceItems"
148+
AfterTargets="DiscoverVsixTemplates;CopyVsixTemplateReferences"
149+
Condition="'$(AutoInjectVsixTemplateContent)' == 'true' and '$(_SourceVsixManifestPath)' != '' and Exists('$(_SourceVsixManifestPath)')">
150+
151+
<!-- Compute intermediate manifest path at execution time when $(IntermediateOutputPath) is available -->
152+
<PropertyGroup>
153+
<_IntermediateVsixManifestPath>$(IntermediateOutputPath)source.extension.vsixmanifest</_IntermediateVsixManifestPath>
154+
</PropertyGroup>
155+
156+
<!-- Determine if we have any templates -->
157+
<PropertyGroup>
158+
<_HasProjectTemplates Condition="'@(VsixProjectTemplate)' != ''">true</_HasProjectTemplates>
159+
<_HasProjectTemplates Condition="'@(VsixTemplateReference->WithMetadataValue('TemplateType', 'Project'))' != ''">true</_HasProjectTemplates>
160+
<_HasItemTemplates Condition="'@(VsixItemTemplate)' != ''">true</_HasItemTemplates>
161+
<_HasItemTemplates Condition="'@(VsixTemplateReference->WithMetadataValue('TemplateType', 'Item'))' != ''">true</_HasItemTemplates>
162+
</PropertyGroup>
163+
164+
<!-- Only run injection if templates are discovered -->
165+
<InjectVsixManifestContentTask
166+
SourceManifestPath="$(_SourceVsixManifestPath)"
167+
OutputManifestPath="$(_IntermediateVsixManifestPath)"
168+
HasProjectTemplates="$(_HasProjectTemplates)"
169+
HasItemTemplates="$(_HasItemTemplates)"
170+
ProjectTemplatesPath="$(VsixProjectTemplatesFolder)"
171+
ItemTemplatesPath="$(VsixItemTemplatesFolder)"
172+
Condition="'$(_HasProjectTemplates)' == 'true' or '$(_HasItemTemplates)' == 'true'" />
173+
174+
<!-- If no templates, just copy the manifest unchanged -->
175+
<Copy SourceFiles="$(_SourceVsixManifestPath)"
176+
DestinationFiles="$(_IntermediateVsixManifestPath)"
177+
SkipUnchangedFiles="true"
178+
Condition="'$(_HasProjectTemplates)' != 'true' and '$(_HasItemTemplates)' != 'true'" />
179+
180+
<!-- Point VSSDK to the intermediate manifest -->
181+
<PropertyGroup Condition="Exists('$(_IntermediateVsixManifestPath)')">
182+
<VsixSourceManifest>$(_IntermediateVsixManifestPath)</VsixSourceManifest>
183+
</PropertyGroup>
184+
185+
<!-- Update item group so VSSDK picks up intermediate manifest -->
186+
<ItemGroup Condition="Exists('$(_IntermediateVsixManifestPath)')">
187+
<None Remove="$(_SourceVsixManifestPath)" />
188+
<None Include="$(_IntermediateVsixManifestPath)">
189+
<SubType>Designer</SubType>
190+
</None>
191+
</ItemGroup>
192+
</Target>
193+
133194
<!--
134195
Target: ValidateVsixTemplateReferenceItems
135196
Warns if VsixTemplateReference items are missing required metadata.
@@ -151,10 +212,11 @@
151212
Target: WarnMissingManifestContent
152213
Warns if templates are discovered but the manifest doesn't have Content entries.
153214
VSSDK requires these entries to properly package and register templates.
215+
Skipped when AutoInjectVsixTemplateContent is enabled (default).
154216
-->
155217
<Target Name="WarnMissingManifestContent"
156218
AfterTargets="DiscoverVsixTemplates;CopyVsixTemplateReferences"
157-
Condition="('@(VsixProjectTemplate)' != '' or '@(VsixItemTemplate)' != '' or '@(VsixTemplateReference)' != '') and '$(_SourceVsixManifestPath)' != ''">
219+
Condition="'$(AutoInjectVsixTemplateContent)' != 'true' and ('@(VsixProjectTemplate)' != '' or '@(VsixItemTemplate)' != '' or '@(VsixTemplateReference)' != '') and '$(_SourceVsixManifestPath)' != ''">
158220

159221
<!-- Determine if we have any project or item templates -->
160222
<PropertyGroup>

tests/e2e/Directory.Build.targets

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44
These add VSIX build behavior without importing Microsoft.NET.Sdk again.
55
-->
66

7+
<!-- Register the custom task for local development -->
8+
<UsingTask TaskName="CodingWithCalvin.VsixSdk.Tasks.InjectVsixManifestContentTask"
9+
AssemblyFile="$(CodingWithCalvinVsixSdkRoot)..\CodingWithCalvin.VsixSdk.Tasks\bin\$(Configuration)\net472\CodingWithCalvin.VsixSdk.Tasks.dll"
10+
Condition="'$(CodingWithCalvinVsixSdkLocalDev)' == 'true'" />
11+
712
<!-- Import VSIX-specific item includes and targets -->
813
<Import Project="$(CodingWithCalvinVsixSdkRoot)Sdk\Sdk.Vsix.targets" />
914

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!--
2+
E2E.Templates.AutoInject - Tests automatic Content injection into manifest.
3+
4+
Validates:
5+
- Templates in ProjectTemplates/ auto-discovered
6+
- Content element auto-injected into manifest (not present in source)
7+
- Intermediate manifest created with Content entries
8+
- Templates included in VSIX at correct paths
9+
-->
10+
<Project Sdk="Microsoft.NET.Sdk">
11+
12+
<PropertyGroup>
13+
<Version>1.0.0</Version>
14+
</PropertyGroup>
15+
16+
<ItemGroup>
17+
<PackageReference Include="Microsoft.VisualStudio.SDK" Version="17.*" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using System;
2+
using System.Runtime.InteropServices;
3+
using System.Threading;
4+
using Microsoft.VisualStudio.Shell;
5+
using Task = System.Threading.Tasks.Task;
6+
7+
namespace E2E.Templates.AutoInject
8+
{
9+
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
10+
[Guid("00000000-0000-0000-0000-000000000010")]
11+
public sealed class E2ETemplatesAutoInjectPackage : AsyncPackage
12+
{
13+
protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
14+
{
15+
await base.InitializeAsync(cancellationToken, progress);
16+
await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
17+
}
18+
}
19+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
namespace $safeprojectname$;
2+
3+
class Program
4+
{
5+
static void Main(string[] args)
6+
{
7+
Console.WriteLine("Hello, World!");
8+
}
9+
}

0 commit comments

Comments
 (0)