diff --git a/.github/actions/deploy-media-theme/action.yml b/.github/actions/deploy-media-theme/action.yml new file mode 100644 index 0000000..2831ab6 --- /dev/null +++ b/.github/actions/deploy-media-theme/action.yml @@ -0,0 +1,44 @@ +name: Deploy Media Theme +description: Deploys an Orchard Core Media Theme to an Orchard site via Remote Deployment. + +inputs: + theme-path: + required: false + default: "." + description: Path to the theme project. + base-theme-id: + required: false + description: ID of the base theme of the theme project, if any. + clear-media-folder: + required: false + default: "true" + description: When set to "true", will clear the Media folder of the Media Theme before deployment. + +runs: + using: "composite" + steps: + - name: Install Lombiq.Hosting.MediaTheme.Deployer + shell: pwsh + run: | + dotnet tool install --global Lombiq.Hosting.MediaTheme.Deployer --version 2.0.2-alpha.0.osoe-514 + + - name: Deploy Media Theme + shell: pwsh + run: | + # Putting --base-id as last, so if it's empty, then the other parameters will still be parsed correctly. + $switches = @( + '--path', '${{ inputs.theme-path }}' + '--clear', '${{ inputs.clear-media-folder }}' + '--deployment-path', '${{ inputs.theme-path }}/Deployment' + '--remote-deployment-url', '${{ env.URL }}' + '--remote-deployment-client-name', '${{ env.CLIENT_NAME }}' + '--remote-deployment-client-api-key', '${{ env.CLIENT_API_KEY }}' + '--base-id', '${{ inputs.base-theme-id }}' + ) + + media-theme-deploy @switches + + if ($LastExitCode -ne 0) + { + Write-Error "Deployment failed, see the errors above." + } diff --git a/.github/workflows/deploy-media-theme.yml b/.github/workflows/deploy-media-theme.yml new file mode 100644 index 0000000..2ded367 --- /dev/null +++ b/.github/workflows/deploy-media-theme.yml @@ -0,0 +1,54 @@ +name: Deploy Media Theme + +on: + workflow_call: + secrets: + CHECKOUT_TOKEN: + required: false + description: > + The GitHub token to authenticate checkout. Pass in a GitHub personal access token if authenticated submodules + are used. + URL: + required: true + description: The URL to use for Remote Deployment, as indicated on the Orchard Core admin. + CLIENT_NAME: + required: true + description: The "Client Name" part of the Remote Deployment client's credentials. + CLIENT_API_KEY: + required: true + description: The "Client API Key" part of the Remote Deployment client's credentials. + inputs: + theme-path: + required: false + type: string + default: "." + description: Path to the theme project. + base-theme-id: + required: false + type: string + description: ID of the base theme of the theme project, if any. + clear-media-folder: + required: false + type: string + default: "true" + description: When set to "true", will clear the Media folder of the Media Theme before deployment. + +jobs: + deploy-media-theme: + runs-on: ubuntu-22.04 + steps: + - name: Checkout + uses: Lombiq/GitHub-Actions/.github/actions/checkout@dev + with: + token: ${{ secrets.CHECKOUT_TOKEN }} + + - name: Deploy Media Theme + uses: Lombiq/Hosting-Media-Theme/.github/actions/deploy-media-theme@dev + env: + URL: ${{ secrets.URL }} + CLIENT_NAME: ${{ secrets.CLIENT_NAME }} + CLIENT_API_KEY: ${{ secrets.CLIENT_API_KEY }} + with: + theme-path: ${{ inputs.theme-path }} + base-theme-id: ${{ inputs.base-theme-id }} + clear-media-folder: ${{ inputs.clear-media-folder }} diff --git a/Lombiq.Hosting.MediaTheme.Bridge/Middlewares/BlockMediaThemeTemplateDirectAccessMiddleware.cs b/Lombiq.Hosting.MediaTheme.Bridge/Middlewares/BlockMediaThemeTemplateDirectAccessMiddleware.cs index f43a780..5a777d3 100644 --- a/Lombiq.Hosting.MediaTheme.Bridge/Middlewares/BlockMediaThemeTemplateDirectAccessMiddleware.cs +++ b/Lombiq.Hosting.MediaTheme.Bridge/Middlewares/BlockMediaThemeTemplateDirectAccessMiddleware.cs @@ -1,4 +1,4 @@ -using Lombiq.Hosting.MediaTheme.Bridge.Constants; +using Lombiq.Hosting.MediaTheme.Bridge.Constants; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Options; using OrchardCore.Media; @@ -29,6 +29,8 @@ public async Task InvokeAsync(HttpContext context) StringComparison.OrdinalIgnoreCase, out _); + // Since this middleware needs to run early (see comment in Startup), the user's authentication state won't yet + // be available. So, we can't let people with the ManageMediaTheme permission still see the templates. if (!isMediaThemeTemplateRequest) { await _next(context); diff --git a/Lombiq.Hosting.MediaTheme.Bridge/Middlewares/MediaThemeAssetUrlRedirectMiddleware.cs b/Lombiq.Hosting.MediaTheme.Bridge/Middlewares/MediaThemeAssetUrlRedirectMiddleware.cs index 662bb0c..bd6810e 100644 --- a/Lombiq.Hosting.MediaTheme.Bridge/Middlewares/MediaThemeAssetUrlRedirectMiddleware.cs +++ b/Lombiq.Hosting.MediaTheme.Bridge/Middlewares/MediaThemeAssetUrlRedirectMiddleware.cs @@ -12,8 +12,7 @@ public class MediaThemeAssetUrlRedirectMiddleware { private readonly RequestDelegate _next; - public MediaThemeAssetUrlRedirectMiddleware(RequestDelegate next) => - _next = next; + public MediaThemeAssetUrlRedirectMiddleware(RequestDelegate next) => _next = next; public async Task InvokeAsync( HttpContext context, diff --git a/Lombiq.Hosting.MediaTheme.Bridge/Services/FileVersionProviderDecorator.cs b/Lombiq.Hosting.MediaTheme.Bridge/Services/FileVersionProviderDecorator.cs new file mode 100644 index 0000000..1dec886 --- /dev/null +++ b/Lombiq.Hosting.MediaTheme.Bridge/Services/FileVersionProviderDecorator.cs @@ -0,0 +1,57 @@ +using Lombiq.Hosting.MediaTheme.Bridge.Constants; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Options; +using OrchardCore.FileStorage; +using OrchardCore.Media; +using OrchardCore.Mvc; +using System; + +namespace Lombiq.Hosting.MediaTheme.Bridge.Services; + +/// +/// Service to add a cache busting version key to the URL of static resources references from Media Theme, like it +/// happens for standard resources. +/// +/// +/// +/// The default , , works just fine, but we need +/// to translate /mediatheme URLs. +/// +/// +internal class FileVersionProviderDecorator : IFileVersionProvider +{ + private readonly IFileVersionProvider _decorated; + private readonly IMediaFileStore _mediaFileStore; + private readonly IOptions _mediaOption; + + public FileVersionProviderDecorator( + IFileVersionProvider decorated, + IMediaFileStore mediaFileStore, + IOptions mediaOptions) + { + _decorated = decorated; + _mediaFileStore = mediaFileStore; + _mediaOption = mediaOptions; + } + + public string AddFileVersionToPath(PathString requestPathBase, string path) + { + var isMediaThemePath = path.StartsWithOrdinalIgnoreCase(Routes.MediaThemeAssets) || + path.ContainsOrdinalIgnoreCase(Routes.MediaThemeAssets + "/"); + + if (isMediaThemePath) + { + var assetsSubPath = _mediaFileStore.Combine( + _mediaOption.Value.AssetsRequestPath, Paths.MediaThemeRootFolder, Paths.MediaThemeAssetsFolder); + path = path.Replace(Routes.MediaThemeAssets, assetsSubPath); + } + + // Note that this will work all the time for local files. When a remote storage implementation is used to store + // Media files though (like Azure Blob Storage) then Media Cache will mirror the files locally. Since this only + // happens on the first request to the file, until then in the HTML output you'll see a URL without the cache + // busting parameter. + // This isn't an issue for real-life scenarios, just be mindful during development. + return _decorated.AddFileVersionToPath(requestPathBase, path); + } +} diff --git a/Lombiq.Hosting.MediaTheme.Bridge/Startup.cs b/Lombiq.Hosting.MediaTheme.Bridge/Startup.cs index 1a48291..fbc9f21 100644 --- a/Lombiq.Hosting.MediaTheme.Bridge/Startup.cs +++ b/Lombiq.Hosting.MediaTheme.Bridge/Startup.cs @@ -1,10 +1,12 @@ -using Lombiq.Hosting.MediaTheme.Bridge.Deployment; +using Lombiq.HelpfulLibraries.OrchardCore.DependencyInjection; +using Lombiq.Hosting.MediaTheme.Bridge.Deployment; using Lombiq.Hosting.MediaTheme.Bridge.Middlewares; using Lombiq.Hosting.MediaTheme.Bridge.Navigation; using Lombiq.Hosting.MediaTheme.Bridge.Permissions; using Lombiq.Hosting.MediaTheme.Bridge.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using OrchardCore.Deployment; @@ -38,6 +40,8 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped, MediaThemeDeploymentStepDriver>(); services.AddScoped(); services.AddScoped(); + services.AddOrchardServices(); + services.Decorate(); } public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) diff --git a/Lombiq.Hosting.MediaTheme.Deployer/Constants/PathConstants.cs b/Lombiq.Hosting.MediaTheme.Deployer/Constants/PathConstants.cs index 62249e7..aaa5d8a 100644 --- a/Lombiq.Hosting.MediaTheme.Deployer/Constants/PathConstants.cs +++ b/Lombiq.Hosting.MediaTheme.Deployer/Constants/PathConstants.cs @@ -1,4 +1,5 @@ namespace Lombiq.Hosting.MediaTheme.Deployer.Constants; + public static class PathConstants { public const string MediaThemeRootDirectory = "_MediaTheme"; @@ -8,12 +9,12 @@ public static class PathConstants public const string MediaThemeTemplatesDirectory = "Templates"; public const string MediaThemeAssetsDirectory = "Assets"; - public const string MediaThemeAssetsWebPath = MediaThemeRootDirectory + "/" + MediaThemeAssetsDirectory; - public const string MediaThemeAssetsCopyDirectoryPath = "\\" + MediaThemeRootDirectory + "\\" + MediaThemeAssetsDirectory; + public const string RecipeFile = "Recipe.json"; + public const string LiquidFileExtension = ".liquid"; - public const string MediaThemeTemplatesWebPath = MediaThemeRootDirectory + "/" + MediaThemeTemplatesDirectory; - public const string MediaThemeTemplatesCopyDirectoryPath = "\\" + MediaThemeRootDirectory + "\\" + MediaThemeTemplatesDirectory; + public static readonly string MediaThemeAssetsCopyDirectoryPath = + Path.Combine(MediaThemeRootDirectory, MediaThemeAssetsDirectory); - public const string RecipeFile = "\\Recipe.json"; - public const string LiquidFileExtension = ".liquid"; + public static readonly string MediaThemeTemplatesCopyDirectoryPath = + Path.Combine(MediaThemeRootDirectory, MediaThemeTemplatesDirectory); } diff --git a/Lombiq.Hosting.MediaTheme.Deployer/GlobalSuppressions.cs b/Lombiq.Hosting.MediaTheme.Deployer/GlobalSuppressions.cs new file mode 100644 index 0000000..c5331d8 --- /dev/null +++ b/Lombiq.Hosting.MediaTheme.Deployer/GlobalSuppressions.cs @@ -0,0 +1,7 @@ +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage( + "Globalization", + "CA1303:Do not pass literals as localized parameters", + Justification = "It's a developer console application, it doesn't need localization.", + Scope = "module")] diff --git a/Lombiq.Hosting.MediaTheme.Deployer/Lombiq.Hosting.MediaTheme.Deployer.csproj b/Lombiq.Hosting.MediaTheme.Deployer/Lombiq.Hosting.MediaTheme.Deployer.csproj index e6fc48e..6d633e2 100644 --- a/Lombiq.Hosting.MediaTheme.Deployer/Lombiq.Hosting.MediaTheme.Deployer.csproj +++ b/Lombiq.Hosting.MediaTheme.Deployer/Lombiq.Hosting.MediaTheme.Deployer.csproj @@ -23,11 +23,13 @@ - + + + diff --git a/Lombiq.Hosting.MediaTheme.Deployer/Program.cs b/Lombiq.Hosting.MediaTheme.Deployer/Program.cs index 157072a..e8961d9 100644 --- a/Lombiq.Hosting.MediaTheme.Deployer/Program.cs +++ b/Lombiq.Hosting.MediaTheme.Deployer/Program.cs @@ -1,8 +1,10 @@ using CommandLine; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO.Compression; +using System.Text.RegularExpressions; using static Lombiq.Hosting.MediaTheme.Deployer.Constants.PathConstants; using static System.Console; @@ -10,29 +12,60 @@ namespace Lombiq.Hosting.MediaTheme.Deployer; public class CommandLineOptions { - [Option('p', "path", Required = true, HelpText = "Path of your theme.")] - public string? PathOfTheTheme { get; set; } - - [Option('i', "base-id", Required = true, HelpText = "Default theme ID.")] + [Option('p', "path", Required = true, HelpText = "Path of your theme project.")] + public string? ThemePath { get; set; } + + // This parameter can still be useful if the base theme can't be parsed out of the Manifest easily, like if it uses + // constants for the ID instead of string literals. + [Option( + 'i', + "base-id", + Required = false, + HelpText = "ID of the base theme, if any. If left empty, will attempt to get the value from the theme's Manifest.")] public string? BaseThemeId { get; set; } - [Option('c', "clear", Required = true, HelpText = "Whether or not to clear media hosting folder.")] - public bool ClearMediaHostingFolder { get; set; } - - [Option('d', "deployment-path", Required = false, HelpText = "The path where you want the deployment package copied.")] + [Option( + 'c', + "clear", + Required = false, + HelpText = "Whether or not to clear the Media Theme media folder of all files before deployment.")] + public bool ClearMediaHostingFolder { get; set; } = true; + + [Option( + 'd', + "deployment-path", + Required = false, + HelpText = "The path where you want the deployment package to be written to.")] public string? DeploymentPackagePath { get; set; } + + [Option( + 'u', + "remote-deployment-url", + Required = false, + HelpText = "The URL to use for Remote Deployment, as indicated on the Orchard Core admin.")] + public string? RemoteDeploymentUrl { get; set; } + + [Option( + 'n', + "remote-deployment-client-name", + Required = false, + HelpText = "The \"Client Name\" part of the Remote Deployment client's credentials.")] + public string? RemoteDeploymentClientName { get; set; } + + [Option( + 'k', + "remote-deployment-client-api-key", + Required = false, + HelpText = "The \"Client API Key\" part of the Remote Deployment client's credentials.")] + public string? RemoteDeploymentClientApiKey { get; set; } } -[System.Diagnostics.CodeAnalysis.SuppressMessage( - "Globalization", - "CA1303:Do not pass literals as localized parameters", - Justification = "It's a console application, it doesn't need localization.")] internal static class Program { - public static void Main(string[] args) => + public static Task Main(string[] args) => Parser.Default.ParseArguments(args) - .WithParsed(options => RunOptions(options)) - .WithNotParsed(HandleParseError); + .WithNotParsed(HandleParseError) + .WithParsedAsync(options => RunOptionsAsync(options)); private static void HandleParseError(IEnumerable errors) { @@ -49,102 +82,161 @@ private static void HandleParseError(IEnumerable errors) } } - private static void RunOptions(CommandLineOptions values) + private static async Task RunOptionsAsync(CommandLineOptions options) + { + try + { + await RunOptionsInnerAsync(options); + } + catch (Exception ex) + { + WriteLine("Deployment failed with the following exception: {0}", ex); + Environment.ExitCode = 1; + } + } + + [SuppressMessage( + "Major Code Smell", + "S4457:Parameter validation in \"sync\"/\"await\" methods should be wrapped", + Justification = "RunOptionsAsync() needs to use await as well to be able to set the exit code on exception.")] + private static async Task RunOptionsInnerAsync(CommandLineOptions options) { // Creating directory for the deployment. - var newDirectoryPath = CreateNewDirectoryPath(values); + var newDirectoryPath = CreateNewDirectoryPath(options); try { - // Determine whether the directory exists. - if (Directory.Exists(newDirectoryPath)) - { - WriteLine("That directory already exists."); - return; - } + if (Directory.Exists(newDirectoryPath)) Directory.Delete(newDirectoryPath, recursive: true); - // Try to create the directory. Directory.CreateDirectory(newDirectoryPath); - WriteLine("The directory was created successfully. {0}", newDirectoryPath); + WriteLine("The \"{0}\" directory was created successfully.", newDirectoryPath); + } + catch (Exception) + { + WriteLine("Creating the directory {0} failed.", newDirectoryPath); + throw; } - catch (Exception exception) + + var themePath = options.ThemePath; + + if (string.IsNullOrEmpty(themePath)) { - WriteLine("The directory creation failed: {0}", exception.ToString()); - return; + throw new ArgumentException("The theme's path must be provided."); } - var pathToTheme = values.PathOfTheTheme; + var recipeSteps = new JArray(); + + // Creating Feature step to enable the Media Theme theme and Bridge module. + var featureStep = JObject.FromObject(new + { + name = "Feature", + enable = new[] { "Lombiq.Hosting.MediaTheme.Bridge", "Lombiq.Hosting.MediaTheme" }, + }); + recipeSteps.Add(featureStep); + + // Creating Themes step to set Media Theme as the site theme. + var themesStep = JObject.FromObject(new + { + name = "Themes", + Site = "Lombiq.Hosting.MediaTheme", + }); + recipeSteps.Add(themesStep); + + var baseThemeId = string.IsNullOrEmpty(options.BaseThemeId) ? null : options.BaseThemeId; + + if (baseThemeId == null) + { + var manifestPath = Path.Combine(themePath, "Manifest.cs"); + var manifestContent = await File.ReadAllTextAsync(manifestPath); + var basteThemeMatch = Regex.Match( + manifestContent, @"BaseTheme\s*=\s*""(?.*)""", RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)); + + if (basteThemeMatch.Success) + { + baseThemeId = basteThemeMatch.Groups["baseThemeId"].Value; + } + } // Creating media theme step. - dynamic mediaThemeStep = new JObject(); - mediaThemeStep.name = "mediatheme"; - mediaThemeStep.BaseThemeId = values.BaseThemeId; - mediaThemeStep.ClearMediaThemeFolder = values.ClearMediaHostingFolder; + var mediaThemeStep = JObject.FromObject(new + { + name = "mediatheme", + ClearMediaThemeFolder = options.ClearMediaHostingFolder, + BaseThemeId = baseThemeId, + }); + recipeSteps.Add(mediaThemeStep); // Creating media step. var files = new JArray(); - // Getting assets. - var pathToAssets = Path.Join(pathToTheme, LocalThemeWwwRootDirectory); + void AddFile(string rootPath, string filePath) + { + // These need to use forward slashes on every platform due to Orchard's import logic. + var importPath = Path.Combine(rootPath, filePath).Replace("\\", "/"); + var templateJObject = JObject.FromObject(new + { + SourcePath = importPath, + TargetPath = importPath, + }); + + files.Add(templateJObject); + } - var allAssetsPaths = Directory.EnumerateFiles(pathToAssets, "*", SearchOption.AllDirectories); + // Getting assets. + var assetsPath = Path.Combine(themePath, LocalThemeWwwRootDirectory); + var allAssetsPaths = Directory.EnumerateFiles(assetsPath, "*", SearchOption.AllDirectories); foreach (var assetPath in allAssetsPaths) { - dynamic assetJObject = new JObject(); - assetJObject.SourcePath = Path.Join( - MediaThemeAssetsWebPath, - assetPath[pathToAssets.Length..].Replace("\\", "/")); - assetJObject.TargetPath = assetJObject.SourcePath; - - files.Add(assetJObject); + AddFile(MediaThemeAssetsCopyDirectoryPath, assetPath[(assetsPath.Length + 1)..]); } // Copying assets to deployment directory. CopyDirectory( - pathToAssets, + assetsPath, Path.Join(newDirectoryPath, MediaThemeAssetsCopyDirectoryPath), areLiquidFiles: false); // Getting templates. - var pathToTemplates = Path.Join(pathToTheme, LocalThemeViewsDirectory); - + var templatesPath = Path.Combine(themePath, LocalThemeViewsDirectory); var allTemplatesPaths = Directory - .EnumerateFiles(pathToTemplates, "*" + LiquidFileExtension, SearchOption.TopDirectoryOnly); + .EnumerateFiles(templatesPath, "*" + LiquidFileExtension, SearchOption.TopDirectoryOnly); foreach (var templatePath in allTemplatesPaths) { - dynamic templateJObject = new JObject(); - templateJObject.SourcePath = Path.Join( - MediaThemeTemplatesWebPath, - templatePath[pathToTemplates.Length..].Replace("\\", "/")); - templateJObject.TargetPath = templateJObject.SourcePath; - - files.Add(templateJObject); + AddFile(MediaThemeTemplatesCopyDirectoryPath, templatePath[(templatesPath.Length + 1)..]); } // Copying templates to deployment directory. CopyDirectory( - pathToTemplates, + templatesPath, Path.Join(newDirectoryPath, MediaThemeTemplatesCopyDirectoryPath), areLiquidFiles: true, recursive: false); - dynamic mediaStep = new JObject(); - mediaStep.name = "media"; - mediaStep.Files = files; + var mediaStep = JObject.FromObject(new + { + name = "media", + Files = files, + }); + recipeSteps.Add(mediaStep); - CreateRecipeAndWriteIt(mediaThemeStep, mediaStep, newDirectoryPath); + CreateRecipeAndWriteIt(recipeSteps, newDirectoryPath); // Zipping the directory. - var zippedDirectoryPath = newDirectoryPath + ".zip"; - ZipFile.CreateFromDirectory(newDirectoryPath, zippedDirectoryPath); + var zipFilePath = newDirectoryPath + ".zip"; + ZipFile.CreateFromDirectory(newDirectoryPath, zipFilePath); // Getting rid of the original directory. Directory.Delete(newDirectoryPath, recursive: true); - WriteLine("{0} was created successfully. ", zippedDirectoryPath); + WriteLine("{0} was created successfully. ", zipFilePath); + + if (!string.IsNullOrEmpty(options.RemoteDeploymentUrl)) + { + await RemoteDeploymentHelper.DeployAsync(options, zipFilePath); + } } private static void CopyDirectory( @@ -158,7 +250,9 @@ private static void CopyDirectory( // Check if the source directory exists. if (!directory.Exists) + { throw new DirectoryNotFoundException($"Source directory not found: {directory.FullName}"); + } // Cache directories before we start copying. var directories = directory.GetDirectories(); @@ -214,23 +308,25 @@ private static string CreateNewDirectoryPath(CommandLineOptions values) + DateTime.Now.ToString("ddMMMyyyyHHmmss", CultureInfo.CurrentCulture); // #spell-check-ignore-line } - private static void CreateRecipeAndWriteIt(JObject mediaThemeStep, JObject mediaStep, string newDirectoryPath) + private static void CreateRecipeAndWriteIt(JArray steps, string newDirectoryPath) { // Creating the recipe itself. - dynamic recipe = new JObject(); - recipe.name = "MediaTheme"; - recipe.displayName = "Media Theme"; - recipe.description = "A recipe created with the media-theme-deployment tool."; - recipe.author = string.Empty; - recipe.website = string.Empty; - recipe.version = string.Empty; - recipe.issetuprecipe = false; - recipe.categories = new JArray(); - recipe.tags = new JArray(); - recipe.steps = new JArray(mediaThemeStep, mediaStep); + var recipe = JObject.FromObject(new + { + name = "MediaTheme", + displayName = "Media Theme", + description = "A recipe created with the media-theme-deployment tool.", + author = string.Empty, + website = string.Empty, + version = string.Empty, + issetuprecipe = false, + categories = new JArray(), + tags = new JArray(), + steps, + }); // Creating JSON file. - using var file = File.CreateText(Path.Join(newDirectoryPath + RecipeFile)); + using var file = File.CreateText(Path.Join(newDirectoryPath, RecipeFile)); using var writer = new JsonTextWriter(file) { Formatting = Formatting.Indented }; recipe.WriteTo(writer); diff --git a/Lombiq.Hosting.MediaTheme.Deployer/RemoteDeploymentHelper.cs b/Lombiq.Hosting.MediaTheme.Deployer/RemoteDeploymentHelper.cs new file mode 100644 index 0000000..f335faa --- /dev/null +++ b/Lombiq.Hosting.MediaTheme.Deployer/RemoteDeploymentHelper.cs @@ -0,0 +1,60 @@ +namespace Lombiq.Hosting.MediaTheme.Deployer; + +internal static class RemoteDeploymentHelper +{ + private static readonly HttpClient _httpClient = new(); + + public static async Task DeployAsync(CommandLineOptions options, string deploymentPackagePath) + { + if (string.IsNullOrEmpty(options.RemoteDeploymentClientName) || string.IsNullOrEmpty(options.RemoteDeploymentClientApiKey)) + { + throw new InvalidOperationException( + "When doing a Remote Deployment, both the Client Name and Client API Key should be provided."); + } + + // The below code is largely taken from Orchard's ExportRemoteInstanceController. + HttpResponseMessage response; + + try + { + // It's disposed via requestContent. +#pragma warning disable CA2000 // Dispose objects before losing scope + using var requestContent = new MultipartFormDataContent + { + { + new StreamContent( + new FileStream( + deploymentPackagePath, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + 1, + FileOptions.Asynchronous | FileOptions.SequentialScan) + ), + "Content", + Path.GetFileName(deploymentPackagePath) + }, + { new StringContent(options.RemoteDeploymentClientName), "ClientName" }, + { new StringContent(options.RemoteDeploymentClientApiKey), "ApiKey" }, + }; +#pragma warning restore CA2000 // Dispose objects before losing scope + + response = await _httpClient.PostAsync(options.RemoteDeploymentUrl, requestContent); + + if (response.StatusCode == System.Net.HttpStatusCode.OK) + { + Console.WriteLine("Remote deployment to {0} succeeded.", options.RemoteDeploymentUrl); + } + else + { + throw new HttpRequestException( + $"Remote deployment to {options.RemoteDeploymentUrl} failed with the HTTP code " + + $"{response.StatusCode} and message \"{response.RequestMessage}\"."); + } + } + finally + { + File.Delete(deploymentPackagePath); + } + } +} diff --git a/Readme.md b/Readme.md index e7dab42..c5e2250 100644 --- a/Readme.md +++ b/Readme.md @@ -43,33 +43,25 @@ You can proceed with developing your theme as you'd typically do: put the templa If you want to reference assets in your templates, you can use the `/mediatheme/` prefix in URLs, like below: -```html - -``` - -Be sure to use Orchard's resource manager for scripts and stylesheets: - ```liquid +{% assign 32pxIconUrl = "~/mediatheme/images/favicon-32x32.png" | href %} +{% link type:"image/png", rel:"icon", sizes:"32x32", href:32pxIconUrl, append_version:"true" %} + {% assign stylesPath = "~/mediatheme/styles/site.css" | href %} {% style src:stylesPath %} ``` -Media Theme will translate this path to either your local theme asset path or Media Library if the file exists. This way, you don't need to update your asset URLs in your templates one-by-one when deploying them. The `~` notation of virtual paths also comes in handy if you want to work with multiple tenants using URL prefixes locally, i.e. develop multiple Media Themes for multiple sites from the same solution. +These use Orchard's resource manager and thus will also include a browser/proxy cache busting `v` parameter that updates when you deploy a new version of your theme. This will ensure that everybody sees the current version of your site's styling. Note that while the Liquid `style` and `script` tags do this by default, for `link` you have to add `append_version` like above too. + +Media Theme will translate this special _~/mediatheme_ path to either your local theme asset path or Media Library if the file exists. This way, you don't need to update your asset URLs in your templates one-by-one when deploying them. The `~` notation of virtual paths also comes in handy if you want to work with multiple tenants using URL prefixes locally, i.e. develop multiple Media Themes for multiple sites from the same solution. If you are developing a theme for your [DotNest](https://dotnest.com) site you can use the [DotNest Core SDK](https://github.com/Lombiq/DotNest-Core-SDK) that has everything prepared for you right away. ### Deployment (import/export) -#### Manual deployment +#### Importing a deployment package created by the Deployer tool -If you want to export your Media Theme, go to the Admin UI → Configuration → Import/Export → Deployment Plans page and create a Deployment Plan with the following steps: - -- Add the "Media Theme" step. Here you can tick the "Clear Media Theme folder" checkbox; if ticked, it will delete all the files in the __MediaTheme_ folder in the Media Library during import. This can be helpful if you have a "Media" step along with this step bringing in all the Media Theme files, but be conscious of the order within the recipe: put the "Media Theme" step first. Leave it disabled if you only want to control the base theme. -- Optionally, add a "Media" step where you select the whole __MediaTheme_ folder. - -#### Deployment with the Deployer tool - -Instead of manual deployment you can [install](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools-how-to-use) the `Lombiq.Hosting.MediaTheme.Deployer` dotnet tool: +Instead of manually uploading files to the Media Library, [install](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools-how-to-use) the `Lombiq.Hosting.MediaTheme.Deployer` dotnet tool: ```pwsh dotnet tool install --global Lombiq.Hosting.MediaTheme.Deployer @@ -87,11 +79,56 @@ A specific example when run in the folder of your theme project: media-theme-deploy --path . --base-id TheTheme --clear true --deployment-path .\Deployment ``` -`--deployment-path` is not required. Without it, the package will be exported to your directory root, for example _C:\MediaThemeDeployment_04Aug2022230500.zip_. The parameters also have shorthand versions, `-p`, `-i`, `-c`, `-d`, respectively. +- `--base-id` is optional. If not provided, the tool will try to get it from the Manifest file, and if it's not defined there either, no base theme will be used. +- `--deployment-path` is optional. Without it, the package will be exported to your directory root, for example _C:\MediaThemeDeployment_04Aug2022230500.zip_. The parameters also have shorthand versions, `-p`, `-i`, `-c`, `-d`, respectively. + +You can then take the resulting ZIP file and import it on your site from the Admin UI → Configuration → Import/Export → Package Import. Everything necessary will be configured by the package. If you don't see this menu item then first enable the "Deployment" feature under Configuration → Features. + +#### Remote deployment with the Deployer tool -You can then take the resulting ZIP file and import it on your site from the Admin UI → Configuration → Import/Export → Package Import. +You can use [Remote Deployment](https://docs.orchardcore.net/en/latest/docs/reference/modules/Deployment.Remote/) to accept packages created with the above-explained Deployer too via the internet, without manually uploading the ZIP file. You can use this to deploy your theme remotely from your local development environment or CI workflow too, for which we provide a ready-to-use [GitHub Actions workflow](https://github.com/features/actions). -You can use Remote Deployment to accept such exported packages to deploy your theme remotely from your local development environment or CI too. +Do the following to set up automated GitHub Actions deployments: + +1. Create a Remote Client on the Orchard admin UI → Configuration → Import/Export → Remote Clients. Use a suitable name and a strong, unique API key. If you don't see this menu item then first enable the "Remote Deployment" feature under Configuration → Features. +2. Configure the Client API Key as a [repository secret](https://docs.github.com/en/actions/security-guides/encrypted-secrets#creating-encrypted-secrets-for-a-repository). While not strictly necessary, we recommend also storing the Client Name and Remote Deployment URL as secrets too. +3. Add a workflow to the _.github/workflows_ folder of your repository that executes the `deploy-media-theme` reusable workflow with some suitable configuration: + +```yaml +name: Deploy Media Theme to DotNest + +on: + push: + branches: + - my-dev + paths: + - 'src/Themes/My.Theme/**' + +jobs: + deploy-media-theme: + uses: Lombiq/Hosting-Media-Theme/.github/workflows/deploy-media-theme.yml@dev + secrets: + URL: ${{ secrets.MY_THEME_DEPLOYMENT_URL }} + CLIENT_NAME: ${{ secrets.MY_THEME_DEPLOYMENT_CLIENT_NAME }} + CLIENT_API_KEY: ${{ secrets.MY_THEME_DEPLOYMENT_CLIENT_API_KEY }} + with: + theme-path: "src/Themes/My.Theme" + # You can leave out base-theme-id to get it from the Manifest, or to not use a base theme at all. + #base-theme-id: "TheBlogTheme" +``` + +If you want to use a different CI system or would like to run remote deployment from the command line otherwise, use the `--remote-deployment-url`, `--remote-deployment-client-name`, and `--remote-deployment-client-api-key` parameters. See this PowerShell script for an example: + +```pwsh +$switches = @( + '--path', '.' + '--remote-deployment-url', 'https://localhost:44335/OrchardCore.Deployment.Remote/ImportRemoteInstance/Import' + '--remote-deployment-client-name', 'demo' + '--remote-deployment-client-api-key', 'Password1!' +) + +media-theme-deploy @switches +``` ## Contributing and support