diff --git a/CHANGELOG.md b/CHANGELOG.md index 15d3263..54fb8eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ - All modules fully migrated to System.Text.Json: AuditLog, Branch, BulkOperation, ContentType, DeliveryToken, Entry, EntryVariant, Environment, Extension, GlobalField, Label, Locale, ManagementToken, Organization, Release, Role, Stack, Taxonomy, Term, User, VariantGroup, Webhook, and Workflow - OAuth auto token refresh wired into the request pipeline - Upgraded target framework to .NET 10 + - **New:** Multi-region endpoint resolution via `Endpoint.GetContentstackEndpoint(region, service)` — resolves Contentstack service URLs for all 7 supported regions (NA, EU, AU, Azure-NA, Azure-EU, GCP-NA, GCP-EU) and 18 service keys (contentManagement, contentDelivery, auth, graphqlDelivery, preview, images, assets, automate, launch, developerHub, brandKit, genAI, personalizeManagement, personalizeEdge, composableStudio, assetManagement, and more). + - **New:** `omitHttps` flag strips the `https://` scheme from returned URLs — pass directly to `ContentstackClientOptions.Host` (e.g. `new ContentstackClientOptions { Host = Endpoint.GetContentstackEndpoint("eu", "contentManagement", omitHttps: true) }`). + - **New:** Case-insensitive region alias support — `"us"`, `"NA"`, `"AWS-NA"`, `"azure_na"` all resolve correctly to the same region. + - **New:** `regions.json` registry auto-downloaded from `artifacts.contentstack.com` on first use and cached on disk — no setup required. The SDK self-heals if the file is missing. + - **New:** `Scripts/refresh-region.cs` bundled inside the NuGet package — automatically placed in your project's `Scripts/` folder on first `dotnet build`. Run `dotnet run Scripts/refresh-region.cs` anytime to pull the latest regions from CDN. ## [v0.10.0](https://github.com/contentstack/contentstack-management-dotnet/tree/v0.10.0) - Feat diff --git a/Contentstack.Management.Core.Unit.Tests/Endpoints/EndpointTest.cs b/Contentstack.Management.Core.Unit.Tests/Endpoints/EndpointTest.cs new file mode 100644 index 0000000..d90e5df --- /dev/null +++ b/Contentstack.Management.Core.Unit.Tests/Endpoints/EndpointTest.cs @@ -0,0 +1,311 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Contentstack.Management.Core.Endpoints; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Contentstack.Management.Core.Unit.Tests.Endpoints +{ + [TestClass] + public class EndpointTest + { + [TestInitialize] + public void Setup() => Endpoint.ResetCache(); + + [TestCleanup] + public void Teardown() => Endpoint.ResetCache(); + + // ------------------------------------------------------------------ + // Basic resolution + // ------------------------------------------------------------------ + + [TestMethod] + public void GetContentstackEndpoint_Na_ReturnsCorrectManagementUrl() + { + string url = Endpoint.GetContentstackEndpoint("na", "contentManagement"); + Assert.AreEqual("https://api.contentstack.io", url); + } + + [DataTestMethod] + [DataRow("na")] + [DataRow("eu")] + [DataRow("au")] + [DataRow("azure-na")] + [DataRow("azure-eu")] + [DataRow("gcp-na")] + [DataRow("gcp-eu")] + public void GetContentstackEndpoint_AllRegionIds_Resolve(string regionId) + { + string url = Endpoint.GetContentstackEndpoint(regionId, "contentManagement"); + Assert.IsFalse(string.IsNullOrEmpty(url)); + Assert.IsTrue(url.StartsWith("https://")); + } + + // ------------------------------------------------------------------ + // Alias resolution (case-insensitive, dash/underscore variants) + // ------------------------------------------------------------------ + + [DataTestMethod] + [DataRow("na")] + [DataRow("us")] + [DataRow("NA")] + [DataRow("US")] + [DataRow("AWS-NA")] + [DataRow("aws_na")] + [DataRow("AWS_NA")] + public void GetContentstackEndpoint_NaAliasVariants_AllResolveToSameUrl(string alias) + { + string url = Endpoint.GetContentstackEndpoint(alias, "contentManagement"); + Assert.AreEqual("https://api.contentstack.io", url); + } + + [DataTestMethod] + [DataRow("azure-na")] + [DataRow("azure_na")] + [DataRow("AZURE-NA")] + [DataRow("AZURE_NA")] + public void GetContentstackEndpoint_AzureNaAliasVariants_AllResolveToSameUrl(string alias) + { + string expected = Endpoint.GetContentstackEndpoint("azure-na", "contentManagement"); + string result = Endpoint.GetContentstackEndpoint(alias, "contentManagement"); + Assert.AreEqual(expected, result); + } + + [DataTestMethod] + [DataRow("eu")] + [DataRow("EU")] + [DataRow("aws-eu")] + [DataRow("AWS-EU")] + [DataRow("aws_eu")] + public void GetContentstackEndpoint_EuAliasVariants_AllResolveToSameUrl(string alias) + { + string expected = Endpoint.GetContentstackEndpoint("eu", "contentManagement"); + string result = Endpoint.GetContentstackEndpoint(alias, "contentManagement"); + Assert.AreEqual(expected, result); + } + + // ------------------------------------------------------------------ + // omitHttps flag + // ------------------------------------------------------------------ + + [TestMethod] + public void GetContentstackEndpoint_OmitHttps_StripsScheme() + { + string url = Endpoint.GetContentstackEndpoint("na", "contentManagement", omitHttps: true); + Assert.IsFalse(url.StartsWith("https://"), "URL should not start with https://"); + Assert.IsFalse(url.StartsWith("http://"), "URL should not start with http://"); + Assert.IsTrue(url.Contains("."), "URL should contain a hostname"); + } + + [TestMethod] + public void GetContentstackEndpoint_OmitHttpsFalse_PreservesScheme() + { + string url = Endpoint.GetContentstackEndpoint("na", "contentManagement", omitHttps: false); + Assert.IsTrue(url.StartsWith("https://")); + } + + [DataTestMethod] + [DataRow("na")] + [DataRow("eu")] + [DataRow("au")] + [DataRow("azure-na")] + [DataRow("gcp-na")] + public void GetContentstackEndpoint_OmitHttps_AllRegions_StripsScheme(string region) + { + string url = Endpoint.GetContentstackEndpoint(region, "contentManagement", omitHttps: true); + Assert.IsFalse(url.StartsWith("https://")); + Assert.IsFalse(url.StartsWith("http://")); + } + + // ------------------------------------------------------------------ + // Dictionary overload + // ------------------------------------------------------------------ + + [TestMethod] + public void GetContentstackEndpoint_DictOverload_ContainsManagementKey() + { + var dict = Endpoint.GetContentstackEndpoint("na"); + Assert.IsTrue(dict.ContainsKey("contentManagement")); + Assert.AreEqual("https://api.contentstack.io", dict["contentManagement"]); + } + + [TestMethod] + public void GetContentstackEndpoint_DictOverload_ContainsDeliveryKey() + { + var dict = Endpoint.GetContentstackEndpoint("na"); + Assert.IsTrue(dict.ContainsKey("contentDelivery")); + } + + [TestMethod] + public void GetContentstackEndpoint_DictOverload_ReturnsMultipleServices() + { + var dict = Endpoint.GetContentstackEndpoint("na"); + Assert.IsTrue(dict.Count >= 2, "Should contain at least 2 service endpoints"); + } + + [TestMethod] + public void GetContentstackEndpoint_DictOverload_OmitHttps_StripsAllSchemes() + { + var dict = Endpoint.GetContentstackEndpoint("na", omitHttps: true); + foreach (var kvp in dict) + { + Assert.IsFalse(kvp.Value.StartsWith("https://"), + $"Service '{kvp.Key}' URL still has https:// prefix"); + } + } + + [DataTestMethod] + [DataRow("na")] + [DataRow("eu")] + [DataRow("au")] + [DataRow("azure-na")] + [DataRow("azure-eu")] + [DataRow("gcp-na")] + [DataRow("gcp-eu")] + public void GetContentstackEndpoint_DictOverload_AllRegions_ReturnNonEmpty(string region) + { + var dict = Endpoint.GetContentstackEndpoint(region); + Assert.IsTrue(dict.Count > 0, $"Expected at least one endpoint for region '{region}'"); + } + + // ------------------------------------------------------------------ + // Error cases + // ------------------------------------------------------------------ + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void GetContentstackEndpoint_EmptyRegion_ThrowsArgumentException() + { + Endpoint.GetContentstackEndpoint("", "contentManagement"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void GetContentstackEndpoint_WhitespaceRegion_ThrowsArgumentException() + { + Endpoint.GetContentstackEndpoint(" ", "contentManagement"); + } + + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void GetContentstackEndpoint_DictOverload_EmptyRegion_ThrowsArgumentException() + { + Endpoint.GetContentstackEndpoint(""); + } + + [TestMethod] + [ExpectedException(typeof(KeyNotFoundException))] + public void GetContentstackEndpoint_UnknownRegion_ThrowsKeyNotFoundException() + { + Endpoint.GetContentstackEndpoint("xyz", "contentManagement"); + } + + [TestMethod] + [ExpectedException(typeof(KeyNotFoundException))] + public void GetContentstackEndpoint_DictOverload_UnknownRegion_ThrowsKeyNotFoundException() + { + Endpoint.GetContentstackEndpoint("xyz"); + } + + [TestMethod] + [ExpectedException(typeof(KeyNotFoundException))] + public void GetContentstackEndpoint_UnknownService_ThrowsKeyNotFoundException() + { + Endpoint.GetContentstackEndpoint("na", "unknownService"); + } + + [TestMethod] + public void GetContentstackEndpoint_UnknownRegion_ErrorMessageContainsInput() + { + try + { + Endpoint.GetContentstackEndpoint("badregion", "contentManagement"); + Assert.Fail("Expected KeyNotFoundException"); + } + catch (KeyNotFoundException ex) + { + Assert.IsTrue(ex.Message.Contains("badregion")); + } + } + + [TestMethod] + public void GetContentstackEndpoint_UnknownService_ErrorMessageContainsServiceName() + { + try + { + Endpoint.GetContentstackEndpoint("na", "badService"); + Assert.Fail("Expected KeyNotFoundException"); + } + catch (KeyNotFoundException ex) + { + Assert.IsTrue(ex.Message.Contains("badService")); + } + } + + // ------------------------------------------------------------------ + // Cache behaviour + // ------------------------------------------------------------------ + + [TestMethod] + public void ResetCache_AllowsSubsequentCallToSucceed() + { + // First call populates cache + string url1 = Endpoint.GetContentstackEndpoint("na", "contentManagement"); + + // Reset and call again — should reload from disk/CDN and return same value + Endpoint.ResetCache(); + string url2 = Endpoint.GetContentstackEndpoint("na", "contentManagement"); + + Assert.AreEqual(url1, url2); + } + + [TestMethod] + public void GetContentstackEndpoint_CalledTwice_ReturnsSameResult() + { + string url1 = Endpoint.GetContentstackEndpoint("eu", "contentManagement"); + string url2 = Endpoint.GetContentstackEndpoint("eu", "contentManagement"); + Assert.AreEqual(url1, url2); + } + + // ------------------------------------------------------------------ + // File path helper + // ------------------------------------------------------------------ + + [TestMethod] + public void GetLocalFilePath_EndsWithExpectedSegments() + { + string path = Endpoint.GetLocalFilePath(); + Assert.IsTrue(path.EndsWith(Path.Combine("Assets", "regions.json")), + $"Expected path to end with Assets/regions.json, got: {path}"); + } + + // ------------------------------------------------------------------ + // All 7 regions × contentManagement spot-checks + // ------------------------------------------------------------------ + + [DataTestMethod] + [DataRow("na", "https://api.contentstack.io")] + [DataRow("us", "https://api.contentstack.io")] + [DataRow("eu", "https://eu-api.contentstack.com")] + [DataRow("au", "https://au-api.contentstack.com")] + public void GetContentstackEndpoint_KnownRegions_ContentManagement_MatchExpected( + string region, string expected) + { + string url = Endpoint.GetContentstackEndpoint(region, "contentManagement"); + Assert.AreEqual(expected, url); + } + + // ------------------------------------------------------------------ + // ID takes priority over alias (two-pass lookup) + // ------------------------------------------------------------------ + + [TestMethod] + public void GetContentstackEndpoint_IdTakesPriorityOverAlias() + { + // "eu" is both a valid region ID and an alias in some registries. + // The two-pass lookup must return the ID match first. + string byId = Endpoint.GetContentstackEndpoint("eu", "contentManagement"); + Assert.IsFalse(string.IsNullOrEmpty(byId)); + } + } +} diff --git a/Contentstack.Management.Core/Endpoints/Endpoint.cs b/Contentstack.Management.Core/Endpoints/Endpoint.cs new file mode 100644 index 0000000..dae4356 --- /dev/null +++ b/Contentstack.Management.Core/Endpoints/Endpoint.cs @@ -0,0 +1,287 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Contentstack.Management.Core.Endpoints +{ + /// + /// Resolves Contentstack service URLs for any supported region. + /// + /// All public methods are static — no instantiation required. + /// + /// Example: + /// + /// // Full URL + /// string url = Endpoint.GetContentstackEndpoint("na", "contentManagement"); + /// // → "https://api.contentstack.io" + /// + /// // Host only (omit https://) — pass directly to ContentstackClientOptions.Host + /// string host = Endpoint.GetContentstackEndpoint("eu", "contentManagement", omitHttps: true); + /// // → "eu-api.contentstack.com" + /// + /// // All endpoints for a region + /// Dictionary<string, string> all = Endpoint.GetContentstackEndpoint("azure-na"); + /// // → { "contentManagement": "...", "contentDelivery": "...", ... } + /// + /// + public static class Endpoint + { + private const string RegionsUrl = "https://artifacts.contentstack.com/regions.json"; + + // Module-level cache — loaded once per process, shared across all calls. + private static JsonElement[]? _regionsData; + private static readonly object _cacheLock = new object(); + private static readonly HttpClient _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(30) + }; + + /// + /// Resolves a single Contentstack service endpoint URL for the given region. + /// + /// + /// Region ID or alias (case-insensitive). Examples: "na", "us", "eu", "AWS-NA", "azure_eu", "gcp-na". + /// + /// + /// Service key. Valid keys include: "contentManagement", "contentDelivery", "auth", + /// "graphqlDelivery", "preview", "images", "assets", "automate", "launch", + /// "developerHub", "brandKit", "genAI", "personalizeManagement", + /// "personalizeEdge", "composableStudio", "assetManagement". + /// + /// + /// When true, strips the https:// scheme from the returned URL. + /// Useful when passing the host to ContentstackClientOptions.Host. + /// + /// The endpoint URL string for the specified service. + /// If region is empty or whitespace. + /// If region or service is not found. + /// If the registry cannot be read or is corrupt. + public static string GetContentstackEndpoint(string region, string service, bool omitHttps = false) + { + if (string.IsNullOrWhiteSpace(region)) + throw new ArgumentException("Empty region provided. Please put valid region.", nameof(region)); + + var regions = LoadRegions(); + string normalized = region.Trim().ToLowerInvariant(); + var regionEl = FindRegion(regions, normalized); + + if (regionEl == null) + throw new KeyNotFoundException($"Invalid region: {region}"); + + var endpoints = regionEl.Value.GetProperty("endpoints"); + if (!endpoints.TryGetProperty(service, out var urlEl)) + { + string regionId = regionEl.Value.GetProperty("id").GetString()!; + throw new KeyNotFoundException($"Service \"{service}\" not found for region \"{regionId}\""); + } + + string url = urlEl.GetString()!; + return omitHttps ? StripHttps(url) : url; + } + + /// + /// Returns all service endpoint URLs for the given region. + /// + /// Region ID or alias (case-insensitive). + /// When true, strips the https:// scheme from all returned URLs. + /// Dictionary mapping service keys to endpoint URLs. + /// If region is empty or whitespace. + /// If region is not found. + /// If the registry cannot be read or is corrupt. + public static Dictionary GetContentstackEndpoint(string region, bool omitHttps = false) + { + if (string.IsNullOrWhiteSpace(region)) + throw new ArgumentException("Empty region provided. Please put valid region.", nameof(region)); + + var regions = LoadRegions(); + string normalized = region.Trim().ToLowerInvariant(); + var regionEl = FindRegion(regions, normalized); + + if (regionEl == null) + throw new KeyNotFoundException($"Invalid region: {region}"); + + var result = new Dictionary(); + var endpoints = regionEl.Value.GetProperty("endpoints"); + foreach (var ep in endpoints.EnumerateObject()) + { + string url = ep.Value.GetString()!; + result[ep.Name] = omitHttps ? StripHttps(url) : url; + } + return result; + } + + /// + /// Clears the in-memory region cache. Intended for testing only — forces the + /// next call to re-read regions.json from disk or re-download from CDN. + /// + public static void ResetCache() + { + lock (_cacheLock) + { + _regionsData = null; + } + } + + // ------------------------------------------------------------------ + // Internal helpers + // ------------------------------------------------------------------ + + /// + /// Load and cache the regions registry. + /// + /// Resolution order: + /// 1. In-memory cache — zero I/O after the first call in a process + /// 2. Local file on disk — Assets/regions.json next to the DLL + /// (written by DownloadAndSave or refresh-region.cs) + /// 3. CDN download — fetches from artifacts.contentstack.com, + /// writes to disk for future calls (silent on failure) + /// + private static JsonElement[] LoadRegions() + { + lock (_cacheLock) + { + if (_regionsData != null) + return _regionsData; + + string localFile = GetLocalFilePath(); + + // Step 2 — local file on disk + string? json = ReadLocalFile(localFile); + + // Step 3 — CDN download, writes to disk so next startup skips this step + if (json == null) + json = DownloadAndSave(localFile); + + if (json == null) + throw new InvalidOperationException( + "contentstack_management: regions.json not found and could not be downloaded. " + + "Run 'dotnet run Scripts/refresh-region.cs' and ensure network access."); + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(json); + } + catch (JsonException ex) + { + throw new InvalidOperationException( + "contentstack_management: regions.json is corrupt. " + + "Run 'dotnet run Scripts/refresh-region.cs' to re-download it.", ex); + } + + if (!doc.RootElement.TryGetProperty("regions", out var regionsEl) || + regionsEl.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + "contentstack_management: regions.json is corrupt. " + + "Run 'dotnet run Scripts/refresh-region.cs' to re-download it."); + } + + var list = new List(); + foreach (var r in regionsEl.EnumerateArray()) + list.Add(r.Clone()); + + _regionsData = list.ToArray(); + return _regionsData; + } + } + + /// + /// Returns the path to regions.json next to the DLL. + /// + internal static string GetLocalFilePath() + { + string assemblyDir = Path.GetDirectoryName(typeof(Endpoint).Assembly.Location) + ?? AppContext.BaseDirectory; + return Path.Combine(assemblyDir, "Assets", "regions.json"); + } + + /// + /// Reads regions.json from disk. Returns null if the file does not exist. + /// + private static string? ReadLocalFile(string path) + { + if (!File.Exists(path)) + return null; + try + { + return File.ReadAllText(path); + } + catch + { + return null; + } + } + + /// + /// Downloads regions.json from the CDN and writes it to disk so that future + /// process startups read from the local file instead of downloading again. + /// Silent on all failures (network error, permission denied). + /// + private static string? DownloadAndSave(string dest) + { + try + { + var task = _httpClient.GetStringAsync(RegionsUrl); + task.Wait(); + string data = task.Result; + + using var doc = JsonDocument.Parse(data); + if (!doc.RootElement.TryGetProperty("regions", out _)) + return null; + + // Write to disk — next startup reads from local file (Step 2). + // Silent on PermissionError or read-only filesystem. + try + { + Directory.CreateDirectory(Path.GetDirectoryName(dest)!); + File.WriteAllText(dest, data); + } + catch { } + + return data; + } + catch + { + return null; + } + } + + /// + /// Two-pass region lookup: ID match wins over alias match. + /// Input must already be lowercased and trimmed. + /// + private static JsonElement? FindRegion(JsonElement[] regions, string normalized) + { + // Pass 1 — exact id match + foreach (var r in regions) + { + if (r.TryGetProperty("id", out var id) && + id.GetString()?.ToLowerInvariant() == normalized) + return r; + } + + // Pass 2 — alias match + foreach (var r in regions) + { + if (!r.TryGetProperty("alias", out var aliases)) continue; + foreach (var alias in aliases.EnumerateArray()) + { + if (alias.GetString()?.ToLowerInvariant() == normalized) + return r; + } + } + + return null; + } + + private static string StripHttps(string url) + { + return Regex.Replace(url, @"^https?://", string.Empty); + } + } +} diff --git a/Contentstack.Management.Core/contentstack.management.core.csproj b/Contentstack.Management.Core/contentstack.management.core.csproj index 619e855..17f949d 100644 --- a/Contentstack.Management.Core/contentstack.management.core.csproj +++ b/Contentstack.Management.Core/contentstack.management.core.csproj @@ -62,12 +62,29 @@ + + + + + true + contentFiles/cs/any/Scripts/refresh-region.cs + Content + false + + + true + build/contentstack.management.csharp.targets + + + diff --git a/Scripts/refresh-region.cs b/Scripts/refresh-region.cs new file mode 100644 index 0000000..1a93977 --- /dev/null +++ b/Scripts/refresh-region.cs @@ -0,0 +1,77 @@ +// Refresh regions.json from the Contentstack CDN. +// +// Works for both SDK developers and SDK consumers — no file copying needed. +// NuGet automatically places this file in your project's Scripts/ folder +// when you install the contentstack.management.csharp package. +// +// Usage (run from your project root after dotnet build): +// dotnet run Scripts/refresh-region.cs +// +// Run whenever Contentstack adds a new region or service. + +using System.IO; +using System.Net.Http; +using System.Text.Json; + +const string RegionsUrl = "https://artifacts.contentstack.com/regions.json"; + +string root = Directory.GetCurrentDirectory(); + +Console.WriteLine($"Fetching {RegionsUrl} ..."); + +string json; +try +{ + using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; + json = await http.GetStringAsync(RegionsUrl); +} +catch (Exception ex) +{ + Console.Error.WriteLine($"ERROR: Could not download regions.json: {ex.Message}"); + return 1; +} + +JsonDocument doc; +try +{ + doc = JsonDocument.Parse(json); +} +catch (JsonException ex) +{ + Console.Error.WriteLine($"ERROR: Downloaded content is not valid JSON: {ex.Message}"); + return 1; +} + +if (!doc.RootElement.TryGetProperty("regions", out var regionsEl)) +{ + Console.Error.WriteLine("ERROR: Downloaded JSON does not contain a 'regions' key."); + return 1; +} + +int regionCount = regionsEl.GetArrayLength(); + +// ── All bin output dirs — finds every Contentstack.Management.Core.dll in bin/ ── +// Works for both the SDK repo and consumer projects after dotnet build. +// Writes Assets/regions.json next to each DLL found. +int binCount = 0; +foreach (string dll in Directory.GetFiles(root, "Contentstack.Management.Core.dll", SearchOption.AllDirectories)) +{ + if (!dll.Contains(Path.DirectorySeparatorChar + "bin" + Path.DirectorySeparatorChar)) + continue; + + string binDest = Path.Combine(Path.GetDirectoryName(dll)!, "Assets", "regions.json"); + await WriteFile(binDest, json); + Console.WriteLine($"[bin] Wrote {regionCount} regions → {binDest}"); + binCount++; +} + +if (binCount == 0) + Console.WriteLine("[bin] No build output found — run 'dotnet build' first, then re-run this script."); + +return 0; + +static async Task WriteFile(string path, string content) +{ + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + await File.WriteAllTextAsync(path, content); +} diff --git a/build/contentstack.management.csharp.targets b/build/contentstack.management.csharp.targets new file mode 100644 index 0000000..5174341 --- /dev/null +++ b/build/contentstack.management.csharp.targets @@ -0,0 +1,19 @@ + + + + <_RefreshScriptDest>$(MSBuildProjectDirectory)/Scripts/refresh-region.cs + <_RefreshScriptSrc>$(MSBuildThisFileDirectory)../contentFiles/cs/any/Scripts/refresh-region.cs + + + + + + + +