diff --git a/Directory.Build.props b/Directory.Build.props index e947a2a..30b1cab 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,9 @@ 1.0.0 - The Docfx plugin to generate documentation from xml-based files via intermediate XSLT transformation into Markdown. + + The Docfx plugin to generate documentation from xml-based files via intermediate template transformations into Markdown. + Docfx plugin XSD XML Markdown documentation Heleonix - Hennadii Lutsyshyn @@ -26,7 +28,7 @@ enable latest true - True + true en-US en-US diff --git a/README.md b/README.md index b8b90d8..62bef81 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,16 @@ [![Release: .NET / NuGet](https://github.com/Heleonix/Heleonix.Docfx.Plugins.XmlDoc/actions/workflows/release-net-nuget.yml/badge.svg)](https://github.com/Heleonix/Heleonix.Docfx.Plugins.XmlDoc/actions/workflows/release-net-nuget.yml) -The Docfx plugin to generate documentation from xml-based files via intermediate XSLT transformation into Markdown. +The Docfx plugin to generate documentation from xml-based files via intermediate template transformations into Markdown. ## Install https://www.nuget.org/packages/Heleonix.Docfx.Plugins.XmlDoc +## Documentation + +See [Heleonix.Docfx.Plugins.XmlDoc](https://heleonix.github.io/docs/Plugins/Heleonix.Docfx.Plugins.XmlDoc.html) + ## Usage 1. Install the plugin and make it accessible for `Docfx` as a custom template. See [How to enable plugins](https://dotnet.github.io/docfx/tutorial/howto_build_your_own_type_of_documentation_with_custom_plug-in.html#enable-plug-in) @@ -19,11 +23,11 @@ https://www.nuget.org/packages/Heleonix.Docfx.Plugins.XmlDoc } ``` By default, `.xml` and `.xsd` file formats are recognized. -3. Configure the `docfx.json` with the plugin features. See the [Example]. +3. Configure the `docfx.json` with the plugin features. See the [docfx.json](#docfx.json). -### Example +### Examples -Example of a configuration in a simple `docfx.json` file: +#### docfx.json ```json { @@ -52,7 +56,7 @@ Example of a configuration in a simple `docfx.json` file: "templates/template-with-xmldoc-plugin" ], "fileMetadata": { - "hx.xmldoc.xslt": { "**.xsd": "./xml-to-md.xslt" }, + "hx.xmldoc.template": { "**.xsd": "./xml-to-md.xslt" }, "hx.xmldoc.store": { "../../some-external-location/*.xsd": "internal-store-folder" } "hx.xmldoc.toc": { "**/*-some.xsd": { "action": "InsertAfter", "key": "~/articles/introduction.md" }, @@ -63,14 +67,122 @@ Example of a configuration in a simple `docfx.json` file: } ``` +#### input xml-based file, i.e. Hx_NetBuild.xsd + +```xml + + + + + + A path to the NetBuild artifacts directory. + + + + + A path to the solution file to build. Default is a .sln file found in the $Hx_WS_Dir. + + + + + The file with public/private keys pair to sign assemblies. + + + + +``` +#### xslt template + +```xml + + + + + + +# +### Properties + +#### + + + + + + + +``` + +#### cshtml template + +```html +@using System +@using System.IO +@using System.Xml.Linq +@inherits RazorEngineCore.RazorEngineTemplateBase +@{ + XDocument model = Model; + XNamespace xs = "http://www.w3.org/2001/XMLSchema"; + + var fileName = Path.GetFileNameWithoutExtension(model.Document.BaseUri); + var elements = model.Document.Element(xs + "schema").Elements(xs + "element"); + var props = elements.Where(e => e.Attribute("substitutionGroup")?.Value == "msb:Property"); +} +--- +uid: @fileName +--- + +# @fileName + +@if (props.Count() > 0) +{ + ## Properties + @: + foreach (var prop in props) + { + #### @prop.Attribute("name").Value + @: + @prop.Element(xs + "annotation").Element(xs + "documentation").Value.Trim() + @: + } +} +``` + +#### markdown output + +```markdown +# Hx_NetBuild +### Properties + +#### Hx_NetBuild_ArtifactsDir + +A path to the NetBuild artifacts directory. + +#### Hx_NetBuild_SlnFile + +A path to the solution file to build. Default is a .sln file found in the $Hx_WS_Dir. + +#### Hx_NetBuild_SnkFile + +The file with public/private keys pair to sign assemblies. +``` + ### File Metadata -`hx.xmldoc.xslt` - path to XSLT file to convert xml-based file to Markdown, which is then converted into output HTML by Docfx. +`hx.xmldoc.template` - path to a template `.cshtml` or `.xslt` file to transform xml-based file to Markdown, which is then converted into output HTML by Docfx. `hx.xmldoc.store` - a folder inside your documentation proejct, where the corresponding xml-based files are copied to -and then used as source files to generate output HTML from. -This is useful, when original files are not always available. -It works like metadata files generated from .NET projects/dlls/xml documentation. +and then used as source files to transform to intermediate markdown and generate output HTML from. +This is useful, when original files are not always available, i.e. when your single documentation project is applied +to different dotnet projects simultaneously to support multi-project documentation. +It works like metadata files generated from .NET projects/dlls/xml documentation, where generated `yaml` metadata could +be commited as part of your documentation project for future re-builds, when the original .NET project is not available. Hrefs to such files can be specified as `internal-store-folder/your-file.xsd`. `hx.xmldoc.toc` - specifies where and how the your xml-based files should be added into Table Of Contents. @@ -88,11 +200,9 @@ Hrefs to such files can be specified as `internal-store-folder/your-file.xsd`. You can watch the progress in the [PR: .NET](https://github.com/Heleonix/Heleonix.Docfx.Plugins.XmlDoc/actions/workflows/pr-net.yml) GitHub workflows 4. [Request review](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/requesting-a-pull-request-review) from the code owner 5. Once approved, merge your Pull Request via [Squash and merge](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#squash-and-merge-your-commits) - > **IMPORTANT** > While merging, enter a [Conventional Commits](https://www.conventionalcommits.org/) commit message. > This commit message will be used in automatically generated [Github Release Notes](https://github.com/Heleonix/Heleonix.Docfx.Plugins.XmlDoc/releases) > and [NuGet Release Notes](https://www.nuget.org/packages/Heleonix.Docfx.Plugins.XmlDoc/#releasenotes-body-tab) - 6. Monitor the [Release: .NET / NuGet](https://github.com/Heleonix/Heleonix.Docfx.Plugins.XmlDoc/actions/workflows/release-net-nuget.yml) GitHub workflow to make sure your changes are delivered successfully 7. In case of any issues, please contact [heleonix.sln@gmail.com](mailto:heleonix.sln@gmail.com) \ No newline at end of file diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/FileMetadata.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/FileMetadata.cs index 0e9aab1..b249ce4 100644 --- a/src/Heleonix.Docfx.Plugins.XmlDoc/FileMetadata.cs +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/FileMetadata.cs @@ -10,46 +10,6 @@ namespace Heleonix.Docfx.Plugins.XmlDoc; /// /// Represents the model of the supported fileMetadata specified for content files to be processed by this plugin. /// -/// -/// An example of the docfx.json: -/// -/// { -/// "build": { -/// "content": [ -/// { -/// "files": [ "**/*.{md,yml}" ], -/// "exclude": [ "_site/**" ] -/// }, -/// { -/// "files": [ "*.xsd" ], -/// "src": "../../some-external-location" -/// }, -/// { -/// "files": [ "internal-store-folder/*.xsd" ] -/// } -/// ], -/// "resource": [ -/// { -/// "files": [ "images/**" ] -/// } -/// ], -/// "output": "_site", -/// "template": [ -/// "default", -/// "templates/your-template-with" -/// ], -/// "fileMetadata": { -/// "hx.xmldoc.xslt": { "**.xsd": "./xml-to-md.xslt" }, -/// "hx.xmldoc.store": { "../../some-external-location/*.xsd": "internal-store-folder" } -/// "hx.xmldoc.toc": { -/// "**/*-some.xsd": { "action": "InsertAfter", "key": "~/articles/introduction.md" }, -/// "**/*-other.xsd": { "action": "AppendChild", "key": "Namespace.Class.whatever.uid" } -/// } -/// } -/// } -/// } -/// -/// public class FileMetadata { /// @@ -58,9 +18,9 @@ public class FileMetadata public const string StoreKey = "hx.xmldoc.store"; /// - /// The name of the xslt key in the fileMetadata of content files. + /// The name of the template key in the fileMetadata of content files. /// - public const string XsltKey = "hx.xmldoc.xslt"; + public const string TemplateKey = "hx.xmldoc.template"; /// /// The name of the Table Of Contents key in the fileMetadata of content files. @@ -77,10 +37,10 @@ public class FileMetadata public string Store { get; set; } /// - /// The hx.xmldoc.xslt file metadata to specify a path to an XSLT file to transform XML-based content files - /// into Markdown for further generation of HTML output files styled with the documentation templates. + /// The hx.xmldoc.template file metadata to specify a path to a template file to transform XML-based + /// content files into Markdown for further generation of HTML output files styled with the documentation templates. /// - public string Xslt { get; set; } + public string Template { get; set; } /// /// The hx.xmldoc.toc file metadata to specify a configuration for @@ -106,8 +66,8 @@ public static FileMetadata From(IDictionary dictionary) dictionary.TryGetValue(FileMetadata.StoreKey, out var obj); metadata.Store = obj as string; - dictionary.TryGetValue(FileMetadata.XsltKey, out obj); - metadata.Xslt = obj as string; + dictionary.TryGetValue(FileMetadata.TemplateKey, out obj); + metadata.Template = obj as string; dictionary.TryGetValue(FileMetadata.TocKey, out var tocObj); metadata.Toc = TocMetadata.From(tocObj as IDictionary); diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/HeaderHandler.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/HeaderHandler.cs new file mode 100644 index 0000000..31794c4 --- /dev/null +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/HeaderHandler.cs @@ -0,0 +1,166 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc; + +using global::Docfx.Common; +using global::Docfx.DataContracts.Common; +using global::Docfx.Plugins; +using HtmlAgilityPack; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Net; + +/// > +[Export(nameof(IHeaderHandler), typeof(IHeaderHandler))] +public class HeaderHandler : IHeaderHandler +{ + /// + public (string h1, string h1Raw, string body) ExtractH1(string html) + { + var document = new HtmlDocument(); + + document.LoadHtml(html); + + // InnerText in HtmlAgilityPack is not decoded, should be a bug + var h1Node = document.DocumentNode.SelectSingleNode("//h1"); + var h1 = WebUtility.HtmlDecode(h1Node?.InnerText); + var h1Raw = string.Empty; + + // If the html content is a fragment, which starts with 'h1' heading, like:

Heading

Content

+ if (h1Node != null && GetFirstNoneCommentChild(document.DocumentNode) == h1Node) + { + h1Raw = h1Node.OuterHtml; + h1Node.Remove(); + } + + return (h1, h1Raw, document.DocumentNode.OuterHtml); + + static HtmlNode GetFirstNoneCommentChild(HtmlNode node) + { + var result = node.FirstChild; + + while (result != null) + { + if (result.NodeType == HtmlNodeType.Comment || string.IsNullOrWhiteSpace(result.OuterHtml)) + { + result = result.NextSibling; + } + else + { + break; + } + } + + return result; + } + } + + /// + public void HandleYamlHeader(ImmutableDictionary yamlHeader, FileModel model) + { + if (yamlHeader == null) + { + return; + } + + foreach (var item in yamlHeader.OrderBy(i => i.Key, StringComparer.Ordinal)) + { + var content = (IDictionary)model.Content; + + switch (item.Key) + { + case Constants.PropertyName.Uid: + var uid = item.Value as string; + + if (!string.IsNullOrWhiteSpace(uid)) + { + content[item.Key] = item.Value; + model.Uids = new[] { new UidDefinition(uid, model.LocalPathFromRoot) }.ToImmutableArray(); + } + + break; + case Constants.PropertyName.DocumentType: + content[item.Key] = item.Value; + model.DocumentType = item.Value as string; + + break; + case Constants.PropertyName.OutputFileName: + content[item.Key] = item.Value; + + var outputFileName = item.Value as string; + + if (!string.IsNullOrWhiteSpace(outputFileName)) + { + if (Path.GetFileName(outputFileName) == outputFileName) + { + model.File = (RelativePath)model.File + (RelativePath)outputFileName; + } + else + { + Logger.LogWarning($"Invalid output file name in yaml header: {outputFileName}, skip rename output file."); + } + } + + break; + default: + content[item.Key] = item.Value; + + break; + } + } + } + + /// + public string GetTitle(FileModel model, ImmutableDictionary yamlHeader, string h1) + { + // title from YAML header + if (yamlHeader != null && TryGetStringValue(yamlHeader, Constants.PropertyName.Title, out var yamlHeaderTitle)) + { + return yamlHeaderTitle; + } + + var content = (IDictionary)model.Content; + + // title from metadata/titleOverwriteH1 + if (TryGetStringValue(content, Constants.PropertyName.TitleOverwriteH1, out var titleOverwriteH1)) + { + return titleOverwriteH1; + } + + // title from H1 + if (!string.IsNullOrEmpty(h1)) + { + return h1; + } + + // title from globalMetadata or fileMetadata + if (TryGetStringValue(content, Constants.PropertyName.Title, out var title)) + { + return title; + } + + return Path.GetFileNameWithoutExtension(model.File); + } + + private static bool TryGetStringValue(IDictionary dictionary, string key, out string strValue) + { + if (dictionary.TryGetValue(key, out var value) && value is string str && !string.IsNullOrEmpty(str)) + { + strValue = str; + + return true; + } + else + { + strValue = null; + + return false; + } + } +} diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/Heleonix.Docfx.Plugins.XmlDoc.csproj b/src/Heleonix.Docfx.Plugins.XmlDoc/Heleonix.Docfx.Plugins.XmlDoc.csproj index 17faf73..5c64cc5 100644 --- a/src/Heleonix.Docfx.Plugins.XmlDoc/Heleonix.Docfx.Plugins.XmlDoc.csproj +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/Heleonix.Docfx.Plugins.XmlDoc.csproj @@ -22,11 +22,12 @@ + - + - + @@ -35,6 +36,10 @@ + + + + diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/IHeaderHandler.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/IHeaderHandler.cs new file mode 100644 index 0000000..1d2335d --- /dev/null +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/IHeaderHandler.cs @@ -0,0 +1,39 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc; + +using System.Collections.Immutable; +using global::Docfx.Plugins; + +/// +/// Handles headers and titles of the generated html result content. +/// +public interface IHeaderHandler +{ + /// + /// Extracts the 'h1' header from the html result content and returns both. + /// + /// The html result content to extract 'h1' header from. + /// Extracted 'h1' header and content. + (string h1, string h1Raw, string body) ExtractH1(string html); + + /// + /// Handles the yaml header of the markdown to apply defined values in the yaml header. + /// + /// The yaml header fo the html result contents of the + /// to handle. + /// The model of the markdown content to handle the yaml header for. + void HandleYamlHeader(ImmutableDictionary yamlHeader, FileModel model); + + /// + /// Gets a title from the passed sources. + /// + /// A model to try to get a title from. + /// A Yaml Header of the markdown file to get a title from. + /// A 'h1' header to return as a title. + /// A title from the provided sources. + string GetTitle(FileModel model, ImmutableDictionary yamlHeader, string h1); +} diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/ITocHandler.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/ITocHandler.cs new file mode 100644 index 0000000..4538562 --- /dev/null +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/ITocHandler.cs @@ -0,0 +1,22 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc; + +using System.Collections.Generic; +using global::Docfx.Plugins; + +/// +/// Handles Table of Contents actions. +/// +public interface ITocHandler +{ + /// + /// Builds TOC restructures and adds into the . + /// + /// Content model of a file to handle. + /// Common list of TOC restructures to add handled TOC for . + void HandleTocRestructions(FileModel model, IList restructions); +} diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/ITransformer.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/ITransformer.cs new file mode 100644 index 0000000..803775f --- /dev/null +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/ITransformer.cs @@ -0,0 +1,22 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc; + +using global::Docfx.Plugins; + +/// +/// Declares functionality to transform xml-based files into markdown using different templates. +/// +public interface ITransformer +{ + /// + /// Transforms the specified model with the template specified in metadata. + /// + /// The xml model to transform. + /// A host service from Docfx. + /// The string containing the transformed contents. + string Transform(FileModel model, IHostService host); +} \ No newline at end of file diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/TocHandler.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/TocHandler.cs new file mode 100644 index 0000000..91b2a39 --- /dev/null +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/TocHandler.cs @@ -0,0 +1,59 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc; + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using global::Docfx.DataContracts.Common; +using global::Docfx.Plugins; + +/// +[Export(nameof(ITocHandler), typeof(ITocHandler))] +public class TocHandler : ITocHandler +{ + /// + public void HandleTocRestructions(FileModel model, IList restructions) + { + var content = (IDictionary)model.Content; + + var metadata = FileMetadata.From(content); + + if (metadata.Toc.Key == null) + { + return; + } + + var treeItem = new TreeItem(); + + treeItem.Metadata[Constants.PropertyName.Name] = content[Constants.PropertyName.Title]; + treeItem.Metadata[Constants.PropertyName.Href] = model.Key; + treeItem.Metadata[Constants.PropertyName.TopicHref] = model.Key; + + if (content.ContainsKey("_appName")) + { + treeItem.Metadata["_appName"] = content["_appName"]; + } + + if (content.ContainsKey("_appTitle")) + { + treeItem.Metadata["_appTitle"] = content["_appTitle"]; + } + + if (content.ContainsKey("_enableSearch")) + { + treeItem.Metadata["_enableSearch"] = content["_enableSearch"]; + } + + restructions.Add(new () + { + ActionType = metadata.Toc.Action, + TypeOfKey = metadata.Toc.Key.StartsWith('~') ? TreeItemKeyType.TopicHref : TreeItemKeyType.TopicUid, + Key = metadata.Toc.Key, + RestructuredItems = new List { treeItem }.ToImmutableList(), + }); + } +} diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/Transformer.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/Transformer.cs new file mode 100644 index 0000000..c56333a --- /dev/null +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/Transformer.cs @@ -0,0 +1,129 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc; + +using System.Collections.Concurrent; +using System.Composition; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Runtime.Loader; +using System.Xml.Linq; +using System.Xml.Xsl; +using global::Docfx.Plugins; +using RazorEngineCore; + +/// > +[Export(nameof(ITransformer), typeof(ITransformer))] +public class Transformer : ITransformer +{ + private static readonly RazorEngine Engine = new (); + + private readonly ConcurrentDictionary xmlTemplates = new (); + + private readonly ConcurrentDictionary>> + razorTemplates = new (); + + static Transformer() + { + AssemblyLoadContext.Default.Resolving += Transformer.Default_Resolving; + } + + /// + public string Transform(FileModel model, IHostService host) + { + try + { + var content = (IDictionary)model.Content; + + var metadata = FileMetadata.From(content); + + if (".xslt".Equals(Path.GetExtension(metadata.Template), StringComparison.OrdinalIgnoreCase)) + { + return this.TransformWithXslt(model, metadata); + } + else if (".cshtml".Equals(Path.GetExtension(metadata.Template), StringComparison.OrdinalIgnoreCase)) + { + return this.TransformWithRazor(model, metadata); + } + + host.LogError($"Unknown template file format: {metadata.Template}.", model.FileAndType.FullPath); + + return null; + } + catch (Exception ex) + { + host.LogError(ex.ToString(), model.FileAndType.FullPath); + + throw; + } + } + + /// + /// Resolves dependencies manually, because MEF2 in Docfx does not handle plugin's dependencies. + /// In unit tests dependencies are resolved automatically, so this method does not need to be covered by tests. + /// + /// The assembly load context. Not used. + /// The name of the dependency assembly to load manually. + /// Returns the resolved assembly, which is dependency for this plugin, otherwise null. + [ExcludeFromCodeCoverage] + private static Assembly Default_Resolving(AssemblyLoadContext context, AssemblyName assemblyName) + { +#pragma warning disable S3885 // "Assembly.Load" should be used + if (assemblyName.Name.Equals("RazorEngineCore", StringComparison.OrdinalIgnoreCase)) + { + var path = Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), + "RazorEngineCore.dll"); + + return Assembly.LoadFile(path); + } + + if (assemblyName.Name.Equals("HtmlAgilityPack", StringComparison.OrdinalIgnoreCase)) + { + var path = Path.Combine( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), + "HtmlAgilityPack.dll"); + + return Assembly.LoadFile(path); +#pragma warning restore S3885 // "Assembly.Load" should be used + } + + return null; + } + + private string TransformWithXslt(FileModel model, FileMetadata metadata) + { + var template = this.xmlTemplates.GetOrAdd( + metadata.Template, + (k, arg) => + { + var t = new XslCompiledTransform(); + t.Load(arg); + return t; + }, metadata.Template); + + using var stringWriter = new StringWriter(); + + var args = new XsltArgumentList(); + + args.AddParam("filename", string.Empty, Path.GetFileNameWithoutExtension(model.File)); + + template.Transform(model.FileAndType.FullPath, args, stringWriter); + + return stringWriter.ToString(); + } + + private string TransformWithRazor(FileModel model, FileMetadata metadata) + { + var template = this.razorTemplates.GetOrAdd( + metadata.Template, + (k, arg) => Engine.Compile>(File.ReadAllText(arg)), + metadata.Template); + + return template.Run(instance => + instance.Model = XDocument.Load(model.FileAndType.FullPath, LoadOptions.SetBaseUri)); + } +} diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/XmlDocBuildStep.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/XmlDocBuildStep.cs index e9e8b8b..f7c1fce 100644 --- a/src/Heleonix.Docfx.Plugins.XmlDoc/XmlDocBuildStep.cs +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/XmlDocBuildStep.cs @@ -5,21 +5,12 @@ namespace Heleonix.Docfx.Plugins.XmlDoc; -using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Composition; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Net; -using System.Reflection; -using System.Runtime.Loader; -using System.Xml.Xsl; using global::Docfx.Common; using global::Docfx.DataContracts.Common; using global::Docfx.Plugins; -using HtmlAgilityPack; /// /// The build step to generate html documentation from the xml-based content files. @@ -28,15 +19,23 @@ namespace Heleonix.Docfx.Plugins.XmlDoc; [Export(nameof(XmlDocProcessor), typeof(IDocumentBuildStep))] public class XmlDocBuildStep : IDocumentBuildStep { - private readonly ConcurrentDictionary transforms = new (); + /// + /// Gets or sets the transformer to use to transform xml-based contents into markdown. + /// + [Import(nameof(ITransformer))] + public ITransformer Transformer { get; set; } /// - /// Initializes a new instance of the class. + /// Gets or sets the header handler to handle headers and titles of the generated html result content. /// - public XmlDocBuildStep() - { - AssemblyLoadContext.Default.Resolving += XmlDocBuildStep.Default_Resolving; - } + [Import(nameof(IHeaderHandler))] + public IHeaderHandler HeaderHandler { get; set; } + + /// + /// Gets or sets the handler of Table of Contents actions specified for xml-based files. + /// + [Import(nameof(ITocHandler))] + public ITocHandler TocHandler { get; set; } /// /// Gets the order of the build step to be executed with. @@ -71,7 +70,7 @@ public void Postbuild(ImmutableList models, IHostService host) } /// - /// Builds the xml-based contents via Markdown XSLT transformation into HTML output. + /// Builds the xml-based contents via transformations into Markdown. /// /// The models to transform from XML into HTML output. /// The host to be used for common tasks. @@ -91,60 +90,30 @@ public IEnumerable Prebuild(ImmutableList models, IHostSer var content = (Dictionary)model.Content; - var metadata = FileMetadata.From(content); - - var transform = this.transforms.GetOrAdd( - metadata.Xslt, - (k, arg) => - { - var t = new XslCompiledTransform(); - t.Load(arg); - return t; - }, metadata.Xslt); - - using (var stringWriter = new StringWriter()) - { - var args = new XsltArgumentList(); - - args.AddParam("filename", string.Empty, Path.GetFileNameWithoutExtension(model.File)); - - transform.Transform(model.FileAndType.FullPath, args, stringWriter); - - content[Constants.PropertyName.Conceptual] = stringWriter.ToString(); - } - - var markdown = (string)content[Constants.PropertyName.Conceptual]; + var markdown = this.Transformer.Transform(model, host); var result = host.Markup(markdown, model.FileAndType, false); - var (h1, h1Raw, conceptual) = XmlDocBuildStep.ExtractH1(result.Html); + var (h1, h1Raw, conceptual) = this.HeaderHandler.ExtractH1(result.Html); content["rawTitle"] = h1Raw; if (!string.IsNullOrEmpty(h1Raw)) { - model.ManifestProperties.rawTitle = h1Raw; + (model.ManifestProperties as IDictionary)["rawTitle"] = h1Raw; } content[Constants.PropertyName.Conceptual] = conceptual; - if (result.YamlHeader != null) - { - foreach (var item in result.YamlHeader.OrderBy(i => i.Key, StringComparer.Ordinal)) - { - XmlDocBuildStep.HandleYamlHeaderPair(model, item.Key, item.Value); - } - } + this.HeaderHandler.HandleYamlHeader(result.YamlHeader, model); - content[Constants.PropertyName.Title] = - XmlDocBuildStep.GetTitle(content, result.YamlHeader, h1) - ?? Path.GetFileNameWithoutExtension(model.File); + content[Constants.PropertyName.Title] = this.HeaderHandler.GetTitle(model, result.YamlHeader, h1); model.LinkToFiles = result.LinkToFiles.ToImmutableHashSet(); model.LinkToUids = result.LinkToUids; model.FileLinkSources = result.FileLinkSources; model.UidLinkSources = result.UidLinkSources; - model.Properties.XrefSpec = null; + (model.Properties as IDictionary)["XrefSpec"] = null; if (model.Uids.Length > 0) { @@ -158,192 +127,11 @@ public IEnumerable Prebuild(ImmutableList models, IHostSer (model.Properties as IDictionary)["XrefSpec"] = xrefSpec; } - if (metadata.Toc.Key == null) - { - continue; - } - - var treeItem = new TreeItem(); - - treeItem.Metadata[Constants.PropertyName.Name] = content[Constants.PropertyName.Title]; - treeItem.Metadata[Constants.PropertyName.Href] = model.Key; - treeItem.Metadata[Constants.PropertyName.TopicHref] = model.Key; - - if (content.ContainsKey("_appName")) - { - treeItem.Metadata["_appName"] = content["_appName"]; - } - - if (content.ContainsKey("_appTitle")) - { - treeItem.Metadata["_appTitle"] = content["_appTitle"]; - } - - if (content.ContainsKey("_enableSearch")) - { - treeItem.Metadata["_enableSearch"] = content["_enableSearch"]; - } - - tocRestructions.Add(new () - { - ActionType = metadata.Toc.Action, - TypeOfKey = metadata.Toc.Key.StartsWith("~") ? TreeItemKeyType.TopicHref : TreeItemKeyType.TopicUid, - Key = metadata.Toc.Key, - RestructuredItems = new List { treeItem }.ToImmutableList(), - }); + this.TocHandler.HandleTocRestructions(model, tocRestructions); } host.TableOfContentRestructions = tocRestructions.ToImmutableList(); return models; } - - [ExcludeFromCodeCoverage] - private static Assembly Default_Resolving(AssemblyLoadContext context, AssemblyName assemblyName) - { - if (assemblyName.Name.Equals("HtmlAgilityPack", StringComparison.OrdinalIgnoreCase)) - { - var path = Path.Combine( - Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), - "HtmlAgilityPack.dll"); - -#pragma warning disable S3885 // "Assembly.Load" should be used - return Assembly.LoadFile(path); -#pragma warning restore S3885 // "Assembly.Load" should be used - } - - return null; - } - - private static (string h1, string h1Raw, string body) ExtractH1(string contentHtml) - { - var document = new HtmlDocument(); - - document.LoadHtml(contentHtml); - - // InnerText in HtmlAgilityPack is not decoded, should be a bug - var h1Node = document.DocumentNode.SelectSingleNode("//h1"); - var h1 = WebUtility.HtmlDecode(h1Node?.InnerText); - var h1Raw = string.Empty; - - // If the html content is a fragment, which starts with 'h1' heading, like:

Heading

Content

- if (h1Node != null && GetFirstNoneCommentChild(document.DocumentNode) == h1Node) - { - h1Raw = h1Node.OuterHtml; - h1Node.Remove(); - } - - return (h1, h1Raw, document.DocumentNode.OuterHtml); - - static HtmlNode GetFirstNoneCommentChild(HtmlNode node) - { - var result = node.FirstChild; - - while (result != null) - { - if (result.NodeType == HtmlNodeType.Comment || string.IsNullOrWhiteSpace(result.OuterHtml)) - { - result = result.NextSibling; - } - else - { - break; - } - } - - return result; - } - } - - private static void HandleYamlHeaderPair(FileModel model, string key, object value) - { - var content = (IDictionary)model.Content; - - switch (key) - { - case Constants.PropertyName.Uid: - var uid = value as string; - - if (!string.IsNullOrWhiteSpace(uid)) - { - content[key] = value; - model.Uids = new[] { new UidDefinition(uid, model.LocalPathFromRoot) }.ToImmutableArray(); - } - - break; - case Constants.PropertyName.DocumentType: - content[key] = value; - model.DocumentType = value as string; - - break; - case Constants.PropertyName.OutputFileName: - content[key] = value; - - var outputFileName = value as string; - - if (!string.IsNullOrWhiteSpace(outputFileName)) - { - if (Path.GetFileName(outputFileName) == outputFileName) - { - model.File = (RelativePath)model.File + (RelativePath)outputFileName; - } - else - { - Logger.LogWarning($"Invalid output file name in yaml header: {outputFileName}, skip rename output file."); - } - } - - break; - default: - content[key] = value; - - break; - } - } - - private static string GetTitle(IDictionary content, ImmutableDictionary yamlHeader, string h1) - { - // title from YAML header - if (yamlHeader != null - && TryGetStringValue(yamlHeader, Constants.PropertyName.Title, out var yamlHeaderTitle)) - { - return yamlHeaderTitle; - } - - // title from metadata/titleOverwriteH1 - if (TryGetStringValue(content, Constants.PropertyName.TitleOverwriteH1, out var titleOverwriteH1)) - { - return titleOverwriteH1; - } - - // title from H1 - if (!string.IsNullOrEmpty(h1)) - { - return h1; - } - - // title from globalMetadata or fileMetadata - if (TryGetStringValue(content, Constants.PropertyName.Title, out var title)) - { - return title; - } - - return null; - } - - private static bool TryGetStringValue(IDictionary dictionary, string key, out string strValue) - { - if (dictionary.TryGetValue(key, out var value) && value is string str && !string.IsNullOrEmpty(str)) - { - strValue = str; - - return true; - } - else - { - strValue = null; - - return false; - } - } } diff --git a/src/Heleonix.Docfx.Plugins.XmlDoc/XmlDocProcessor.cs b/src/Heleonix.Docfx.Plugins.XmlDoc/XmlDocProcessor.cs index de2a308..74c552d 100644 --- a/src/Heleonix.Docfx.Plugins.XmlDoc/XmlDocProcessor.cs +++ b/src/Heleonix.Docfx.Plugins.XmlDoc/XmlDocProcessor.cs @@ -74,7 +74,8 @@ public XmlDocProcessor() /// otherwise . public ProcessingPriority GetProcessingPriority(FileAndType file) { - if (file.Type == DocumentType.Article && this.Settings.SupportedFormats.Contains(Path.GetExtension(file.File))) + if (file.Type == DocumentType.Article + && this.Settings.SupportedFormats.Contains(Path.GetExtension(file.File), StringComparer.OrdinalIgnoreCase)) { this.ContentFiles.TryAdd(file.File, string.Empty); @@ -136,9 +137,9 @@ public FileModel Load(FileAndType file, ImmutableDictionary meta content[key] = value; } - if (PathUtility.IsRelativePath(fileMetadata.Xslt)) + if (PathUtility.IsRelativePath(fileMetadata.Template)) { - content[FileMetadata.XsltKey] = Path.Combine(EnvironmentContext.BaseDirectory, fileMetadata.Xslt); + content[FileMetadata.TemplateKey] = Path.Combine(EnvironmentContext.BaseDirectory, fileMetadata.Template); } content[Constants.PropertyName.SystemKeys] = this.systemKeys; diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/FileMetadataTests.cs b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/FileMetadataTests.cs index b05ee8d..3463284 100644 --- a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/FileMetadataTests.cs +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/FileMetadataTests.cs @@ -36,7 +36,7 @@ public static void From() Should("return the empty FileMetadata instance", () => { Assert.That(metadata.Store, Is.Null); - Assert.That(metadata.Xslt, Is.Null); + Assert.That(metadata.Template, Is.Null); Assert.That(metadata.Toc, Is.Null); }); }); @@ -46,7 +46,7 @@ public static void From() dictionary = new Dictionary { { FileMetadata.StoreKey, "./store" }, - { FileMetadata.XsltKey, "./transform.xslt" }, + { FileMetadata.TemplateKey, "./transform.xslt" }, { FileMetadata.TocKey, new Dictionary { @@ -59,7 +59,7 @@ public static void From() Should("return the filled in FileMetadata instance", () => { Assert.That(metadata.Store, Is.EqualTo("./store")); - Assert.That(metadata.Xslt, Is.EqualTo("./transform.xslt")); + Assert.That(metadata.Template, Is.EqualTo("./transform.xslt")); Assert.That(metadata.Toc.Key, Is.EqualTo("SomeToc")); Assert.That(metadata.Toc.Action, Is.EqualTo(TreeItemActionType.InsertAfter)); }); @@ -70,13 +70,13 @@ public static void From() dictionary = new Dictionary { { FileMetadata.StoreKey, "./store" }, - { FileMetadata.XsltKey, "./transform.xslt" }, + { FileMetadata.TemplateKey, "./transform.xslt" }, }; Should("return the filled in FileMetadata instance without TOC", () => { Assert.That(metadata.Store, Is.EqualTo("./store")); - Assert.That(metadata.Xslt, Is.EqualTo("./transform.xslt")); + Assert.That(metadata.Template, Is.EqualTo("./transform.xslt")); Assert.That(metadata.Toc.Key, Is.Null); Assert.That(metadata.Toc.Action, Is.EqualTo(TreeItemActionType.ReplaceSelf)); }); diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/HeaderHandlerTests.cs b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/HeaderHandlerTests.cs new file mode 100644 index 0000000..147a518 --- /dev/null +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/HeaderHandlerTests.cs @@ -0,0 +1,273 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc.Tests; + +using global::Docfx.DataContracts.Common; +using System.Collections.Immutable; + +/// +/// Tests the . +/// +[ComponentTest(Type = typeof(HeaderHandler))] +public class HeaderHandlerTests +{ + /// + /// Tests the . + /// + [MemberTest(Name = nameof(HeaderHandler.ExtractH1))] + public void ExtractH1() + { + var headerHandler = new HeaderHandler(); + string html = null; + var result = default((string h1, string h1Raw, string body)); + + When("the method is called", () => + { + Act(() => + { + result = headerHandler.ExtractH1(html); + }); + + And("there is a full html content", () => + { + Arrange(() => + { + html = "

Heading

Content

"; + }); + + Should("extract 'h1' and body with 'h1'", () => + { + Assert.That(result.h1, Is.EqualTo("Heading")); + Assert.That(result.h1Raw, Is.Empty); + Assert.That(result.body, Is.EqualTo(html)); + }); + }); + + And("the loaded html content is a fragment of html document", () => + { + Arrange(() => + { + html = "

Heading

Text

"; + }); + + Should("extract 'h1' and body without 'h1'", () => + { + Assert.That(result.h1, Is.EqualTo("Heading")); + Assert.That(result.h1Raw, Is.EqualTo("

Heading

")); + Assert.That(result.body, Is.EqualTo("

Text

")); + }); + }); + + And("there is no 'h1' html tag", () => + { + Arrange(() => + { + html = "

Text

"; + }); + + Should("return body", () => + { + Assert.That(result.h1, Is.Null); + Assert.That(result.h1Raw, Is.Empty); + Assert.That(result.body, Is.EqualTo("

Text

")); + }); + }); + }); + } + + /// + /// Tests the . + /// + [MemberTest(Name = nameof(HeaderHandler.HandleYamlHeader))] + public void HandleYamlHeader() + { + var headerHandler = new HeaderHandler(); + ImmutableDictionary yamlHeader = null; + FileModel model = null; + + When("the method is called", () => + { + Arrange(() => + { + model = new FileModel( + new FileAndType("X:/Base", "some\\file.xsd", DocumentType.Article), + new Dictionary()); + }); + + Act(() => + { + headerHandler.HandleYamlHeader(yamlHeader, model); + }); + + And("the yaml header is not provided", () => + { + Arrange(() => + { + yamlHeader = null; + model = null; + }); + + Should("do nothing", () => + { + Assert.That(model, Is.Null); + }); + }); + + And("the yaml header is provided", () => + { + yamlHeader = new Dictionary + { + { Constants.PropertyName.Uid, "some-uid" }, + { Constants.PropertyName.DocumentType, "Conceptual" }, + { Constants.PropertyName.OutputFileName, "output-file-name.html" }, + { Constants.PropertyName.Title, "Some Title" }, + { "any_other_key", "any-other-value" }, + }.ToImmutableDictionary(); + + Should("populate the model with properties specified in the yaml header", () => + { + var content = model.Content as Dictionary; + + Assert.That(content[Constants.PropertyName.Uid], Contains.Substring("some-uid")); + Assert.That(model.Uids[0].Name, Is.EqualTo("some-uid")); + Assert.That(model.Uids[0].File, Is.EqualTo(model.LocalPathFromRoot)); + + Assert.That(content[Constants.PropertyName.DocumentType], Is.EqualTo("Conceptual")); + Assert.That(model.DocumentType, Is.EqualTo("Conceptual")); + + Assert.That(content[Constants.PropertyName.OutputFileName], Is.EqualTo("output-file-name.html")); + Assert.That(model.File, Is.EqualTo("some/output-file-name.html")); + + Assert.That(content["any_other_key"], Is.EqualTo("any-other-value")); + }); + + And("the yaml header has an incorrect output file name", () => + { + yamlHeader = new Dictionary + { + { Constants.PropertyName.OutputFileName, "extra-path/output-file-name.html" }, + }.ToImmutableDictionary(); + + Should("keep the File in the model unchanged", () => + { + Assert.That(model.File, Is.EqualTo("some/file.xsd")); + }); + }); + }); + }); + } + + /// + /// Tests the . + /// + [MemberTest(Name = nameof(HeaderHandler.GetTitle))] + public void GetTitle() + { + var headerHandler = new HeaderHandler(); + FileModel model = null; + ImmutableDictionary yamlHeader = null; + string h1 = null; + var result = default(string); + + When("the method is called", () => + { + Act(() => + { + result = headerHandler.GetTitle(model, yamlHeader, h1); + }); + + And("there is a yaml header specified with a title", () => + { + Arrange(() => + { + yamlHeader = new Dictionary + { + { Constants.PropertyName.Title, "Title" }, + }.ToImmutableDictionary(); + }); + + Should("return a title from the yaml header", () => + { + Assert.That(result, Is.EqualTo("Title")); + }); + }); + + And("the title is specified in metadata/titleOverwriteH1", () => + { + Arrange(() => + { + yamlHeader = null; + + model = new FileModel( + new FileAndType("X:/Base", "file.xsd", DocumentType.Article), + new Dictionary + { + { Constants.PropertyName.TitleOverwriteH1, "Title 1" }, + }); + }); + + Should("return a title from the metadata/titleOverwriteH1", () => + { + Assert.That(result, Is.EqualTo("Title 1")); + }); + }); + + And("the title is specified in global metadata/title", () => + { + Arrange(() => + { + yamlHeader = null; + + model = new FileModel( + new FileAndType("X:/Base", "file.xsd", DocumentType.Article), + new Dictionary + { + { Constants.PropertyName.Title, "Title 2" }, + }); + }); + + Should("return a title from the metadata/title", () => + { + Assert.That(result, Is.EqualTo("Title 2")); + }); + }); + + And("the title is specified in the 'h1' parameter", () => + { + Arrange(() => + { + yamlHeader = null; + model = new FileModel( + new FileAndType("X:/Base", "file.xsd", DocumentType.Article), + new Dictionary()); + h1 = "Title 3"; + }); + + Should("return a title from the metadata/title", () => + { + Assert.That(result, Is.EqualTo("Title 3")); + }); + }); + + And("no title is specified", () => + { + Arrange(() => + { + yamlHeader = null; + model = new FileModel( + new FileAndType("X:/Base", "file.xsd", DocumentType.Article), + new Dictionary()); + h1 = null; + }); + + Should("return a title as the file name", () => + { + Assert.That(result, Is.EqualTo("file")); + }); + }); + }); + } +} diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Heleonix.Docfx.Plugins.XmlDoc.Tests.csproj b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Heleonix.Docfx.Plugins.XmlDoc.Tests.csproj index da709c5..f51d8d7 100644 --- a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Heleonix.Docfx.Plugins.XmlDoc.Tests.csproj +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Heleonix.Docfx.Plugins.XmlDoc.Tests.csproj @@ -2,7 +2,6 @@ net7.0 false - en @@ -15,10 +14,13 @@ Always + + Always + - + @@ -26,10 +28,10 @@ - + - + diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Input.xsd b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Input.xsd index a916b8c..d82b261 100644 --- a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Input.xsd +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Input.xsd @@ -4,9 +4,6 @@ elementFormDefault="qualified" targetNamespace="http://schemas.microsoft.com/developer/msbuild/2003" xmlns:xs="http://www.w3.org/2001/XMLSchema"> - - A path to the NetBuild artifacts directory. @@ -22,49 +19,4 @@ The file with public/private keys pair to sign assemblies. - - - - The semantic version. It is passed as /p:Version property to the Build target. - Default is a version retrieved from $Hx_ChangeLog_ArtifactsDir/semver.txt. - - - - - - - The .NET Assembly version, like '1.0.0.0'. It is passed as /p:AssemblyVersion property to the Build target. - Default version is composed as $Hx_NetBuild_Version.$Hx_Run_Number. - - - - - - - A text file with package release notes. It is passed as /p:PackageReleaseNotes property into the Build target. - Default is $Hx_ChangeLog_ArtifactsDir/ReleaseNotes.txt. - - - - - - - Files to delete during cleaning. - - - - - Directories to delete during cleaning. - - - - - Directories to clean but not delete during cleaning. - - - - - Custom files to be copied to the artifacts directory. - - diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Properties/Usings.cs b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Properties/Usings.cs index 2230036..18c08fb 100644 --- a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Properties/Usings.cs +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Properties/Usings.cs @@ -4,6 +4,7 @@ // #pragma warning disable SA1200 // Using directives should be placed correctly +global using global::Docfx.Plugins; global using Heleonix.Docfx.Plugins.XmlDoc; global using Heleonix.Testing.NUnit.Aaa; global using NUnit.Framework; diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Template.cshtml b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Template.cshtml new file mode 100644 index 0000000..03d6d58 --- /dev/null +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Template.cshtml @@ -0,0 +1,35 @@ +@using System +@using System.IO +@using System.Xml.Linq +@inherits RazorEngineCore.RazorEngineTemplateBase +@{ + XDocument model = Model; + XNamespace xs = "http://www.w3.org/2001/XMLSchema"; + + var fileName = Path.GetFileNameWithoutExtension(model.Document.BaseUri); + var elements = model.Document.Element(xs + "schema").Elements(xs + "element"); + var desc = elements.FirstOrDefault(e => e.Attribute("name")?.Value == fileName) + ?.Element(xs + "annotation") + ?.Element(xs + "documentation")?.Value?.Trim(); + var props = elements.Where(e => e.Attribute("substitutionGroup")?.Value == "msb:Property"); +} +--- +uid: @fileName +--- + +# @fileName + +@desc + +@if (props.Count() > 0) +{ + ## Properties + @: + foreach (var prop in props) + { + #### @prop.Attribute("name").Value + @: + @prop.Element(xs + "annotation").Element(xs + "documentation").Value.Trim() + @: + } +} diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Template.xslt b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Template.xslt index 3544e09..5b94b17 100644 --- a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Template.xslt +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/Template.xslt @@ -15,12 +15,5 @@ -### Items - -#### - - - - diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/TocHandlerTests.cs b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/TocHandlerTests.cs new file mode 100644 index 0000000..4c5dcc0 --- /dev/null +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/TocHandlerTests.cs @@ -0,0 +1,137 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc.Tests; + +using global::Docfx.DataContracts.Common; +using Moq; + +/// +/// Tests the . +/// +[ComponentTest(Type = typeof(TocHandler))] +public class TocHandlerTests +{ + /// + /// Tests the . + /// + [MemberTest(Name = nameof(TocHandler.HandleTocRestructions))] + public void HandleTocRestructions() + { + var tocHandler = new TocHandler(); + FileModel model = null; + IList restructions = null; + + When("the method is called", () => + { + Arrange(() => + { + restructions = new List(); + }); + + Act(() => + { + tocHandler.HandleTocRestructions(model, restructions); + }); + + And("metadata TOC key is null", () => + { + Arrange(() => + { + model = new FileModel( + new FileAndType("X:/BaseDir", "file.xsd", DocumentType.Article), + new Dictionary + { + { + FileMetadata.TocKey, + new Dictionary + { + { "action", "AppendChild" }, + { "key", null }, + } + }, + }); + }); + + Should("do nothing", () => + { + Assert.That(restructions, Is.Empty); + }); + }); + + And("metadata TOC Key is Href", () => + { + Arrange(() => + { + model = new FileModel( + new FileAndType("X:/BaseDir", "file.xsd", DocumentType.Article), + new Dictionary + { + { Constants.PropertyName.Title, "Title" }, + { "_appName", "App name" }, + { "_appTitle", "App title" }, + { "_enableSearch", "Enable search" }, + { + FileMetadata.TocKey, + new Dictionary + { + { "action", "AppendChild" }, + { "key", "~/some/path" }, + } + }, + }); + }); + + Should("generate one TOC restructure with the specified Href key", () => + { + var treeItem = restructions.Single().RestructuredItems.Single(); + + Assert.That(treeItem.Metadata[Constants.PropertyName.Name], Is.EqualTo("Title")); + Assert.That(treeItem.Metadata[Constants.PropertyName.Href], Is.EqualTo("~/file.xsd")); + Assert.That(treeItem.Metadata[Constants.PropertyName.TopicHref], Is.EqualTo("~/file.xsd")); + Assert.That(restructions.Single().Key, Is.EqualTo("~/some/path")); + Assert.That(restructions.Single().TypeOfKey, Is.EqualTo(TreeItemKeyType.TopicHref)); + Assert.That(restructions.Single().ActionType, Is.EqualTo(TreeItemActionType.AppendChild)); + }); + }); + + And("metadata TOC Key is Uid", () => + { + Arrange(() => + { + model = new FileModel( + new FileAndType("X:/BaseDir", "file.xsd", DocumentType.Article), + new Dictionary + { + { Constants.PropertyName.Title, "Title" }, + { "_appName", "App name" }, + { "_appTitle", "App title" }, + { "_enableSearch", "Enable search" }, + { + FileMetadata.TocKey, + new Dictionary + { + { "action", "AppendChild" }, + { "key", "some.key" }, + } + }, + }); + }); + + Should("generate one TOC restructure with the specified Uid key", () => + { + var treeItem = restructions.Single().RestructuredItems.Single(); + + Assert.That(treeItem.Metadata[Constants.PropertyName.Name], Is.EqualTo("Title")); + Assert.That(treeItem.Metadata[Constants.PropertyName.Href], Is.EqualTo("~/file.xsd")); + Assert.That(treeItem.Metadata[Constants.PropertyName.TopicHref], Is.EqualTo("~/file.xsd")); + Assert.That(restructions.Single().Key, Is.EqualTo("some.key")); + Assert.That(restructions.Single().TypeOfKey, Is.EqualTo(TreeItemKeyType.TopicUid)); + Assert.That(restructions.Single().ActionType, Is.EqualTo(TreeItemActionType.AppendChild)); + }); + }); + }); + } +} diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/TransformerTests.cs b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/TransformerTests.cs new file mode 100644 index 0000000..f1b024d --- /dev/null +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/TransformerTests.cs @@ -0,0 +1,135 @@ +// +// Copyright (c) Heleonix - Hennadii Lutsyshyn. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the repository root for full license information. +// + +namespace Heleonix.Docfx.Plugins.XmlDoc.Tests; + +using global::Docfx.Plugins; +using Moq; + +/// +/// Tests the . +/// +[ComponentTest(Type = typeof(Transformer))] +public static class TransformerTests +{ + /// + /// Tests the . + /// + [MemberTest(Name = nameof(Transformer.Transform))] + public static void Transform() + { + var transformer = new Transformer(); + FileModel model = null; + Mock hostMock = null; + var result = default(string); + Exception exception = null; + + When("the method is called", () => + { + Act(() => + { + try + { + result = transformer.Transform(model, hostMock.Object); + } + catch (Exception ex) + { + exception = ex; + } + }); + + And("the template format is 'xslt'", () => + { + Arrange(() => + { + hostMock = new Mock(); + + model = new FileModel( + new FileAndType(Environment.CurrentDirectory, "Input.xsd", DocumentType.Article), + new Dictionary + { + { FileMetadata.TemplateKey, Path.Combine(Environment.CurrentDirectory, "Template.xslt") }, + }); + }); + + Should("successfully transform the xml-base file into markdown", () => + { + Assert.That(result, Contains.Substring("Hx_NetBuild_ArtifactsDir")); + Assert.That(result, Contains.Substring("Hx_NetBuild_SlnFile")); + Assert.That(result, Contains.Substring("Hx_NetBuild_SnkFile")); + }); + }); + + And("the template format is 'cshtml'", () => + { + Arrange(() => + { + model = new FileModel( + new FileAndType(Environment.CurrentDirectory, "Input.xsd", DocumentType.Article), + new Dictionary + { + { FileMetadata.TemplateKey, Path.Combine(Environment.CurrentDirectory, "Template.cshtml") }, + }); + }); + + Should("successfully transform the xml-base file into markdown", () => + { + Assert.That(result, Contains.Substring("Hx_NetBuild_ArtifactsDir")); + Assert.That(result, Contains.Substring("Hx_NetBuild_SlnFile")); + Assert.That(result, Contains.Substring("Hx_NetBuild_SnkFile")); + }); + }); + + And("the template format is not recognized", () => + { + Arrange(() => + { + model = new FileModel( + new FileAndType(Environment.CurrentDirectory, "Input.xsd", DocumentType.Article), + new Dictionary + { + { FileMetadata.TemplateKey, Path.Combine(Environment.CurrentDirectory, "Template.unknown") }, + }); + + hostMock.Setup((IHostService hostService) => hostService.LogError( + It.Is(s => s == $"Unknown template file format: {Path.Combine(Environment.CurrentDirectory, "Template.unknown")}."), + It.Is(s => s == model.FileAndType.FullPath), + It.IsAny())).Verifiable(); + }); + + Should("log an error and return null", () => + { + Assert.That(result, Is.Null); + hostMock.Verify(); + }); + }); + + And("some exception is thrown during transformation", () => + { + Arrange(() => + { + model = new FileModel( + new FileAndType(Environment.CurrentDirectory, "Input.xsd", DocumentType.Article), + new Dictionary + { + { FileMetadata.TemplateKey, Path.Combine(Environment.CurrentDirectory, "NO_FILE.cshtml") }, + }); + + hostMock.Setup((IHostService hostService) => hostService.LogError( + It.Is(s => s.Contains("FileNotFoundException")), + It.Is(s => s == model.FileAndType.FullPath), + It.IsAny())).Verifiable(); + }); + + Should("log an error and throw exception", () => + { + Assert.That(result, Is.Null); + Assert.That(exception, Is.Not.Null); + hostMock.Verify(); + }); + }); + }); + } +} \ No newline at end of file diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/XmlDocBuildStepTests.cs b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/XmlDocBuildStepTests.cs index aac62a8..1e51527 100644 --- a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/XmlDocBuildStepTests.cs +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/XmlDocBuildStepTests.cs @@ -5,11 +5,15 @@ namespace Heleonix.Docfx.Plugins.XmlDoc.Tests; +using global::Docfx.Common; using global::Docfx.DataContracts.Common; using global::Docfx.Plugins; using Heleonix.Docfx.Plugins.XmlDoc; +using Microsoft.CodeAnalysis; using Moq; +using NUnit.Framework.Constraints; using System.Collections.Immutable; +using System.Reflection; /// /// Tests the . @@ -23,33 +27,44 @@ public static class XmlDocBuildStepTests [MemberTest(Name = nameof(XmlDocBuildStep.Prebuild))] public static void Prebuild() { - var xmlDocBuildStep = new XmlDocBuildStep(); var hostMock = new Mock(); - var ft = new FileAndType(Environment.CurrentDirectory, "Input.xsd", DocumentType.Article); + var transformerMock = new Mock(); + var headerHandlerMock = new Mock(); + var tocHandlerMock = new Mock(); + var xmlDocBuildStep = new XmlDocBuildStep + { + Transformer = transformerMock.Object, + HeaderHandler = headerHandlerMock.Object, + TocHandler = tocHandlerMock.Object, + }; + + var fileAndType = new FileAndType("X:/BaseDir", "file.xsd", DocumentType.Article); ImmutableList models = null; - string contentHtml = null; - ImmutableDictionary yamlHeader = null; XmlDocProcessor processor = null; FileModel result = null; - Dictionary metadata = null; - - metadata = new Dictionary - { - { FileMetadata.XsltKey, Path.Combine(Environment.CurrentDirectory, "Template.xslt") }, - }; Arrange(() => { processor = new XmlDocProcessor(); - processor.GetProcessingPriority(ft); + processor.GetProcessingPriority(fileAndType); - var model = processor.Load(ft, metadata.ToImmutableDictionary()); + var metadata = new Dictionary + { + { FileMetadata.TemplateKey, Path.Combine(Environment.CurrentDirectory, "Template.xslt") }, + }; + + var model = processor.Load(fileAndType, metadata.ToImmutableDictionary()); + + model.Uids = new[] { new UidDefinition("some.uid", model.LocalPathFromRoot) }.ToImmutableArray(); + + transformerMock.Setup((ITransformer t) => t.Transform(model, hostMock.Object)) + .Returns("Transformed MD").Verifiable(); var markupResult = new MarkupResult { - Html = contentHtml, - YamlHeader = yamlHeader, + Html = "Markup Html", + YamlHeader = new Dictionary().ToImmutableDictionary(), LinkToFiles = new string[] { "link1" }.ToImmutableArray(), FileLinkSources = new Dictionary> { @@ -62,12 +77,22 @@ public static void Prebuild() }.ToImmutableDictionary(), }; - models = new List { model }.ToImmutableList(); + hostMock.SetupGet((IHostService hs) => hs.Processor) + .Returns(processor).Verifiable(); + hostMock.Setup((IHostService hs) => hs.Markup("Transformed MD", fileAndType, false)) + .Returns(markupResult).Verifiable(); - hostMock.SetupGet((IHostService hostService) => hostService.Processor).Returns(processor); - hostMock.Setup((IHostService hostService) => hostService.Markup( - It.Is(c => c.Contains("### Properties")), ft, false)) - .Returns(markupResult); + headerHandlerMock.Setup((IHeaderHandler hh) => hh.ExtractH1("Markup Html")) + .Returns(("h1", "h1 raw", "Conceptual Content")).Verifiable(); + headerHandlerMock.Setup((IHeaderHandler hh) => hh.HandleYamlHeader(markupResult.YamlHeader, model)) + .Verifiable(); + headerHandlerMock.Setup((IHeaderHandler hh) => hh.GetTitle(model, markupResult.YamlHeader, "h1")) + .Returns("Some Title").Verifiable(); + + tocHandlerMock.Setup((ITocHandler th) => th.HandleTocRestructions(model, It.IsAny>())) + .Verifiable(); + + models = new List { model }.ToImmutableList(); }); When("the method is called", () => @@ -77,30 +102,39 @@ public static void Prebuild() result = xmlDocBuildStep.Prebuild(models, hostMock.Object).Single(); }); - And("there is a full html content", () => + And("the content file model should be pre-built", () => { - contentHtml = "

Heading

Content

"; - - Should("generate html content", () => + Should("prebuild the passed model", () => { var content = result.Content as IDictionary; - Assert.That(content[Constants.PropertyName.Conceptual], Contains.Substring("Content")); - Assert.That(content[Constants.PropertyName.Title], Contains.Substring("Heading")); - Assert.That(result.ManifestProperties.rawTitle, Is.Null); + Assert.That(content["rawTitle"], Is.EqualTo("h1 raw")); + Assert.That(result.ManifestProperties.rawTitle, Is.EqualTo("h1 raw")); + Assert.That(content[Constants.PropertyName.Conceptual], Is.EqualTo("Conceptual Content")); + Assert.That(content[Constants.PropertyName.Title], Is.EqualTo("Some Title")); + Assert.That(result.LinkToFiles, Contains.Item("link1")); Assert.That(result.LinkToUids, Contains.Item("uid1")); - Assert.That(result.FileLinkSources["key1"][0], Is.EqualTo("src1")); - Assert.That(result.FileLinkSources["key2"][0], Is.EqualTo("src2")); - Assert.That(result.Properties.XrefSpec, Is.Null); + Assert.That(result.FileLinkSources["key1"][0].SourceFile, Is.EqualTo("src1")); + Assert.That(result.UidLinkSources["key2"][0].SourceFile, Is.EqualTo("src2")); + Assert.That(result.Properties.XrefSpec.Uid, Is.EqualTo(result.Uids[0].Name)); + Assert.That(result.Properties.XrefSpec.Name, Is.EqualTo("Some Title")); + Assert.That( + result.Properties.XrefSpec.Href, + Is.EqualTo((string)((RelativePath)result.File).GetPathFromWorkingFolder())); + + hostMock.Verify(); + transformerMock.Verify(); + headerHandlerMock.Verify(); + tocHandlerMock.Verify(); }); }); - And("there is an unrecognized content file", () => + And("there is an unrecognized content file model", () => { Arrange(() => { - processor.ContentFiles.Remove(ft.File, out _); + processor.ContentFiles.Remove(fileAndType.File, out _); }); Should("skip the unrecognized file", () => @@ -110,207 +144,6 @@ public static void Prebuild() Assert.That(content[Constants.PropertyName.Conceptual], Is.Empty); }); }); - - And("Table Of Contents is specified in metadata to be added after Href item", () => - { - metadata = new Dictionary - { - { FileMetadata.XsltKey, Path.Combine(Environment.CurrentDirectory, "Template.xslt") }, - { - FileMetadata.TocKey, - new Dictionary - { - { "action", "InsertAfter" }, - { "key", "~/articles/introduction.md" }, - } - }, - { "_appName", "App Name" }, - { "_appTitle", "App Title" }, - { "_enableSearch", true }, - }; - - Should("generate html content with TOC restructions", () => - { - var content = result.Content as IDictionary; - - Assert.That(content[Constants.PropertyName.Conceptual], Contains.Substring("Content")); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].ActionType, - Is.EqualTo(TreeItemActionType.InsertAfter)); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].Key, - Is.EqualTo("~/articles/introduction.md")); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].TypeOfKey, - Is.EqualTo(TreeItemKeyType.TopicHref)); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].RestructuredItems[0].Metadata["name"], - Is.EqualTo("Heading")); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].RestructuredItems[0].Metadata["_appName"], - Is.EqualTo("App Name")); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].RestructuredItems[0].Metadata["_appTitle"], - Is.EqualTo("App Title")); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].RestructuredItems[0].Metadata["_enableSearch"], - Is.True); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].RestructuredItems[0].Metadata[Constants.PropertyName.Href], - Is.EqualTo("~/articles/introduction.md")); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].RestructuredItems[0].Metadata[Constants.PropertyName.TopicHref], - Is.EqualTo("~/articles/introduction.md")); - }); - }); - - And("Table Of Contents is specified in metadata to be added after Uid item", () => - { - metadata = new Dictionary - { - { FileMetadata.XsltKey, Path.Combine(Environment.CurrentDirectory, "Template.xslt") }, - { - FileMetadata.TocKey, - new Dictionary - { - { "action", "InsertAfter" }, - { "key", "introduction" }, - } - }, - { "_appName", "App Name" }, - { "_appTitle", "App Title" }, - { "_enableSearch", true }, - }; - - Should("generate html content with TOC restructions", () => - { - var content = result.Content as IDictionary; - - Assert.That(content[Constants.PropertyName.Conceptual], Contains.Substring("Content")); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].ActionType, - Is.EqualTo(TreeItemActionType.InsertAfter)); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].Key, - Is.EqualTo("introduction")); - Assert.That( - hostMock.Object.TableOfContentRestructions[0].TypeOfKey, - Is.EqualTo(TreeItemKeyType.TopicUid)); - }); - }); - - And("the loaded html content is a fragment of html document", () => - { - contentHtml = "

Heading

Text

"; - - Should("generate html content", () => - { - var content = result.Content as IDictionary; - - Assert.That(content[Constants.PropertyName.Conceptual], Contains.Substring("Text")); - Assert.That(content[Constants.PropertyName.Title], Contains.Substring("Heading")); - }); - - And("the html content fragment has a comment", () => - { - contentHtml = "

Heading

Text

"; - - Should("generate html content", () => - { - var content = result.Content as IDictionary; - - Assert.That(content[Constants.PropertyName.Conceptual], Contains.Substring("Text")); - Assert.That(content[Constants.PropertyName.Title], Contains.Substring("Heading")); - }); - }); - }); - - And("there is no 'h1' html tag", () => - { - contentHtml = "

Text

"; - - Should("generate html content with the file name as a title", () => - { - var content = result.Content as IDictionary; - - Assert.That(content[Constants.PropertyName.Conceptual], Contains.Substring("Text")); - Assert.That(content[Constants.PropertyName.Title], Contains.Substring("Input")); - }); - }); - - And("the 'h1' title is overwritten in metadata", () => - { - metadata = new Dictionary - { - { FileMetadata.XsltKey, Path.Combine(Environment.CurrentDirectory, "Template.xslt") }, - { Constants.PropertyName.TitleOverwriteH1, "Header Overwrite" }, - }; - - Should("generate html content with overridden title", () => - { - var content = result.Content as IDictionary; - - Assert.That(content[Constants.PropertyName.Title], Is.EqualTo("Header Overwrite")); - }); - }); - - And("there the 'h1' title is overwritten in global metadata", () => - { - metadata = new Dictionary - { - { FileMetadata.XsltKey, Path.Combine(Environment.CurrentDirectory, "Template.xslt") }, - { Constants.PropertyName.Title, "Global Header" }, - }; - - Should("generate html content with overridden title", () => - { - var content = result.Content as IDictionary; - - Assert.That(content[Constants.PropertyName.Title], Is.EqualTo("Global Header")); - }); - }); - - And("there is a yaml header specified", () => - { - yamlHeader = new Dictionary - { - { Constants.PropertyName.Uid, "some-uid" }, - { Constants.PropertyName.DocumentType, "Conceptual" }, - { Constants.PropertyName.OutputFileName, "output-file-name.html" }, - { Constants.PropertyName.Title, "Some Title" }, - { "any_other_key", "any-other-value" }, - }.ToImmutableDictionary(); - - Should("generate html content with properties specified in the yaml header", () => - { - var content = result.Content as IDictionary; - - Assert.That(content[Constants.PropertyName.Uid], Contains.Substring("some-uid")); - Assert.That(result.Uids[0].Name, Is.EqualTo("some-uid")); - Assert.That(result.Uids[0].File, Is.EqualTo(models[0].LocalPathFromRoot)); - - Assert.That(content[Constants.PropertyName.DocumentType], Is.EqualTo("Conceptual")); - Assert.That(result.DocumentType, Is.EqualTo("Conceptual")); - - Assert.That(content[Constants.PropertyName.OutputFileName], Is.EqualTo("output-file-name.html")); - Assert.That(result.File, Is.EqualTo("output-file-name.html")); - - Assert.That(content["any_other_key"], Is.EqualTo("any-other-value")); - }); - - And("the yaml header has incorrect output file name", () => - { - yamlHeader = new Dictionary - { - { Constants.PropertyName.OutputFileName, "extra-path/output-file-name.html" }, - }.ToImmutableDictionary(); - - Should("generate html content", () => - { - Assert.That(result.File, Is.EqualTo(models[0].File)); - }); - }); - }); }); } diff --git a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/XmlDocProcessorTests.cs b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/XmlDocProcessorTests.cs index 2f5211b..e88dda0 100644 --- a/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/XmlDocProcessorTests.cs +++ b/test/Heleonix.Docfx.Plugins.XmlDoc.Tests/XmlDocProcessorTests.cs @@ -201,7 +201,7 @@ public static void Load() { { "key1", 111 }, { "key2", 222 }, - { FileMetadata.XsltKey, "./relative/to/docfx-json/transform.xslt" }, + { FileMetadata.TemplateKey, "./relative/to/docfx-json/transform.xslt" }, }.ToImmutableDictionary(); }); @@ -219,8 +219,8 @@ public static void Load() Assert.That(content["key1"], Is.EqualTo(111)); Assert.That(content["key2"], Is.EqualTo(222)); Assert.That( - content[FileMetadata.XsltKey], - Is.EqualTo(Path.Combine(EnvironmentContext.BaseDirectory, metadata[FileMetadata.XsltKey] as string))); + content[FileMetadata.TemplateKey], + Is.EqualTo(Path.Combine(EnvironmentContext.BaseDirectory, metadata[FileMetadata.TemplateKey] as string))); Assert.That(content[Constants.PropertyName.SystemKeys], Has.Length.EqualTo(8));