diff --git a/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs b/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs index f05fa73531..4c59ebbcc0 100644 --- a/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs +++ b/src/Elastic.Documentation/ReleaseNotes/ChangelogVersionMatch.cs @@ -7,8 +7,9 @@ namespace Elastic.Documentation.ReleaseNotes; /// /// Shared matching rule for the changelog directive's :version: filter. A bundle /// matches a requested version when the requested value equals either the bundle's declared target -/// or its file name (with or without extension), compared case-insensitively. Kept in one place so -/// the directive's post-load filter and the CDN fetcher's download-time filter agree. +/// or its file name (with or without extension), compared case-insensitively and ignoring a leading +/// v on either side (release tags are v9.5.0; generated version pages are 9.5.0). +/// Kept in one place so the directive's post-load filter and the CDN fetcher's download-time filter agree. /// public static class ChangelogVersionMatch { @@ -21,16 +22,18 @@ public static bool Matches(string requested, string? target, string? file) if (string.IsNullOrWhiteSpace(requested)) return true; - var value = requested.Trim(); + var value = StripLeadingV(requested.Trim()); - if (!string.IsNullOrWhiteSpace(target) && string.Equals(target.Trim(), value, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(target) && string.Equals(StripLeadingV(target.Trim()), value, StringComparison.OrdinalIgnoreCase)) return true; if (string.IsNullOrWhiteSpace(file)) return false; var name = Path.GetFileName(file); - return string.Equals(name, value, StringComparison.OrdinalIgnoreCase) - || string.Equals(Path.GetFileNameWithoutExtension(file), value, StringComparison.OrdinalIgnoreCase); + return string.Equals(StripLeadingV(name), value, StringComparison.OrdinalIgnoreCase) + || string.Equals(StripLeadingV(Path.GetFileNameWithoutExtension(file)), value, StringComparison.OrdinalIgnoreCase); } + + private static string StripLeadingV(string version) => version.TrimStart('v', 'V'); } diff --git a/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesBuildService.cs b/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesBuildService.cs index 601213288e..d8ed43b638 100644 --- a/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesBuildService.cs +++ b/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesBuildService.cs @@ -11,6 +11,7 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.FileSystems; using Elastic.Documentation.Services; +using Elastic.Documentation.Versions; using Microsoft.Extensions.Logging; namespace Elastic.Documentation.Isolated; @@ -53,7 +54,10 @@ IEnvironmentVariables environmentVariables { private readonly ILogger _logger = logFactory.CreateLogger(); - private sealed record ProductBundles(Product Product, IReadOnlyList<(string FileName, string Content)> Files); + private sealed record ProductBundles(ProductVersions Versions, IReadOnlyList<(string FileName, string Content)> Files) + { + public Product Product => Versions.Product; + } public async Task Build(IDiagnosticsCollector collector, UnifiedReleaseNotesBuildOptions options, Cancel ctx = default) { @@ -78,7 +82,7 @@ public async Task Build(IDiagnosticsCollector collector, UnifiedReleaseNot products = [.. bundles.Select(b => b.Product)]; var docsRoot = Path.Join(Paths.WorkingDirectoryRoot.FullName, "unified-release-notes", "docs"); - var mockFs = CreateVirtualDocset(docsRoot, products, bundles); + var mockFs = CreateVirtualDocset(docsRoot, bundles); PrepareOutput(realFs, options.Output.FullName); var docFs = DocumentationFileSystem.Resolve( @@ -155,7 +159,9 @@ UnifiedReleaseNotesBuildOptions options .Order(StringComparer.Ordinal) .Select(file => (FileName: realFs.Path.GetFileName(file), Content: realFs.File.ReadAllText(file))) .ToArray(); - result.Add(new ProductBundles(product, files)); + result.Add( + new ProductBundles(new ProductVersions(product, NewestFirst(loaded.Select(b => NormalizeVersion(b.Version)))), files) + ); } return result; @@ -169,22 +175,21 @@ private static IReadOnlyList MissingVersions(IEnumerable bundleV private static string NormalizeVersion(string version) => version.TrimStart('v'); - private static MockFileSystem CreateVirtualDocset( - string docsRoot, - IReadOnlyList products, - IReadOnlyList bundles - ) + private static IReadOnlyList NewestFirst(IEnumerable versions) => + [.. versions.Distinct(StringComparer.OrdinalIgnoreCase).OrderByDescending(VersionOrDate.Parse)]; + + private static MockFileSystem CreateVirtualDocset(string docsRoot, IReadOnlyList bundles) { var mockFs = new MockFileSystem(new MockFileSystemOptions { CurrentDirectory = docsRoot, CreateDefaultTempDir = false }); - foreach (var (relativePath, content) in UnifiedReleaseNotesDocset.Generate(products)) + foreach (var (relativePath, content) in UnifiedReleaseNotesDocset.Generate([.. bundles.Select(b => b.Versions)])) mockFs.AddFile(mockFs.Path.Join(docsRoot, relativePath), new MockFileData(content)); - foreach (var (product, files) in bundles) + foreach (var (productVersions, files) in bundles) { foreach (var (fileName, content) in files) { - var path = mockFs.Path.Join(docsRoot, UnifiedReleaseNotesDocset.BundlesPath(product.Id), fileName); + var path = mockFs.Path.Join(docsRoot, UnifiedReleaseNotesDocset.BundlesPath(productVersions.Product.Id), fileName); mockFs.AddFile(path, new MockFileData(content)); } } diff --git a/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesDocset.cs b/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesDocset.cs index 554eef67d6..9b74e060a2 100644 --- a/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesDocset.cs +++ b/src/services/Elastic.Documentation.Isolated/UnifiedReleaseNotesDocset.cs @@ -7,18 +7,25 @@ namespace Elastic.Documentation.Isolated; +/// A product and the versions it has bundles for, newest first. +public sealed record ProductVersions(Product Product, IReadOnlyList Versions); + /// /// Synthesizes the markdown and navigation files of the unified release notes docset in memory. -/// Every product with release-notes: dra gets the same four pages, so onboarding a product -/// is a one-line change in products.yml. +/// Every product with release-notes: dra gets the same pages, so onboarding a product is a +/// one-line change in products.yml. A version has a page of its own, so its URL can be shared. /// public static class UnifiedReleaseNotesDocset { public const string BundlesFolder = "changelog/bundles"; + private const string UnreleasedNotice = + ":::{warning}\nThis is a preview of an unreleased version. The release notes can change until the version ships.\n:::"; + + private const string VersionOptions = ":highlights:\n:subsections:"; + private static readonly (string FileName, string NavigationTitle, string Heading, string Options)[] Pages = [ - ("index.md", "Release notes", "release notes", ":highlights:"), ("known-issues.md", "Known issues", "known issues", ":type: known-issue"), ("breaking-changes.md", "Breaking changes", "breaking changes", ":type: breaking-change\n:dropdowns:"), ("deprecations.md", "Deprecations", "deprecations", ":type: deprecation") @@ -32,7 +39,7 @@ public static IReadOnlyList SelectProducts(IEnumerable product public static string BundlesPath(string productId) => $"{BundlesFolder}/{productId}"; /// Generates every file of the docset, keyed by its path relative to the docset root. - public static IReadOnlyDictionary Generate(IReadOnlyList products) + public static IReadOnlyDictionary Generate(IReadOnlyList products) { var files = new Dictionary(StringComparer.Ordinal) { @@ -40,10 +47,13 @@ public static IReadOnlyDictionary Generate(IReadOnlyList Generate(IReadOnlyList products) + private static string GenerateToc(IReadOnlyList products) { var lines = new List { "toc:", " - folder: release-notes", " children:", " - file: index.md" }; - lines.AddRange(products.Select(p => $" - toc: {p.Id}")); + lines.AddRange(products.Select(p => $" - toc: {p.Product.Id}")); return string.Join('\n', lines) + "\n"; } - private static string GenerateProductToc() => string.Join('\n', Pages.Select(p => $" - file: {p.FileName}").Prepend("toc:")) + "\n"; + private static string GenerateProductToc(IReadOnlyList versions) + { + IEnumerable files = ["index.md", .. versions.Select(v => $"{v}.md"), .. Pages.Select(p => p.FileName)]; + return string.Join('\n', files.Select(f => $" - file: {f}").Prepend("toc:")) + "\n"; + } - private static string GenerateRootIndex(IReadOnlyList products) + private static string GenerateRootIndex(IReadOnlyList products) { var lines = new List { @@ -74,13 +88,51 @@ private static string GenerateRootIndex(IReadOnlyList products) "", "A scheduled workflow rebuilds it whenever a new build candidate is cut or a SNAPSHOT advances.", "", + UnreleasedNotice, + "", "## Products", "" }; - lines.AddRange(products.Select(p => $"- [{p.DisplayName}]({p.Id}/index.md)")); + lines.AddRange(products.Select(p => $"- [{p.Product.DisplayName}]({p.Product.Id}/index.md)")); return string.Join('\n', lines) + "\n"; } + private static string GenerateProductIndex(Product product, IReadOnlyList versions) => + string.Join( + '\n', + new[] + { + "---", + $"navigation_title: {product.DisplayName}", + "---", + "", + $"# {product.DisplayName} release notes", + "", + UnreleasedNotice, + "", + "## Versions", + "" + }.Concat(versions.Select(v => $"- [{v}]({v}.md)")).Append("") + ); + + private static string GenerateVersionPage(Product product, string version) => + string.Join( + '\n', + "---", + $"navigation_title: {version}", + "---", + "", + $"# {product.DisplayName} {version}", + "", + UnreleasedNotice, + "", + ":::{changelog} /" + BundlesPath(product.Id), + $":version: {version}", + VersionOptions, + ":::", + "" + ); + private static string GeneratePage(Product product, (string FileName, string NavigationTitle, string Heading, string Options) page) => string.Join( '\n', @@ -90,6 +142,8 @@ private static string GeneratePage(Product product, (string FileName, string Nav "", $"# {product.DisplayName} {page.Heading}", "", + UnreleasedNotice, + "", ":::{changelog} /" + BundlesPath(product.Id), page.Options, ":::", diff --git a/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs b/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs index fc4539854d..b23b378c18 100644 --- a/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs +++ b/tests/Elastic.Documentation.Build.Tests/UnifiedReleaseNotesTests.cs @@ -103,18 +103,25 @@ private UnifiedReleaseNotesBuildService Service() ); } + private static IReadOnlyDictionary GenerateFiles() => + UnifiedReleaseNotesDocset.Generate([ + .. UnifiedReleaseNotesDocset.SelectProducts(Products).Select(p => new ProductVersions(p, ["9.6.0", "9.5.6"])) + ]); + [Test] public void SelectProducts_MixedReleaseNotesPaths_KeepsOnlyDraProductsInIdOrder() => UnifiedReleaseNotesDocset.SelectProducts(Products).Select(p => p.Id).Should().Equal("elasticsearch", "kibana"); [Test] - public void Generate_DraProducts_CreatesFourPagesAndTocPerProduct() + public void Generate_DraProducts_CreatesAPagePerVersionAndTheSharedPagesAndTocPerProduct() { - var files = UnifiedReleaseNotesDocset.Generate(UnifiedReleaseNotesDocset.SelectProducts(Products)); + var files = GenerateFiles(); foreach (var product in new[] { "elasticsearch", "kibana" }) { files.Should().ContainKey($"release-notes/{product}/index.md"); + files.Should().ContainKey($"release-notes/{product}/9.6.0.md"); + files.Should().ContainKey($"release-notes/{product}/9.5.6.md"); files.Should().ContainKey($"release-notes/{product}/known-issues.md"); files.Should().ContainKey($"release-notes/{product}/breaking-changes.md"); files.Should().ContainKey($"release-notes/{product}/deprecations.md"); @@ -127,7 +134,7 @@ public void Generate_DraProducts_CreatesFourPagesAndTocPerProduct() [Test] public void Generate_Docset_DeclaresTheTocWithProductsInOrder() { - var files = UnifiedReleaseNotesDocset.Generate(UnifiedReleaseNotesDocset.SelectProducts(Products)); + var files = GenerateFiles(); files["docset.yml"] .Should() @@ -140,21 +147,49 @@ public void Generate_Docset_DeclaresTheTocWithProductsInOrder() [Test] public void Generate_ProductPages_PointTheChangelogDirectiveAtTheProductBundles() { - var files = UnifiedReleaseNotesDocset.Generate(UnifiedReleaseNotesDocset.SelectProducts(Products)); + var files = GenerateFiles(); files["release-notes/elasticsearch/index.md"].Should().Contain("# Elasticsearch release notes"); - files["release-notes/elasticsearch/index.md"] + files["release-notes/elasticsearch/index.md"].Should().Contain("- [9.6.0](9.6.0.md)\n- [9.5.6](9.5.6.md)"); + files["release-notes/elasticsearch/9.6.0.md"] .Should() - .Contain(":::{changelog} /changelog/bundles/elasticsearch\n:highlights:\n:::"); + .Contain(":::{changelog} /changelog/bundles/elasticsearch\n:version: 9.6.0\n:highlights:\n:subsections:\n:::"); + files["release-notes/elasticsearch/9.6.0.md"].Should().Contain("# Elasticsearch 9.6.0"); files["release-notes/kibana/breaking-changes.md"].Should().Contain(":type: breaking-change\n:dropdowns:"); files["release-notes/kibana/known-issues.md"].Should().Contain(":type: known-issue"); files["release-notes/kibana/deprecations.md"].Should().Contain(":type: deprecation"); } + [Test] + public void Generate_ProductPages_AreNamedInTheNavigationAfterTheProductAndTheVersion() + { + var files = GenerateFiles(); + + files["release-notes/kibana/9.6.0.md"].Should().Contain("navigation_title: 9.6.0\n"); + files["release-notes/kibana/toc.yml"] + .Should() + .Be( + "toc:\n - file: index.md\n - file: 9.6.0.md\n - file: 9.5.6.md\n - file: known-issues.md\n - file: breaking-changes.md\n - file: deprecations.md\n" + ); + + files["release-notes/kibana/index.md"].Should().Contain("navigation_title: Kibana\n"); + files["release-notes/kibana/known-issues.md"].Should().Contain("navigation_title: Known issues\n"); + files["release-notes/index.md"].Should().Contain("navigation_title: Release notes\n"); + } + + [Test] + public void Generate_EveryPage_WarnsThatTheVersionsAreUnreleased() + { + var files = GenerateFiles(); + + foreach (var (path, content) in files.Where(f => f.Key.EndsWith(".md", StringComparison.Ordinal))) + content.Should().Contain(":::{warning}\nThis is a preview of an unreleased version.", $"{path} shows unreleased versions"); + } + [Test] public void Generate_RootIndex_LinksEveryProductByDisplayName() { - var files = UnifiedReleaseNotesDocset.Generate(UnifiedReleaseNotesDocset.SelectProducts(Products)); + var files = GenerateFiles(); files["release-notes/index.md"].Should().Contain("- [Elasticsearch](elasticsearch/index.md)"); files["release-notes/index.md"].Should().Contain("- [Kibana](kibana/index.md)"); @@ -180,11 +215,20 @@ public async Task Build_BundlesForEveryProduct_WritesAPageSetPerProduct() .Exists(_fileSystem.Path.Join(output, "release-notes", "kibana", "breaking-changes", "index.html")) .Should() .BeTrue(); + _fileSystem + .File + .ReadAllText(_fileSystem.Path.Join(output, "release-notes", "elasticsearch", "9.5.0", "index.html")) + .Should() + .Contain("Elasticsearch unified change") + .And + .Contain("This is a preview of an unreleased version"); _fileSystem .File .ReadAllText(_fileSystem.Path.Join(output, "release-notes", "elasticsearch", "index.html")) .Should() - .Contain("Elasticsearch unified change"); + .Contain("9.5.0") + .And + .NotContain("Elasticsearch unified change"); _fileSystem.Directory.Exists(_fileSystem.Path.Join(output, "release-notes", "auditbeat")).Should().BeFalse(); } @@ -217,6 +261,43 @@ public async Task Build_NoProductHasBundles_FailsWithoutWritingASite() _fileSystem.Directory.Exists(_fileSystem.Path.Join(_temp.FullName, "site")).Should().BeFalse(); } + [Test] + public async Task Build_MultipleBundleVersionsSuppliedOutOfOrder_EachVersionPageHasOnlyItsOwnEntriesAndNavigationIsNewestFirst() + { + // Written oldest-first, and with the 'v' prefix a release tag actually has, to prove that + // neither enumeration order nor the target/filename version mismatch affects the result. + _ = WriteBundle("elasticsearch", "elasticsearch-elasticsearch-v9.5.0.yaml", "v9.5.0", "Elasticsearch 9.5.0 change"); + _ = WriteBundle("elasticsearch", "elasticsearch-elasticsearch-v9.6.0.yaml", "v9.6.0", "Elasticsearch 9.6.0 change"); + var collector = new DiagnosticsCollector([]); + + var succeeded = await Service().Build(collector, Options("v9.5.0", "v9.6.0"), TestContext.Current!.Execution.CancellationToken); + + succeeded.Should().BeTrue(); + collector + .Warnings + .Should() + .Be(0, "each version page's :version: filter should match its own bundle despite the bundle target's 'v' prefix"); + + var output = _fileSystem.Path.Join(_temp.FullName, "site", "release-notes", "elasticsearch"); + _fileSystem + .File + .ReadAllText(_fileSystem.Path.Join(output, "9.5.0", "index.html")) + .Should() + .Contain("Elasticsearch 9.5.0 change") + .And + .NotContain("Elasticsearch 9.6.0 change"); + _fileSystem + .File + .ReadAllText(_fileSystem.Path.Join(output, "9.6.0", "index.html")) + .Should() + .Contain("Elasticsearch 9.6.0 change") + .And + .NotContain("Elasticsearch 9.5.0 change"); + + var index = _fileSystem.File.ReadAllText(_fileSystem.Path.Join(output, "index.html")); + index.IndexOf("9.6.0", StringComparison.Ordinal).Should().BeLessThan(index.IndexOf("9.5.0", StringComparison.Ordinal)); + } + [Test] public async Task Build_ExpectedVersionMissingForAProduct_StillBuilds() { @@ -289,7 +370,7 @@ public async Task Build_BundleWithAmendSidecar_RendersTheAmendedEntries() succeeded.Should().BeTrue(); var html = _fileSystem.File.ReadAllText( - _fileSystem.Path.Join(_temp.FullName, "site", "release-notes", "elasticsearch", "index.html") + _fileSystem.Path.Join(_temp.FullName, "site", "release-notes", "elasticsearch", "9.5.0", "index.html") ); html.Should().Contain("Original change"); html.Should().Contain("Change added by the amend");