diff --git a/README.md b/README.md index bc10966..96cce15 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ This repository contains a series of tools, templates, tips and tricks to make y ## Tools +* [DocAssembler 🆕](./src/DocAssembler): assemble documentation and assets from various locations on disk and assemble them in one place. It is possible to restructure, where the links are changed to the right location. * [DocFxTocGenerator](./src/DocFxTocGenerator): generate a Table of Contents (TOC) in YAML format for DocFX. It has features like the ability to configure the order of files and the names of documents and folders. * [DocLinkChecker](./src/DocLinkChecker): validate links in documents and check for orphaned attachments in the `.attachments` folder. The tool indicates whether there are errors or warnings, so it can be used in a CI pipeline. It can also clean up orphaned attachments automatically. And it can validate table syntax. * [DocLanguageTranslator](./src/DocLanguageTranslator): allows to generate and translate automatically missing files or identify missing files in multi language pattern directories. @@ -66,6 +67,7 @@ choco install docfx-companion-tools You can as well install the tools through `dotnet tool`. ```shell +dotnet tool install DocAssembler -g dotnet tool install DocFxTocGenerator -g dotnet tool install DocLanguageTranslator -g dotnet tool install DocLinkChecker -g diff --git a/src/DocAssembler/DocAssembler.Test/AssembleActionTests.cs b/src/DocAssembler/DocAssembler.Test/AssembleActionTests.cs new file mode 100644 index 0000000..65336e4 --- /dev/null +++ b/src/DocAssembler/DocAssembler.Test/AssembleActionTests.cs @@ -0,0 +1,348 @@ +// +// Copyright (c) DocFx Companion Tools. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +// +using Bogus; +using DocAssembler.Actions; +using DocAssembler.Configuration; +using DocAssembler.Test.Helpers; +using FluentAssertions; +using Microsoft.Extensions.Logging; + +namespace DocAssembler.Test; + +public class AssembleActionTests +{ + private Faker _faker = new(); + private MockFileService _fileService = new(); + private MockLogger _mockLogger = new(); + private ILogger _logger; + + private string _workingFolder = string.Empty; + private string _outputFolder = string.Empty; + + public AssembleActionTests() + { + _fileService.FillDemoSet(); + _logger = _mockLogger.Logger; + + _workingFolder = _fileService.Root; + _outputFolder = Path.Combine(_fileService.Root, "out"); + } + + [Fact] + public async void Run_MinimumConfigAllCopied() + { + // arrange + AssembleConfiguration config = new AssembleConfiguration + { + DestinationFolder = "out", + Content = + [ + new Content + { + SourceFolder = ".docfx", + Files = { "**" }, + RawCopy = true, // just copy the content + } + ], + }; + + // all files in .docfx and docs-children + int count = _fileService.Files.Count; + var expected = _fileService.Files.Where(x => x.Key.Contains("/.docfx/")); + + InventoryAction inventory = new(_workingFolder, config, _fileService, _logger); + await inventory.RunAsync(); + AssembleAction action = new(config, inventory.Files, _fileService, _logger); + + // act + var ret = await action.RunAsync(); + + // assert + ret.Should().Be(ReturnCode.Normal); + // expect files to be original count + expected files and folders + new "out" folder. + _fileService.Files.Should().HaveCount(count + expected.Count() + 1); + + // validate file content is copied + var file = expected.Single(x => x.Key.EndsWith("index.md")); + var expectedPath = file.Key.Replace("/.docfx/", $"/{config.DestinationFolder}/"); + var newFile = _fileService.Files.SingleOrDefault(x => x.Key == expectedPath); + newFile.Should().NotBeNull(); + newFile.Value.Should().Be(file.Value); + } + + [Fact] + public async void Run_MinimumConfigAllCopied_WithGlobalContentReplace() + { + // arrange + AssembleConfiguration config = new AssembleConfiguration + { + DestinationFolder = "out", + ContentReplacements = + [ + new Replacement + { + Expression = @"(?
[$\s])AB#(?[0-9]{3,6})",
+                    Value = @"${pre}[AB#${id}](https://dev.azure.com/MyCompany/MyProject/_workitems/edit/${id})"
+                }
+            ],
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = "docs",
+                        DestinationFolder = "general",
+                        Files = { "**" },
+                    }
+                ],
+        };
+
+        // all files in .docfx and docs-children
+        int count = _fileService.Files.Count;
+        var expected = _fileService.Files.Where(x => x.Key.StartsWith($"{_fileService.Root}/docs/"));
+
+        InventoryAction inventory = new(_workingFolder, config, _fileService, _logger);
+        await inventory.RunAsync();
+        AssembleAction action = new(config, inventory.Files, _fileService, _logger);
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        // expect files to be original count + expected files and folders + new "out" folder.
+        _fileService.Files.Should().HaveCount(count + expected.Count() + 1);
+
+        // validate file content is copied with changed AB# reference
+        var file = expected.Single(x => x.Key == $"{_fileService.Root}/docs/guidelines/documentation-guidelines.md");
+        var expectedPath = file.Key.Replace("/docs/", $"/{config.DestinationFolder}/general/");
+        var newFile = _fileService.Files.SingleOrDefault(x => x.Key == expectedPath);
+        newFile.Should().NotBeNull();
+        string[] lines = _fileService.ReadAllLines(newFile.Key);
+        var testLine = lines.Single(x => x.StartsWith("STANDARD:"));
+        testLine.Should().NotBeNull();
+        testLine.Should().Be("STANDARD: [AB#1234](https://dev.azure.com/MyCompany/MyProject/_workitems/edit/1234) reference");
+    }
+
+    [Fact]
+    public async void Run_MinimumConfigAllCopied_WithContentReplace()
+    {
+        // arrange
+        AssembleConfiguration config = new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = "docs",
+                        DestinationFolder = "general",
+                        Files = { "**" },
+                        ContentReplacements =
+                        [
+                            new Replacement
+                            {
+                                Expression = @"(?
[$\s])AB#(?[0-9]{3,6})",
+                                Value = @"${pre}[AB#${id}](https://dev.azure.com/MyCompany/MyProject/_workitems/edit/${id})"
+                            }
+                        ],
+                    }
+                ],
+        };
+
+        // all files in .docfx and docs-children
+        int count = _fileService.Files.Count;
+        var expected = _fileService.Files.Where(x => x.Key.StartsWith($"{_fileService.Root}/docs/"));
+
+        InventoryAction inventory = new(_workingFolder, config, _fileService, _logger);
+        await inventory.RunAsync();
+        AssembleAction action = new(config, inventory.Files, _fileService, _logger);
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        // expect files to be original count + expected files and folders + new "out" folder.
+        _fileService.Files.Should().HaveCount(count + expected.Count() + 1);
+
+        // validate file content is copied with changed AB# reference
+        var file = expected.Single(x => x.Key == $"{_fileService.Root}/docs/guidelines/documentation-guidelines.md");
+        var expectedPath = file.Key.Replace("/docs/", $"/{config.DestinationFolder}/general/");
+        var newFile = _fileService.Files.SingleOrDefault(x => x.Key == expectedPath);
+        newFile.Should().NotBeNull();
+        string[] lines = _fileService.ReadAllLines(newFile.Key);
+        var testLine = lines.Single(x => x.StartsWith("STANDARD:"));
+        testLine.Should().NotBeNull();
+        testLine.Should().Be("STANDARD: [AB#1234](https://dev.azure.com/MyCompany/MyProject/_workitems/edit/1234) reference");
+    }
+
+    [Fact]
+    public async void Run_MinimumConfigAllCopied_ContentShouldOverrideGlobal()
+    {
+        // arrange
+        AssembleConfiguration config = new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            ContentReplacements =
+            [
+                new Replacement
+                {
+                    Expression = @"(?
[$\s])AB#(?[0-9]{3,6})",
+                    Value = @"${pre}[AB#${id}](https://dev.azure.com/MyCompany/MyProject/_workitems/edit/${id})"
+                }
+            ],
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = "docs",
+                        DestinationFolder = "general",
+                        Files = { "**" },
+                        ContentReplacements = [],
+                    }
+                ],
+        };
+
+        // all files in .docfx and docs-children
+        int count = _fileService.Files.Count;
+        var expected = _fileService.Files.Where(x => x.Key.StartsWith($"{_fileService.Root}/docs/"));
+
+        InventoryAction inventory = new(_workingFolder, config, _fileService, _logger);
+        await inventory.RunAsync();
+        AssembleAction action = new(config, inventory.Files, _fileService, _logger);
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        // expect files to be original count + expected files and folders + new "out" folder.
+        _fileService.Files.Should().HaveCount(count + expected.Count() + 1);
+
+        // validate file content is copied with changed AB# reference
+        var file = expected.Single(x => x.Key == $"{_fileService.Root}/docs/guidelines/documentation-guidelines.md");
+        var expectedPath = file.Key.Replace("/docs/", $"/{config.DestinationFolder}/general/");
+        var newFile = _fileService.Files.SingleOrDefault(x => x.Key == expectedPath);
+        newFile.Should().NotBeNull();
+        string[] lines = _fileService.ReadAllLines(newFile.Key);
+        var testLine = lines.Single(x => x.StartsWith("STANDARD:"));
+        testLine.Should().NotBeNull();
+        testLine.Should().Be("STANDARD: AB#1234 reference");
+    }
+
+    [Fact]
+    public async void Run_StandardConfigAllCopied()
+    {
+        // arrange
+        AssembleConfiguration config = GetStandardConfiguration();
+
+        // all files in .docfx and docs-children
+        int count = _fileService.Files.Count;
+        var expected = _fileService.Files.Where(x => x.Key.Contains("/.docfx/") ||
+                                                     x.Key.Contains("/docs/"));
+
+        InventoryAction inventory = new(_workingFolder, config, _fileService, _logger);
+        await inventory.RunAsync();
+        AssembleAction action = new(config, inventory.Files, _fileService, _logger);
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        // expect files to be original count + expected files and folders + new "out" folder.
+        _fileService.Files.Should().HaveCount(count + expected.Count() + 1);
+
+        // validate file content is copied with changed AB# reference
+        var file = expected.Single(x => x.Key == $"{_fileService.Root}/docs/getting-started/README.md");
+        var expectedPath = file.Key.Replace("/docs/", $"/{config.DestinationFolder}/general/");
+        var newFile = _fileService.Files.SingleOrDefault(x => x.Key == expectedPath);
+        newFile.Should().NotBeNull();
+
+        string[] lines = _fileService.ReadAllLines(newFile.Key);
+
+        string testLine = lines.Single(x => x.StartsWith("EXTERNAL:"));
+        testLine.Should().Be("EXTERNAL: [.docassemble.json](https://github.com/example/blob/main/.docassemble.json)");
+
+        testLine = lines.Single(x => x.StartsWith("RESOURCE:"));
+        testLine.Should().Be("RESOURCE: ![computer](assets/computer.jpg)");
+
+        testLine = lines.Single(x => x.StartsWith("PARENT-DOC:"));
+        testLine.Should().Be("PARENT-DOC: [Docs readme](../README.md)");
+
+        testLine = lines.Single(x => x.StartsWith("RELATIVE-DOC:"));
+        testLine.Should().Be("RELATIVE-DOC: [Documentation guidelines](../guidelines/documentation-guidelines.md)");
+
+        testLine = lines.Single(x => x.StartsWith("ANOTHER-DOCS-TREE:"));
+        testLine.Should().Be("ANOTHER-DOCS-TREE: [System Copilot](../tools/system-copilot/README.md#usage)");
+
+        testLine = lines.Single(x => x.StartsWith("ANOTHER-DOCS-TREE-BACKSLASH:"));
+        testLine.Should().Be("ANOTHER-DOCS-TREE-BACKSLASH: [System Copilot](../tools/system-copilot/README.md#usage)");
+    }
+
+    private AssembleConfiguration GetStandardConfiguration()
+    {
+        return new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            ExternalFilePrefix = "https://github.com/example/blob/main/",
+            UrlReplacements =
+            [
+                new Replacement
+                {
+                    Expression = @"/[Dd]ocs/",
+                    Value = "/"
+                }
+            ],
+            ContentReplacements =
+            [
+                new Replacement
+                {
+                    Expression = @"(?
[$\s])AB#(?[0-9]{3,6})",
+                    Value = @"${pre}[AB#${id}](https://dev.azure.com/MyCompany/MyProject/_workitems/edit/${id})"
+                },
+                new Replacement     // Remove markdown style table of content
+                {
+                    Expression = @"\[\[_TOC_\]\]",
+                    Value = ""
+                }
+            ],
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = ".docfx",
+                        Files = { "**" },
+                        RawCopy = true,         // just copy the content
+                        UrlReplacements = []    // reset URL replacements
+                    },
+                    new Content
+                    {
+                        SourceFolder = "docs",
+                        DestinationFolder = "general",
+                        Files = { "**" },
+                    },
+                    new Content
+                    {
+                        SourceFolder = "shared",    // part of general docs
+                        DestinationFolder = "general/shared",
+                        Files = { "**/docs/**" },
+                    },
+                    new Content
+                    {
+                        SourceFolder = "tools",     // part of general docs
+                        DestinationFolder = "general/tools",
+                        Files = { "**/docs/**" },
+                    },
+                    new Content
+                    {
+                        SourceFolder = "backend",
+                        DestinationFolder = "services", // change name to services
+                        Files = { "**/docs/**" },
+                    },
+                ],
+        };
+    }
+}
diff --git a/src/DocAssembler/DocAssembler.Test/ConfigInitActionTests.cs b/src/DocAssembler/DocAssembler.Test/ConfigInitActionTests.cs
new file mode 100644
index 0000000..cd707b5
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/ConfigInitActionTests.cs
@@ -0,0 +1,72 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using Bogus;
+using DocAssembler.Actions;
+using DocAssembler.Configuration;
+using DocAssembler.Test.Helpers;
+using DocAssembler.Utils;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+
+namespace DocAssembler.Test;
+
+public class ConfigInitActionTests
+{
+    private Faker _faker = new();
+    private MockFileService _fileService = new();
+    private MockLogger _mockLogger = new();
+    private ILogger _logger;
+    private string _outputFolder = string.Empty;
+
+    public ConfigInitActionTests()
+    {
+        _fileService.FillDemoSet();
+        _logger = _mockLogger.Logger;
+        _outputFolder = Path.Combine(_fileService.Root, "out");
+    }
+
+    [Fact]
+    public async void Run_ConfigShouldBeCreated()
+    {
+        // arrange
+        ConfigInitAction action = new(_outputFolder, _fileService, _logger);
+        int count = _fileService.Files.Count;
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        _fileService.Files.Count.Should().Be(count + 1);
+
+        // read generated content and see if it deserializes
+        string content = _fileService.ReadAllText(_fileService.Files.Last().Key);
+        var config = SerializationUtil.Deserialize(content);
+        config.Should().NotBeNull();
+        config.DestinationFolder.Should().Be("out");
+    }
+
+    [Fact]
+    public async void Run_ConfigShouldNotBeCreatedWhenExists()
+    {
+        // arrange
+        ConfigInitAction action = new(_outputFolder, _fileService, _logger);
+        var folder = _fileService.AddFolder(_outputFolder);
+        var fileContents = "some content";
+        _fileService.AddFile(folder, ".docassembler.json", fileContents);
+        int count = _fileService.Files.Count;
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Error);
+        _fileService.Files.Count.Should().Be(count); // nothing added
+
+        // read file to see if still has the same content
+        string content = _fileService.ReadAllText(_fileService.Files.Last().Key);
+        content.Should().Be(fileContents);
+    }
+}
diff --git a/src/DocAssembler/DocAssembler.Test/Directory.Build.props b/src/DocAssembler/DocAssembler.Test/Directory.Build.props
new file mode 100644
index 0000000..63f85d6
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/Directory.Build.props
@@ -0,0 +1,3 @@
+
+  
+
diff --git a/src/DocAssembler/DocAssembler.Test/DocAssembler.Test.csproj b/src/DocAssembler/DocAssembler.Test/DocAssembler.Test.csproj
new file mode 100644
index 0000000..faf2b6a
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/DocAssembler.Test.csproj
@@ -0,0 +1,27 @@
+
+
+  
+    net8.0
+    enable
+    enable
+
+    false
+    true
+  
+
+  
+    
+    
+    
+    
+  
+
+  
+    
+  
+
+  
+    
+  
+
+
diff --git a/src/DocAssembler/DocAssembler.Test/FileInfoServiceTests.cs b/src/DocAssembler/DocAssembler.Test/FileInfoServiceTests.cs
new file mode 100644
index 0000000..ce6cc4b
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/FileInfoServiceTests.cs
@@ -0,0 +1,70 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using Bogus;
+using DocAssembler.FileService;
+using DocAssembler.Test.Helpers;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+
+namespace DocAssembler.Test;
+
+public class FileInfoServiceTests
+{
+    private Faker _faker = new();
+    private MockFileService _fileService = new();
+    private MockLogger _mockLogger = new();
+    private ILogger _logger;
+
+    public FileInfoServiceTests()
+    {
+        _fileService.FillDemoSet();
+        _logger = _mockLogger.Logger;
+    }
+
+    [Fact]
+    public void GetLocalHyperlinks_GetAllWithoutResourceOrWeblink()
+    {
+        // arrange
+        FileInfoService service = new(_fileService.Root, _fileService, _logger);
+
+        // act
+        var links = service.GetLocalHyperlinks("docs", "docs/getting-started/README.md");
+
+        // assert
+        links.Should().NotBeNull();
+        links.Should().HaveCount(7);
+
+        // testing a correction in our code, as the original is parsed weird by Markdig.
+        // reason is that back-slashes in links are formally not supported.
+        links[6].OriginalUrl.Should().Be(@"..\..\tools\system-copilot\docs\README.md#usage");
+        links[6].Url.Should().Be(@"..\..\tools\system-copilot\docs\README.md#usage");
+        links[6].UrlWithoutTopic.Should().Be(@"..\..\tools\system-copilot\docs\README.md");
+        links[6].UrlTopic.Should().Be("#usage");
+    }
+
+    [Fact]
+    public void GetLocalHyperlinks_SkipEmptyLink()
+    {
+        // arrange
+        FileInfoService service = new(_fileService.Root, _fileService, _logger);
+
+        // act
+        var links = service.GetLocalHyperlinks("docs", "docs/guidelines/documentation-guidelines.md");
+
+        // assert
+        links.Should().NotBeNull();
+        links.Should().BeEmpty();
+    }
+
+    [Fact]
+    public void GetLocalHyperlinks_NotExistingFileThrows()
+    {
+        // arrange
+        FileInfoService service = new(_fileService.Root, _fileService, _logger);
+
+        // act
+        Assert.Throws(() => _ = service.GetLocalHyperlinks("docs", "docs/not-existing/phantom-file.md"));
+    }
+}
diff --git a/src/DocAssembler/DocAssembler.Test/FileServiceTests.cs b/src/DocAssembler/DocAssembler.Test/FileServiceTests.cs
new file mode 100644
index 0000000..2cd073c
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/FileServiceTests.cs
@@ -0,0 +1,56 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Text.RegularExpressions;
+using Bogus;
+using DocAssembler.Test.Helpers;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+
+namespace DocAssembler.Test;
+
+public class FileServiceTests
+{
+    private Faker _faker = new();
+    private MockFileService _fileService = new();
+    private MockLogger _mockLogger = new();
+    private ILogger _logger;
+
+    private string _workingFolder = string.Empty;
+    private string _outputFolder = string.Empty;
+
+    public FileServiceTests()
+    {
+        _fileService.FillDemoSet();
+        _logger = _mockLogger.Logger;
+
+        _workingFolder = _fileService.Root;
+        _outputFolder = Path.Combine(_fileService.Root, "out");
+    }
+
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/dotnet/MyLibrary/docs/README.md", "**/docs/**", true)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/dotnet/MyLibrary/src/README.md", "**/docs/**", false)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/dotnet/MyLibrary/docs/README.md", "**/*.md", true)]
+    [InlineData("/Git/Projec/sharedt", "/Git/Project/shared/dotnet/MyLibrary/docs/images/machine.jpg", "**/*.md", false)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/dotnet/MyLibrary/docs/README.md", "**", true)]
+    [InlineData("/Git/Project/shared", "/Git/Project/README.md", "**", false)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/dotnet/MyLibrary/src/MyProject.Test.csproj", "**/*.Test.*", true)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/dotnet/MyLibrary/src/MyProject.Test.csproj", "**/*Test*", true)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/README.md", "*.md", true)]
+    [InlineData("/Git/Project/shared", "/Git/Project/toc.yml", "*.md", false)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/dotnet/MyLibrary/docs/README.md", "*", false)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/README.md", "*", true)]
+    [InlineData("/Git/Project/shared", "/Git/Project/shared/dotnet/MyLibrary/src/MyProject.Tests.csproj", @"**/*\.Test\.*", false)]
+    [InlineData("/Git/Project/backend", "/Git/Project/backend/docs/README.md", "**/docs/**", true)]
+    [Theory]
+    public void GlobToRegex(string root, string input, string pattern, bool selected)
+    {
+        // test of the Glob to Regex method in MockFileService class
+        // to make sure we're having the right pattern to match files for the tests.
+        string regex = _fileService.GlobToRegex(root, pattern);
+        var ret = Regex.Match(input, regex).Success;
+        ret.Should().Be(selected);
+    }
+}
+
diff --git a/src/DocAssembler/DocAssembler.Test/Helpers/MarkdownExtensions.cs b/src/DocAssembler/DocAssembler.Test/Helpers/MarkdownExtensions.cs
new file mode 100644
index 0000000..44ca30f
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/Helpers/MarkdownExtensions.cs
@@ -0,0 +1,130 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using Bogus;
+
+namespace DocAssembler.Test.Helpers;
+
+internal static class MarkdownExtensions
+{
+    internal static string AddHeading(this string s, string title, int level)
+    {
+        var content = $"{new string('#', level)} {title}" + Environment.NewLine + Environment.NewLine;
+        if (string.IsNullOrEmpty(s))
+        {
+            return content;
+        }
+        return s + Environment.NewLine + content;
+    }
+
+    internal static string AddParagraphs(this string s, int count = 1)
+    {
+        var faker = new Faker();
+        var content = (count == 1 ? faker.Lorem.Paragraph() : faker.Lorem.Paragraphs(count)) + Environment.NewLine;
+        if (string.IsNullOrEmpty(s))
+        {
+            return content;
+        }
+        return s + Environment.NewLine + content;
+    }
+
+    internal static string AddResourceLink(this string s, string url)
+    {
+        var faker = new Faker();
+        var content = $" ![some resource {faker.Random.Int(1)}]({url})" + Environment.NewLine;
+        if (string.IsNullOrEmpty(s))
+        {
+            return content;
+        }
+        return s + Environment.NewLine + content;
+    }
+
+    internal static string AddLink(this string s, string url)
+    {
+        var faker = new Faker();
+        var content = $" [some link {faker.Random.Int(1)}]({url})" + Environment.NewLine;
+        if (string.IsNullOrEmpty(s))
+        {
+            return content;
+        }
+        return s + Environment.NewLine + content;
+    }
+
+    internal static string AddCodeLink(this string s, string name, string url)
+    {
+        var faker = new Faker();
+        var content = $" [!code-csharp[{name}]({url})]" + Environment.NewLine;
+        if (string.IsNullOrEmpty(s))
+        {
+            return content;
+        }
+        return s + Environment.NewLine + content;
+    }
+
+    internal static string AddRawLink(this string s, string name, string url)
+    {
+        var faker = new Faker();
+        var content = $" [{name}]({url})" + Environment.NewLine;
+        if (string.IsNullOrEmpty(s))
+        {
+            return content;
+        }
+        return s + Environment.NewLine + content;
+    }
+
+    internal static string AddTableStart(this string s, int columns = 3)
+    {
+        var faker = new Faker();
+        var content = "|";
+        for (var col = 0; col < columns; col++)
+        {
+            content += $" {faker.Lorem.Words(2)} |";
+        }
+        content += Environment.NewLine;
+        for (var col = 0; col < columns; col++)
+        {
+            content += $" --- |";
+        }
+        content += Environment.NewLine;
+        if (string.IsNullOrEmpty(s))
+        {
+            return content;
+        }
+        return s + Environment.NewLine + content;
+    }
+
+    internal static string AddTableRow(this string s, params string[] columns)
+    {
+        var faker = new Faker();
+        var content = "|";
+        foreach (var col in columns)
+        {
+            content += $" {col} |";
+        }
+        content += Environment.NewLine;
+        if (string.IsNullOrEmpty(s))
+        {
+            return content;
+        }
+        return s + Environment.NewLine + content;
+    }
+
+    internal static string AddNewLine(this string s)
+    {
+        if (string.IsNullOrEmpty(s))
+        {
+            return Environment.NewLine;
+        }
+        return s + Environment.NewLine;
+    }
+
+    internal static string AddRaw(this string s, string markdown)
+    {
+        if (string.IsNullOrEmpty(s))
+        {
+            return markdown;
+        }
+        return s + markdown + Environment.NewLine;
+    }
+}
diff --git a/src/DocAssembler/DocAssembler.Test/Helpers/MockFileService.cs b/src/DocAssembler/DocAssembler.Test/Helpers/MockFileService.cs
new file mode 100644
index 0000000..55a85f4
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/Helpers/MockFileService.cs
@@ -0,0 +1,386 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Text;
+using System.Text.RegularExpressions;
+using DocAssembler.FileService;
+
+namespace DocAssembler.Test.Helpers;
+
+public class MockFileService : IFileService
+{
+    public string Root;
+
+    public Dictionary Files { get; set; } = new();
+
+    public MockFileService()
+    {
+        // determine if we're testing on Windows. If not, use linux paths.
+        if (Path.IsPathRooted("c://"))
+        {
+            // windows
+            Root = "c:/Git/Project";
+        }
+        else
+        {
+            // linux
+            Root = "/Git/Project";
+        }
+    }
+
+    public void FillDemoSet()
+    {
+        Files.Clear();
+
+        // make sure that root folders are available
+        EnsurePath(Root);
+        
+        // 4 files
+        var folder = AddFolder(".docfx");
+        AddFile(folder, "docfx.json", "");
+        AddFile(folder, "index.md", string.Empty
+            .AddHeading("Test Repo", 1)
+            .AddParagraphs(1)
+            .AddRawLink("Keyboard", "images/keyboard.jpg")
+            .AddParagraphs(1)
+            .AddRawLink("setup your dev machine", "./general/getting-started/setup-dev-machine.md"));
+        AddFile(folder, "toc.yml",
+@"---
+- name: General
+  href: general/README.md
+- name: Services
+  href: services/README.md");
+        folder = AddFolder(".docfx/images");
+        AddFile(folder, "keyboard.jpg", "");
+
+        // docs: 1 + 2 + 1 + 3 = 7 files
+        folder = AddFolder($"docs");
+        AddFile(folder, "README.md", string.Empty
+            .AddHeading("Documentation Readme", 1)
+            .AddParagraphs(3));
+        folder = AddFolder("docs/getting-started");
+        AddFile(folder, "README.md", string.Empty
+            .AddHeading("Getting Started", 1)
+            .AddRaw("EXTERNAL: [.docassemble.json](../../.docassemble.json)")
+            .AddRaw("WEBLINK: [Microsoft](https://www.microsoft.com)")
+            .AddRaw("RESOURCE: ![computer](assets/computer.jpg)")
+            .AddRaw("PARENT-DOC: [Docs readme](../README.md)")
+            .AddRaw("RELATIVE-DOC: [Documentation guidelines](../guidelines/documentation-guidelines.md)")
+            .AddRaw("ANOTHER-SUBFOLDER-DOC: [Documentation guidelines](../guidelines/documentation-guidelines.md)")
+            .AddRaw("ANOTHER-DOCS-TREE: [System Copilot](../../tools/system-copilot/docs/README.md#usage)")
+            .AddRaw("ANOTHER-DOCS-TREE-BACKSLASH: [System Copilot](..\\..\\tools\\system-copilot\\docs\\README.md#usage)"));
+        folder = AddFolder("docs/getting-started/assets");
+        AddFile(folder, "computer.jpg", "");
+        folder = AddFolder("docs/guidelines");
+        AddFile(folder, "documentation-guidelines.md", string.Empty
+            .AddHeading("Documentation Guidelines", 1)
+            .AddRaw("STANDARD: AB#1234 reference")
+            .AddRaw("AB#4321 at START")
+            .AddRaw("EMPTY-LINK: [AB#1000]() is okay."));
+        AddFile(folder, "dotnet-guidelines.md", string.Empty
+            .AddHeading(".NET Guidelines", 1)
+            .AddParagraphs(3));
+
+        // 
+        folder = AddFolder("backend");
+        folder = AddFolder("backend/docs");
+        AddFile(folder, "README.md", string.Empty
+            .AddHeading("Backend", 1)
+            .AddParagraphs(2));
+        folder = AddFolder("backend/app1");
+        folder = AddFolder("backend/app1/docs");
+        AddFile(folder, "README.md", string.Empty
+            .AddHeading("App1", 1)
+            .AddRaw(@"We're using the [system copilot](../../../tools/system-copilot/docs/README.md#usage)")
+            .AddParagraphs(2)
+            );
+        folder = AddFolder("backend/app1/src");
+        AddFile(folder, "app1.cs", "");
+        folder = AddFolder("backend/subsystem1");
+        folder = AddFolder("backend/subsystem1/docs");
+        AddFile(folder, "explain-subsystem.md", string.Empty
+            .AddHeading("Subsystem 1", 1)
+            .AddParagraphs(3));
+        folder = AddFolder("backend/subsystem1/app20");
+        folder = AddFolder("backend/subsystem1/app20/docs");
+        AddFile(folder, "README.md", string.Empty
+            .AddHeading("app20", 1)
+            .AddRaw(
+@"This is part of [the subsystem1](../../docs/explain-subsystem1.md).
+
+We're following the [Documentation Guidelines](../../../../docs/guidelines/documentation-guidelines.md)")
+            .AddParagraphs(1)
+            .AddRaw("It's also important to look at [the code](../src/app20.cs)")
+            .AddParagraphs(3));
+        folder = AddFolder("backend/subsystem1/app20/src");
+        AddFile(folder, "app20.cs", "");
+        folder = AddFolder("backend/subsystem1/app30");
+        folder = AddFolder("backend/subsystem1/app30/docs");
+        AddFile(folder, "README.md", string.Empty
+            .AddHeading("app30", 1)
+            .AddRaw(
+@"This is part of [the subsystem1](../../docs/explain-subsystem1.md).
+
+We're using [My Library](../../../../shared/dotnet/MyLibrary/docs/README.md) in this app.")
+            .AddParagraphs(2));
+        folder = AddFolder("backend/subsystem1/app30/src");
+        AddFile(folder, "app30.cs", "");
+
+        folder = AddFolder("shared");
+        folder = AddFolder("shared/dotnet");
+        folder = AddFolder("shared/dotnet/MyLibrary");
+        folder = AddFolder("shared/dotnet/MyLibrary/docs");
+        AddFile(folder, "README.md", string.Empty
+            .AddHeading("My Library", 1)
+            .AddParagraphs(2));
+        folder = AddFolder("shared/dotnet/MyLibrary/src");
+        AddFile(folder, "MyLogic.cs", "");
+
+        folder = AddFolder("tools");
+        folder = AddFolder("tools/system-copilot");
+        folder = AddFolder("tools/system-copilot/docs");
+        AddFile(folder, "README.md", string.Empty
+            .AddHeading("The system copilot", 1)
+            .AddParagraphs(2)
+            .AddRaw("Go to [usage][#usage]")
+            .AddHeading("Usage", 2)
+            .AddRaw("You can use the tool like this:")
+            .AddRaw(
+@"```shell
+system-copilot -q ""provide your question here""
+```"));
+        folder = AddFolder("tools/system-copilot/SRC");
+        AddFile(folder, "system-copilot.cs", "");
+    }
+
+    public string AddFolder(string relativePath)
+    {
+        var fullPath = Path.Combine(Root, relativePath).NormalizePath();
+        Files.Add(fullPath, string.Empty);
+        return relativePath;
+    }
+
+    public void AddFile(string folderPath, string filename, string content)
+    {
+        var fullPath = Path.Combine(Root, folderPath, filename).NormalizePath();
+        Files.Add(fullPath, content.NormalizeContent());
+    }
+
+    public void Delete(string path)
+    {
+        Files.Remove(GetFullPath(path));
+    }
+
+    public void Delete(string[] paths)
+    {
+        foreach (var path in paths)
+        {
+            Files.Remove(GetFullPath(path));
+        }
+    }
+
+    public bool ExistsFileOrDirectory(string path)
+    {
+        string fullPath = GetFullPath(path);
+        return Root.Equals(fullPath, StringComparison.OrdinalIgnoreCase) || Files.ContainsKey(fullPath);
+    }
+
+    public IEnumerable GetFiles(string root, List includes, List? excludes)
+    {
+        string fullRoot = GetFullPath(root);
+
+        List rgInc = includes.Select(x => GlobToRegex(root, x)).ToList();
+        List rgExc = [];
+        if (excludes is not null)
+        {
+            rgExc = excludes.Select(x => GlobToRegex(root, x)).ToList();
+        }
+
+        List files = [];
+
+        var filesNoFolders = Files.Where(x => !string.IsNullOrEmpty(x.Value));
+        foreach (var file in filesNoFolders)
+        {
+            string selection = string.Empty;
+            // see if it matches any of the include patterns
+            foreach (string pattern in rgInc)
+            {
+                if (Regex.Match(file.Key, pattern).Success)
+                {
+                    // yes, so we're done here
+                    selection = file.Key;
+                    break;
+                }
+            }
+
+            if (!string.IsNullOrEmpty(selection))
+            {
+                // see if it's excluded by any pattern
+                foreach (string pattern in rgExc)
+                {
+                    if (Regex.Match(file.Key, pattern).Success)
+                    {
+                        // yes, so we can skip this one
+                        selection = string.Empty;
+                        break;
+                    }
+                }
+            }
+
+            if (!string.IsNullOrEmpty(selection))
+            {
+                // still have a selection, so add it to the list.
+                files.Add(selection);
+            }
+        }
+
+        return files;
+    }
+
+    public string GetFullPath(string path)
+    {
+        if (Path.IsPathRooted(path))
+        {
+            return path.NormalizePath();
+        }
+        else
+        {
+            return Path.Combine(Root, path).NormalizePath();
+        }
+    }
+
+    public string GetRelativePath(string relativeTo, string path)
+    {
+        return Path.GetRelativePath(relativeTo, path).NormalizePath();
+    }
+
+    public IEnumerable GetDirectories(string folder)
+    {
+        return Files.Where(x => x.Value == string.Empty &&
+                                x.Key.StartsWith(GetFullPath(folder)) &&
+                                !x.Key.Substring(Math.Min(GetFullPath(folder).Length + 1, x.Key.Length)).Contains("/") &&
+                                !x.Key.Equals(GetFullPath(folder), StringComparison.OrdinalIgnoreCase))
+            .Select(x => x.Key).ToList();
+    }
+
+    public string ReadAllText(string path)
+    {
+        string ipath = GetFullPath(path);
+        if (Files.TryGetValue(ipath, out var content) && !string.IsNullOrEmpty(content))
+        {
+            return content.NormalizeContent();
+        }
+
+        throw new FileNotFoundException($"File not found: '{path}'");
+    }
+
+    public string[] ReadAllLines(string path)
+    {
+        if (Files.TryGetValue(GetFullPath(path), out var content) && !string.IsNullOrEmpty(content))
+        {
+            return content.NormalizeContent().Split("\n");
+        }
+
+        throw new FileNotFoundException($"File not found: '{path}'");
+    }
+
+    public void WriteAllText(string path, string content)
+    {
+        string ipath = GetFullPath(path);
+        if (Files.TryGetValue(ipath, out var x))
+        {
+            Files.Remove(ipath);
+        }
+        Files.Add(ipath, content!.NormalizeContent());
+    }
+
+    public Stream OpenRead(string path)
+    {
+        return new MemoryStream(Encoding.UTF8.GetBytes(ReadAllText(path)));
+    }
+
+    public void Copy(string source, string destination)
+    {
+        string file = Path.GetFileName(destination);
+        string path = Path.GetDirectoryName(destination)!.NormalizePath();
+
+        if (!ExistsFileOrDirectory(source))
+        {
+            throw new FileNotFoundException($"Source file {source} not found");
+        }
+
+        EnsurePath(path);
+
+        if (!ExistsFileOrDirectory(destination))
+        {
+            string content = ReadAllText(source);
+            AddFile(path, file, content);
+        }
+    }
+
+    public void DeleteFolder(string path)
+    {
+        Delete(path);
+    }
+
+    public string GlobToRegex(string root, string input)
+    {
+        // replace **/*. where  is the extension. E.g. **/*.md
+        string pattern = @"\*\*\\\/\*\\\.(?\w+)";
+        if (Regex.Match(input, pattern).Success)
+        {
+            return root.TrimEnd('/') + "/" + Regex.Replace(input, pattern, @".+\/*\.${ext}$");
+        }
+
+        // replace **/** where  can be any test. E.g. **/*.Test.*
+        pattern = @"\*\*\/\*(?.+)\*";
+        if (Regex.Match(input, pattern).Success)
+        {
+            return Regex.Replace(input, pattern, ".+${part}.+$");
+        }
+
+        // replace **
+        pattern = @"\*\*";
+        if (Regex.Match(input, pattern).Success)
+        {
+            return root.TrimEnd('/') + Regex.Replace(input, pattern, ".*/*");
+        }
+
+        // replace *. where  is the extension. E.g. *.md
+        pattern = @"\*\.(?\w+)$";
+        if (Regex.Match(input, pattern).Success)
+        {
+            return root.TrimEnd('/') + "/" + Regex.Replace(input, pattern, @"[^\/\\]*\.${ext}$");
+        }
+
+        // replace *
+        pattern = @"\*";
+        if (Regex.Match(input, pattern).Success)
+        {
+            return root.TrimEnd('/') + "/" + Regex.Replace(input, pattern, @"[^\/\\]*$");
+        }
+
+        return input;
+    }
+
+    private void EnsurePath(string path)
+    {
+        // ensure path exists
+        string[] elms = path.Split('/');
+        string elmPath = string.Empty;
+        foreach (string elm in elms)
+        {
+            if (!string.IsNullOrEmpty(elm))
+            {
+                elmPath += "/";
+            }
+            elmPath += elm;
+            if (!ExistsFileOrDirectory(elmPath))
+            {
+                AddFolder(elmPath);
+            }
+        }
+    }
+}
+
diff --git a/src/DocAssembler/DocAssembler.Test/Helpers/MockLogger.cs b/src/DocAssembler/DocAssembler.Test/Helpers/MockLogger.cs
new file mode 100644
index 0000000..c8de8b8
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/Helpers/MockLogger.cs
@@ -0,0 +1,100 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace DocAssembler.Test.Helpers;
+
+internal class MockLogger
+{
+    private readonly Mock _logger = new();
+
+    public Mock Mock => _logger;
+    public ILogger Logger => _logger.Object;
+
+    public Mock VerifyWarningWasCalled()
+    {
+        Mock.Verify(
+            x => x.Log(
+                It.Is(l => l == LogLevel.Warning),
+                It.IsAny(),
+                It.Is((v, t) => true),
+                It.IsAny(),
+                It.Is>((v, t) => true)));
+
+        return Mock;
+    }
+
+    public Mock VerifyWarningWasCalled(string expectedMessage)
+    {
+        Func state = (v, t) => v.ToString()!.CompareTo(expectedMessage) == 0;
+
+        Mock.Verify(
+            x => x.Log(
+                It.Is(l => l == LogLevel.Warning),
+                It.IsAny(),
+                It.Is((v, t) => state(v, t)),
+                It.IsAny(),
+                It.Is>((v, t) => true)));
+
+        return Mock;
+    }
+
+    public Mock VerifyErrorWasCalled()
+    {
+        Mock.Verify(
+            x => x.Log(
+                It.Is(l => l == LogLevel.Error),
+                It.IsAny(),
+                It.Is((v, t) => true),
+                It.IsAny(),
+                It.Is>((v, t) => true)));
+
+        return Mock;
+    }
+
+    public Mock VerifyErrorWasCalled(string expectedMessage)
+    {
+        Func state = (v, t) => v.ToString()!.CompareTo(expectedMessage) == 0;
+
+        Mock.Verify(
+            x => x.Log(
+                It.Is(l => l == LogLevel.Error),
+                It.IsAny(),
+                It.Is((v, t) => state(v, t)),
+                It.IsAny(),
+                It.Is>((v, t) => true)));
+
+        return Mock;
+    }
+
+    public Mock VerifyCriticalWasCalled()
+    {
+        Mock.Verify(
+            x => x.Log(
+                It.Is(l => l == LogLevel.Critical),
+                It.IsAny(),
+                It.Is((v, t) => true),
+                It.IsAny(),
+                It.Is>((v, t) => true)));
+
+        return Mock;
+    }
+
+    public Mock VerifyCriticalWasCalled(string expectedMessage)
+    {
+        Func state = (v, t) => v.ToString()!.CompareTo(expectedMessage) == 0;
+
+        Mock.Verify(
+            x => x.Log(
+                It.Is(l => l == LogLevel.Critical),
+                It.IsAny(),
+                It.Is((v, t) => state(v, t)),
+                It.IsAny(),
+                It.Is>((v, t) => true)));
+
+        return Mock;
+    }
+}
diff --git a/src/DocAssembler/DocAssembler.Test/InventoryActionTests.cs b/src/DocAssembler/DocAssembler.Test/InventoryActionTests.cs
new file mode 100644
index 0000000..dc017c2
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.Test/InventoryActionTests.cs
@@ -0,0 +1,307 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using Bogus;
+using DocAssembler.Actions;
+using DocAssembler.Configuration;
+using DocAssembler.Test.Helpers;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+
+namespace DocAssembler.Test;
+
+public class InventoryActionTests
+{
+    private Faker _faker = new();
+    private MockFileService _fileService = new();
+    private MockLogger _mockLogger = new();
+    private ILogger _logger;
+
+    private string _workingFolder = string.Empty;
+    private string _outputFolder = string.Empty;
+
+    public InventoryActionTests()
+    {
+        _fileService.FillDemoSet();
+        _logger = _mockLogger.Logger;
+
+        _workingFolder = _fileService.Root;
+        _outputFolder = Path.Combine(_fileService.Root, "out");
+    }
+
+    [Fact]
+    public async void Run_StandardConfigProducesExpectedFiles()
+    {
+        // arrange
+        AssembleConfiguration config = GetStandardConfiguration();
+        InventoryAction action = new(_workingFolder, config, _fileService, _logger);
+        // all files in .docfx and docs-children
+        var expected = _fileService.Files.Where(x => !string.IsNullOrEmpty(x.Value) &&
+                                                     (x.Key.Contains("/.docfx/") || x.Key.Contains("/docs/")));
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        action.Files.Should().HaveCount(expected.Count());
+    }
+
+    [Fact]
+    public async void Run_MinimimalRawConfigProducesExpectedFiles()
+    {
+        // arrange
+        AssembleConfiguration config = new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = ".docfx",
+                        Files = { "**" },
+                        RawCopy = true,         // just copy the content
+                    }
+                ],
+        };
+        InventoryAction action = new(_workingFolder, config, _fileService, _logger);
+        // all files in .docfx
+        int expected = _fileService.Files.Count(x => !string.IsNullOrEmpty(x.Value) && x.Key.Contains("/.docfx/"));
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        action.Files.Should().HaveCount(expected);
+    }
+
+    [Fact]
+    public async void Run_MinimalRawConfigWithDoubleContent_ShouldFail()
+    {
+        // arrange
+        AssembleConfiguration config = new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            Content =
+            [
+                new Content
+                {
+                    SourceFolder = ".docfx",
+                    Files = { "**" },
+                    RawCopy = true,         // just copy the content
+                },
+                new Content
+                {
+                    SourceFolder = ".docfx", // same content and destination should fail.
+                    Files = { "**" },
+                    RawCopy = true,
+                }
+            ],
+        };
+        InventoryAction action = new(_workingFolder, config, _fileService, _logger);
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Error);
+    }
+
+    [Fact]
+    public async void Run_MinimalRawConfig_WithGlobalChangedPaths()
+    {
+        // arrange
+        AssembleConfiguration config = new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            UrlReplacements =
+            [
+                new Replacement
+                {
+                    Expression = @"/[Ii]mages/",
+                    Value = "/assets/"
+                }
+            ],
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = ".docfx",
+                        Files = { "**" },
+                        RawCopy = true,         // just copy the content
+                    }
+                ],
+        };
+        InventoryAction action = new(_workingFolder, config, _fileService, _logger);
+        // all files in .docfx
+        int expected = _fileService.Files.Count(x => !string.IsNullOrEmpty(x.Value) && x.Key.Contains("/.docfx/"));
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        action.Files.Should().HaveCount(expected);
+        var assets = action.Files.Where(x => x.SourcePath.Contains("/images"));
+        assets.Should().HaveCount(1);
+
+        string expectedPath = assets.First().SourcePath
+            .Replace($"{_fileService.Root}/.docfx", $"{_fileService.Root}/{config.DestinationFolder}")
+            .Replace("/images/", "/assets/");
+        assets.First().DestinationPath.Should().Be(expectedPath);
+    }
+
+    [Fact]
+    public async void Run_MinimalRawConfig_WithContentChangedPaths()
+    {
+        // arrange
+        AssembleConfiguration config = new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = ".docfx",
+                        Files = { "**" },
+                        RawCopy = true,         // just copy the content
+                        UrlReplacements =
+                        [
+                            new Replacement
+                            {
+                                Expression = @"/[Ii]mages/",
+                                Value = "/assets/"
+                            }
+                        ],
+                    }
+                ],
+        };
+        InventoryAction action = new(_workingFolder, config, _fileService, _logger);
+        // all files in .docfx
+        int expected = _fileService.Files.Count(x => !string.IsNullOrEmpty(x.Value) && x.Key.Contains("/.docfx/"));
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        action.Files.Should().HaveCount(expected);
+        var assets = action.Files.Where(x => x.SourcePath.Contains("/images"));
+        assets.Should().HaveCount(1);
+
+        string expectedPath = assets.First().SourcePath
+            .Replace($"{_fileService.Root}/.docfx", $"{_fileService.Root}/{config.DestinationFolder}")
+            .Replace("/images/", "/assets/");
+        assets.First().DestinationPath.Should().Be(expectedPath);
+    }
+
+    [Fact]
+    public async void Run_MinimalRawConfig_WithContentOverruledNotChangedPaths()
+    {
+        // arrange
+        AssembleConfiguration config = new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            UrlReplacements =
+            [
+                new Replacement
+                {
+                    Expression = @"/[Ii]mages/",
+                    Value = "/assets/"
+                }
+            ],
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = ".docfx",
+                        Files = { "**" },
+                        RawCopy = true,         // just copy the content
+                        UrlReplacements = [],   // this overrides the global replacement
+                    }
+                ],
+        };
+        InventoryAction action = new(_workingFolder, config, _fileService, _logger);
+        // all files in .docfx
+        int expected = _fileService.Files.Count(x => !string.IsNullOrEmpty(x.Value) && x.Key.Contains("/.docfx/"));
+
+        // act
+        var ret = await action.RunAsync();
+
+        // assert
+        ret.Should().Be(ReturnCode.Normal);
+        action.Files.Should().HaveCount(expected);
+        var assets = action.Files.Where(x => x.SourcePath.Contains("/images"));
+        assets.Should().HaveCount(1);
+
+        string expectedPath = assets.First().SourcePath
+            .Replace($"{_fileService.Root}/.docfx", $"{_fileService.Root}/{config.DestinationFolder}");
+        assets.First().DestinationPath.Should().Be(expectedPath);
+    }
+
+    private AssembleConfiguration GetStandardConfiguration()
+    {
+        return new AssembleConfiguration
+        {
+            DestinationFolder = "out",
+            ExternalFilePrefix = "https://github.com/example/blob/main/",
+            UrlReplacements =
+            [
+                new Replacement
+                {
+                    Expression = @"/[Dd]ocs/",
+                    Value = "/"
+                }
+            ],
+            ContentReplacements =
+            [
+                new Replacement
+                {
+                    Expression = @"(?
[$\s])AB#(?[0-9]{3,6})",
+                    Value = @"${pre}[AB#${id}](https://dev.azure.com/MyCompany/MyProject/_workitems/edit/${id})"
+                },
+                new Replacement     // Remove markdown style table of content
+                {
+                    Expression = @"\[\[_TOC_\]\]",
+                    Value = ""
+                }
+            ],
+            Content =
+            [
+                new Content
+                    {
+                        SourceFolder = ".docfx",
+                        Files = { "**" },
+                        RawCopy = true,         // just copy the content
+                        UrlReplacements = []    // reset URL replacements
+                    },
+                    new Content
+                    {
+                        SourceFolder = "docs",
+                        DestinationFolder = "general",
+                        Files = { "**" },
+                    },
+                    new Content
+                    {
+                        SourceFolder = "shared",    // part of general docs
+                        DestinationFolder = "general/shared",
+                        Files = { "**/docs/**" },
+                    },
+                    new Content
+                    {
+                        SourceFolder = "tools",     // part of general docs
+                        DestinationFolder = "general/tools",
+                        Files = { "**/docs/**" },
+                    },
+                    new Content
+                    {
+                        SourceFolder = "backend",
+                        DestinationFolder = "services", // change name to services
+                        Files = { "**/docs/**" },
+                    },
+                ],
+        };
+    }
+}
diff --git a/src/DocAssembler/DocAssembler.sln b/src/DocAssembler/DocAssembler.sln
new file mode 100644
index 0000000..496c49d
--- /dev/null
+++ b/src/DocAssembler/DocAssembler.sln
@@ -0,0 +1,31 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.11.35431.28
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DocAssembler", "DocAssembler\DocAssembler.csproj", "{20348289-FB98-4EE3-987D-576E3C568EB3}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DocAssembler.Test", "DocAssembler.Test\DocAssembler.Test.csproj", "{BA44E0E9-6D85-4185-99BA-8697A02663A1}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{20348289-FB98-4EE3-987D-576E3C568EB3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{20348289-FB98-4EE3-987D-576E3C568EB3}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{20348289-FB98-4EE3-987D-576E3C568EB3}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{20348289-FB98-4EE3-987D-576E3C568EB3}.Release|Any CPU.Build.0 = Release|Any CPU
+		{BA44E0E9-6D85-4185-99BA-8697A02663A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{BA44E0E9-6D85-4185-99BA-8697A02663A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{BA44E0E9-6D85-4185-99BA-8697A02663A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{BA44E0E9-6D85-4185-99BA-8697A02663A1}.Release|Any CPU.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {97487205-BC8C-4B1C-B40E-EDC19E4F01B9}
+	EndGlobalSection
+EndGlobal
diff --git a/src/DocAssembler/DocAssembler/Actions/ActionException.cs b/src/DocAssembler/DocAssembler/Actions/ActionException.cs
new file mode 100644
index 0000000..a55bec9
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Actions/ActionException.cs
@@ -0,0 +1,40 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Diagnostics.CodeAnalysis;
+
+namespace DocAssembler.Actions;
+
+/// 
+/// Exception class for the ParserService.
+/// 
+[ExcludeFromCodeCoverage]
+public class ActionException : Exception
+{
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    public ActionException()
+    {
+    }
+
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    /// Message of exception.
+    public ActionException(string message)
+        : base(message)
+    {
+    }
+
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    /// Message of exception.
+    /// Inner exception.
+    public ActionException(string message, Exception innerException)
+        : base(message, innerException)
+    {
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/Actions/AssembleAction.cs b/src/DocAssembler/DocAssembler/Actions/AssembleAction.cs
new file mode 100644
index 0000000..7cff8b4
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Actions/AssembleAction.cs
@@ -0,0 +1,124 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System;
+using System.Text;
+using System.Text.RegularExpressions;
+using DocAssembler.Configuration;
+using DocAssembler.FileService;
+using Microsoft.Extensions.Logging;
+
+namespace DocAssembler.Actions;
+
+/// 
+/// Assemble documentation in the output folder. The tool will also fix links following configuration.
+/// 
+public class AssembleAction
+{
+    private readonly AssembleConfiguration _config;
+    private readonly List _files;
+    private readonly IFileService _fileService;
+    private readonly ILogger _logger;
+
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    /// Configuration.
+    /// List of files to process.
+    /// File service.
+    /// Logger.
+    public AssembleAction(
+        AssembleConfiguration config,
+        List files,
+        IFileService fileService,
+        ILogger logger)
+    {
+        _config = config;
+        _files = files;
+        _fileService = fileService;
+        _logger = logger;
+    }
+
+    /// 
+    /// Run the action.
+    /// 
+    /// 0 on success, 1 on warning, 2 on error.
+    public Task RunAsync()
+    {
+        ReturnCode ret = ReturnCode.Normal;
+        _logger.LogInformation($"\n*** ASSEMBLE STAGE.");
+
+        try
+        {
+            foreach (var file in _files)
+            {
+                // get all links that need to be changed
+                var updates = file.Links
+                    .Where(x => !x.OriginalUrl.Equals(x.DestinationRelativeUrl ?? x.DestinationFullUrl, StringComparison.Ordinal))
+                    .OrderBy(x => x.UrlSpanStart);
+                if (file.IsMarkdown && (updates.Any() || _config.ContentReplacements is not null || file.ContentSet?.ContentReplacements is not null))
+                {
+                    var markdown = _fileService.ReadAllText(file.SourcePath);
+                    StringBuilder sb = new StringBuilder();
+                    int pos = 0;
+                    foreach (var update in updates)
+                    {
+                        // first append text so far from markdown
+                        sb.Append(markdown.AsSpan(pos, update.UrlSpanStart - pos));
+
+                        // append new link
+                        sb.Append(update.DestinationRelativeUrl ?? update.DestinationFullUrl);
+
+                        // set new starting position
+                        pos = update.UrlSpanEnd + 1;
+                    }
+
+                    // add final part of markdown
+                    sb.Append(markdown.AsSpan(pos));
+                    string output = sb.ToString();
+
+                    // if replacement patterns are defined, apply them to the content
+                    // If content replacements are defined, we use that one, otherwise the global replacements.
+                    int replacementCount = 0;
+                    var replacements = file.ContentSet?.ContentReplacements ?? _config.ContentReplacements;
+                    if (replacements is not null)
+                    {
+                        try
+                        {
+                            // apply all replacements
+                            foreach (var replacement in replacements)
+                            {
+                                string r = replacement.Value ?? string.Empty;
+                                output = Regex.Replace(output, replacement.Expression, r);
+                                replacementCount++;
+                            }
+                        }
+                        catch (Exception ex)
+                        {
+                            _logger.LogError($"Regex error for source `{file.SourcePath}`: {ex.Message}. No replacement done.");
+                            ret = ReturnCode.Warning;
+                        }
+                    }
+
+                    Directory.CreateDirectory(Path.GetDirectoryName(file.DestinationPath)!);
+                    _fileService.WriteAllText(file.DestinationPath, output);
+                    _logger.LogInformation($"Copied '{file.SourcePath}' to '{file.DestinationPath}' with {updates.Count()} URL replacements and {replacementCount} content replacements.");
+                }
+                else
+                {
+                    _fileService.Copy(file.SourcePath, file.DestinationPath);
+                    _logger.LogInformation($"Copied '{file.SourcePath}' to '{file.DestinationPath}'.");
+                }
+            }
+        }
+        catch (Exception ex)
+        {
+            _logger.LogCritical($"Assembly error: {ex.Message}.");
+            ret = ReturnCode.Error;
+        }
+
+        _logger.LogInformation($"END OF ASSEMBLE STAGE. Result: {ret}");
+        return Task.FromResult(ret);
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/Actions/ConfigInitAction.cs b/src/DocAssembler/DocAssembler/Actions/ConfigInitAction.cs
new file mode 100644
index 0000000..c75cfab
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Actions/ConfigInitAction.cs
@@ -0,0 +1,104 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using DocAssembler.Configuration;
+using DocAssembler.FileService;
+using DocAssembler.Utils;
+using Microsoft.Extensions.Logging;
+
+namespace DocAssembler.Actions;
+
+/// 
+/// Initialize and save an initial configuration file if it doesn't exist yet.
+/// 
+public class ConfigInitAction
+{
+    private const string ConfigFileName = ".docassembler.json";
+
+    private readonly string _outFolder;
+
+    private readonly IFileService? _fileService;
+    private readonly ILogger _logger;
+
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    /// Output folder.
+    /// File service.
+    /// Logger.
+    public ConfigInitAction(
+        string outFolder,
+        IFileService fileService,
+        ILogger logger)
+    {
+        _outFolder = outFolder;
+
+        _fileService = fileService;
+        _logger = logger;
+    }
+
+    /// 
+    /// Run the action.
+    /// 
+    /// 0 on success, 1 on warning, 2 on error.
+    public Task RunAsync()
+    {
+        ReturnCode ret = ReturnCode.Normal;
+
+        try
+        {
+            string path = Path.Combine(_outFolder, ConfigFileName);
+            if (_fileService!.ExistsFileOrDirectory(path))
+            {
+                _logger.LogError($"*** ERROR: '{path}' already exists. We don't overwrite.");
+
+                // indicate we're done with an error
+                return Task.FromResult(ReturnCode.Error);
+            }
+
+            var config = new AssembleConfiguration
+            {
+                DestinationFolder = "out",
+                ExternalFilePrefix = "https://github.com/example/blob/main/",
+                Content =
+                [
+                    new Content
+                    {
+                        SourceFolder = ".docfx",
+                        Files = { "**" },
+                        RawCopy = true,
+                    },
+                    new Content
+                    {
+                        SourceFolder = "docs",
+                        Files = { "**" },
+                    },
+                    new Content
+                    {
+                        SourceFolder = "backend",
+                        DestinationFolder = "services",
+                        Files = { "**/docs/**" },
+                        UrlReplacements = [
+                                new Replacement
+                                {
+                                    Expression = "/[Dd]ocs/",
+                                    Value = "/",
+                                }
+                            ],
+                    },
+                ],
+            };
+
+            _fileService.WriteAllText(path, SerializationUtil.Serialize(config));
+            _logger.LogInformation($"Initial configuration saved in '{path}'");
+        }
+        catch (Exception ex)
+        {
+            _logger.LogCritical($"Saving initial configuration error: {ex.Message}.");
+            ret = ReturnCode.Error;
+        }
+
+        return Task.FromResult(ret);
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/Actions/InventoryAction.cs b/src/DocAssembler/DocAssembler/Actions/InventoryAction.cs
new file mode 100644
index 0000000..d64b5ac
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Actions/InventoryAction.cs
@@ -0,0 +1,230 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Diagnostics;
+using System.Text.RegularExpressions;
+using DocAssembler.Configuration;
+using DocAssembler.FileService;
+using DocAssembler.Utils;
+using Microsoft.Extensions.Logging;
+
+namespace DocAssembler.Actions;
+
+/// 
+/// Inventory action to retrieve configured content.
+/// 
+public class InventoryAction
+{
+    private readonly string _workingFolder;
+    private readonly string _outputFolder;
+    private readonly FileInfoService _fileInfoService;
+    private readonly IFileService _fileService;
+    private readonly ILogger _logger;
+
+    private readonly AssembleConfiguration _config;
+
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    /// Working folder.
+    /// Configuration.
+    /// File service.
+    /// Logger.
+    public InventoryAction(string workingFolder, AssembleConfiguration config, IFileService fileService, ILogger logger)
+    {
+        _workingFolder = workingFolder;
+        _config = config;
+        _fileService = fileService;
+        _logger = logger;
+
+        _fileInfoService = new(workingFolder, _fileService, _logger);
+
+        // set full path of output folder
+        _outputFolder = _fileService.GetFullPath(Path.Combine(_workingFolder, _config.DestinationFolder));
+    }
+
+    /// 
+    /// Gets the list of files. This is a result from the RunAsync() method.
+    /// 
+    public List Files { get; private set; } = [];
+
+    /// 
+    /// Run the action.
+    /// 
+    /// 0 on success, 1 on warning, 2 on error.
+    public Task RunAsync()
+    {
+        ReturnCode ret = ReturnCode.Normal;
+        _logger.LogInformation($"\n*** INVENTORY STAGE.");
+
+        try
+        {
+            ret = GetAllFiles();
+            if (ret != ReturnCode.Error)
+            {
+                ret = ValidateFiles();
+            }
+
+            if (ret != ReturnCode.Error)
+            {
+                ret = UpdateLinks();
+
+                // log result of inventory (verbose)
+                foreach (var file in Files)
+                {
+                    _logger.LogInformation($"{file.SourcePath}  \n\t==>  {file.DestinationPath}");
+                    foreach (var link in file.Links)
+                    {
+                        _logger.LogInformation($"\t{link.OriginalUrl} => {link.DestinationRelativeUrl ?? link.DestinationFullUrl}");
+                    }
+                }
+            }
+        }
+        catch (Exception ex)
+        {
+            _logger.LogCritical($"Inventory error: {ex.Message}");
+            ret = ReturnCode.Error;
+        }
+
+        _logger.LogInformation($"END OF INVENTORY STAGE. Result: {ret}");
+        return Task.FromResult(ret);
+    }
+
+    private ReturnCode UpdateLinks()
+    {
+        ReturnCode ret = ReturnCode.Normal;
+
+        foreach (var file in Files)
+        {
+            if (file.Links.Count > 0)
+            {
+                _logger.LogInformation($"Updating links for '{file.SourcePath}'");
+
+                foreach (var link in file.Links)
+                {
+                    var dest = Files.SingleOrDefault(x => x.SourcePath.Equals(link.UrlFullPath, StringComparison.Ordinal));
+                    if (dest != null)
+                    {
+                        // destination found. register and also (new) calculate relative path
+                        link.DestinationFullUrl = dest.DestinationPath;
+                        string dir = Path.GetDirectoryName(file.DestinationPath)!;
+                        link.DestinationRelativeUrl = Path.GetRelativePath(dir, dest.DestinationPath).NormalizePath();
+                        if (!string.IsNullOrEmpty(link.UrlTopic))
+                        {
+                            link.DestinationFullUrl += link.UrlTopic;
+                            link.DestinationRelativeUrl += link.UrlTopic;
+                        }
+                    }
+                    else
+                    {
+                        var prefix = file.ContentSet!.ExternalFilePrefix ?? _config.ExternalFilePrefix;
+                        if (string.IsNullOrEmpty(prefix))
+                        {
+                            // ERROR: no solution to fix this reference
+                            _logger.LogCritical($"Error in a file reference. Link '{link.OriginalUrl}' in '{file.SourcePath}' cannot be resolved and no external file prefix was given.");
+                            ret = ReturnCode.Error;
+                        }
+                        else
+                        {
+                            // we're calculating the link with the external file prefix, usualy a repo web link prefix.
+                            string subpath = link.UrlFullPath.Substring(_workingFolder.Length).TrimStart('/');
+                            link.DestinationFullUrl = prefix.TrimEnd('/') + "/" + subpath;
+                        }
+                    }
+                }
+            }
+        }
+
+        return ret;
+    }
+
+    private ReturnCode ValidateFiles()
+    {
+        ReturnCode ret = ReturnCode.Normal;
+
+        var duplicates = Files.GroupBy(x => x.DestinationPath).Where(g => g.Count() > 1);
+        if (duplicates.Any())
+        {
+            _logger.LogCritical("ERROR: one or more files will be overwritten. Validate content definitions. Consider exclude paths.");
+            foreach (var dup in duplicates)
+            {
+                _logger.LogCritical($"{dup.Key} used for:");
+                foreach (var source in dup)
+                {
+                    _logger.LogCritical($"\t{source.SourcePath} (Content group '{source.ContentSet!.SourceFolder}')");
+                }
+            }
+
+            ret = ReturnCode.Error;
+        }
+
+        return ret;
+    }
+
+    private ReturnCode GetAllFiles()
+    {
+        ReturnCode ret = ReturnCode.Normal;
+
+        // loop through all content definitions
+        foreach (var content in _config.Content)
+        {
+            // determine source and destination folders
+            var sourceFolder = _fileService.GetFullPath(Path.Combine(_workingFolder, content.SourceFolder));
+            var destFolder = _outputFolder!;
+            if (!string.IsNullOrEmpty(content.DestinationFolder))
+            {
+                destFolder = _fileService.GetFullPath(Path.Combine(destFolder, content.DestinationFolder.Trim()));
+            }
+
+            _logger.LogInformation($"Processing content for '{sourceFolder}' => '{destFolder}'");
+
+            // get all files and loop through them to add to the this.Files collection
+            var files = _fileService.GetFiles(sourceFolder, content.Files, content.Exclude);
+            foreach (var file in files)
+            {
+                _logger.LogInformation($"- '{file}'");
+                FileData fileData = new FileData
+                {
+                    ContentSet = content,
+                    SourcePath = file.NormalizePath(),
+                };
+
+                if (content.RawCopy != true && Path.GetExtension(file).Equals(".md", StringComparison.OrdinalIgnoreCase))
+                {
+                    // only for markdown, get the links
+                    fileData.Links = _fileInfoService.GetLocalHyperlinks(sourceFolder, file);
+                }
+
+                // set destination path of the file
+                string subpath = fileData.SourcePath.Substring(sourceFolder.Length).TrimStart('/');
+                fileData.DestinationPath = _fileService.GetFullPath(Path.Combine(destFolder, subpath));
+
+                // if replace patterns are defined, apply them to the destination path
+                // content replacements will be used if defined, otherwise the global replacements are used.
+                var replacements = content.UrlReplacements ?? _config.UrlReplacements;
+                if (replacements != null)
+                {
+                    try
+                    {
+                        // apply all replacements
+                        foreach (var replacement in replacements)
+                        {
+                            string r = replacement.Value ?? string.Empty;
+                            fileData.DestinationPath = Regex.Replace(fileData.DestinationPath, replacement.Expression, r);
+                        }
+                    }
+                    catch (Exception ex)
+                    {
+                        _logger.LogError($"Regex error for file `{file}`: {ex.Message}. No replacement done.");
+                        ret = ReturnCode.Warning;
+                    }
+                }
+
+                Files.Add(fileData);
+            }
+        }
+
+        return ret;
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/Configuration/AssembleConfiguration.cs b/src/DocAssembler/DocAssembler/Configuration/AssembleConfiguration.cs
new file mode 100644
index 0000000..90f7d09
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Configuration/AssembleConfiguration.cs
@@ -0,0 +1,43 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Text.Json.Serialization;
+
+namespace DocAssembler.Configuration;
+
+/// 
+/// Assemble configuration.
+/// 
+public sealed record AssembleConfiguration
+{
+    /// 
+    /// Gets or sets the destination folder.
+    /// 
+    [JsonPropertyName("dest")]
+    public string DestinationFolder { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the global URL replacements. Can be overruled by  settings.
+    /// 
+    public List? UrlReplacements { get; set; }
+
+    /// 
+    /// Gets or sets the global content replacements. Can be overruled by  settings.
+    /// 
+    public List? ContentReplacements { get; set; }
+
+    /// 
+    /// Gets or sets the prefix for external files like source files.
+    /// This is for all references to files that are not part of the
+    /// selected files (mostly markdown and assets).
+    /// An example use is to prefix the URL with the url of the github repo.
+    /// This is the global setting, that can be overruled by  settings.
+    /// 
+    public string? ExternalFilePrefix { get; set; }
+
+    /// 
+    /// Gets or sets the content to process.
+    /// 
+    public List Content { get; set; } = new();
+}
diff --git a/src/DocAssembler/DocAssembler/Configuration/Content.cs b/src/DocAssembler/DocAssembler/Configuration/Content.cs
new file mode 100644
index 0000000..ec0d432
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Configuration/Content.cs
@@ -0,0 +1,61 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json.Serialization;
+
+namespace DocAssembler.Configuration;
+
+/// 
+/// Content definition using globbing patterns.
+/// 
+public sealed record Content
+{
+    /// 
+    /// Gets or sets the source folder.
+    /// 
+    [JsonPropertyName("src")]
+    public string SourceFolder { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the optional destination folder.
+    /// 
+    [JsonPropertyName("dest")]
+    public string? DestinationFolder { get; set; }
+
+    /// 
+    /// Gets or sets the folders and files to include.
+    /// This list supports the file glob pattern.
+    /// 
+    public List Files { get; set; } = new();
+
+    /// 
+    /// Gets or sets the folders and files to exclude.
+    /// This list supports the file glob pattern.
+    /// 
+    public List? Exclude { get; set; }
+
+    /// 
+    /// Gets or sets a value indicating whether we need to do just a raw copy.
+    /// 
+    public bool? RawCopy { get; set; }
+
+    /// 
+    /// Gets or sets the URL replacements.
+    /// 
+    public List? UrlReplacements { get; set; }
+
+    /// 
+    /// Gets or sets the content replacements.
+    /// 
+    public List? ContentReplacements { get; set; }
+
+    /// 
+    /// Gets or sets the prefix for external files like source files.
+    /// This is for all references to files that are not part of the
+    /// selected files (mostly markdown and assets).
+    /// An example use is to prefix the URL with the url of the github repo.
+    /// 
+    public string? ExternalFilePrefix { get; set; }
+}
diff --git a/src/DocAssembler/DocAssembler/Configuration/Replacement.cs b/src/DocAssembler/DocAssembler/Configuration/Replacement.cs
new file mode 100644
index 0000000..d9bbaba
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Configuration/Replacement.cs
@@ -0,0 +1,21 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+namespace DocAssembler.Configuration;
+
+/// 
+/// Replacement definition.
+/// 
+public sealed record Replacement
+{
+    /// 
+    /// Gets or sets the regex expression for the replacement.
+    /// 
+    public string Expression { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the replacement value.
+    /// 
+    public string? Value { get; set; }
+}
diff --git a/src/DocAssembler/DocAssembler/DocAssembler.csproj b/src/DocAssembler/DocAssembler/DocAssembler.csproj
new file mode 100644
index 0000000..bcec377
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/DocAssembler.csproj
@@ -0,0 +1,42 @@
+
+
+  
+    Exe
+    net8.0
+    12.0
+    true
+    true
+    enable
+    enable
+    true
+    latest-Recommended
+
+    MIT
+    README.md
+    DocFx Companion Tools contributors
+    DocFx Companion Tools
+    DocAssembler
+    git
+    https://github.com/Ellerbach/docfx-companion-tools
+    https://github.com/Ellerbach/docfx-companion-tools
+    Tool to assemble documentation from various locations and change links where necessary.
+    docfx tools companion documentation assembler
+  
+
+  
+    
+  
+
+  
+    
+  
+
+  
+    
+    
+    
+    
+    
+    
+  
+
diff --git a/src/DocAssembler/DocAssembler/FileService/FileData.cs b/src/DocAssembler/DocAssembler/FileService/FileData.cs
new file mode 100644
index 0000000..da20ab1
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/FileService/FileData.cs
@@ -0,0 +1,38 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using DocAssembler.Configuration;
+
+namespace DocAssembler.FileService;
+
+/// 
+/// File data.
+/// 
+public sealed record FileData
+{
+    /// 
+    /// Gets or sets the source full path of the file.
+    /// 
+    public string SourcePath { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the destination full path of the file.
+    /// 
+    public string DestinationPath { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the content set the file belongs to.
+    /// 
+    public Content? ContentSet { get; set; }
+
+    /// 
+    /// Gets or sets all links in the document we might need to work on.
+    /// 
+    public List Links { get; set; } = [];
+
+    /// 
+    /// Gets a value indicating whether the file is a markdown file.
+    /// 
+    public bool IsMarkdown => Path.GetExtension(SourcePath).Equals(".md", StringComparison.OrdinalIgnoreCase);
+}
diff --git a/src/DocAssembler/DocAssembler/FileService/FileInfoService.cs b/src/DocAssembler/DocAssembler/FileService/FileInfoService.cs
new file mode 100644
index 0000000..ecf6f99
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/FileService/FileInfoService.cs
@@ -0,0 +1,110 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System;
+using System.Diagnostics;
+using Markdig;
+using Markdig.Syntax;
+using Markdig.Syntax.Inlines;
+using Microsoft.Extensions.Logging;
+
+namespace DocAssembler.FileService;
+
+/// 
+/// File info service.
+/// 
+public class FileInfoService
+{
+    private readonly string _workingFolder;
+    private readonly IFileService _fileService;
+    private readonly ILogger _logger;
+
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    /// Working folder.
+    /// File service.
+    /// Logger.
+    public FileInfoService(string workingFolder, IFileService fileService, ILogger logger)
+    {
+        _workingFolder = workingFolder;
+        _fileService = fileService;
+        _logger = logger;
+    }
+
+    /// 
+    /// Get the local links in the markdown file.
+    /// 
+    /// Root path of the documentation.
+    /// File path of the markdown file.
+    /// List of local links in the document. If none found, the list is empty.
+    public List GetLocalHyperlinks(string root, string filePath)
+    {
+        string markdownFilePath = _fileService.GetFullPath(filePath);
+        string markdown = _fileService.ReadAllText(markdownFilePath);
+
+        MarkdownPipeline pipeline = new MarkdownPipelineBuilder()
+            .UseAdvancedExtensions()
+            .Build();
+        MarkdownDocument document = Markdown.Parse(markdown, pipeline);
+
+        // get all links
+        var links = document
+            .Descendants()
+            .Where(x => !x.UrlHasPointyBrackets &&
+                        !string.IsNullOrEmpty(x.Url) &&
+                        !Hyperlink.Protocols.Any(p => x.Url.StartsWith(p.Key, StringComparison.OrdinalIgnoreCase)))
+            .Select(d => new Hyperlink(markdownFilePath, d.Line + 1, d.Column + 1, d.Url ?? string.Empty)
+            {
+                UrlSpanStart = d.UrlSpan.Start,
+                UrlSpanEnd = d.UrlSpan.End,
+                UrlSpanLength = d.UrlSpan.Length,
+            })
+            .ToList();
+
+        // updating the links
+        foreach (var link in links)
+        {
+            if (link.Url != null && !link.Url.Equals(markdown.Substring(link.UrlSpanStart, link.UrlSpanLength), StringComparison.Ordinal))
+            {
+                // MARKDIG FIX
+                // In some cases the Url in MarkDig LinkInline is not equal to the original
+                // e.g. a link "..\..\somefile.md" resolves in "....\somefile.md"
+                // we fix that here. This will probably not be fixed in the markdig
+                // library, as you shouldn't use backslash, but Unix-style slash.
+                link.OriginalUrl = markdown.Substring(link.UrlSpanStart, link.UrlSpanLength);
+                link.Url = markdown.Substring(link.UrlSpanStart, link.UrlSpanLength);
+            }
+
+            if (link.Url?.StartsWith('~') == true)
+            {
+                // special reference to root. We need to expand that to the root folder.
+                link.Url = _workingFolder + link.Url.AsSpan(1).ToString();
+            }
+
+            if (link.IsLocal)
+            {
+                int pos = link.Url!.IndexOf('#', StringComparison.InvariantCulture);
+                if (pos == -1)
+                {
+                    // if we don't have a header delimiter, we might have a url delimiter
+                    pos = link.Url.IndexOf('?', StringComparison.InvariantCulture);
+                }
+
+                // we want to know that the link is not starting with a # for local reference.
+                // if local reference, return the filename otherwise the calculated path.
+                if (pos != 0)
+                {
+                    link.UrlFullPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(link.FilePath)!.NormalizePath(), link.UrlWithoutTopic)).NormalizePath();
+                }
+            }
+            else
+            {
+                link.UrlFullPath = link.Url!;
+            }
+        }
+
+        return links;
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/FileService/FilePathExtensions.cs b/src/DocAssembler/DocAssembler/FileService/FilePathExtensions.cs
new file mode 100644
index 0000000..d236af1
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/FileService/FilePathExtensions.cs
@@ -0,0 +1,36 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Diagnostics.CodeAnalysis;
+
+namespace DocAssembler.FileService;
+
+/// 
+/// File path extension methods.
+/// 
+[ExcludeFromCodeCoverage]
+public static class FilePathExtensions
+{
+    /// 
+    /// Normalize the path to have a common notation of directory separators.
+    /// This is needed when used in Equal() methods and such.
+    /// 
+    /// Path to normalize.
+    /// Normalized path.
+    public static string NormalizePath(this string path)
+    {
+        return path.Replace("\\", "/");
+    }
+
+    /// 
+    /// Normalize the content. This is used to make sure we always
+    /// have "\n" only for new lines. Mostly used by the test mocks.
+    /// 
+    /// Content to normalize.
+    /// Normalized content.
+    public static string NormalizeContent(this string content)
+    {
+        return content.Replace("\r", string.Empty);
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/FileService/FileService.cs b/src/DocAssembler/DocAssembler/FileService/FileService.cs
new file mode 100644
index 0000000..b0dd192
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/FileService/FileService.cs
@@ -0,0 +1,97 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.Extensions.FileSystemGlobbing;
+
+namespace DocAssembler.FileService;
+
+/// 
+/// File service implementation working with  class.
+/// 
+[ExcludeFromCodeCoverage]
+public class FileService : IFileService
+{
+    /// 
+    public string GetFullPath(string path)
+    {
+        return Path.GetFullPath(path).NormalizePath();
+    }
+
+    /// 
+    public bool ExistsFileOrDirectory(string path)
+    {
+        return File.Exists(path) || Directory.Exists(path);
+    }
+
+    /// 
+    public IEnumerable GetFiles(string root, List includes, List? excludes)
+    {
+        string fullRoot = Path.GetFullPath(root);
+        Matcher matcher = new();
+        foreach (string folderName in includes)
+        {
+            matcher.AddInclude(folderName);
+        }
+
+        if (excludes != null)
+        {
+            foreach (string folderName in excludes)
+            {
+                matcher.AddExclude(folderName);
+            }
+        }
+
+        // make sure we normalize the directory separator
+        return matcher.GetResultsInFullPath(fullRoot)
+            .Select(x => x.NormalizePath())
+            .ToList();
+    }
+
+    /// 
+    public IEnumerable GetDirectories(string folder)
+    {
+        return Directory.GetDirectories(folder);
+    }
+
+    /// >
+    public string ReadAllText(string path)
+    {
+        return File.ReadAllText(path);
+    }
+
+    /// 
+    public string[] ReadAllLines(string path)
+    {
+        return File.ReadAllLines(path);
+    }
+
+    /// 
+    public void WriteAllText(string path, string content)
+    {
+        File.WriteAllText(path, content);
+    }
+
+    /// 
+    public Stream OpenRead(string path)
+    {
+        return File.OpenRead(path);
+    }
+
+    /// 
+    public void Copy(string source, string destination)
+    {
+        Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
+        File.Copy(source, destination);
+    }
+
+    /// 
+    public void DeleteFolder(string path)
+    {
+        if (Directory.Exists(path))
+        {
+            Directory.Delete(path);
+        }
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/FileService/Hyperlink.cs b/src/DocAssembler/DocAssembler/FileService/Hyperlink.cs
new file mode 100644
index 0000000..20cbe45
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/FileService/Hyperlink.cs
@@ -0,0 +1,282 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System;
+using System.Text;
+using DocAssembler.FileService;
+
+namespace DocAssembler.FileService;
+
+/// 
+/// Hyperlink in document.
+/// 
+public class Hyperlink
+{
+    /// 
+    /// Gets the protocol mappings to s.
+    /// 
+    public static readonly Dictionary Protocols = new Dictionary()
+    {
+        { "https://", HyperlinkType.Webpage },
+        { "http://", HyperlinkType.Webpage },
+        { "ftps://", HyperlinkType.Ftp },
+        { "ftp://", HyperlinkType.Ftp },
+        { "mailto://", HyperlinkType.Mail },
+        { "xref://", HyperlinkType.CrossReference },
+    };
+
+    private static readonly char[] _uriFragmentOrQueryString = new char[] { '#', '?' };
+    private static readonly char[] _additionalInvalidChars = @"\/?:*".ToArray();
+    private static readonly char[] _invalidPathChars = Path.GetInvalidPathChars().Concat(_additionalInvalidChars).ToArray();
+
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    public Hyperlink()
+    {
+    }
+
+    /// 
+    /// Initializes a new instance of the  class.
+    /// 
+    /// Path of the markdown file.
+    /// Line number.
+    /// Column.
+    /// Url.
+    public Hyperlink(string filePath, int line, int col, string url)
+    {
+        FilePath = filePath;
+        Line = line;
+        Column = col;
+
+        Url = url;
+        OriginalUrl = Url;
+
+        LinkType = HyperlinkType.Empty;
+        if (!string.IsNullOrWhiteSpace(url))
+        {
+            foreach (var protocol in Protocols)
+            {
+                if (url.StartsWith(protocol.Key, StringComparison.OrdinalIgnoreCase))
+                {
+                    LinkType = protocol.Value;
+                    break;
+                }
+            }
+
+            if (LinkType == HyperlinkType.Empty)
+            {
+                Url = UrlDecode(Url).NormalizePath();
+
+                if (Path.GetExtension(url).Equals(".md", StringComparison.OrdinalIgnoreCase) || Path.GetExtension(url) == string.Empty)
+                {
+                    // link to an MD file or a folder
+                    LinkType = HyperlinkType.Local;
+                }
+                else
+                {
+                    // link to image or something like that.
+                    LinkType = HyperlinkType.Resource;
+                }
+            }
+        }
+    }
+
+    /// 
+    /// Gets or sets the file path name of the markdown file.
+    /// 
+    public string FilePath { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the line number in the file.
+    /// 
+    public int Line { get; set; }
+
+    /// 
+    /// Gets or sets the column in the file.
+    /// 
+    public int Column { get; set; }
+
+    /// 
+    /// Gets or sets the URL span start.
+    /// 
+    public int UrlSpanStart { get; set; }
+
+    /// 
+    /// Gets or sets the URL span end. This might differ from Markdig span end,
+    /// as we're trimming any #-reference at the end.
+    /// 
+    public int UrlSpanEnd { get; set; }
+
+    /// 
+    /// Gets or sets the URL span length. This might differ from Markdig span length,
+    /// as we're trimming any #-reference at the end.
+    /// 
+    public int UrlSpanLength { get; set; }
+
+    /// 
+    /// Gets or sets the URL.
+    /// 
+    public string Url { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the URL full path.
+    /// 
+    public string UrlFullPath { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the original URL as found in the Markdown document. Used for reporting to user so they can find the correct location. Url will be modified.
+    /// 
+    public string OriginalUrl { get; set; } = string.Empty;
+
+    /// 
+    /// Gets or sets the full destination url.
+    /// 
+    public string? DestinationFullUrl { get; set; }
+
+    /// 
+    /// Gets or sets the relative destination url.
+    /// 
+    public string? DestinationRelativeUrl { get; set; }
+
+    /// 
+    /// Gets or sets a value indicating whether this is a web link.
+    /// 
+    public HyperlinkType LinkType { get; set; }
+
+    /// 
+    /// Gets a value indicating whether this is a local link.
+    /// 
+    public bool IsLocal
+    {
+        get
+        {
+            return LinkType == HyperlinkType.Local || LinkType == HyperlinkType.Resource;
+        }
+    }
+
+    /// 
+    /// Gets a value indicating whether this is a web link.
+    /// 
+    public bool IsWeb
+    {
+        get
+        {
+            return LinkType == HyperlinkType.Webpage || LinkType == HyperlinkType.Ftp;
+        }
+    }
+
+    /// 
+    /// Gets the topic in the url. This is the id after the # in a local link.
+    /// Otherwise it's returned empty.
+    /// 
+    public string UrlTopic
+    {
+        get
+        {
+            if (IsLocal)
+            {
+                int pos = Url.IndexOf('#', StringComparison.InvariantCulture);
+                if (pos == -1)
+                {
+                    // if we don't have a header delimiter, we might have a url delimiter
+                    pos = Url.IndexOf('?', StringComparison.InvariantCulture);
+                }
+
+                // include the separator
+                return pos == -1 ? string.Empty : Url.Substring(pos);
+            }
+
+            return string.Empty;
+        }
+    }
+
+    /// 
+    /// Gets the url without a (possible) topic. This is the id after the # in a local link.
+    /// 
+    public string UrlWithoutTopic
+    {
+        get
+        {
+            if (IsLocal)
+            {
+                int pos = Url.IndexOf('#', StringComparison.InvariantCulture);
+                if (pos == -1)
+                {
+                    // if we don't have a header delimiter, we might have a url delimiter
+                    pos = Url.IndexOf('?', StringComparison.InvariantCulture);
+                }
+
+                switch (pos)
+                {
+                    case -1:
+                        return Url;
+                    case 0:
+                        return FilePath;
+                    default:
+                        return Url.Substring(0, pos);
+                }
+            }
+
+            return Url;
+        }
+    }
+
+    /// 
+    /// Decoding of local Urls. Similar to logic from DocFx RelativePath class.
+    /// https://github.com/dotnet/docfx/blob/cca05f505e30c5ede36973c4b989fce711f2e8ad/src/Docfx.Common/Path/RelativePath.cs .
+    /// 
+    /// Url.
+    /// Decoded Url.
+    private string UrlDecode(string url)
+    {
+        // This logic only applies to relative paths.
+        if (Path.IsPathRooted(url))
+        {
+            return url;
+        }
+
+        var anchor = string.Empty;
+        var index = url.IndexOfAny(_uriFragmentOrQueryString);
+        if (index != -1)
+        {
+            anchor = url.Substring(index);
+            url = url.Remove(index);
+        }
+
+        var parts = url.Split('/', '\\');
+        var newUrl = new StringBuilder();
+        for (int i = 0; i < parts.Length; i++)
+        {
+            if (i > 0)
+            {
+                newUrl.Append('/');
+            }
+
+            var origin = parts[i];
+            var value = Uri.UnescapeDataString(origin);
+
+            var splittedOnInvalidChars = value.Split(_invalidPathChars);
+            var originIndex = 0;
+            var valueIndex = 0;
+            for (int j = 0; j < splittedOnInvalidChars.Length; j++)
+            {
+                if (j > 0)
+                {
+                    var invalidChar = value[valueIndex];
+                    valueIndex++;
+                    newUrl.Append(Uri.EscapeDataString(invalidChar.ToString()));
+                }
+
+                var splitOnInvalidChars = splittedOnInvalidChars[j];
+                originIndex += splitOnInvalidChars.Length;
+                valueIndex += splitOnInvalidChars.Length;
+                newUrl.Append(splitOnInvalidChars);
+            }
+        }
+
+        newUrl.Append(anchor);
+        return newUrl.ToString();
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/FileService/HyperlinkType.cs b/src/DocAssembler/DocAssembler/FileService/HyperlinkType.cs
new file mode 100644
index 0000000..48d1bb9
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/FileService/HyperlinkType.cs
@@ -0,0 +1,47 @@
+namespace DocAssembler.FileService;
+
+/// 
+/// Enumeration of hyperlink types.
+/// 
+public enum HyperlinkType
+{
+    /// 
+    /// Local file.
+    /// 
+    Local,
+
+    /// 
+    /// A web page (http or https).
+    /// 
+    Webpage,
+
+    /// 
+    /// A download link (ftp or ftps).
+    /// 
+    Ftp,
+
+    /// 
+    /// Mail address (mailto).
+    /// 
+    Mail,
+
+    /// 
+    /// A cross reference (xref).
+    /// 
+    CrossReference,
+
+    /// 
+    /// A local resource, like an image.
+    /// 
+    Resource,
+
+    /// 
+    /// A tab - DocFx special. See https://dotnet.github.io/docfx/docs/markdown.html?tabs=linux%2Cdotnet#tabs.
+    /// 
+    Tab,
+
+    /// 
+    /// Empty link.
+    /// 
+    Empty,
+}
diff --git a/src/DocAssembler/DocAssembler/FileService/IFileService.cs b/src/DocAssembler/DocAssembler/FileService/IFileService.cs
new file mode 100644
index 0000000..37ec990
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/FileService/IFileService.cs
@@ -0,0 +1,83 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+namespace DocAssembler.FileService;
+
+/// 
+/// File service interface. This is to hide file system access behind an interface.
+/// This allows for the implementation of a mock for unit testing.
+/// 
+public interface IFileService
+{
+    /// 
+    /// Get the full path of the given path.
+    /// 
+    /// Path of file or folder.
+    /// The full path of the file or folder.
+    string GetFullPath(string path);
+
+    /// 
+    /// Check if the given path exists as file or directory.
+    /// 
+    /// Path to check.
+    /// A value indicating whether the path exists.
+    bool ExistsFileOrDirectory(string path);
+
+    /// 
+    /// Get files with the Glob File Pattern.
+    /// 
+    /// Root path.
+    /// Include patterns.
+    /// Exclude patterns.
+    /// List of files.
+    IEnumerable GetFiles(string root, List includes, List? excludes);
+
+    /// 
+    /// Get directories in the given path.
+    /// 
+    /// Folder path.
+    /// List of folders.
+    IEnumerable GetDirectories(string folder);
+
+    /// 
+    /// Read the file as text string.
+    /// 
+    /// Path of the file.
+    /// Contents of the file or empty if doesn't exist.
+    string ReadAllText(string path);
+
+    /// 
+    /// Read the file as array of strings split on newlines.
+    /// 
+    /// Path of the file.
+    /// All lines of text or empty if doesn't exist.
+    string[] ReadAllLines(string path);
+
+    /// 
+    /// Write content to given path.
+    /// 
+    /// Path of the file.
+    /// Content to write to the file.
+    void WriteAllText(string path, string content);
+
+    /// 
+    /// Get a stream for the given path to read.
+    /// 
+    /// Path of the file.
+    /// A .
+    Stream OpenRead(string path);
+
+    /// 
+    /// Copy the given file to the destination.
+    /// 
+    /// Source file path.
+    /// Destination file path.
+    void Copy(string source, string destination);
+
+    /// 
+    /// Delete given folder path.
+    /// 
+    /// Path of the folder.
+    void DeleteFolder(string path);
+}
diff --git a/src/DocAssembler/DocAssembler/GlobalSuppressions.cs b/src/DocAssembler/DocAssembler/GlobalSuppressions.cs
new file mode 100644
index 0000000..772da81
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/GlobalSuppressions.cs
@@ -0,0 +1,16 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+
+// This file is used by Code Analysis to maintain SuppressMessage
+// attributes that are applied to this project.
+// Project-level suppressions either have no target or are given
+// a specific target and scoped to a namespace, type, member, etc.
+using System.Diagnostics.CodeAnalysis;
+
+[assembly: SuppressMessage("Performance", "CA1848:Use the LoggerMessage delegates", Justification = "No need to optimaze in console app.")]
+[assembly: SuppressMessage("Usage", "CA2254:Template should be a static expression", Justification = "No need to optimize in console app.")]
+[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "Coding style different")]
+[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "We don't want this.")]
+[assembly: SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "We will decide case by case.")]
diff --git a/src/DocAssembler/DocAssembler/Program.cs b/src/DocAssembler/DocAssembler/Program.cs
new file mode 100644
index 0000000..6ecdf40
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Program.cs
@@ -0,0 +1,221 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.CommandLine;
+using System.CommandLine.Invocation;
+using System.CommandLine.Parsing;
+using DocAssembler;
+using DocAssembler.Actions;
+using DocAssembler.Configuration;
+using DocAssembler.FileService;
+using DocAssembler.Utils;
+using Microsoft.Extensions.Logging;
+
+var logLevel = LogLevel.Warning;
+
+// parameters/options
+var configFileOption = new Option(
+    name: "--config",
+    description: "The configuration file for the assembled documentation.")
+{
+    IsRequired = true,
+};
+
+var workingFolderOption = new Option(
+    name: "--workingfolder",
+    description: "The working folder. Default is the current folder.");
+
+var outputFolderOption = new Option(
+    name: "--outfolder",
+    description: "Override the output folder for the assembled documentation in the config file.");
+
+var cleanupOption = new Option(
+    name: "--cleanup-output",
+    description: "Cleanup the output folder before generating. NOTE: This will delete all folders and files!");
+
+var verboseOption = new Option(
+    name: "--verbose",
+    description: "Show verbose messages of the process.");
+verboseOption.AddAlias("-v");
+
+// construct the root command
+var rootCommand = new RootCommand(
+    """
+    DocAssembler.
+    Assemble documentation in the output folder. The tool will also fix links following configuration.
+ 
+    Return values:
+    0 - succesfull.
+    1 - some warnings, but process could be completed.
+    2 - a fatal error occurred.
+    """);
+
+rootCommand.AddOption(workingFolderOption);
+rootCommand.AddOption(configFileOption);
+rootCommand.AddOption(outputFolderOption);
+rootCommand.AddOption(cleanupOption);
+rootCommand.AddOption(verboseOption);
+
+var initCommand = new Command("init", "Intialize a configuration file in the current directory if it doesn't exist yet.");
+rootCommand.Add(initCommand);
+
+// handle the execution of the root command
+rootCommand.SetHandler(async (context) =>
+{
+    // setup logging
+    SetLogLevel(context);
+
+    LogParameters(
+        context.ParseResult.GetValueForOption(configFileOption)!,
+        context.ParseResult.GetValueForOption(outputFolderOption),
+        context.ParseResult.GetValueForOption(workingFolderOption),
+        context.ParseResult.GetValueForOption(cleanupOption));
+
+    // execute the generator
+    context.ExitCode = (int)await AssembleDocumentationAsync(
+        context.ParseResult.GetValueForOption(configFileOption)!,
+        context.ParseResult.GetValueForOption(outputFolderOption),
+        context.ParseResult.GetValueForOption(workingFolderOption),
+        context.ParseResult.GetValueForOption(cleanupOption));
+});
+
+// handle the execution of the root command
+initCommand.SetHandler(async (context) =>
+{
+    // setup logging
+    SetLogLevel(context);
+
+    // execute the configuration file initializer
+    context.ExitCode = (int)await GenerateConfigurationFile();
+});
+
+return await rootCommand.InvokeAsync(args);
+
+// main process for configuration file generation.
+async Task GenerateConfigurationFile()
+{
+    // setup services
+    ILogger logger = GetLogger();
+    IFileService fileService = new FileService();
+
+    try
+    {
+        // the actual generation of the configuration file
+        ConfigInitAction action = new(Environment.CurrentDirectory, fileService, logger);
+        ReturnCode ret = await action.RunAsync();
+
+        logger.LogInformation($"Command completed. Return value: {ret}.");
+        return ret;
+    }
+    catch (Exception ex)
+    {
+        logger.LogCritical(ex.Message);
+        return ReturnCode.Error;
+    }
+}
+
+// main process for assembling documentation.
+async Task AssembleDocumentationAsync(
+    FileInfo configFile,
+    DirectoryInfo? outputFolder,
+    DirectoryInfo? workingFolder,
+    bool cleanup)
+{
+    // setup services
+    ILogger logger = GetLogger();
+    IFileService fileService = new FileService();
+
+    try
+    {
+        ReturnCode ret = ReturnCode.Normal;
+
+        string currentFolder = workingFolder?.FullName ?? Directory.GetCurrentDirectory();
+
+        // CONFIGURATION
+        if (!Path.Exists(configFile.FullName))
+        {
+            // error: not found
+            logger.LogCritical($"Configuration file '{configFile}' doesn't exist.");
+            return ReturnCode.Error;
+        }
+
+        string json = File.ReadAllText(configFile.FullName);
+        var config = SerializationUtil.Deserialize(json);
+        string outputFolderPath = string.Empty;
+        if (outputFolder != null)
+        {
+            // overwrite output folder with given override value
+            config.DestinationFolder = outputFolder.FullName;
+            outputFolderPath = outputFolder.FullName;
+        }
+        else
+        {
+            outputFolderPath = Path.GetFullPath(Path.Combine(currentFolder, config.DestinationFolder));
+        }
+
+        // INVENTORY
+        InventoryAction inventory = new(currentFolder, config, fileService, logger);
+        ret = await inventory.RunAsync();
+
+        if (ret != ReturnCode.Error)
+        {
+            if (cleanup && Directory.Exists(outputFolderPath))
+            {
+                // CLEANUP OUTPUT
+                Directory.Delete(outputFolderPath, true);
+            }
+
+            // ASSEMBLE
+            AssembleAction assemble = new(config, inventory.Files, fileService, logger);
+            ret = await assemble.RunAsync();
+        }
+
+        logger.LogInformation($"Command completed. Return value: {ret}.");
+
+        return ret;
+    }
+    catch (Exception ex)
+    {
+        logger.LogCritical(ex.Message);
+        return ReturnCode.Error;
+    }
+}
+
+// output logging of parameters
+void LogParameters(
+    FileInfo configFile,
+    DirectoryInfo? outputFolder,
+    DirectoryInfo? workingFolder,
+    bool cleanup)
+{
+    ILogger logger = GetLogger();
+
+    logger.LogInformation($"Configuration : {configFile.FullName}");
+    if (outputFolder != null)
+    {
+        logger.LogInformation($"Output  folder: {outputFolder.FullName}");
+    }
+
+    if (workingFolder != null)
+    {
+        logger.LogInformation($"Working folder: {workingFolder.FullName}");
+    }
+
+    logger.LogInformation($"Cleanup       : {cleanup}");
+}
+
+void SetLogLevel(InvocationContext context)
+{
+    if (context.ParseResult.GetValueForOption(verboseOption))
+    {
+        logLevel = LogLevel.Debug;
+    }
+    else
+    {
+        logLevel = LogLevel.Warning;
+    }
+}
+
+ILoggerFactory GetLoggerFactory() => LogUtil.GetLoggerFactory(logLevel);
+ILogger GetLogger() => GetLoggerFactory().CreateLogger(nameof(DocAssembler));
diff --git a/src/DocAssembler/DocAssembler/ReturnCode.cs b/src/DocAssembler/DocAssembler/ReturnCode.cs
new file mode 100644
index 0000000..3a72073
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/ReturnCode.cs
@@ -0,0 +1,22 @@
+namespace DocAssembler;
+
+/// 
+/// Return code for the application.
+/// 
+public enum ReturnCode
+{
+    /// 
+    /// All went well.
+    /// 
+    Normal = 0,
+
+    /// 
+    /// A few warnings, but process completed.
+    /// 
+    Warning = 1,
+
+    /// 
+    /// An error occurred, process not completed.
+    /// 
+    Error = 2,
+}
diff --git a/src/DocAssembler/DocAssembler/Utils/LogUtil.cs b/src/DocAssembler/DocAssembler/Utils/LogUtil.cs
new file mode 100644
index 0000000..20ac599
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Utils/LogUtil.cs
@@ -0,0 +1,35 @@
+// 
+// Copyright (c) DocFx Companion Tools. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+// 
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+using Serilog;
+using Serilog.Events;
+
+namespace DocAssembler.Utils;
+
+/// 
+/// Log utils.
+/// 
+[ExcludeFromCodeCoverage]
+internal static class LogUtil
+{
+    /// 
+    /// Get the logger factory.
+    /// 
+    /// Log level.
+    /// Logger factory.
+    /// When an unknown log level is given.
+    public static ILoggerFactory GetLoggerFactory(LogLevel logLevel1)
+    {
+        var serilogLevel = (LogEventLevel)logLevel1;
+
+        var serilog = new LoggerConfiguration()
+            .MinimumLevel.Is(serilogLevel)
+            .WriteTo.Console(standardErrorFromLevel: LogEventLevel.Warning, outputTemplate: "{Message:lj}{NewLine}", formatProvider: CultureInfo.InvariantCulture)
+            .CreateLogger();
+        return LoggerFactory.Create(p => p.AddSerilog(serilog));
+    }
+}
diff --git a/src/DocAssembler/DocAssembler/Utils/SerializationUtil.cs b/src/DocAssembler/DocAssembler/Utils/SerializationUtil.cs
new file mode 100644
index 0000000..0c5ed01
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/Utils/SerializationUtil.cs
@@ -0,0 +1,36 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DocAssembler.Utils;
+
+/// 
+/// Serialization utilities.
+/// 
+public static class SerializationUtil
+{
+    /// 
+    /// Gets the JSON serializer options.
+    /// 
+    public static JsonSerializerOptions Options => new()
+    {
+        ReadCommentHandling = JsonCommentHandling.Skip,
+        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+        WriteIndented = true,
+        Converters =
+        {
+            new JsonStringEnumConverter(),
+        },
+    };
+
+    /// 
+    /// Serialize object.
+    /// 
+    public static string Serialize(object value) => JsonSerializer.Serialize(value, Options);
+
+    /// 
+    /// Deserialize JSON string.
+    /// 
+    /// Target type.
+    public static T Deserialize(string json) => JsonSerializer.Deserialize(json, Options)!;
+}
diff --git a/src/DocAssembler/DocAssembler/stylecop.json b/src/DocAssembler/DocAssembler/stylecop.json
new file mode 100644
index 0000000..3feaecc
--- /dev/null
+++ b/src/DocAssembler/DocAssembler/stylecop.json
@@ -0,0 +1,13 @@
+{
+  "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
+  "settings": {
+    "documentationRules": {
+      "companyName": "DocFx Companion Tools",
+      "copyrightText": "Copyright (c) {companyName}. All rights reserved.\nLicensed under the {licenseName} license. See {licenseFile} file in the project root for full license information.",
+      "variables": {
+        "licenseName": "MIT",
+        "licenseFile": "LICENSE"
+      }
+    }
+  }
+}
diff --git a/src/DocAssembler/README.md b/src/DocAssembler/README.md
new file mode 100644
index 0000000..93d1712
--- /dev/null
+++ b/src/DocAssembler/README.md
@@ -0,0 +1,155 @@
+# Documentation Assembler Tool
+
+This tool can be used to assemble documentation from various locations on disk and make sure all links still work.
+
+## Usage
+
+```text
+DocAssembler [command] [options]
+
+Options:
+  --workingfolder   The working folder. Default is the current folder.
+  --config  (REQUIRED)     The configuration file for the assembled documentation.
+  --outfolder           Override the output folder for the assembled documentation in the config file.
+  --cleanup-output                 Cleanup the output folder before generating. NOTE: This will delete all folders and files!
+  -v, --verbose                    Show verbose messages of the process.
+  --version                        Show version information
+  -?, -h, --help                   Show help and usage information
+
+Commands:
+  init  Intialize a configuration file in the current directory if it doesn't exist yet.
+```
+
+If normal return code of the tool is 0, but on error it returns 1.
+
+Return values:
+  0 - successful.
+  1 - some warnings, but process could be completed.
+  2 - a fatal error occurred.
+
+## Warnings, errors and verbose
+
+If the tool encounters situations that might need some action, a warning is written to the output. Documentation is still assembled. If the tool encounters an error, an error message is written to the output. Documentation might not be assembled or complete.
+
+If you want to trace what the tool is doing, use the `-v or --verbose` flag to output all details of processing the files and folders and assembling content.
+
+## Overall process
+
+The overall process of this tool is:
+
+1. Content inventory - retrieve all folders and files that can be found with the configured content sets. In this stage we already calculate the new path in the configured output folder. Url replacements when configured are executed here (see [`Replacement`](#replacement) for more details).
+2. If configured, delete the existing output folder.
+3. Copy over all found files to the newly calculated location. Content replacements when configured are executed here. We also change links in markdown files to the new location of the referenced files, unless it's a 'raw copy'. Referenced files that are not found in the content sets are prefixed with the configured prefix.
+
+The basic idea is to define a content set that will be copied to the destination folder. The reason to do this, is because we now have the possibility to completely restructure the documentation, but also apply changes in the content. In a CI/CD process this can be used to assemble all documentation to prepare it for the use of the [DocFxTocGenerator](https://github.com/Ellerbach/docfx-companion-tools/blob/main/src/DocFxTocGenerator) to generate the table of content and then use tools as [DocFx](https://dotnet.github.io/docfx/) to generate a documentation website. The tool expects the content set to be validated for valid links. This can be done using the [DocLinkChecker](https://github.com/Ellerbach/docfx-companion-tools/blob/main/src/DocLinkChecker).
+
+## Configuration file
+
+A configuration file is used for settings. Command line parameters will overwrite these settings if provided.
+
+An initialized configuration file called `.docassembler.json` can be generated in the working directory by using the command:
+
+```shell
+DocAssembler init
+```
+
+If a `.docassembler.json` file already exists in the working directory, an error is given that it will not be overwritten. The generated structure will look like this:
+
+```json
+{
+  "dest": "out",
+  "externalFilePrefix": "https://github.com/example/blob/main/",
+  "content": [
+    {
+      "src": ".docfx",
+      "files": [
+        "**"
+      ],
+      "rawCopy": true
+    },
+    {
+      "src": "docs",
+      "files": [
+        "**"
+      ]
+    },
+    {
+      "src": "backend",
+      "dest": "services",
+      "files": [
+        "**/docs/**"
+      ],
+      "urlReplacements": [
+        {
+          "expression": "/[Dd]ocs/",
+          "value": "/"
+        }
+      ]
+    }
+  ]
+}
+```
+
+### General settings
+
+In the general settings these properties can be set:
+
+| Property              | Description                                                  |
+| --------------------- | ------------------------------------------------------------ |
+| `dest` (Required)     | Destination sub-folder in the working folder to copy the assembled documentation to. This value can be overruled with the `--outfolder` command line argument. |
+| `urlReplacements`     | A global collection of [`Replacement`](#replacement) objects to use across content sets for URL paths. These replacements are applied to calculated destination paths for files in the content sets. This can be used to modify the path. The generated template removes /docs/ from paths and replaces it by a /. If a content set has `urlReplacements` configured, it overrules these global ones. More information can be found under [`Replacement`](#replacement). |
+| `contentReplacements` | A global collection of [`Replacement`](#replacement) objects to use across content sets for content of files. These replacements are applied to all content of markdown files in the content sets. This can be used to modify for instance URLs or other content items. If a content set has `contentReplacements` configured, it overrules these global ones. More information can be found under [`Replacement`](#replacement). |
+| `externalFilePrefix`  | The global prefix to use for all referenced files in all content sets that are not part of the documentation, like source files. This prefix is used in combination with the sub-path from the working folder. If a content set has `externalFilePrefix` configured, it overrules this global one. |
+| `content` (Required)  | A collection of [`Content`](#content) objects to define all content sets to assemble. |
+
+### `Replacement`
+
+A replacement definition has these properties:
+
+| Property     | Description                                                  |
+| ------------ | ------------------------------------------------------------ |
+| `expression` | A regular expression to find specific text. |
+| `value` | The value that replaces the found text. Named matched subexpressions can be used here as well as explained below. |
+
+This type is used in collections for URL replacements or content replacements. They are applied one after another, starting with the first entry. The regular expression is used to find text that will be replaced by the value. The expressions are regular expression as described in [.NET Regular Expressions - .NET  Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/standard/base-types/regular-expressions). Examples can be found there as well. There are websites like [regex101: build, test, and debug regex](https://regex101.com/) to build, debug and validate the expression you need.
+
+#### Using named matched subexpressions
+
+Sometimes you want to find specific content, but also reuse parts of it in the value replacement. An example would be to find all `AB#1234` notations and replace it by a URL to the referenced Azure Boards work-item or GitHub item. But in this case we want to use the ID (1234) in the value. To do that, you can use [Named matched subexpressions](https://learn.microsoft.com/en-us/dotnet/standard/base-types/grouping-constructs-in-regular-expressions#named-matched-subexpressions).
+
+This expression could be used to find all those references:
+
+```regex
+(?
[$\\s])AB#(?[0-9]{3,6})
+```
+
+As we don't want to find a link like `[AB#1234](https://...)`, we look for all AB# references that are at the start of a line (using the `$` tag) or are prefixed by a whitespace (using the `\s` tag). As we need to keep that prefix in place, we capture it as a named subexpression called `pre`.
+
+> [!NOTE]
+>
+> As the expression is configured in a string in a JSON file, special characters like back-slashes need to be escaped by an (extra) back-slash like you see in the example above, where `\s` is escaped with an extra `\`.
+
+The second part is to get the numbers after the AB# text. This is configured here to be between 3 and 6 characters. We also want to reuse this ID in the value, so we capture it as a named subexpression called `id`.
+
+In the value we can reuse these named subexpression like this:
+
+```text
+${pre}[AB#${id}](https://dev.azure.com/[your organization]/_workitems/edit/${id})
+```
+
+We start with the `pre` value, after which we build a markdown link with AB# combined with the `id` as the text and the `id` as parameter for the URL. We reference an Azure Board work item here. Of course you need to replace the `[your organization]` with the proper value for your ADO environment here. With the examples above the text *AB#1234* would be translated to *[AB#1234(https://dev.azure.com/[your organization]/_workitems/edit/1234)*.
+
+### `Content`
+
+The content is defined with these properties:
+
+| Property         | Description                                                  |
+| ---------------- | ------------------------------------------------------------ |
+| `src` (Required) | The source sub-folder relative to the working folder.        |
+| `dest`           | An optional destination sub-folder path in the output folder. If this is not given, the relative path to the source folder is used. |
+| `files`          | This is a  [File Globbing in .NET](https://learn.microsoft.com/en-us/dotnet/core/extensions/file-globbing) pattern. Make sure to also include all needed files for documentation like images and assets. |
+| `exclude`        | This is a  [File Globbing in .NET](https://learn.microsoft.com/en-us/dotnet/core/extensions/file-globbing) pattern. This can be used to exclude specific folders or files from the content set. |
+| `rawCopy` | If this value is `true` then we don't look for any links in markdown files and therefor also don't fix them. This can be used for raw content you want to include in the documentation set like `.docfx.json`, templates and such. |
+| `urlReplacements`     | A collection of [`Replacement`](#replacement) objects to use for URL paths in this content set, overruling any global setting. These replacements are applied to calculated destination paths for files in the content sets. This can be used to modify the path. The generated template removes /docs/ from paths and replaces it by a /. More information can be found under [`Replacement`](#replacement). |
+| `contentReplacements` | A collection of [`Replacement`](#replacement) objects to use for content of files in this content set, overruling any global setting. These replacements are applied to all content of markdown files in the content sets. This can be used to modify for instance URLs or other content items. More information can be found under [`Replacement`](#replacement). |
+| `externalFilePrefix`  | The prefix to use for all referenced files in this content sets that are not part of the complete documentation set, like source files. It overrides any global prefix. This prefix is used in combination with the sub-path from the working folder. |