Skip to content

Commit b51d311

Browse files
authored
Add docs-builder mv command (#376)
* Add mv command * Add license header * Also change links in source file * ok * Add tests * ok * Fix help text * fix * fix * Fix
1 parent f7b4340 commit b51d311

File tree

11 files changed

+365
-8
lines changed

11 files changed

+365
-8
lines changed

docs-builder.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs-assembler", "src\docs-
5353
EndProject
5454
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "authoring", "tests\authoring\authoring.fsproj", "{018F959E-824B-4664-B345-066784478D24}"
5555
EndProject
56+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "docs-mover", "src\docs-mover\docs-mover.csproj", "{7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}"
57+
EndProject
5658
Global
5759
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5860
Debug|Any CPU = Debug|Any CPU
@@ -95,6 +97,10 @@ Global
9597
{018F959E-824B-4664-B345-066784478D24}.Debug|Any CPU.Build.0 = Debug|Any CPU
9698
{018F959E-824B-4664-B345-066784478D24}.Release|Any CPU.ActiveCfg = Release|Any CPU
9799
{018F959E-824B-4664-B345-066784478D24}.Release|Any CPU.Build.0 = Release|Any CPU
100+
{7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
101+
{7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Debug|Any CPU.Build.0 = Debug|Any CPU
102+
{7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|Any CPU.ActiveCfg = Release|Any CPU
103+
{7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166}.Release|Any CPU.Build.0 = Release|Any CPU
98104
EndGlobalSection
99105
GlobalSection(NestedProjects) = preSolution
100106
{4D198E25-C211-41DC-9E84-B15E89BD7048} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A}
@@ -106,5 +112,6 @@ Global
106112
{A2A34BBC-CB5E-4100-9529-A12B6ECB769C} = {245023D2-D3CA-47B9-831D-DAB91A2FFDC7}
107113
{28350800-B44B-479B-86E2-1D39E321C0B4} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A}
108114
{018F959E-824B-4664-B345-066784478D24} = {67B576EE-02FA-4F9B-94BC-3630BC09ECE5}
115+
{7D36DDDA-9E0B-4D2C-8033-5D62FF8B6166} = {BE6011CC-1200-4957-B01F-FCCA10C5CF5A}
109116
EndGlobalSection
110117
EndGlobal

docs/docset.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,3 +98,7 @@ toc:
9898
- file: req.md
9999
- folder: nested
100100
- file: cross-links.md
101+
- folder: mover
102+
children:
103+
- file: first-page.md
104+
- file: second-page.md

docs/testing/mover/first-page.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# First Page
2+
3+
[Link to second page](second-page.md)

docs/testing/mover/second-page.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Second Page
2+
3+
[Link to first page](first-page.md)
4+
5+
[Absolut link to first page](/testing/mover/first-page.md)

src/docs-builder/Cli/Commands.cs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
// Licensed to Elasticsearch B.V under one or more agreements.
22
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information
4+
using System.Collections.Generic;
45
using System.IO.Abstractions;
56
using Actions.Core.Services;
67
using ConsoleAppFramework;
7-
using Documentation.Builder.Diagnostics;
88
using Documentation.Builder.Diagnostics.Console;
99
using Documentation.Builder.Http;
10+
using Documentation.Mover;
1011
using Elastic.Markdown;
1112
using Elastic.Markdown.IO;
1213
using Microsoft.Extensions.Logging;
@@ -102,4 +103,33 @@ public async Task<int> GenerateDefault(
102103
Cancel ctx = default
103104
) =>
104105
await Generate(path, output, pathPrefix, force, strict, allowIndexing, ctx);
106+
107+
108+
/// <summary>
109+
/// Move a file from one location to another and update all links in the documentation
110+
/// </summary>
111+
/// <param name="source">The source file or folder path to move from</param>
112+
/// <param name="target">The target file or folder path to move to</param>
113+
/// <param name="path"> -p, Defaults to the`{pwd}` folder</param>
114+
/// <param name="dryRun">Dry run the move operation</param>
115+
/// <param name="ctx"></param>
116+
[Command("mv")]
117+
public async Task<int> Move(
118+
[Argument] string? source = null,
119+
[Argument] string? target = null,
120+
bool? dryRun = null,
121+
string? path = null,
122+
Cancel ctx = default
123+
)
124+
{
125+
var fileSystem = new FileSystem();
126+
var context = new BuildContext(fileSystem, fileSystem, path, null)
127+
{
128+
Collector = new ConsoleDiagnosticsCollector(logger, null),
129+
};
130+
var set = new DocumentationSet(context);
131+
132+
var moveCommand = new Move(fileSystem, fileSystem, set, logger);
133+
return await moveCommand.Execute(source, target, dryRun ?? false, ctx);
134+
}
105135
}

src/docs-builder/docs-builder.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
</ItemGroup>
3131

3232
<ItemGroup>
33+
<ProjectReference Include="..\docs-mover\docs-mover.csproj" />
3334
<ProjectReference Include="..\Elastic.Markdown\Elastic.Markdown.csproj" />
3435
</ItemGroup>
35-
36-
</Project>
36+
</Project>

src/docs-mover/Move.cs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Collections.ObjectModel;
6+
using System.IO.Abstractions;
7+
using System.Text.RegularExpressions;
8+
using Elastic.Markdown.IO;
9+
using Elastic.Markdown.Slices;
10+
using Microsoft.Extensions.Logging;
11+
12+
namespace Documentation.Mover;
13+
14+
public class Move(IFileSystem readFileSystem, IFileSystem writeFileSystem, DocumentationSet documentationSet, ILoggerFactory loggerFactory)
15+
{
16+
private readonly ILogger _logger = loggerFactory.CreateLogger<Move>();
17+
private readonly List<(string filePath, string originalContent, string newContent)> _changes = [];
18+
private readonly List<LinkModification> _linkModifications = [];
19+
private const string ChangeFormatString = "Change \e[31m{0}\e[0m to \e[32m{1}\e[0m at \e[34m{2}:{3}:{4}\e[0m";
20+
21+
public record LinkModification(string OldLink, string NewLink, string SourceFile, int LineNumber, int ColumnNumber);
22+
23+
24+
public ReadOnlyCollection<LinkModification> LinkModifications => _linkModifications.AsReadOnly();
25+
26+
public async Task<int> Execute(string? source, string? target, bool isDryRun, Cancel ctx = default)
27+
{
28+
if (isDryRun)
29+
_logger.LogInformation("Running in dry-run mode");
30+
31+
if (!ValidateInputs(source, target))
32+
{
33+
return 1;
34+
}
35+
36+
37+
var sourcePath = Path.GetFullPath(source!);
38+
var targetPath = Path.GetFullPath(target!);
39+
40+
var sourceContent = await readFileSystem.File.ReadAllTextAsync(sourcePath, ctx);
41+
42+
var markdownLinkRegex = new Regex(@"\[([^\]]*)\]\(((?:\.{0,2}\/)?[^:)]+\.md(?:#[^)]*)?)\)", RegexOptions.Compiled);
43+
44+
var change = Regex.Replace(sourceContent, markdownLinkRegex.ToString(), match =>
45+
{
46+
var originalPath = match.Value.Substring(match.Value.IndexOf('(') + 1, match.Value.LastIndexOf(')') - match.Value.IndexOf('(') - 1);
47+
48+
var newPath = originalPath;
49+
var isAbsoluteStylePath = originalPath.StartsWith('/');
50+
if (!isAbsoluteStylePath)
51+
{
52+
var targetDirectory = Path.GetDirectoryName(targetPath)!;
53+
var sourceDirectory = Path.GetDirectoryName(sourcePath)!;
54+
var fullPath = Path.GetFullPath(Path.Combine(sourceDirectory, originalPath));
55+
var relativePath = Path.GetRelativePath(targetDirectory, fullPath);
56+
57+
if (originalPath.StartsWith("./") && !relativePath.StartsWith("./"))
58+
newPath = "./" + relativePath;
59+
else
60+
newPath = relativePath;
61+
}
62+
var newLink = $"[{match.Groups[1].Value}]({newPath})";
63+
var lineNumber = sourceContent.Substring(0, match.Index).Count(c => c == '\n') + 1;
64+
var columnNumber = match.Index - sourceContent.LastIndexOf('\n', match.Index);
65+
_linkModifications.Add(new LinkModification(
66+
match.Value,
67+
newLink,
68+
sourcePath,
69+
lineNumber,
70+
columnNumber
71+
));
72+
return newLink;
73+
});
74+
75+
_changes.Add((sourcePath, sourceContent, change));
76+
77+
foreach (var (_, markdownFile) in documentationSet.MarkdownFiles)
78+
{
79+
await ProcessMarkdownFile(
80+
sourcePath,
81+
targetPath,
82+
markdownFile,
83+
ctx
84+
);
85+
}
86+
87+
foreach (var (oldLink, newLink, sourceFile, lineNumber, columnNumber) in LinkModifications)
88+
{
89+
_logger.LogInformation(string.Format(
90+
ChangeFormatString,
91+
oldLink,
92+
newLink,
93+
sourceFile == sourcePath && !isDryRun ? targetPath : sourceFile,
94+
lineNumber,
95+
columnNumber
96+
));
97+
}
98+
99+
if (isDryRun)
100+
return 0;
101+
102+
103+
try
104+
{
105+
foreach (var (filePath, _, newContent) in _changes)
106+
await writeFileSystem.File.WriteAllTextAsync(filePath, newContent, ctx);
107+
var targetDirectory = Path.GetDirectoryName(targetPath);
108+
readFileSystem.Directory.CreateDirectory(targetDirectory!);
109+
readFileSystem.File.Move(sourcePath, targetPath);
110+
}
111+
catch (Exception)
112+
{
113+
foreach (var (filePath, originalContent, _) in _changes)
114+
await writeFileSystem.File.WriteAllTextAsync(filePath, originalContent, ctx);
115+
writeFileSystem.File.Move(targetPath, sourcePath);
116+
_logger.LogError("An error occurred while moving files. Reverting changes");
117+
throw;
118+
}
119+
return 0;
120+
}
121+
122+
private bool ValidateInputs(string? source, string? target)
123+
{
124+
125+
if (string.IsNullOrEmpty(source))
126+
{
127+
_logger.LogError("Source path is required");
128+
return false;
129+
}
130+
131+
if (string.IsNullOrEmpty(target))
132+
{
133+
_logger.LogError("Target path is required");
134+
return false;
135+
}
136+
137+
if (!Path.GetExtension(source).Equals(".md", StringComparison.OrdinalIgnoreCase))
138+
{
139+
_logger.LogError("Source path must be a markdown file. Directory paths are not supported yet");
140+
return false;
141+
}
142+
143+
if (!Path.GetExtension(target).Equals(".md", StringComparison.OrdinalIgnoreCase))
144+
{
145+
_logger.LogError("Target path must be a markdown file. Directory paths are not supported yet");
146+
return false;
147+
}
148+
149+
if (!readFileSystem.File.Exists(source))
150+
{
151+
_logger.LogError($"Source file {source} does not exist");
152+
return false;
153+
}
154+
155+
if (readFileSystem.File.Exists(target))
156+
{
157+
_logger.LogError($"Target file {target} already exists");
158+
return false;
159+
}
160+
161+
return true;
162+
}
163+
164+
private async Task ProcessMarkdownFile(
165+
string source,
166+
string target,
167+
MarkdownFile value,
168+
Cancel ctx)
169+
{
170+
var content = await readFileSystem.File.ReadAllTextAsync(value.FilePath, ctx);
171+
var currentDir = Path.GetDirectoryName(value.FilePath)!;
172+
var pathInfo = GetPathInfo(currentDir, source, target);
173+
var linkPattern = BuildLinkPattern(pathInfo);
174+
175+
if (Regex.IsMatch(content, linkPattern))
176+
{
177+
var newContent = ReplaceLinks(content, linkPattern, pathInfo.absoluteStyleTarget, target, value);
178+
_changes.Add((value.FilePath, content, newContent));
179+
}
180+
}
181+
182+
private (string relativeSource, string relativeSourceWithDotSlash, string absolutStyleSource, string absoluteStyleTarget) GetPathInfo(
183+
string currentDir,
184+
string sourcePath,
185+
string targetPath
186+
)
187+
{
188+
var relativeSource = Path.GetRelativePath(currentDir, sourcePath);
189+
var relativeSourceWithDotSlash = Path.Combine(".", relativeSource);
190+
var relativeToDocsFolder = Path.GetRelativePath(documentationSet.SourcePath.FullName, sourcePath);
191+
var absolutStyleSource = $"/{relativeToDocsFolder}";
192+
var relativeToDocsFolderTarget = Path.GetRelativePath(documentationSet.SourcePath.FullName, targetPath);
193+
var absoluteStyleTarget = $"/{relativeToDocsFolderTarget}";
194+
return (
195+
relativeSource,
196+
relativeSourceWithDotSlash,
197+
absolutStyleSource,
198+
absoluteStyleTarget
199+
);
200+
}
201+
202+
private static string BuildLinkPattern(
203+
(string relativeSource, string relativeSourceWithDotSlash, string absolutStyleSource, string _) pathInfo) =>
204+
$@"\[([^\]]*)\]\((?:{pathInfo.relativeSource}|{pathInfo.relativeSourceWithDotSlash}|{pathInfo.absolutStyleSource})(?:#[^\)]*?)?\)";
205+
206+
private string ReplaceLinks(
207+
string content,
208+
string linkPattern,
209+
string absoluteStyleTarget,
210+
string target,
211+
MarkdownFile value
212+
) =>
213+
Regex.Replace(
214+
content,
215+
linkPattern,
216+
match =>
217+
{
218+
var originalPath = match.Value.Substring(match.Value.IndexOf('(') + 1, match.Value.LastIndexOf(')') - match.Value.IndexOf('(') - 1);
219+
var anchor = originalPath.Contains('#')
220+
? originalPath[originalPath.IndexOf('#')..]
221+
: "";
222+
223+
string newLink;
224+
if (originalPath.StartsWith('/'))
225+
{
226+
newLink = $"[{match.Groups[1].Value}]({absoluteStyleTarget}{anchor})";
227+
}
228+
else
229+
{
230+
var relativeTarget = Path.GetRelativePath(Path.GetDirectoryName(value.FilePath)!, target);
231+
newLink = originalPath.StartsWith("./") && !relativeTarget.StartsWith("./")
232+
? $"[{match.Groups[1].Value}](./{relativeTarget}{anchor})"
233+
: $"[{match.Groups[1].Value}]({relativeTarget}{anchor})";
234+
}
235+
236+
var lineNumber = content.Substring(0, match.Index).Count(c => c == '\n') + 1;
237+
var columnNumber = match.Index - content.LastIndexOf('\n', match.Index);
238+
_linkModifications.Add(new LinkModification(
239+
match.Value,
240+
newLink,
241+
value.SourceFile.FullName,
242+
lineNumber,
243+
columnNumber
244+
));
245+
return newLink;
246+
});
247+
}

src/docs-mover/docs-mover.csproj

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net9.0</TargetFramework>
5+
<RootNamespace>Documentation.Mover</RootNamespace>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<Nullable>enable</Nullable>
8+
<AssemblyName>Documentation.Mover</AssemblyName>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<ProjectReference Include="..\Elastic.Markdown\Elastic.Markdown.csproj" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.0" />
17+
</ItemGroup>
18+
19+
</Project>

0 commit comments

Comments
 (0)