diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs index b1913037a3..77628d1953 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.Collections.cs @@ -1,4 +1,3 @@ -using System; using Umbraco.Cms.Core.Actions; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Composing; @@ -10,11 +9,11 @@ using Umbraco.Cms.Core.HealthChecks.NotificationMethods; using Umbraco.Cms.Core.Manifest; using Umbraco.Cms.Core.Mapping; using Umbraco.Cms.Core.Media.EmbedProviders; -using Umbraco.Cms.Core.Packaging; using Umbraco.Cms.Core.PropertyEditors; using Umbraco.Cms.Core.PropertyEditors.Validators; using Umbraco.Cms.Core.Routing; using Umbraco.Cms.Core.Sections; +using Umbraco.Cms.Core.Snippets; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Tour; using Umbraco.Cms.Core.Trees; @@ -92,6 +91,8 @@ namespace Umbraco.Cms.Core.DependencyInjection .Add() .Add() .Add(builder.TypeLoader.GetTypes()); + builder.PartialViewSnippets(); + builder.PartialViewMacroSnippets(); builder.DataValueReferenceFactories(); builder.PropertyValueConverters()?.Append(builder.TypeLoader.GetTypes()); builder.UrlSegmentProviders()?.Append(); @@ -202,6 +203,20 @@ namespace Umbraco.Cms.Core.DependencyInjection public static DashboardCollectionBuilder? Dashboards(this IUmbracoBuilder builder) => builder.WithCollectionBuilder(); + /// + /// Gets the partial view snippets collection builder. + /// + /// The builder. + public static PartialViewSnippetCollectionBuilder? PartialViewSnippets(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); + + /// + /// Gets the partial view macro snippets collection builder. + /// + /// The builder. + public static PartialViewMacroSnippetCollectionBuilder? PartialViewMacroSnippets(this IUmbracoBuilder builder) + => builder.WithCollectionBuilder(); + /// /// Gets the cache refreshers collection builder. /// diff --git a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs index 4a05cb0268..327304fcc2 100644 --- a/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs +++ b/src/Umbraco.Core/DependencyInjection/UmbracoBuilder.cs @@ -38,6 +38,7 @@ using Umbraco.Cms.Core.Runtime; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Snippets; using Umbraco.Cms.Core.Sync; using Umbraco.Cms.Core.Telemetry; using Umbraco.Cms.Core.Templates; diff --git a/src/Umbraco.Core/Services/IFileService.cs b/src/Umbraco.Core/Services/IFileService.cs index 6cbc06208c..baccd5dedf 100644 --- a/src/Umbraco.Core/Services/IFileService.cs +++ b/src/Umbraco.Core/Services/IFileService.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; -using System.IO; using Umbraco.Cms.Core.Models; namespace Umbraco.Cms.Core.Services @@ -10,6 +7,7 @@ namespace Umbraco.Cms.Core.Services /// public interface IFileService : IService { + [Obsolete("Please use SnippetCollection.GetPartialViewSnippetNames() or SnippetCollection.GetPartialViewMacroSnippetNames() instead. Scheduled for removal in V12.")] IEnumerable GetPartialViewSnippetNames(params string[] filterNames); void CreatePartialViewFolder(string folderPath); void CreatePartialViewMacroFolder(string folderPath); @@ -295,6 +293,7 @@ namespace Umbraco.Cms.Core.Services /// /// The name of the snippet /// + [Obsolete("Please use SnippetCollection.GetPartialViewMacroSnippetContent instead. Scheduled for removal in V12.")] string GetPartialViewMacroSnippetContent(string snippetName); /// @@ -302,6 +301,7 @@ namespace Umbraco.Cms.Core.Services /// /// The name of the snippet /// The content of the partial view. + [Obsolete("Please use SnippetCollection.GetPartialViewSnippetContent instead. Scheduled for removal in V12.")] string GetPartialViewSnippetContent(string snippetName); } } diff --git a/src/Umbraco.Core/Snippets/ISnippet.cs b/src/Umbraco.Core/Snippets/ISnippet.cs new file mode 100644 index 0000000000..67c9bf9e7f --- /dev/null +++ b/src/Umbraco.Core/Snippets/ISnippet.cs @@ -0,0 +1,18 @@ +namespace Umbraco.Cms.Core.Snippets +{ + /// + /// Defines a partial view macro snippet. + /// + public interface ISnippet + { + /// + /// Gets the name of the snippet. + /// + string Name { get; } + + /// + /// Gets the content of the snippet. + /// + string Content { get; } + } +} diff --git a/src/Umbraco.Core/Snippets/PartialViewMacroSnippetCollection.cs b/src/Umbraco.Core/Snippets/PartialViewMacroSnippetCollection.cs new file mode 100644 index 0000000000..b2fb79553c --- /dev/null +++ b/src/Umbraco.Core/Snippets/PartialViewMacroSnippetCollection.cs @@ -0,0 +1,66 @@ +using System.Text.RegularExpressions; +using Umbraco.Cms.Core.Composing; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Snippets +{ + /// + /// The collection of partial view macro snippets. + /// + public class PartialViewMacroSnippetCollection : BuilderCollectionBase + { + public PartialViewMacroSnippetCollection(Func> items) : base(items) + { + } + + /// + /// Gets the partial view macro snippet names. + /// + /// The names of all partial view macro snippets. + public IEnumerable GetNames() + { + var snippetNames = this.Select(x => Path.GetFileNameWithoutExtension(x.Name)).ToArray(); + + // Ensure the ones that are called 'Empty' are at the top + var empty = snippetNames.Where(x => Path.GetFileName(x)?.InvariantStartsWith("Empty") ?? false) + .OrderBy(x => x?.Length).ToArray(); + + return empty.Union(snippetNames.Except(empty)).WhereNotNull(); + } + + /// + /// Gets the content of a partial view macro snippet as a string. + /// + /// The name of the snippet. + /// The content of the partial view macro. + public string GetContentFromName(string snippetName) + { + if (snippetName.IsNullOrWhiteSpace()) + { + throw new ArgumentNullException(nameof(snippetName)); + } + + string partialViewMacroHeader = "@inherits Umbraco.Cms.Web.Common.Macros.PartialViewMacroPage"; + + var snippet = this.Where(x => x.Name.Equals(snippetName + ".cshtml")).FirstOrDefault(); + + // Try and get the snippet path + if (snippet is null) + { + throw new InvalidOperationException("Could not load snippet with name " + snippetName); + } + + // Strip the @inherits if it's there + var snippetContent = StripPartialViewHeader(snippet.Content); + + var content = $"{partialViewMacroHeader}{Environment.NewLine}{snippetContent}"; + return content; + } + + private string StripPartialViewHeader(string contents) + { + var headerMatch = new Regex("^@inherits\\s+?.*$", RegexOptions.Multiline); + return headerMatch.Replace(contents, string.Empty); + } + } +} diff --git a/src/Umbraco.Core/Snippets/PartialViewMacroSnippetCollectionBuilder.cs b/src/Umbraco.Core/Snippets/PartialViewMacroSnippetCollectionBuilder.cs new file mode 100644 index 0000000000..cf737368ce --- /dev/null +++ b/src/Umbraco.Core/Snippets/PartialViewMacroSnippetCollectionBuilder.cs @@ -0,0 +1,56 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.Extensions; + +namespace Umbraco.Cms.Core.Snippets +{ + /// + /// The partial view macro snippet collection builder. + /// + public class PartialViewMacroSnippetCollectionBuilder : LazyCollectionBuilderBase + { + protected override PartialViewMacroSnippetCollectionBuilder This => this; + + protected override IEnumerable CreateItems(IServiceProvider factory) + { + var hostEnvironment = factory.GetRequiredService(); + + var embeddedSnippets = new List(base.CreateItems(factory)); + var snippetProvider = new EmbeddedFileProvider(typeof(IAssemblyProvider).Assembly, "Umbraco.Cms.Core.EmbeddedResources.Snippets"); + var embeddedFiles = snippetProvider.GetDirectoryContents(string.Empty) + .Where(x => !x.IsDirectory && x.Name.EndsWith(".cshtml")); + + foreach (var file in embeddedFiles) + { + using var stream = new StreamReader(file.CreateReadStream()); + embeddedSnippets.Add(new Snippet(file.Name, stream.ReadToEnd().Trim())); + } + + var customSnippetsDir = new DirectoryInfo(hostEnvironment.MapPathContentRoot($"{Constants.SystemDirectories.Umbraco}/PartialViewMacros/Templates")); + if (!customSnippetsDir.Exists) + { + return embeddedSnippets; + } + + var customSnippets = customSnippetsDir.GetFiles().Select(f => new Snippet(f.Name, File.ReadAllText(f.FullName))); + var allSnippets = Merge(embeddedSnippets, customSnippets); + + return allSnippets; + } + + private IEnumerable Merge(IEnumerable embeddedSnippets, IEnumerable customSnippets) + { + var allSnippets = embeddedSnippets.Concat(customSnippets); + + var duplicates = allSnippets.GroupBy(s => s.Name) + .Where(gr => gr.Count() > 1) // Finds the snippets with the same name + .Select(s => s.First()); // Takes the first element from a grouping, which is the embeded snippet with that same name, + // since the physical snippet files are placed after the embedded ones in the all snippets colleciton + + // Remove any embedded snippets if a physical file with the same name can be found + return allSnippets.Except(duplicates); + } + } +} diff --git a/src/Umbraco.Core/Snippets/PartialViewSnippetCollection.cs b/src/Umbraco.Core/Snippets/PartialViewSnippetCollection.cs new file mode 100644 index 0000000000..5a0cda96e9 --- /dev/null +++ b/src/Umbraco.Core/Snippets/PartialViewSnippetCollection.cs @@ -0,0 +1,70 @@ +using System.Text.RegularExpressions; +using Umbraco.Cms.Core.Composing; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Snippets +{ + /// + /// The collection of partial view snippets. + /// + public class PartialViewSnippetCollection : BuilderCollectionBase + { + public PartialViewSnippetCollection(Func> items) : base(items) + { + } + + /// + /// Gets the partial view snippet names. + /// + /// The names of all partial view snippets. + public IEnumerable GetNames() + { + var snippetNames = this.Select(x => Path.GetFileNameWithoutExtension(x.Name)).ToArray(); + + // Ensure the ones that are called 'Empty' are at the top + var empty = snippetNames.Where(x => Path.GetFileName(x)?.InvariantStartsWith("Empty") ?? false) + .OrderBy(x => x?.Length).ToArray(); + + return empty.Union(snippetNames.Except(empty)).WhereNotNull(); + } + + /// + /// Gets the content of a partial view snippet as a string. + /// + /// The name of the snippet. + /// The content of the partial view. + public string GetContentFromName(string snippetName) + { + if (snippetName.IsNullOrWhiteSpace()) + { + throw new ArgumentNullException(nameof(snippetName)); + } + + string partialViewHeader = "@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage"; + + var snippet = this.Where(x => x.Name.Equals(snippetName + ".cshtml")).FirstOrDefault(); + + // Try and get the snippet path + if (snippet is null) + { + throw new InvalidOperationException("Could not load snippet with name " + snippetName); + } + + var snippetContent = CleanUpContents(snippet.Content); + + var content = $"{partialViewHeader}{Environment.NewLine}{snippetContent}"; + return content; + } + + private string CleanUpContents(string content) + { + // Strip the @inherits if it's there + var headerMatch = new Regex("^@inherits\\s+?.*$", RegexOptions.Multiline); + var newContent = headerMatch.Replace(content, string.Empty); + + return newContent + .Replace("Model.Content.", "Model.") + .Replace("(Model.Content)", "(Model)"); + } + } +} diff --git a/src/Umbraco.Core/Snippets/PartialViewSnippetCollectionBuilder.cs b/src/Umbraco.Core/Snippets/PartialViewSnippetCollectionBuilder.cs new file mode 100644 index 0000000000..730e984d33 --- /dev/null +++ b/src/Umbraco.Core/Snippets/PartialViewSnippetCollectionBuilder.cs @@ -0,0 +1,42 @@ +using Microsoft.Extensions.FileProviders; +using Umbraco.Cms.Core.Composing; + +namespace Umbraco.Cms.Core.Snippets +{ + /// + /// The partial view snippet collection builder. + /// + public class PartialViewSnippetCollectionBuilder : LazyCollectionBuilderBase + { + protected override PartialViewSnippetCollectionBuilder This => this; + + protected override IEnumerable CreateItems(IServiceProvider factory) + { + var embeddedSnippets = new List(base.CreateItems(factory)); + + // Ignore these + var filterNames = new List + { + "Gallery", + "ListChildPagesFromChangeableSource", + "ListChildPagesOrderedByProperty", + "ListImagesFromMediaFolder" + }; + + var snippetProvider = new EmbeddedFileProvider(typeof(IAssemblyProvider).Assembly, "Umbraco.Cms.Core.EmbeddedResources.Snippets"); + var embeddedFiles = snippetProvider.GetDirectoryContents(string.Empty) + .Where(x => !x.IsDirectory && x.Name.EndsWith(".cshtml")); + + foreach (var file in embeddedFiles) + { + if (!filterNames.Contains(Path.GetFileNameWithoutExtension(file.Name))) + { + using var stream = new StreamReader(file.CreateReadStream()); + embeddedSnippets.Add(new Snippet(file.Name, stream.ReadToEnd().Trim())); + } + } + + return embeddedSnippets; + } + } +} diff --git a/src/Umbraco.Core/Snippets/Snippet.cs b/src/Umbraco.Core/Snippets/Snippet.cs new file mode 100644 index 0000000000..bcb03b6d11 --- /dev/null +++ b/src/Umbraco.Core/Snippets/Snippet.cs @@ -0,0 +1,14 @@ +namespace Umbraco.Cms.Core.Snippets +{ + public class Snippet : ISnippet + { + public string Name { get; } + public string Content { get; } + + public Snippet(string name, string content) + { + Name = name; + Content = content; + } + } +} diff --git a/src/Umbraco.Web.BackOffice/Controllers/CodeFileController.cs b/src/Umbraco.Web.BackOffice/Controllers/CodeFileController.cs index a302edc56b..fbcfe283ea 100644 --- a/src/Umbraco.Web.BackOffice/Controllers/CodeFileController.cs +++ b/src/Umbraco.Web.BackOffice/Controllers/CodeFileController.cs @@ -1,10 +1,7 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Configuration.Models; @@ -15,6 +12,7 @@ using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.ContentEditing; using Umbraco.Cms.Core.Security; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Snippets; using Umbraco.Cms.Core.Strings; using Umbraco.Cms.Core.Strings.Css; using Umbraco.Cms.Web.BackOffice.Filters; @@ -22,6 +20,7 @@ using Umbraco.Cms.Web.BackOffice.Trees; using Umbraco.Cms.Web.Common.ActionsResults; using Umbraco.Cms.Web.Common.Attributes; using Umbraco.Cms.Web.Common.Authorization; +using Umbraco.Cms.Web.Common.DependencyInjection; using Umbraco.Extensions; using Constants = Umbraco.Cms.Core.Constants; using Stylesheet = Umbraco.Cms.Core.Models.Stylesheet; @@ -45,7 +44,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers private readonly IUmbracoMapper _umbracoMapper; private readonly IShortStringHelper _shortStringHelper; private readonly GlobalSettings _globalSettings; + private readonly PartialViewSnippetCollection _partialViewSnippetCollection; + private readonly PartialViewMacroSnippetCollection _partialViewMacroSnippetCollection; + [ActivatorUtilitiesConstructor] public CodeFileController( IHostingEnvironment hostingEnvironment, FileSystems fileSystems, @@ -54,7 +56,9 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers ILocalizedTextService localizedTextService, IUmbracoMapper umbracoMapper, IShortStringHelper shortStringHelper, - IOptionsSnapshot globalSettings) + IOptionsSnapshot globalSettings, + PartialViewSnippetCollection partialViewSnippetCollection, + PartialViewMacroSnippetCollection partialViewMacroSnippetCollection) { _hostingEnvironment = hostingEnvironment; _fileSystems = fileSystems; @@ -64,6 +68,31 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers _umbracoMapper = umbracoMapper; _shortStringHelper = shortStringHelper; _globalSettings = globalSettings.Value; + _partialViewSnippetCollection = partialViewSnippetCollection; + _partialViewMacroSnippetCollection = partialViewMacroSnippetCollection; + } + + [Obsolete("Use ctor will all params. Scheduled for removal in V12.")] + public CodeFileController( + IHostingEnvironment hostingEnvironment, + FileSystems fileSystems, + IFileService fileService, + IBackOfficeSecurityAccessor backOfficeSecurityAccessor, + ILocalizedTextService localizedTextService, + IUmbracoMapper umbracoMapper, + IShortStringHelper shortStringHelper, + IOptionsSnapshot globalSettings) : this( + hostingEnvironment, + fileSystems, + fileService, + backOfficeSecurityAccessor, + localizedTextService, + umbracoMapper, + shortStringHelper, + globalSettings, + StaticServiceProvider.Instance.GetRequiredService(), + StaticServiceProvider.Instance.GetRequiredService()) + { } /// @@ -272,15 +301,10 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers switch (type) { case Constants.Trees.PartialViews: - snippets = _fileService.GetPartialViewSnippetNames( - //ignore these - (this is taken from the logic in "PartialView.ascx.cs") - "Gallery", - "ListChildPagesFromChangeableSource", - "ListChildPagesOrderedByProperty", - "ListImagesFromMediaFolder"); + snippets = _partialViewSnippetCollection.GetNames(); break; case Constants.Trees.PartialViewMacros: - snippets = _fileService.GetPartialViewSnippetNames(); + snippets = _partialViewMacroSnippetCollection.GetNames(); break; default: return NotFound(); @@ -312,7 +336,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers codeFileDisplay.VirtualPath = Constants.SystemDirectories.PartialViews; if (snippetName.IsNullOrWhiteSpace() == false) { - codeFileDisplay.Content = _fileService.GetPartialViewSnippetContent(snippetName!); + codeFileDisplay.Content = _partialViewSnippetCollection.GetContentFromName(snippetName!); } } @@ -324,7 +348,7 @@ namespace Umbraco.Cms.Web.BackOffice.Controllers codeFileDisplay.VirtualPath = Constants.SystemDirectories.MacroPartials; if (snippetName.IsNullOrWhiteSpace() == false) { - codeFileDisplay.Content = _fileService.GetPartialViewMacroSnippetContent(snippetName!); + codeFileDisplay.Content = _partialViewMacroSnippetCollection.GetContentFromName(snippetName!); } }