diff --git a/docs-builder.sln b/docs-builder.sln index 4a08a8478..933bb0790 100644 --- a/docs-builder.sln +++ b/docs-builder.sln @@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs-assembler", "src\docs- EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "authoring", "tests\authoring\authoring.fsproj", "{018F959E-824B-4664-B345-066784478D24}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs-mover", "src\docs-mover\docs-mover.csproj", "{7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -95,6 +97,10 @@ Global {018F959E-824B-4664-B345-066784478D24}.Debug|Any CPU.Build.0 = Debug|Any CPU {018F959E-824B-4664-B345-066784478D24}.Release|Any CPU.ActiveCfg = Release|Any CPU {018F959E-824B-4664-B345-066784478D24}.Release|Any CPU.Build.0 = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {4D198E25-C211-41DC-9E84-B15E89BD7048} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} @@ -106,5 +112,6 @@ Global {A2A34BBC-CB5E-4100-9529-A12B6ECB769C} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7} {28350800-B44B-479B-86E2-1D39E321C0B4} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} {018F959E-824B-4664-B345-066784478D24} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5} + {7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A} EndGlobalSection EndGlobal diff --git a/docs/docset.yml b/docs/docset.yml index 814e38237..e82b7c4cd 100644 --- a/docs/docset.yml +++ b/docs/docset.yml @@ -97,3 +97,7 @@ toc: - file: req.md - folder: nested - file: cross-links.md + - folder: mover + children: + - file: first-page.md + - file: second-page.md diff --git a/docs/testing/mover/first-page.md b/docs/testing/mover/first-page.md new file mode 100644 index 000000000..be5e8c7b8 --- /dev/null +++ b/docs/testing/mover/first-page.md @@ -0,0 +1,3 @@ +# First Page + +[Link to second page](second-page.md) \ No newline at end of file diff --git a/docs/testing/mover/second-page.md b/docs/testing/mover/second-page.md new file mode 100644 index 000000000..d4b84efea --- /dev/null +++ b/docs/testing/mover/second-page.md @@ -0,0 +1,5 @@ +# Second Page + +[Link to first page](first-page.md) + +[Absolut link to first page](/testing/mover/first-page.md) diff --git a/src/docs-builder/Cli/Commands.cs b/src/docs-builder/Cli/Commands.cs index afa70d6d3..087e6f58a 100644 --- a/src/docs-builder/Cli/Commands.cs +++ b/src/docs-builder/Cli/Commands.cs @@ -1,12 +1,13 @@ // Licensed to Elasticsearch B.V under one or more agreements. // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information +using System.Collections.Generic; using System.IO.Abstractions; using Actions.Core.Services; using ConsoleAppFramework; -using Documentation.Builder.Diagnostics; using Documentation.Builder.Diagnostics.Console; using Documentation.Builder.Http; +using Documentation.Mover; using Elastic.Markdown; using Elastic.Markdown.IO; using Microsoft.Extensions.Logging; @@ -102,4 +103,33 @@ public async Task GenerateDefault( Cancel ctx = default ) => await Generate(path, output, pathPrefix, force, strict, allowIndexing, ctx); + + + /// + /// Move a file from one location to another and update all links in the documentation + /// + /// The source file or folder path to move from + /// The target file or folder path to move to + /// -p, Defaults to the`{pwd}` folder + /// Dry run the move operation + /// + [Command("mv")] + public async Task Move( + [Argument] string? source = null, + [Argument] string? target = null, + bool? dryRun = null, + string? path = null, + Cancel ctx = default + ) + { + var fileSystem = new FileSystem(); + var context = new BuildContext(fileSystem, fileSystem, path, null) + { + Collector = new ConsoleDiagnosticsCollector(logger, null), + }; + var set = new DocumentationSet(context); + + var moveCommand = new Move(fileSystem, fileSystem, set, logger); + return await moveCommand.Execute(source, target, dryRun ?? false, ctx); + } } diff --git a/src/docs-builder/docs-builder.csproj b/src/docs-builder/docs-builder.csproj index 08c3f4959..85eee0a02 100644 --- a/src/docs-builder/docs-builder.csproj +++ b/src/docs-builder/docs-builder.csproj @@ -30,7 +30,7 @@ + - - \ No newline at end of file + diff --git a/src/docs-mover/Move.cs b/src/docs-mover/Move.cs new file mode 100644 index 000000000..46a21f2a6 --- /dev/null +++ b/src/docs-mover/Move.cs @@ -0,0 +1,247 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using System.Collections.ObjectModel; +using System.IO.Abstractions; +using System.Text.RegularExpressions; +using Elastic.Markdown.IO; +using Elastic.Markdown.Slices; +using Microsoft.Extensions.Logging; + +namespace Documentation.Mover; + +public class Move(IFileSystem readFileSystem, IFileSystem writeFileSystem, DocumentationSet documentationSet, ILoggerFactory loggerFactory) +{ + private readonly ILogger _logger = loggerFactory.CreateLogger(); + private readonly List<(string filePath, string originalContent, string newContent)> _changes = []; + private readonly List _linkModifications = []; + private const string ChangeFormatString = "Change \e[31m{0}\e[0m to \e[32m{1}\e[0m at \e[34m{2}:{3}:{4}\e[0m"; + + public record LinkModification(string OldLink, string NewLink, string SourceFile, int LineNumber, int ColumnNumber); + + + public ReadOnlyCollection LinkModifications => _linkModifications.AsReadOnly(); + + public async Task Execute(string? source, string? target, bool isDryRun, Cancel ctx = default) + { + if (isDryRun) + _logger.LogInformation("Running in dry-run mode"); + + if (!ValidateInputs(source, target)) + { + return 1; + } + + + var sourcePath = Path.GetFullPath(source!); + var targetPath = Path.GetFullPath(target!); + + var sourceContent = await readFileSystem.File.ReadAllTextAsync(sourcePath, ctx); + + var markdownLinkRegex = new Regex(@"\[([^\]]*)\]\(((?:\.{0,2}\/)?[^:)]+\.md(?:#[^)]*)?)\)", RegexOptions.Compiled); + + var change = Regex.Replace(sourceContent, markdownLinkRegex.ToString(), match => + { + var originalPath = match.Value.Substring(match.Value.IndexOf('(') + 1, match.Value.LastIndexOf(')') - match.Value.IndexOf('(') - 1); + + var newPath = originalPath; + var isAbsoluteStylePath = originalPath.StartsWith('/'); + if (!isAbsoluteStylePath) + { + var targetDirectory = Path.GetDirectoryName(targetPath)!; + var sourceDirectory = Path.GetDirectoryName(sourcePath)!; + var fullPath = Path.GetFullPath(Path.Combine(sourceDirectory, originalPath)); + var relativePath = Path.GetRelativePath(targetDirectory, fullPath); + + if (originalPath.StartsWith("./") && !relativePath.StartsWith("./")) + newPath = "./" + relativePath; + else + newPath = relativePath; + } + var newLink = $"[{match.Groups[1].Value}]({newPath})"; + var lineNumber = sourceContent.Substring(0, match.Index).Count(c => c == '\n') + 1; + var columnNumber = match.Index - sourceContent.LastIndexOf('\n', match.Index); + _linkModifications.Add(new LinkModification( + match.Value, + newLink, + sourcePath, + lineNumber, + columnNumber + )); + return newLink; + }); + + _changes.Add((sourcePath, sourceContent, change)); + + foreach (var (_, markdownFile) in documentationSet.MarkdownFiles) + { + await ProcessMarkdownFile( + sourcePath, + targetPath, + markdownFile, + ctx + ); + } + + foreach (var (oldLink, newLink, sourceFile, lineNumber, columnNumber) in LinkModifications) + { + _logger.LogInformation(string.Format( + ChangeFormatString, + oldLink, + newLink, + sourceFile == sourcePath && !isDryRun ? targetPath : sourceFile, + lineNumber, + columnNumber + )); + } + + if (isDryRun) + return 0; + + + try + { + foreach (var (filePath, _, newContent) in _changes) + await writeFileSystem.File.WriteAllTextAsync(filePath, newContent, ctx); + var targetDirectory = Path.GetDirectoryName(targetPath); + readFileSystem.Directory.CreateDirectory(targetDirectory!); + readFileSystem.File.Move(sourcePath, targetPath); + } + catch (Exception) + { + foreach (var (filePath, originalContent, _) in _changes) + await writeFileSystem.File.WriteAllTextAsync(filePath, originalContent, ctx); + writeFileSystem.File.Move(targetPath, sourcePath); + _logger.LogError("An error occurred while moving files. Reverting changes"); + throw; + } + return 0; + } + + private bool ValidateInputs(string? source, string? target) + { + + if (string.IsNullOrEmpty(source)) + { + _logger.LogError("Source path is required"); + return false; + } + + if (string.IsNullOrEmpty(target)) + { + _logger.LogError("Target path is required"); + return false; + } + + if (!Path.GetExtension(source).Equals(".md", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError("Source path must be a markdown file. Directory paths are not supported yet"); + return false; + } + + if (!Path.GetExtension(target).Equals(".md", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError("Target path must be a markdown file. Directory paths are not supported yet"); + return false; + } + + if (!readFileSystem.File.Exists(source)) + { + _logger.LogError($"Source file {source} does not exist"); + return false; + } + + if (readFileSystem.File.Exists(target)) + { + _logger.LogError($"Target file {target} already exists"); + return false; + } + + return true; + } + + private async Task ProcessMarkdownFile( + string source, + string target, + MarkdownFile value, + Cancel ctx) + { + var content = await readFileSystem.File.ReadAllTextAsync(value.FilePath, ctx); + var currentDir = Path.GetDirectoryName(value.FilePath)!; + var pathInfo = GetPathInfo(currentDir, source, target); + var linkPattern = BuildLinkPattern(pathInfo); + + if (Regex.IsMatch(content, linkPattern)) + { + var newContent = ReplaceLinks(content, linkPattern, pathInfo.absoluteStyleTarget, target, value); + _changes.Add((value.FilePath, content, newContent)); + } + } + + private (string relativeSource, string relativeSourceWithDotSlash, string absolutStyleSource, string absoluteStyleTarget) GetPathInfo( + string currentDir, + string sourcePath, + string targetPath + ) + { + var relativeSource = Path.GetRelativePath(currentDir, sourcePath); + var relativeSourceWithDotSlash = Path.Combine(".", relativeSource); + var relativeToDocsFolder = Path.GetRelativePath(documentationSet.SourcePath.FullName, sourcePath); + var absolutStyleSource = $"/{relativeToDocsFolder}"; + var relativeToDocsFolderTarget = Path.GetRelativePath(documentationSet.SourcePath.FullName, targetPath); + var absoluteStyleTarget = $"/{relativeToDocsFolderTarget}"; + return ( + relativeSource, + relativeSourceWithDotSlash, + absolutStyleSource, + absoluteStyleTarget + ); + } + + private static string BuildLinkPattern( + (string relativeSource, string relativeSourceWithDotSlash, string absolutStyleSource, string _) pathInfo) => + $@"\[([^\]]*)\]\((?:{pathInfo.relativeSource}|{pathInfo.relativeSourceWithDotSlash}|{pathInfo.absolutStyleSource})(?:#[^\)]*?)?\)"; + + private string ReplaceLinks( + string content, + string linkPattern, + string absoluteStyleTarget, + string target, + MarkdownFile value + ) => + Regex.Replace( + content, + linkPattern, + match => + { + var originalPath = match.Value.Substring(match.Value.IndexOf('(') + 1, match.Value.LastIndexOf(')') - match.Value.IndexOf('(') - 1); + var anchor = originalPath.Contains('#') + ? originalPath[originalPath.IndexOf('#')..] + : ""; + + string newLink; + if (originalPath.StartsWith('/')) + { + newLink = $"[{match.Groups[1].Value}]({absoluteStyleTarget}{anchor})"; + } + else + { + var relativeTarget = Path.GetRelativePath(Path.GetDirectoryName(value.FilePath)!, target); + newLink = originalPath.StartsWith("./") && !relativeTarget.StartsWith("./") + ? $"[{match.Groups[1].Value}](./{relativeTarget}{anchor})" + : $"[{match.Groups[1].Value}]({relativeTarget}{anchor})"; + } + + var lineNumber = content.Substring(0, match.Index).Count(c => c == '\n') + 1; + var columnNumber = match.Index - content.LastIndexOf('\n', match.Index); + _linkModifications.Add(new LinkModification( + match.Value, + newLink, + value.SourceFile.FullName, + lineNumber, + columnNumber + )); + return newLink; + }); +} diff --git a/src/docs-mover/docs-mover.csproj b/src/docs-mover/docs-mover.csproj new file mode 100644 index 000000000..0cf1779a7 --- /dev/null +++ b/src/docs-mover/docs-mover.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + Documentation.Mover + enable + enable + Documentation.Mover + + + + + + + + + + + diff --git a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs index fabac673d..f08354e78 100644 --- a/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs +++ b/tests/Elastic.Markdown.Tests/DocSet/NavigationTestsBase.cs @@ -8,6 +8,7 @@ using Elastic.Markdown.IO; using Elastic.Markdown.IO.Configuration; using FluentAssertions; +using Microsoft.Extensions.Logging; using Xunit.Abstractions; namespace Elastic.Markdown.Tests.DocSet; @@ -16,14 +17,14 @@ public class NavigationTestsBase : IAsyncLifetime { protected NavigationTestsBase(ITestOutputHelper output) { - var logger = new TestLoggerFactory(output); + LoggerFactory = new TestLoggerFactory(output); ReadFileSystem = new FileSystem(); //use real IO to read docs. - var writeFs = new MockFileSystem(new MockFileSystemOptions //use in memory mock fs to test generation + WriteFileSystem = new MockFileSystem(new MockFileSystemOptions //use in memory mock fs to test generation { CurrentDirectory = Paths.Root.FullName }); var collector = new TestDiagnosticsCollector(output); - var context = new BuildContext(ReadFileSystem, writeFs) + var context = new BuildContext(ReadFileSystem, WriteFileSystem) { Force = false, UrlPathPrefix = null, @@ -33,11 +34,13 @@ protected NavigationTestsBase(ITestOutputHelper output) Set = new DocumentationSet(context); Set.Files.Should().HaveCountGreaterThan(10); - Generator = new DocumentationGenerator(Set, logger); - + Generator = new DocumentationGenerator(Set, LoggerFactory); } + protected ILoggerFactory LoggerFactory { get; } + protected FileSystem ReadFileSystem { get; set; } + protected IFileSystem WriteFileSystem { get; set; } protected DocumentationSet Set { get; } protected DocumentationGenerator Generator { get; } protected ConfigurationFile Configuration { get; set; } = default!; diff --git a/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj b/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj index d3b123dc7..0a2eb3645 100644 --- a/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj +++ b/tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs b/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs new file mode 100644 index 000000000..ea4d68d81 --- /dev/null +++ b/tests/Elastic.Markdown.Tests/Mover/MoverTests.cs @@ -0,0 +1,38 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// Elasticsearch B.V licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information + +using Documentation.Mover; +using Elastic.Markdown.Tests.DocSet; +using FluentAssertions; + +using Xunit.Abstractions; + +namespace Elastic.Markdown.Tests.Mover; + + +public class MoverTests(ITestOutputHelper output) : NavigationTestsBase(output) +{ + [Fact] + public async Task RelativeLinks() + { + var workingDirectory = Set.Configuration.SourceFile.DirectoryName; + Directory.SetCurrentDirectory(workingDirectory!); + + var mover = new Move(ReadFileSystem, WriteFileSystem, Set, LoggerFactory); + await mover.Execute("testing/mover/first-page.md", "new-folder/hello-world.md", true); + mover.LinkModifications.Should().HaveCount(3); + + Path.GetRelativePath(".", mover.LinkModifications[0].SourceFile).Should().Be("testing/mover/first-page.md"); + mover.LinkModifications[0].OldLink.Should().Be("[Link to second page](second-page.md)"); + mover.LinkModifications[0].NewLink.Should().Be("[Link to second page](../testing/mover/second-page.md)"); + + Path.GetRelativePath(".", mover.LinkModifications[1].SourceFile).Should().Be("testing/mover/second-page.md"); + mover.LinkModifications[1].OldLink.Should().Be("[Link to first page](first-page.md)"); + mover.LinkModifications[1].NewLink.Should().Be("[Link to first page](../../new-folder/hello-world.md)"); + + Path.GetRelativePath(".", mover.LinkModifications[2].SourceFile).Should().Be("testing/mover/second-page.md"); + mover.LinkModifications[2].OldLink.Should().Be("[Absolut link to first page](/testing/mover/first-page.md)"); + mover.LinkModifications[2].NewLink.Should().Be("[Absolut link to first page](/new-folder/hello-world.md)"); + } +}