diff --git a/Lombiq.Hosting.MediaTheme.Deployer/Constants/PathConstants.cs b/Lombiq.Hosting.MediaTheme.Deployer/Constants/PathConstants.cs
new file mode 100644
index 0000000..2f53012
--- /dev/null
+++ b/Lombiq.Hosting.MediaTheme.Deployer/Constants/PathConstants.cs
@@ -0,0 +1,19 @@
+namespace Lombiq.Hosting.MediaTheme.Deployer.Constants;
+public static class PathConstants
+{
+ public const string MediaThemeRootDirectory = "_MediaTheme";
+ public const string MediaThemeDeploymentDirectory = "MediaThemeDeployment_";
+ public const string LocalThemeWwwRootDirectory = "wwwroot";
+ public const string LocalThemeViewsDirectory = "Views";
+ public const string MediaThemeTemplatesDirectory = "Templates";
+ public const string MediaThemeAssetsDriectory = "Assets";
+
+ public const string MediaThemeAssetsWebPath = MediaThemeRootDirectory + "/" + MediaThemeAssetsDriectory;
+ public const string MediaThemeAssetsCopyDirectoryPath = "\\" + MediaThemeRootDirectory + "\\" + MediaThemeAssetsDriectory;
+
+ public const string MediaThemeTemplatesWebPath = MediaThemeRootDirectory + "/" + MediaThemeTemplatesDirectory;
+ public const string MediaThemeTemplatesCopyDirectoryPath = "\\" + MediaThemeRootDirectory + "\\" + MediaThemeTemplatesDirectory;
+
+ public const string RecipeFile = "\\Recipe.json";
+ public const string LiquidFileExtension = ".liquid";
+}
diff --git a/Lombiq.Hosting.MediaTheme.Deployer/Lombiq.Hosting.MediaTheme.Deployer.csproj b/Lombiq.Hosting.MediaTheme.Deployer/Lombiq.Hosting.MediaTheme.Deployer.csproj
new file mode 100644
index 0000000..930feb6
--- /dev/null
+++ b/Lombiq.Hosting.MediaTheme.Deployer/Lombiq.Hosting.MediaTheme.Deployer.csproj
@@ -0,0 +1,18 @@
+
+
+
+ Exe
+ net6.0
+ enable
+ enable
+ true
+ media-theme-deploy
+ ./nupkg
+
+
+
+
+
+
+
+
diff --git a/Lombiq.Hosting.MediaTheme.Deployer/Program.cs b/Lombiq.Hosting.MediaTheme.Deployer/Program.cs
new file mode 100644
index 0000000..77e058b
--- /dev/null
+++ b/Lombiq.Hosting.MediaTheme.Deployer/Program.cs
@@ -0,0 +1,231 @@
+using CommandLine;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using System.Globalization;
+using System.IO.Compression;
+using static Lombiq.Hosting.MediaTheme.Deployer.Constants.PathConstants;
+using static System.Console;
+
+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.")]
+ 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.")]
+ public string? DeploymentPackagePath { 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) =>
+ Parser.Default.ParseArguments(args)
+ .WithParsed(options => RunOptions(options))
+ .WithNotParsed(HandleParseError);
+
+ private static void HandleParseError(IEnumerable errors)
+ {
+ if (errors.Any())
+ {
+ foreach (var error in errors)
+ {
+ WriteLine(error);
+ }
+ }
+ else
+ {
+ WriteLine("Unknown error.");
+ }
+ }
+
+ private static void RunOptions(CommandLineOptions values)
+ {
+ // Creating directory for the deployment.
+ var newDirectoryPath = CreateNewDirectoryPath(values);
+
+ try
+ {
+ // Determine whether the directory exists.
+ if (Directory.Exists(newDirectoryPath))
+ {
+ WriteLine("That directory already exists.");
+ return;
+ }
+
+ // Try to create the directory.
+ Directory.CreateDirectory(newDirectoryPath);
+
+ WriteLine("The directory was created successfully. {0}", newDirectoryPath);
+ }
+ catch (Exception exception)
+ {
+ WriteLine("The directory creation failed: {0}", exception.ToString());
+ return;
+ }
+
+ var pathToTheme = values.PathOfTheTheme;
+
+ // Creating media theme step.
+ dynamic mediaThemeStep = new JObject();
+ mediaThemeStep.name = "mediatheme";
+ mediaThemeStep.BaseThemeId = values.BaseThemeId;
+ mediaThemeStep.ClearMediaThemeFolder = values.ClearMediaHostingFolder;
+
+ // Creating media step.
+ var files = new JArray();
+
+ // Getting assets.
+ var pathToAssets = Path.Join(pathToTheme, LocalThemeWwwRootDirectory);
+
+ var allAssetsPaths = Directory.EnumerateFiles(pathToAssets, "*", 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);
+ }
+
+ // Copying assets to deployment directory.
+ CopyDirectory(
+ pathToAssets,
+ Path.Join(newDirectoryPath, MediaThemeAssetsCopyDirectoryPath),
+ areLiquidFiles: false);
+
+ // Getting templates.
+ var pathToTemplates = Path.Join(pathToTheme, LocalThemeViewsDirectory);
+
+ var allTemplatesPaths = Directory
+ .EnumerateFiles(pathToTemplates, "*" + 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);
+ }
+
+ // Copying templates to deployment directory.
+ CopyDirectory(
+ pathToTemplates,
+ Path.Join(newDirectoryPath, MediaThemeTemplatesCopyDirectoryPath),
+ areLiquidFiles: true,
+ recursive: false);
+
+ dynamic mediaStep = new JObject();
+ mediaStep.name = "media";
+ mediaStep.Files = files;
+
+ CreateRecipeAndWriteIt(mediaThemeStep, mediaStep, newDirectoryPath);
+
+ // Zipping the directory.
+ var zippedDirectoryPath = newDirectoryPath + ".zip";
+ ZipFile.CreateFromDirectory(newDirectoryPath, zippedDirectoryPath);
+
+ // Getting rid of the original directory.
+ Directory.Delete(newDirectoryPath, recursive: true);
+
+ WriteLine("{0} was created successfully. ", zippedDirectoryPath);
+ }
+
+ private static void CopyDirectory(
+ string sourceDir,
+ string destinationDirectory,
+ bool areLiquidFiles,
+ bool recursive = true)
+ {
+ // Get information about the source directory.
+ var directory = new DirectoryInfo(sourceDir);
+
+ // 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();
+
+ // Create the destination directory.
+ Directory.CreateDirectory(destinationDirectory);
+
+ // Get the files in the source directory and copy to the destination directory.
+ foreach (var file in directory.GetFiles())
+ {
+ var fileName = file.Name;
+
+ if (areLiquidFiles)
+ {
+ var fileNameWithoutExtension = fileName[..fileName.LastIndexOf(
+ LiquidFileExtension,
+ StringComparison.InvariantCulture)];
+
+ fileName = fileNameWithoutExtension.Replace('.', '_').Replace("_", "__") + LiquidFileExtension;
+ }
+
+ string targetFilePath = Path.Combine(destinationDirectory, fileName);
+ file.CopyTo(targetFilePath);
+ }
+
+ // If recursive and copying subdirectories, recursively call this method.
+ if (recursive)
+ {
+ foreach (var subDirectory in directories)
+ {
+ string newDestinationDir = Path.Combine(destinationDirectory, subDirectory.Name);
+ CopyDirectory(subDirectory.FullName, newDestinationDir, areLiquidFiles);
+ }
+ }
+ }
+
+ private static string CreateNewDirectoryPath(CommandLineOptions values)
+ {
+ var deploymentPathCommandLineValue = values.DeploymentPackagePath;
+ var deploymentPath = !string.IsNullOrEmpty(deploymentPathCommandLineValue)
+ ? deploymentPathCommandLineValue
+ : Directory.GetDirectoryRoot(Directory.GetCurrentDirectory());
+
+ return Path.Join(deploymentPath, MediaThemeDeploymentDirectory)
+ + DateTime.Now.ToString("ddMMMyyyyHHmmss", CultureInfo.CurrentCulture);
+ }
+
+ private static void CreateRecipeAndWriteIt(JObject mediaThemeStep, JObject mediaStep, 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);
+
+ // Creating JSON file.
+ using var file = File.CreateText(Path.Join(newDirectoryPath + RecipeFile));
+ using var writer = new JsonTextWriter(file) { Formatting = Formatting.Indented };
+ recipe.WriteTo(writer);
+
+ file.Close();
+ }
+}
diff --git a/Lombiq.Hosting.MediaTheme.Deployer/nupkg/lombiq.hosting.mediatheme.deployer.1.0.0.nupkg b/Lombiq.Hosting.MediaTheme.Deployer/nupkg/lombiq.hosting.mediatheme.deployer.1.0.0.nupkg
new file mode 100644
index 0000000..d8b947d
Binary files /dev/null and b/Lombiq.Hosting.MediaTheme.Deployer/nupkg/lombiq.hosting.mediatheme.deployer.1.0.0.nupkg differ
diff --git a/Readme.md b/Readme.md
index 40e897a..a009423 100644
--- a/Readme.md
+++ b/Readme.md
@@ -50,6 +50,21 @@ If you want to export your Media Theme, go to the `_Admin UI > Configuration > I
- Add _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. It 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.
+Alternatively you can [install](https://docs.microsoft.com/en-us/dotnet/core/tools/local-tools-how-to-use) the `Lombiq.Hosting.MediaTheme.Deployer` dotnet tool in your root project. Then you can use the tool:
+
+
+```xml
+dotnet tool run media-theme-deploy -p [path of your theme] -i [base theme id] -c [clear media hosting folder] -d [deployment path]
+```
+
+A specific example:
+
+```xml
+dotnet tool run media-theme-deploy -p .\src\Themes\MyTheme -i TheTheme -c true -d C:\MyFolder
+```
+
+Option `-d` is not required. Without it, the package will be exported to your directory root. For example `C:\MediaThemeDeployment_04Aug2022230500.zip`.
+
You can use Remote Deployment to accept such exported packages to deploy your theme remotely from your local development environment or CI.
## Contributing and support