Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/docs-builder/Cli/Commands.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// 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 Elastic.Markdown;
Expand Down Expand Up @@ -102,4 +102,33 @@ public async Task<int> GenerateDefault(
Cancel ctx = default
) =>
await Generate(path, output, pathPrefix, force, strict, allowIndexing, ctx);


/// <summary>
/// Move a file or folder from one location to another and update all links in the documentation
/// </summary>
/// <param name="source">The source file or folder path to move from</param>
/// <param name="target">The target file or folder path to move to</param>
/// <param name="path"> -p, Defaults to the`{pwd}` folder</param>
/// <param name="dryRun">Dry run the move operation</param>
/// <param name="ctx"></param>
[Command("mv")]
public async Task<int> 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, set, logger);
return await moveCommand.Execute(source, target, dryRun ?? false, ctx);
}
}
228 changes: 228 additions & 0 deletions src/docs-builder/Cli/Move.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// 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.IO.Abstractions;
using System.Text.RegularExpressions;
using Elastic.Markdown.IO;
using Microsoft.Extensions.Logging;

namespace Documentation.Builder.Cli;

internal class Move(IFileSystem fileSystem, DocumentationSet documentationSet, ILoggerFactory loggerFactory)
{
private readonly ILogger _logger = loggerFactory.CreateLogger<Move>();
private readonly List<(string filePath, string originalContent, string newContent)> _changes = [];
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 async Task<int> 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 (_, sourceMarkdownFile) = documentationSet.MarkdownFiles.Single(i => i.Value.FilePath == sourcePath);

var sourceContent = await fileSystem.File.ReadAllTextAsync(sourceMarkdownFile.FilePath, 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(sourceMarkdownFile.FilePath)!;
var fullPath = Path.GetFullPath(Path.Combine(sourceDirectory, originalPath));
var relativePath = Path.GetRelativePath(targetDirectory, fullPath);
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);
_logger.LogInformation(
string.Format(
ChangeFormatString,
match.Value,
newLink,
sourceMarkdownFile.SourceFile.FullName,
lineNumber,
columnNumber
)
);
return newLink;
});

_changes.Add((sourceMarkdownFile.FilePath, sourceContent, change));

foreach (var (_, markdownFile) in documentationSet.MarkdownFiles)
{
await ProcessMarkdownFile(
sourcePath,
targetPath,
markdownFile,
ctx
);
}

if (isDryRun)
return 0;

var targetDirectory = Path.GetDirectoryName(targetPath);
fileSystem.Directory.CreateDirectory(targetDirectory!);
fileSystem.File.Move(sourcePath, targetPath);
try
{
foreach (var (filePath, _, newContent) in _changes)
await fileSystem.File.WriteAllTextAsync(filePath, newContent, ctx);
}
catch (Exception)
{
foreach (var (filePath, originalContent, _) in _changes)
await fileSystem.File.WriteAllTextAsync(filePath, originalContent, ctx);
fileSystem.File.Move(targetPath, sourcePath);
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 (!fileSystem.File.Exists(source))
{
_logger.LogError($"Source file {source} does not exist");
return false;
}

if (fileSystem.File.Exists(target))
{
_logger.LogError($"Target file {target} already exists");
return false;
}

return true;
}

private async Task ProcessMarkdownFile(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was hoping that we could leverage markdig here.

E.g we parse the document AST and include a linkrewriter parser (or make it part of our existing DiagnosticLinksParser. To mutate the links.

Then we can write the AST back out again as markdown.

markdig supports roundtipping like this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes totally sense.

Given the time and the need for it already today/tomorrow, do you think we can leave it this way for now and refactor it in a follow-up using Markdig?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

100%! progress over perfection.

string source,
string target,
MarkdownFile value,
Cancel ctx)
{
var content = await fileSystem.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("/"))
{
// Absolute style link
newLink = $"[{match.Groups[1].Value}]({absoluteStyleTarget}{anchor})";
}
else
{
// Relative link
var relativeTarget = Path.GetRelativePath(Path.GetDirectoryName(value.FilePath)!, target);
newLink = $"[{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);
_logger.LogInformation(
string.Format(
ChangeFormatString,
match.Value,
newLink,
value.SourceFile.FullName,
lineNumber,
columnNumber
)
);
return newLink;
});
}
3 changes: 1 addition & 2 deletions src/docs-builder/docs-builder.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,4 @@
<ItemGroup>
<ProjectReference Include="..\Elastic.Markdown\Elastic.Markdown.csproj" />
</ItemGroup>

</Project>
</Project>
Loading