diff --git a/Directory.Packages.props b/Directory.Packages.props index bf3edf858d0..4285113a518 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ - + diff --git a/docs/docs/links-and-cross-references.md b/docs/docs/links-and-cross-references.md index 285fa7509f4..584117e071d 100644 --- a/docs/docs/links-and-cross-references.md +++ b/docs/docs/links-and-cross-references.md @@ -148,7 +148,7 @@ Since `@` is a common character in a document, a warning would appear if the UID ## Cross reference to .NET basic class library -To cross reference .NET basic class libary types from markdown using the `xref` syntax, add `https://learn.microsoft.com/en-us/dotnet/.xrefmap.json` to the `xref` property in `docfx.json`, which contains all the BCL types published from . +To cross reference .NET basic class library types from markdown using the `xref` syntax, add `https://learn.microsoft.com/en-us/dotnet/.xrefmap.json` to the `xref` property in `docfx.json`, which contains all the BCL types published from . ```json { diff --git a/schemas/docfx.schema.json b/schemas/docfx.schema.json new file mode 100644 index 00000000000..114e39d382b --- /dev/null +++ b/schemas/docfx.schema.json @@ -0,0 +1,783 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "title": "JSON Schema for docfx configuration file.", + "type": "object", + "additionalProperties": false, + "properties": { + "build": { + "$ref": "#/$defs/buildConfig" + }, + "metadata": { + "$ref": "#/$defs/metadataConfig" + }, + "merge": { + "$ref": "#/$defs/mergeConfig" + }, + "rules": { + "$ref": "#/$defs/ruleConfig" + } + }, + "$defs": { + "buildConfig": { + "title": "BuildJsonConfig", + "type": "object", + "description": "Build section defines configuration values for the build command.", + "additionalProperties": false, + "properties": { + "content": { + "$ref": "#/$defs/contentFileMapping" + }, + "resource": { + "$ref": "#/$defs/resourceFileMapping" + }, + "overwrite": { + "$ref": "#/$defs/overwriteFileMapping" + }, + "xref": { + "$ref": "#/$defs/xref" + }, + "dest": { + "type": "string", + "description": "(Deprecated) Defines the output folder of the generated build files.", + "deprecated": true + }, + "output": { + "type": "string", + "description": "Defines the output folder of the generated build files." + }, + "globalMetadata": { + "$ref": "#/$defs/globalMetadata" + }, + "globalMetadataFiles": { + "$ref": "#/$defs/globalMetadataFiles" + }, + "fileMetadata": { + "$ref": "#/$defs/fileMetadata" + }, + "fileMetadataFiles": { + "$ref": "#/$defs/fileMetadataFiles" + }, + "template": { + "$ref": "#/$defs/template" + }, + "theme": { + "$ref": "#/$defs/theme" + }, + "postProcessors": { + "$ref": "#/$defs/postProcessors" + }, + "debug": { + "type": "boolean", + "default": false, + "description": "Run in debug mode." + }, + "debugOutput": { + "type": "string", + "description": "The output folder for files generated for debugging purpose when in debug mode." + }, + "exportRawModel": { + "type": "boolean", + "default": false, + "description": "If set to true, data model to run template script will be extracted in .raw.model.json extension." + }, + "rawModelOutputFolder": { + "type": "string", + "description": "Specify the output folder for the raw model." + }, + "exportViewModel": { + "type": "boolean", + "default": false, + "description": "If set to true, data model to apply template will be extracted in .view.model.json extension." + }, + "viewModelOutputFolder": { + "type": "string", + "description": "Specify the output folder for the view model." + }, + "dryRun": { + "type": "boolean", + "default": false, + "description": "If set to true, template will not be actually applied to the documents." + }, + "maxParallelism": { + "type": "integer", + "description": "Set the max parallelism, 0 is auto." + }, + "markdownEngineProperties": { + "$ref": "#/$defs/markdownEngineProperties" + }, + "customLinkResolver": { + "type": "string", + "description": "Set the name of ICustomHrefGenerator derived class." + }, + "groups": { + "$ref": "#/$defs/groups" + }, + "sitemap": { + "$ref": "#/$defs/sitemap" + }, + "disableGitFeatures": { + "type": "boolean", + "default": false, + "description": "Disable fetching Git related information for articles." + } + } + }, + "contentFileMapping": { + "title": "Content", + "description": "Contains all the files to generate documentation, including metadata yml files and conceptual md files.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/fileMappingItem" + } + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/fileMappingItem" + } + ] + }, + "resourceFileMapping": { + "title": "Resource", + "description": "Contains all the resource files that conceptual and metadata files dependent on, e.g. image files.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/fileMappingItem" + } + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/fileMappingItem" + } + ] + }, + "overwriteFileMapping": { + "title": "Overwrite", + "description": "Contains all the conceptual files which contains yaml header with uid and is intended to override the existing metadata yml files.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/fileMappingItem" + } + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/fileMappingItem" + } + ] + }, + "fileMappingItem": { + "type": "object", + "description": "FileMappingItem", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The name of current item, the value is not used for now" + }, + "files": { + "$ref": "#/$defs/fileItems" + }, + "exclude": { + "$ref": "#/$defs/excludeFileItems" + }, + "src": { + "type": "string", + "description": "`src` defines the root folder for the source files." + }, + "dest": { + "type": "string", + "description": "The destination folder for the files if copy/transform is used." + }, + "group": { + "type": "string", + "description": "Group name for the current file-mapping item." + }, + "rootTocPath": { + "type": "string", + "description": "The Root TOC Path used for navbar in current group, relative to output root." + }, + "case": { + "type": "boolean", + "description": "Pattern match will be case sensitive." + }, + "noNegate": { + "type": "boolean", + "description": "Disable pattern begin with `!` to mean negate." + }, + "noExpand": { + "type": "boolean", + "description": "Disable `{a,b}c` => `[\"ac\", \"bc\"]`." + }, + "noEscape": { + "type": "boolean", + "description": "Disable the usage of `\\` to escape values." + }, + "noGlobStar": { + "type": "boolean", + "description": "Disable the usage of `**` to match everything including `/` when it is the beginning of the pattern or is after `/`." + }, + "dot": { + "type": "boolean", + "description": "Allow files start with `.` to be matched even if `.` is not explicitly specified in the pattern." + } + } + }, + "fileItems": { + "description": "The file glob pattern collection, with path relative to property `src` is value is set.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + }, + "excludeFileItems": { + "description": "The file glob pattern collection for files that should be excluded, with path relative to property `src` is value is set.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + }, + "globalMetadata": { + "type": "object", + "description": "Contains metadata that will be applied to every file, in key-value pair format.", + "additionalProperties": true + }, + "globalMetadataFiles": { + "description": "Specify a list of JSON file path containing globalMetadata settings.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + }, + "xref": { + "description": "Specifies the urls of xrefmap used by content files. Supports local file path and HTTP/HTTPS urls.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + }, + "fileMetadata": { + "type": "object", + "description": "Metadata that applies to some specific files.", + "additionalProperties": true + }, + "fileMetadataFiles": { + "description": "Specify a list of JSON file path containing fileMetadata settings.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + }, + "template": { + "description": "The templates applied to each file in the documentation.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + }, + "theme": { + "description": "The themes applied to the documentation.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + }, + "postProcessors": { + "description": "Specify PostProcessor array. Build-in HtmlProcessor is automatically added by default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + }, + "groups": { + "type": "object", + "description": "Specifies the output folder and metadata of specified group name.", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "dest": { + "type": "string", + "description": "Defines the output folder of the generated build files." + } + } + } + }, + "sitemap": { + "type": "object", + "description": "Specifies the options for the sitemap.xml file.", + "properties": { + "baseUrl": { + "type": "string", + "format": "uri", + "description": "Base URL for the website. It should start with http or https." + }, + "changefreq": { + "$ref": "#/$defs/changefreq" + }, + "priority": { + "type": "number", + "default": 0.5, + "minimum": 0.0, + "maximum": 1.0, + "description": "the priority of this URL relative to other URLs on your site. Valid values range from 0.0 to 1.0." + }, + "lastmod": { + "type": "string", + "description": "The date of last modification of the page. If not specified, docfx sets the date to the build time." + }, + "fileOptions": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "baseUrl": { + "type": "string", + "format": "uri", + "description": "Base URL for the website. It should start with http or https." + }, + "changefreq": { + "$ref": "#/$defs/changefreq" + }, + "priority": { + "type": "number", + "default": 0.5, + "minimum": 0.0, + "maximum": 1.0, + "description": "the priority of this URL relative to other URLs on your site. Valid values range from 0.0 to 1.0." + }, + "lastmod": { + "type": "string", + "description": "The date of last modification of the page. If not specified, docfx sets the date to the build time." + } + } + } + } + } + }, + "changefreq": { + "type": "string", + "description": "Determines how frequently the page is likely to change. Valid values are always, hourly, daily, weekly, monthly, yearly, never.", + "default": "daily", + "enum": [ + "always", + "hourly", + "daily", + "weekly", + "monthly", + "yearly", + "never" + ] + }, + "markdownEngineProperties": { + "description": "Set the parameters for markdown engine, value should be a JSON string.", + "type": "object", + "properties": { + "enableSourceInfo": { + "type": "boolean", + "default": true, + "description": "Enables line numbers" + }, + "markdigExtensions": { + "description": "List of optional Markdig extensions to add or modify settings.", + "type": "array", + "items": { + "$ref": "#/$defs/markdigExtensionSetting" + } + }, + "fallbackFolders": { + "description": "Fallback folders", + "type": "array", + "items": { + "type": "string" + } + }, + "alerts": { + "title": "Alerts", + "description": "Alert keywords in markdown without the surrounding [!] and the corresponding CSS class names.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "plantUml": { + "$ref": "#/$defs/plantUmlOptions" + } + } + }, + "markdigExtensionSetting": { + "description": "Markdig extension setting.", + "anyOf": [ + { + "type": "string", + "description": "String" + }, + { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "markdig extension name", + "additionalProperties": {} + } + } + } + ] + }, + "plantUmlOptions": { + "type": "object", + "description": "PlantUml extension configuration parameters", + "properties": { + "javaPath": { + "type": "string", + "description": "" + }, + "remoteUrl": { + "type": "string", + "description": "" + }, + "localPlantUmlPath": { + "type": "string", + "description": "" + }, + "localGraphvizDotPath": { + "type": "string", + "description": "" + }, + "renderingMode": { + "type": "string", + "description": "", + "enum": [ + "Remote", + "Local" + ] + }, + "delimitor": { + "type": "string", + "description": "" + }, + "outputFormat": { + "type": "string", + "description": "", + "enum": [ + "Png", + "Svg", + "Eps", + "Pdf", + "Vdx", + "Xmi", + "Scxml", + "Html", + "Ascii", + "Ascii_Unicode", + "LaTeX" + ] + } + } + }, + "metadataConfig": { + "type": "array", + "items": { + "type": "object", + "properties": { + "src": { + "$ref": "#/$defs/srcFileMapping" + }, + "dest": { + "type": "string", + "description": "(Deprecated) Defines the output folder of the generated metadata files.", + "deprecated": true + }, + "output": { + "type": "string", + "description": "Defines the output folder of the generated metadata files." + }, + "outputFormat": { + "type": "string", + "description": "Defines the output file format.", + "default": "mref", + "enum": [ + "mref", + "markdown", + "apiPage" + ] + }, + "shouldSkipMarkup": { + "type": "boolean", + "default": false, + "description": "If set to true, DocFX would not render triple-slash-comments in source code as markdown." + }, + "references": { + "$ref": "#/$defs/referencesFileMapping", + "description": "Specify additinal assembly reference files." + }, + "filter": { + "type": "string", + "description": "Defines the filter configuration file." + }, + "includePrivateMembers": { + "type": "boolean", + "default": false, + "description": "Include private or internal APIs." + }, + "includeExplicitInterfaceImplementations": { + "type": "boolean", + "default": false, + "description": "Include explicit interface implementations." + }, + "globalNamespaceId": { + "type": "string", + "description": "Specify the name to use for the global namespace." + }, + "properties": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "disableGitFeatures": { + "type": "boolean", + "default": false, + "description": "Disables generation of view source links." + }, + "codeSourceBasePath": { + "type": "string", + "description": "Specify the base directory that is used to resolve code source." + }, + "disableDefaultFilter": { + "type": "boolean", + "default": false, + "description": "Disables the default filter configuration file." + }, + "noRestore": { + "type": "boolean", + "default": false, + "description": "Do not run dotnet restore before building the projects." + }, + "namespaceLayout": { + "type": "string", + "description": "Defines how namespaces in TOC are organized.", + "default": "flattened", + "enum": [ + "flattened", + "nested" + ] + }, + "memberLayout": { + "type": "string", + "description": "Defines how member pages are organized.", + "default": "samePage", + "enum": [ + "samePage", + "separatePages" + ] + }, + "enumSortOrder": { + "type": "string", + "description": "Defines enum sort orders.", + "default": "alphabetic", + "enum": [ + "alphabetic", + "declaringOrder" + ] + }, + "allowCompilationErrors": { + "type": "boolean", + "default": false, + "description": "When enabled, continues documentation generation in case of compilation errors." + } + } + } + }, + "srcFileMapping": { + "description": "Defines the source projects to have metadata generated.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/fileMappingItem" + } + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/fileMappingItem" + } + ] + }, + "referencesFileMapping": { + "description": "Specify additinal assembly reference files.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/fileMappingItem" + } + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "$ref": "#/$defs/fileMappingItem" + } + ] + }, + "mergeConfig": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { + "$ref": "#/$defs/contentFileMapping" + }, + "dest": { + "type": "string", + "description": "Defines the output folder of the generated merge files." + }, + "globalMetadata": { + "type": "object", + "description": "Contains metadata that will be applied to every file, in key-value pair format.", + "additionalProperties": {} + }, + "fileMetadata": { + "$ref": "#/$defs/fileMetadata" + }, + "tocMetadata": { + "description": "Metadata that applies to toc files.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "uniqueItems": true + } + ] + } + } + } + }, + "ruleConfig": { + "type": "object", + "additionalProperties": { + "enum": [ + "verbose", + "info", + "suggestion", + "warning", + "error", + "diagnostic" + ] + } + } + } +} diff --git a/schemas/filterconfig.schema.json b/schemas/filterconfig.schema.json new file mode 100644 index 00000000000..10e362ffdc1 --- /dev/null +++ b/schemas/filterconfig.schema.json @@ -0,0 +1,97 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/filterconfig.schema.json", + "title": "JSON Schema for docfx TOC file.", + "type": "object", + "additionalProperties": false, + "properties": { + "apiRules": { + "type": "array", + "description": "Include/exclude rules using uid.", + "items": { + "$ref": "#/$defs/configFilterRuleItemUnion" + } + }, + "attributeRules": { + "type": "array", + "description": "Include/exclude rules using attribute.", + "items": { + "$ref": "#/$defs/configFilterRuleItemUnion" + } + } + }, + "$defs": { + "configFilterRuleItemUnion": { + "type": "object", + "additionalProperties": false, + "properties": { + "include": { + "type": "object", + "properties": { + "uidRegex": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/extendedSymbolKind" + }, + "attribute": { + "$ref": "#/$defs/attributeFilterInfo" + } + } + }, + "exclude": { + "type": "object", + "properties": { + "uidRegex": { + "type": "string" + }, + "kind": { + "$ref": "#/$defs/extendedSymbolKind" + }, + "attribute": { + "$ref": "#/$defs/attributeFilterInfo" + } + } + } + } + }, + "extendedSymbolKind": { + "enum": [ + "assembly", + "namespace", + "class", + "struct", + "enum", + "interface", + "delegate", + "type", + "event", + "field", + "method", + "property", + "member" + ] + }, + "attributeFilterInfo": { + "type": "object", + "additionalProperties": false, + "properties": { + "uid": { + "type": "string" + }, + "ctorArguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "ctorNamedArguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } +} diff --git a/schemas/toc.schema.json b/schemas/toc.schema.json new file mode 100644 index 00000000000..90d97035294 --- /dev/null +++ b/schemas/toc.schema.json @@ -0,0 +1,105 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/toc.schema.json", + "title": "JSON Schema for docfx TOC file.", + "anyOf": [ + { + "$ref": "#/$defs/tocItem" + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/tocItem" + } + } + ], + "$defs": { + "tocItem": { + "type": "object", + "additionalProperties": true, + "properties": { + "uid": { + "type": "string", + "description": "The uid of the article. Can be used instead of href." + }, + "name": { + "type": "string", + "description": "" + }, + "displayName": { + "type": "string", + "description": "An optional display name for the TOC node. When not specified, uses the title metadata or the first Heading 1 element from the referenced article as the display name." + }, + "href": { + "type": "string", + "description": "The path the TOC node leads to. Optional because a node can exist just to parent other nodes." + }, + "originalHref": { + "type": "string", + "description": "" + }, + "tocHref": { + "type": "string", + "description": "" + }, + "originalTocHref": { + "type": "string", + "description": "" + }, + "topicHref": { + "type": "string", + "description": "Specify the topic href of the TOC Item. It is useful when href is linking to a folder or TOC file or tocHref is used." + }, + "originalTopicHref": { + "type": "string", + "description": "" + }, + "includedFrom": { + "type": "string", + "description": "" + }, + "homepage": { + "type": "string", + "description": "(Deprecated)", + "deprecated": true + }, + "originalHomepage": { + "type": "string", + "description": "(Deprecated).", + "deprecated": true + }, + "homepageUid": { + "type": "string", + "description": "(Deprecated).", + "deprecated": true + }, + "topicUid": { + "type": "string", + "description": "Specify the uid of the referenced file. If the value is set, it overwrites the value of topicHref." + }, + "order": { + "type": "integer", + "default": 0, + "description": "Specify the order of toc item, TOCs with a smaller order value are picked first." + }, + "items": { + "type": "array", + "description": "List of TOC items.", + "items": { + "$ref": "#/$defs/tocItem" + } + }, + "expanded": { + "type": "boolean", + "default": false, + "description": "If set to true, Child items are displayed as expanded." + }, + "dropdown": { + "type": "boolean", + "default": false, + "description": "If set to true, Child items are displayed as dropdown on top navigation bar." + } + } + } + } +} diff --git a/schemas/xrefmap.schema.json b/schemas/xrefmap.schema.json new file mode 100644 index 00000000000..c79672c96f0 --- /dev/null +++ b/schemas/xrefmap.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/xrefmap.schema.json", + "title": "JSON Schema for docfx xrefmap file.", + "type": "object", + "additionalProperties": true, + "properties": { + "sorted": { + "type": "boolean", + "default": false, + "description": "Indicate references are sorted by uid or not." + }, + "hrefUpdated": { + "type": "boolean", + "default": false, + "description": "Indicate href links are updated or not." + }, + "baseUrl": { + "type": "string", + "format": "uri", + "description": "Base url. It's used when href is specified as relative url." + }, + "redirections": { + "type": "array", + "description": "List of XRefMapRedirection items.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "uidPrefix": { + "type": "string", + "description": "Prefix of the UID to redirect." + }, + "href": { + "type": "string", + "format": "uri-reference", + "description": "URL to redirect." + } + } + } + }, + "references": { + "type": "array", + "description": "List of XRefSpec items.", + "items": { + "$ref": "#/$defs/xrefSpec" + } + } + }, + "$defs": { + "xrefSpec": { + "type": "object", + "description": "", + "additionalProperties": true, + "properties": { + "uid": { + "type": "string", + "description": "UID to a conceptual topic or API reference." + }, + "name": { + "type": "string", + "description": "Title of the topic." + }, + "href": { + "type": "string", + "description": "URL to the topic, which is an absolute url or relative path to current file (xrefmap.yml)" + }, + "fullName": { + "type": "string", + "description": "The fully qualified name of API. For example, for String class, its name is String and fully qualified name is System.String. This property is not used in link title resolve for now but reserved for future use." + }, + "nameWithType": { + "type": "string", + "description": "Display name of type." + }, + "commentId": { + "type": "string", + "description": "The id of API comment." + }, + "isSpec": { + "type": "string", + "description": "" + } + } + } + } +} diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 4dd9531dd27..b31a1ac8c8b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,5 +3,6 @@ + - \ No newline at end of file + diff --git a/src/Docfx.Build/XRefMaps/XRefArchiveBuilder.cs b/src/Docfx.Build/XRefMaps/XRefArchiveBuilder.cs index 9542058fa96..515cec49607 100644 --- a/src/Docfx.Build/XRefMaps/XRefArchiveBuilder.cs +++ b/src/Docfx.Build/XRefMaps/XRefArchiveBuilder.cs @@ -47,16 +47,6 @@ private async Task DownloadCoreAsync(Uri uri, XRefArchive xa) return null; } - // If BaseUrl is not set. Use xrefmap file download url as basePath. - if (string.IsNullOrEmpty(map.BaseUrl)) - { - var baseUrl = uri.GetLeftPart(UriPartial.Path); - baseUrl = baseUrl.Substring(0, baseUrl.LastIndexOf('/') + 1); - map.BaseUrl = baseUrl; - map.UpdateHref(new Uri(baseUrl)); // Update hrefs from relative to absolute url. - map.HrefUpdated = null; // Don't save this flag for downloaded XRefMap. - } - // Enforce XRefMap's references are sorted by uid. // Note: // Sort is not needed if `map.Sorted == true`. diff --git a/src/Docfx.Build/XRefMaps/XRefMapDownloader.cs b/src/Docfx.Build/XRefMaps/XRefMapDownloader.cs index 55d477394b5..10e7423c28b 100644 --- a/src/Docfx.Build/XRefMaps/XRefMapDownloader.cs +++ b/src/Docfx.Build/XRefMaps/XRefMapDownloader.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Diagnostics; using System.Net; using Docfx.Common; @@ -8,7 +10,7 @@ namespace Docfx.Build.Engine; -public class XRefMapDownloader +public sealed class XRefMapDownloader { private readonly SemaphoreSlim _semaphore; private readonly IReadOnlyList _localFileFolders; @@ -38,22 +40,22 @@ public XRefMapDownloader(string baseFolder = null, IReadOnlyList fallbac /// The uri of xref map file. /// An instance of . /// This method is thread safe. - public async Task DownloadAsync(Uri uri) + public async Task DownloadAsync(Uri uri, CancellationToken token = default) { ArgumentNullException.ThrowIfNull(uri); - await _semaphore.WaitAsync(); + await _semaphore.WaitAsync(token); return await Task.Run(async () => { try { if (uri.IsAbsoluteUri) { - return await DownloadBySchemeAsync(uri); + return await DownloadBySchemeAsync(uri, token); } else { - return ReadLocalFileWithFallback(uri); + return await ReadLocalFileWithFallback(uri, token); } } finally @@ -63,14 +65,14 @@ public async Task DownloadAsync(Uri uri) }); } - private IXRefContainer ReadLocalFileWithFallback(Uri uri) + private ValueTask ReadLocalFileWithFallback(Uri uri, CancellationToken token = default) { foreach (var localFileFolder in _localFileFolders) { var localFilePath = Path.Combine(localFileFolder, uri.OriginalString); if (File.Exists(localFilePath)) { - return ReadLocalFile(localFilePath); + return ReadLocalFileAsync(localFilePath, token); } } throw new FileNotFoundException($"Cannot find xref map file {uri.OriginalString} in path: {string.Join(",", _localFileFolders)}", uri.OriginalString); @@ -79,17 +81,17 @@ private IXRefContainer ReadLocalFileWithFallback(Uri uri) /// /// Support scheme: http, https, file. /// - protected virtual async Task DownloadBySchemeAsync(Uri uri) + private async ValueTask DownloadBySchemeAsync(Uri uri, CancellationToken token = default) { IXRefContainer result; if (uri.IsFile) { - result = DownloadFromLocal(uri); + result = await DownloadFromLocalAsync(uri, token); } else if (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) { - result = await DownloadFromWebAsync(uri); + result = await DownloadFromWebAsync(uri, token); } else { @@ -102,13 +104,13 @@ protected virtual async Task DownloadBySchemeAsync(Uri uri) return result; } - protected static IXRefContainer DownloadFromLocal(Uri uri) + private static ValueTask DownloadFromLocalAsync(Uri uri, CancellationToken token = default) { var filePath = uri.LocalPath; - return ReadLocalFile(filePath); + return ReadLocalFileAsync(filePath, token); } - private static IXRefContainer ReadLocalFile(string filePath) + private static async ValueTask ReadLocalFileAsync(string filePath, CancellationToken token = default) { Logger.LogVerbose($"Reading from file: {filePath}"); @@ -119,8 +121,8 @@ private static IXRefContainer ReadLocalFile(string filePath) case ".json": { - using var stream = File.OpenText(filePath); - return JsonUtility.Deserialize(stream); + using var stream = File.OpenRead(filePath); + return await SystemTextJsonUtility.DeserializeAsync(stream, token); } case ".yml": @@ -132,7 +134,7 @@ private static IXRefContainer ReadLocalFile(string filePath) } } - protected static async Task DownloadFromWebAsync(Uri uri) + private static async Task DownloadFromWebAsync(Uri uri, CancellationToken token = default) { Logger.LogVerbose($"Reading from web: {uri.OriginalString}"); @@ -145,40 +147,35 @@ protected static async Task DownloadFromWebAsync(Uri uri) Timeout = TimeSpan.FromMinutes(30), // Default: 100 seconds }; - using var stream = await httpClient.GetStreamAsync(uri); + using var stream = await httpClient.GetStreamAsync(uri, token); switch (Path.GetExtension(uri.AbsolutePath).ToLowerInvariant()) { case ".json": { - using var sr = new StreamReader(stream, bufferSize: 81920); // Default :1024 byte - return JsonUtility.Deserialize(sr); + var xrefMap = await SystemTextJsonUtility.DeserializeAsync(stream, token); + xrefMap.BaseUrl = ResolveBaseUrl(xrefMap, uri); + return xrefMap; } case ".yml": default: { using var sr = new StreamReader(stream, bufferSize: 81920); // Default :1024 byte - return YamlUtility.Deserialize(sr); + var xrefMap = YamlUtility.Deserialize(sr); + xrefMap.BaseUrl = ResolveBaseUrl(xrefMap, uri); + return xrefMap; } } } - public static void UpdateHref(XRefMap map, Uri uri) + private static string ResolveBaseUrl(XRefMap map, Uri uri) { if (!string.IsNullOrEmpty(map.BaseUrl)) - { - if (!Uri.TryCreate(map.BaseUrl, UriKind.Absolute, out Uri baseUri)) - { - throw new InvalidDataException($"Xref map file (from {uri.AbsoluteUri}) has an invalid base url: {map.BaseUrl}."); - } - map.UpdateHref(baseUri); - return; - } - if (uri.Scheme == "http" || uri.Scheme == "https") - { - map.UpdateHref(uri); - return; - } - throw new InvalidDataException($"Xref map file (from {uri.AbsoluteUri}) missing base url."); + return map.BaseUrl; + + // If downloaded xrefmap has no baseUrl. + // Use xrefmap file download url as basePath. + var baseUrl = uri.GetLeftPart(UriPartial.Path); + return baseUrl.Substring(0, baseUrl.LastIndexOf('/') + 1); } } diff --git a/src/Docfx.Build/XRefMaps/XRefMapRedirection.cs b/src/Docfx.Build/XRefMaps/XRefMapRedirection.cs index 1035a7f0650..a053fe1862c 100644 --- a/src/Docfx.Build/XRefMaps/XRefMapRedirection.cs +++ b/src/Docfx.Build/XRefMaps/XRefMapRedirection.cs @@ -15,7 +15,7 @@ public class XRefMapRedirection public string UidPrefix { get; set; } [YamlMember(Alias = "href")] - [JsonProperty("Href")] + [JsonProperty("href")] [JsonPropertyName("href")] public string Href { get; set; } } diff --git a/src/Docfx.Common/Docfx.Common.csproj b/src/Docfx.Common/Docfx.Common.csproj index 46d7bccbca5..d83fc78f609 100644 --- a/src/Docfx.Common/Docfx.Common.csproj +++ b/src/Docfx.Common/Docfx.Common.csproj @@ -1,4 +1,4 @@ - + @@ -7,4 +7,10 @@ + + + + + + diff --git a/src/Docfx.Common/FileMappingItem.cs b/src/Docfx.Common/FileMappingItem.cs index bd51811df11..99454ba40d8 100644 --- a/src/Docfx.Common/FileMappingItem.cs +++ b/src/Docfx.Common/FileMappingItem.cs @@ -19,21 +19,21 @@ public class FileMappingItem public string Name { get; set; } /// - /// The file glob pattern collection, with path relative to property `src`/`cwd` is value is set + /// The file glob pattern collection, with path relative to property `src` is value is set /// [JsonProperty("files")] [JsonPropertyName("files")] public FileItems Files { get; set; } /// - /// The file glob pattern collection for files that should be excluded, with path relative to property `src`/`cwd` is value is set + /// The file glob pattern collection for files that should be excluded, with path relative to property `src` is value is set /// [JsonProperty("exclude")] [JsonPropertyName("exclude")] public FileItems Exclude { get; set; } /// - /// `src` defines the root folder for the source files, it has the same meaning as `cwd` + /// `src` defines the root folder for the source files. /// [JsonProperty("src")] [JsonPropertyName("src")] diff --git a/src/Docfx.Common/Json/System.Text.Json/ObjectToInferredTypesConverter.cs b/src/Docfx.Common/Json/System.Text.Json/ObjectToInferredTypesConverter.cs new file mode 100644 index 00000000000..ac3664e6d3c --- /dev/null +++ b/src/Docfx.Common/Json/System.Text.Json/ObjectToInferredTypesConverter.cs @@ -0,0 +1,77 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; + +#nullable enable + +namespace Docfx.Common; + +/// +/// Custom JsonConverters for . +/// +/// +/// +internal class ObjectToInferredTypesConverter : JsonConverter +{ + /// + public override object? Read( + ref Utf8JsonReader reader, + Type typeToConvert, + JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.True: + return true; + case JsonTokenType.False: + return false; + case JsonTokenType.Number when reader.TryGetInt32(out int intValue): + return intValue; + case JsonTokenType.Number when reader.TryGetInt64(out long longValue): + return longValue; + case JsonTokenType.Number: + return reader.GetDouble(); + case JsonTokenType.String when reader.TryGetDateTime(out DateTime datetime): + return datetime; + case JsonTokenType.String: + return reader.GetString(); + case JsonTokenType.Null: + return null; + case JsonTokenType.StartArray: + { + var list = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + object? element = Read(ref reader, typeof(object), options); + list.Add(element); + } + return list; + } + case JsonTokenType.StartObject: + { + try + { + using var doc = JsonDocument.ParseValue(ref reader); + return JsonSerializer.Deserialize>(doc, options); + } + catch (Exception) + { + goto default; + } + } + default: + { + using var doc = JsonDocument.ParseValue(ref reader); + return doc.RootElement.Clone(); + } + } + } + + /// + public override void Write(Utf8JsonWriter writer, object objectToWrite, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, objectToWrite, objectToWrite.GetType(), options); + } +} diff --git a/src/Docfx.Common/Json/System.Text.Json/SystemTextJsonUtility.cs b/src/Docfx.Common/Json/System.Text.Json/SystemTextJsonUtility.cs new file mode 100644 index 00000000000..e952067a491 --- /dev/null +++ b/src/Docfx.Common/Json/System.Text.Json/SystemTextJsonUtility.cs @@ -0,0 +1,101 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using System.Text.Json.Serialization; + +#nullable enable + +namespace Docfx.Common; + +/// +/// Utility class for JSON serialization/deserialization. +/// +internal static class SystemTextJsonUtility +{ + /// + /// Default JsonSerializerOptions options. + /// + public static readonly JsonSerializerOptions DefaultSerializerOptions; + + /// + /// Default JsonSerializerOptions options with indent setting. + /// + public static readonly JsonSerializerOptions IndentedSerializerOptions; + + static SystemTextJsonUtility() + { + DefaultSerializerOptions = new JsonSerializerOptions() + { + // DefaultBufferSize = 1024 * 16, // TODO: Set appropriate buffer size based on benchmark.(Default: 16KB) + AllowTrailingCommas = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + // DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, // This setting is not compatible to `Newtonsoft.Json` serialize result. + NumberHandling = JsonNumberHandling.AllowReadingFromString, + Converters = + { + new JsonStringEnumConverter(), + new ObjectToInferredTypesConverter(), // Required for `Dictionary` type deserialization. + }, + WriteIndented = false, + }; + + IndentedSerializerOptions = new JsonSerializerOptions(DefaultSerializerOptions) + { + WriteIndented = true, + }; + } + + /// + /// Converts the value of a type specified by a generic type parameter into a JSON string. + /// + public static string Serialize(T model, bool indented = false) + { + var options = indented + ? IndentedSerializerOptions + : DefaultSerializerOptions; + + return JsonSerializer.Serialize(model, options); + } + + /// + /// Converts the value of a type specified by a generic type parameter into a JSON string. + /// + public static string Serialize(Stream stream, bool indented = false) + { + var options = indented + ? IndentedSerializerOptions + : DefaultSerializerOptions; + + return JsonSerializer.Serialize(stream, options); + } + + /// + /// Reads the UTF-8 encoded text representing a single JSON value into a TValue. + /// The Stream will be read to completion. + /// + public static T? Deserialize(string json) + { + return JsonSerializer.Deserialize(json, DefaultSerializerOptions); + } + + /// + /// Reads the UTF-8 encoded text representing a single JSON value into a TValue. + /// The Stream will be read to completion. + /// + public static T? Deserialize(Stream stream) + { + return JsonSerializer.Deserialize(stream, DefaultSerializerOptions); + } + + /// + /// Asynchronously reads the UTF-8 encoded text representing a single JSON value + // into an instance of a type specified by a generic type parameter. The stream + // will be read to completion. + public static async ValueTask DeserializeAsync(Stream stream, CancellationToken token = default) + { + return await JsonSerializer.DeserializeAsync(stream, DefaultSerializerOptions, cancellationToken: token); + } +} diff --git a/src/Docfx.DataContracts.Common/Constants.cs b/src/Docfx.DataContracts.Common/Constants.cs index d481b7ba86e..270982e7508 100644 --- a/src/Docfx.DataContracts.Common/Constants.cs +++ b/src/Docfx.DataContracts.Common/Constants.cs @@ -114,6 +114,14 @@ public static class TableOfContents public const string YamlTocFileName = "toc.yml"; } + public static class JsonSchemas + { + public const string Docfx = "schemas/docfx.schema.json"; + public const string Toc = "schemas/toc.schema.json"; + public const string XrefMap = "schemas/xrefmap.schema.json"; + public const string FilterConfig = "schemas/filterconfig.schema.json"; + } + public static class EnvironmentVariables { #pragma warning disable format diff --git a/test/Docfx.Build.Tests/XRefMapDownloaderTest.cs b/test/Docfx.Build.Tests/XRefMapDownloaderTest.cs index c8ae4b7ec57..50b389263f4 100644 --- a/test/Docfx.Build.Tests/XRefMapDownloaderTest.cs +++ b/test/Docfx.Build.Tests/XRefMapDownloaderTest.cs @@ -50,4 +50,58 @@ public async Task ReadLocalXRefMapJsonFileTest() xrefMap.Should().NotBeNull(); xrefMap.References.Should().HaveCount(1); } + + /// + /// XrefmapDownloader test for xrefmap that has no baseUrl and href is defined by relative path. + /// + [Fact(Skip = "Has dependency to external site content.")] + public async Task ReadRemoteXRefMapYamlFileTest1() + { + // Arrange + var path = "https://horizongir.github.io/ZedGraph/xrefmap.yml"; + + XRefMapDownloader downloader = new XRefMapDownloader(); + var xrefMap = await downloader.DownloadAsync(new Uri(path)) as XRefMap; + + // Assert + xrefMap.Sorted.Should().BeTrue(); + xrefMap.HrefUpdated.Should().BeNull(); + + // If baseUrl is not exists. Set download URL is set automatically. + xrefMap.BaseUrl.Should().Be("https://horizongir.github.io/ZedGraph/"); + + // Test relative URL is preserved. + xrefMap.References[0].Href.Should().Be("api/ZedGraph.html"); + xrefMap.References[0].Href = "https://horizongir.github.io/ZedGraph/api/ZedGraph.html"; + xrefMap.BaseUrl = "http://localhost"; + + // Test url is resolved as absolute URL. + var reader = xrefMap.GetReader(); + reader.Find("ZedGraph").Href.Should().Be("https://horizongir.github.io/ZedGraph/api/ZedGraph.html"); + } + + /// + /// XrefmapDownloader test for xrefmap that has no baseUrl, and href is defined by absolute path. + /// + [Fact(Skip = "Has dependency to external site content.")] + public async Task ReadRemoteXRefMapJsonFileTest2() + { + // Arrange + var path = "https://normanderwan.github.io/UnityXrefMaps/xrefmap.yml"; + + XRefMapDownloader downloader = new XRefMapDownloader(); + var xrefMap = await downloader.DownloadAsync(new Uri(path)) as XRefMap; + + // Assert + xrefMap.Sorted.Should().BeTrue(); + xrefMap.HrefUpdated.Should().BeNull(); + + // If baseUrl is not exists. XrefMap download URL is set automatically. + xrefMap.BaseUrl.Should().Be("https://normanderwan.github.io/UnityXrefMaps/"); + + // If href is absolute URL. baseURL is ignored. + var xrefSpec = xrefMap.References[0]; + xrefSpec.Href.Should().Be("https://docs.unity3d.com/ScriptReference/index.html"); + xrefMap.GetReader().Find(xrefSpec.Uid).Href.Should().Be("https://docs.unity3d.com/ScriptReference/index.html"); + } } diff --git a/test/Docfx.Build.Tests/XRefMapSerializationTest.cs b/test/Docfx.Build.Tests/XRefMapSerializationTest.cs index 2300f4696d6..b36348bfb36 100644 --- a/test/Docfx.Build.Tests/XRefMapSerializationTest.cs +++ b/test/Docfx.Build.Tests/XRefMapSerializationTest.cs @@ -10,7 +10,7 @@ namespace Docfx.Build.Engine.Tests; public class XRefMapSerializationTest -{ +{ [Fact] public void XRefMapSerializationRoundTripTest() { @@ -43,17 +43,49 @@ public void XRefMapSerializationRoundTripTest() }, Others = new Dictionary { - ["Other1"] = "Dummy", + ["StringValue"] = "Dummy", + ["BooleanValue"] = true, + ["IntValue"] = int.MaxValue, + ["LongValue"] = long.MaxValue, + ["DoubleValue"] = 1.234d, + + //// YamlDotNet don't deserialize dictionary's null value. + // ["NullValue"] = null, + + //// Following types has no deserialize compatibility (NewtonsoftJson deserialize value to JArray/Jvalue) + // ["ArrayValue"] = new object[] { 1, 2, 3 }, + // ["ObjectValue"] = new Dictionary{["Prop1"="Dummy"]} } }; - // Arrange - var jsonResult = RoundtripByNewtonsoftJson(model); - var yamlResult = RoundtripWithYamlDotNet(model); + // Validate serialized JSON text. + { + // Arrange + var systemTextJson = SystemTextJsonUtility.Serialize(model); + var newtonsoftJson = JsonUtility.Serialize(model); - // Assert - jsonResult.Should().BeEquivalentTo(model); - yamlResult.Should().BeEquivalentTo(model); + // Assert + systemTextJson.Should().Be(newtonsoftJson); + } + + // Validate roundtrip result. + { + // Arrange + var systemTextJsonResult = RoundtripBySystemTextJson(model); + var newtonsoftJsonResult = RoundtripByNewtonsoftJson(model); + var yamlResult = RoundtripWithYamlDotNet(model); + + // Assert + systemTextJsonResult.Should().BeEquivalentTo(model); + newtonsoftJsonResult.Should().BeEquivalentTo(model); + yamlResult.Should().BeEquivalentTo(model); + } + } + + private static T RoundtripBySystemTextJson(T model) + { + var json = SystemTextJsonUtility.Serialize(model); + return SystemTextJsonUtility.Deserialize(json); } private static T RoundtripByNewtonsoftJson(T model) diff --git a/test/docfx.Tests/Api.verified.cs b/test/docfx.Tests/Api.verified.cs index a18ce4c6167..978bdf5a41f 100644 --- a/test/docfx.Tests/Api.verified.cs +++ b/test/docfx.Tests/Api.verified.cs @@ -469,14 +469,10 @@ public Docfx.Build.Engine.IXRefContainerReader GetReader() { } public void Sort() { } public void UpdateHref(System.Uri baseUri) { } } - public class XRefMapDownloader + public sealed class XRefMapDownloader { public XRefMapDownloader(string baseFolder = null, System.Collections.Generic.IReadOnlyList fallbackFolders = null, int maxParallelism = 16) { } - public System.Threading.Tasks.Task DownloadAsync(System.Uri uri) { } - protected virtual System.Threading.Tasks.Task DownloadBySchemeAsync(System.Uri uri) { } - protected static Docfx.Build.Engine.IXRefContainer DownloadFromLocal(System.Uri uri) { } - protected static System.Threading.Tasks.Task DownloadFromWebAsync(System.Uri uri) { } - public static void UpdateHref(Docfx.Build.Engine.XRefMap map, System.Uri uri) { } + public System.Threading.Tasks.Task DownloadAsync(System.Uri uri, System.Threading.CancellationToken token = default) { } } public sealed class XRefMapReader : Docfx.Build.Engine.XRefRedirectionReader { @@ -486,7 +482,7 @@ protected override Docfx.Build.Engine.IXRefContainer GetMap(string name) { } public class XRefMapRedirection { public XRefMapRedirection() { } - [Newtonsoft.Json.JsonProperty("Href")] + [Newtonsoft.Json.JsonProperty("href")] [System.Text.Json.Serialization.JsonPropertyName("href")] [YamlDotNet.Serialization.YamlMember(Alias="href")] public string Href { get; set; } @@ -2383,6 +2379,13 @@ public static class ExtensionMemberPrefix public const string Source = "source."; public const string Spec = "spec."; } + public static class JsonSchemas + { + public const string Docfx = "schemas/docfx.schema.json"; + public const string FilterConfig = "schemas/filterconfig.schema.json"; + public const string Toc = "schemas/toc.schema.json"; + public const string XrefMap = "schemas/xrefmap.schema.json"; + } public static class MetadataName { public const string Version = "version"; diff --git a/test/docfx.Tests/Assets/docfx.json_build/docfx.json b/test/docfx.Tests/Assets/docfx.json_build/docfx.json index 5cc41b5b839..ffbaaa6fe96 100644 --- a/test/docfx.Tests/Assets/docfx.json_build/docfx.json +++ b/test/docfx.Tests/Assets/docfx.json_build/docfx.json @@ -1,22 +1,21 @@ { "build": { // input could be YAML or MARKDOWN files, outputs are Final-YAML files - "content": - [ - { - "files": ["**/*.yml"], - "cwd": "obj/docfx" - }, - { - "files": ["tutorial/**/*.md", "spec/**/*.md"] - }, - { - "files": ["toc.yml"] - } - ], + "content": [ + { + "files": [ "**/*.yml" ], + "src": "obj/docfx" + }, + { + "files": [ "tutorial/**/*.md", "spec/**/*.md" ] + }, + { + "files": [ "toc.yml" ] + } + ], "resource": [ - { - "files": ["images/**"] - } + { + "files": [ "images/**" ] + } ], "globalMetadata": { "key": "value" @@ -26,17 +25,16 @@ "filepattern1": "string", "filePattern2": 2, "filePattern3": true, - "filePattern4": [ ], - "filePattern5": { } + "filePattern4": [], + "filePattern5": {} } }, "overwrite": "apispec/*.md", - "externalReference": [ + "xref": [ "external/*.yml.zip" ], "dest": "_site", "template": "default", - "theme": "happy", - "title": "Doc-as-code documentation" + "theme": "happy" } -} \ No newline at end of file +} diff --git a/test/docfx.Tests/Assets/docfx.json_empty/docfx.json b/test/docfx.Tests/Assets/docfx.json_empty/docfx.json index 8593c62d965..0f530c147cf 100644 --- a/test/docfx.Tests/Assets/docfx.json_empty/docfx.json +++ b/test/docfx.Tests/Assets/docfx.json_empty/docfx.json @@ -1,2 +1,2 @@ { -} \ No newline at end of file +} diff --git a/test/docfx.Tests/Assets/docfx.json_invalid_key/docfx.json b/test/docfx.Tests/Assets/docfx.json_invalid_key/docfx.json index 9b054bc98f6..4453b55bdb3 100644 --- a/test/docfx.Tests/Assets/docfx.json_invalid_key/docfx.json +++ b/test/docfx.Tests/Assets/docfx.json_invalid_key/docfx.json @@ -1,3 +1,3 @@ { - "invalid": { } -} \ No newline at end of file + "invalid": {} +} diff --git a/test/docfx.Tests/Assets/docfx.json_metadata/docfx.json b/test/docfx.Tests/Assets/docfx.json_metadata/docfx.json index 38be66b74d3..9bf9640cf08 100644 --- a/test/docfx.Tests/Assets/docfx.json_metadata/docfx.json +++ b/test/docfx.Tests/Assets/docfx.json_metadata/docfx.json @@ -3,9 +3,9 @@ { "src": [ { - "files": ["**/*.csproj"], - "exclude": [ "**/bin/**", "**/obj/**" ], // `exclude` is also relative to `cwd` - "cwd": "../src" + "files": [ "**/*.csproj" ], + "exclude": [ "**/bin/**", "**/obj/**" ], // `exclude` is also relative to `src` + "src": "../src" } ], "dest": "obj/docfx/api/dotnet" @@ -13,11 +13,11 @@ { "src": [ { - "files": ["**/*.js"], - "cwd": "../src" + "files": [ "**/*.js" ], + "src": "../src" } ], "dest": "obj/docfx/api/js" // throw error when dest is not unique } ] -} \ No newline at end of file +} diff --git a/test/docfx.Tests/Assets/docfx.json_metadata/docfxWithFilter.json b/test/docfx.Tests/Assets/docfx.json_metadata/docfxWithFilter.json index 442ab519054..69f8dde8f2d 100644 --- a/test/docfx.Tests/Assets/docfx.json_metadata/docfxWithFilter.json +++ b/test/docfx.Tests/Assets/docfx.json_metadata/docfxWithFilter.json @@ -4,7 +4,7 @@ "src": [ { "files": [ "**/*.csproj" ], - "exclude": [ "**/bin/**", "**/obj/**" ], // `exclude` is also relative to `cwd` + "exclude": [ "**/bin/**", "**/obj/**" ], // `exclude` is also relative to `src` "src": "../src" } ], @@ -12,4 +12,4 @@ "filter": "filter.yaml" } ] -} \ No newline at end of file +} diff --git a/test/docfx.Tests/Assets/docfx.json_metadata_build/docfx.json b/test/docfx.Tests/Assets/docfx.json_metadata_build/docfx.json index a69ef626303..d6811fd99d8 100644 --- a/test/docfx.Tests/Assets/docfx.json_metadata_build/docfx.json +++ b/test/docfx.Tests/Assets/docfx.json_metadata_build/docfx.json @@ -3,9 +3,9 @@ { "src": [ { - "files": ["**/*.csproj"], - "exclude": [ "**/bin/**", "**/obj/**" ], // `exclude` is also relative to `cwd` - "cwd": "../src" + "files": [ "**/*.csproj" ], + "exclude": [ "**/bin/**", "**/obj/**" ], // `exclude` is also relative to `src` + "src": "../src" } ], "dest": "obj/docfx/api/dotnet" @@ -13,31 +13,30 @@ { "src": [ { - "files": ["**/*.js"], - "cwd": "../src" + "files": [ "**/*.js" ], + "src": "../src" } ], "dest": "obj/docfx/api/js" // throw error when dest is not unique } ], "build": { // input could be YAML or MARKDOWN files, outputs are Final-YAML files - "content": - [ - { - "files": ["**/*.yml"], - "cwd": "obj/docfx" - }, - { - "files": ["tutorial/**/*.md", "spec/**/*.md"] - }, - { - "files": ["toc.yml"] - } - ], + "content": [ + { + "files": [ "**/*.yml" ], + "src": "obj/docfx" + }, + { + "files": [ "tutorial/**/*.md", "spec/**/*.md" ] + }, + { + "files": [ "toc.yml" ] + } + ], "resource": [ - { - "files": ["images/**"] - } + { + "files": [ "images/**" ] + } ], "globalMetadata": { "key": "value" @@ -47,17 +46,16 @@ "filepattern1": "string", "filePattern2": 2, "filePattern3": true, - "filePattern4": [ ], - "filePattern5": { } + "filePattern4": [], + "filePattern5": {} } }, "overwrite": "apispec/*.md", - "externalReference": [ + "xref": [ "external/*.yml.zip" ], - "dest": "_site", + "output": "_site", "template": "default", - "theme": "happy", - "title": "Doc-as-code documentation" + "theme": "happy" } -} \ No newline at end of file +} diff --git a/test/docfx.Tests/Assets/docfx.sample.1.json b/test/docfx.Tests/Assets/docfx.sample.1.json index a69ef626303..ded53486622 100644 --- a/test/docfx.Tests/Assets/docfx.sample.1.json +++ b/test/docfx.Tests/Assets/docfx.sample.1.json @@ -4,8 +4,8 @@ "src": [ { "files": ["**/*.csproj"], - "exclude": [ "**/bin/**", "**/obj/**" ], // `exclude` is also relative to `cwd` - "cwd": "../src" + "exclude": [ "**/bin/**", "**/obj/**" ], // `exclude` is also relative to `src` + "src": "../src" } ], "dest": "obj/docfx/api/dotnet" @@ -14,7 +14,7 @@ "src": [ { "files": ["**/*.js"], - "cwd": "../src" + "src": "../src" } ], "dest": "obj/docfx/api/js" // throw error when dest is not unique @@ -25,7 +25,7 @@ [ { "files": ["**/*.yml"], - "cwd": "obj/docfx" + "src": "obj/docfx" }, { "files": ["tutorial/**/*.md", "spec/**/*.md"] @@ -52,12 +52,11 @@ } }, "overwrite": "apispec/*.md", - "externalReference": [ + "xref": [ "external/*.yml.zip" ], "dest": "_site", "template": "default", - "theme": "happy", - "title": "Doc-as-code documentation" + "theme": "happy" } -} \ No newline at end of file +} diff --git a/test/docfx.Tests/JsonSchemaTest.cs b/test/docfx.Tests/JsonSchemaTest.cs new file mode 100644 index 00000000000..4fa2c37aa15 --- /dev/null +++ b/test/docfx.Tests/JsonSchemaTest.cs @@ -0,0 +1,189 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Docfx.Common; +using Docfx.DataContracts.Common; +using Docfx.JsonSchemaGenerator.Tests; +using Docfx.Tests.Common; +using FluentAssertions; +using Json.Schema; +using Xunit.Abstractions; +using YamlDotNet.Serialization; + +namespace Docfx.Tests; + +[Collection("docfx STA")] +public class JsonSchemaTest : TestBase +{ + private readonly ITestOutputHelper output; + + public JsonSchemaTest(ITestOutputHelper output) + { + this.output = output; + } + + [Theory] + [InlineData("docs/docfx.json")] + [InlineData("samples/csharp/docfx.json")] + [InlineData("samples/extensions/docfx.json")] + [InlineData("samples/seed/docfx.json")] + [InlineData("test/docfx.Tests/Assets/docfx.json_build/docfx.json")] + [InlineData("test/docfx.Tests/Assets/docfx.json_empty/docfx.json")] + [InlineData("test/docfx.Tests/Assets/docfx.json_metadata/docfx.json")] + [InlineData("test/docfx.Tests/Assets/docfx.json_metadata/docfxWithFilter.json")] + [InlineData("test/docfx.Tests/Assets/docfx.json_metadata_build/docfx.json")] + public void JsonSchemaTest_Docfx_Json(string path) + { + // Arrange + var jsonElement = LoadAsJsonElement(path); + + // Act + var result = JsonSchemaUtility.ValidateJsonSchema(jsonElement, Constants.JsonSchemas.Docfx); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("test/docfx.Tests/Assets/docfx.json_invalid_format/docfx.json")] + [InlineData("test/docfx.Tests/Assets/docfx.json_invalid_key/docfx.json")] + public void JsonSchemaTest_Docfx_Json_Invalid(string path) + { + // Arrange + var jsonElement = LoadAsJsonElement(path); + + // Act + var result = JsonSchemaUtility.ValidateJsonSchema(jsonElement, Constants.JsonSchemas.Docfx); + + // Assert + result.IsValid.Should().BeFalse(); + } + + [Theory] + [InlineData("src/Docfx.Dotnet/Resources/defaultfilterconfig.yml")] + [InlineData("test/Docfx.Dotnet.Tests/TestData/filterconfig.yml")] + [InlineData("test/Docfx.Dotnet.Tests/TestData/filterconfig_attribute.yml")] + [InlineData("test/Docfx.Dotnet.Tests/TestData/filterconfig_docs_sample.yml")] + public void JsonSchemaTest_FilterConfig(string path) + { + // Arrange + var jsonElement = LoadAsJsonElement(path); + + // Act + var result = JsonSchemaUtility.ValidateJsonSchema(jsonElement, Constants.JsonSchemas.FilterConfig); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.CSharp/api/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Extensions/api/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Extensions/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Seed/api/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Seed/apipage/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Seed/articles/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Seed/md/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Seed/pdf/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Seed/restapi/toc.json.view.verified.json")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Seed/toc.json.view.verified.json")] + public void JsonSchemaTest_Toc_Json(string path) + { + // Arrange + var jsonElement = LoadAsJsonElement(path); + + // Act + var result = JsonSchemaUtility.ValidateJsonSchema(jsonElement, Constants.JsonSchemas.Toc); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("test/Docfx.Build.RestApi.WithPlugins.Tests/TestData/swagger/toc.yml")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.SeedMarkdown/toc.verified.yml")] + public void JsonSchemaTest_Toc_Yaml(string path) + { + // Arrange + var jsonElement = LoadAsJsonElement(path); + + // Act + var result = JsonSchemaUtility.ValidateJsonSchema(jsonElement, Constants.JsonSchemas.Toc); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("test/Docfx.Build.Tests/TestData/xrefmap.json")] + public void JsonSchemaTest_XrefMap_Json(string path) + { + // Arrange + var jsonElement = LoadAsJsonElement(path); + + // Act + var result = JsonSchemaUtility.ValidateJsonSchema(jsonElement, Constants.JsonSchemas.XrefMap); + + // Assert + result.IsValid.Should().BeTrue(); + } + + [Theory] + [InlineData("test/Docfx.Build.Tests/TestData/xrefmap.yml")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.CSharp/xrefmap.verified.yml")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Extensions/xrefmap.verified.yml")] + [InlineData("test/docfx.Snapshot.Tests/SamplesTest.Seed/xrefmap.verified.yml")] + public void JsonSchemaTest_XrefMap_Yaml(string path) + { + // Arrange + var jsonElement = LoadAsJsonElement(path); + + // Act + var result = JsonSchemaUtility.ValidateJsonSchema(jsonElement, Constants.JsonSchemas.XrefMap); + + // Assert + result.IsValid.Should().BeTrue(); + } + + /// + /// Load file content as JsonElement. + /// + private static JsonElement LoadAsJsonElement(string path) + { + var solutionDir = PathHelper.GetSolutionFolder(); + + var filePath = Path.Combine(solutionDir, path); + + if (!File.Exists(filePath)) + throw new FileNotFoundException(filePath); + + switch (Path.GetExtension(filePath)) + { + case ".json": + var doc = JsonDocument.Parse(File.OpenRead(filePath), JsonSchemaUtility.DefaultJsonDocumentOptions); + return doc.RootElement; + case ".yml": + var yaml = File.ReadAllText(filePath); + var yamlObject = YamlUtility.Deserialize(new StringReader(yaml)); + + var serializer = new SerializerBuilder() + .JsonCompatible() + .Build(); + var json = serializer.Serialize(yamlObject); + return JsonSerializer.Deserialize(json); + + default: + throw new NotSupportedException(path); + } + } + + private void WriteFailedResultsDetails(EvaluationResults result) + { + if (result.IsValid) + return; + + var json = JsonSerializer.Serialize(result, JsonSerializerOptions.Default); + output.WriteLine(json); + } +} diff --git a/test/docfx.Tests/Utilities/JsonSchemaUtility.cs b/test/docfx.Tests/Utilities/JsonSchemaUtility.cs new file mode 100644 index 00000000000..c96f65aa433 --- /dev/null +++ b/test/docfx.Tests/Utilities/JsonSchemaUtility.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Json.Schema; +using System.Text; +using System.Text.Json; + +namespace Docfx.JsonSchemaGenerator.Tests; + +internal static class JsonSchemaUtility +{ + public static readonly JsonSerializerOptions DefaultSerializerOptions = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DictionaryKeyPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + public static readonly EvaluationOptions DefaultEvaluationOptions = new EvaluationOptions + { + ValidateAgainstMetaSchema = false, + OutputFormat = OutputFormat.List, + }; + + public static readonly JsonDocumentOptions DefaultJsonDocumentOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + + public static EvaluationResults ValidateJsonSchema(JsonElement jsonElement, string schemaPath) + { + var solutionDir = PathHelper.GetSolutionFolder(); + var jsonSchemaPath = Path.Combine(solutionDir, schemaPath); + + if (!File.Exists(jsonSchemaPath)) + throw new FileNotFoundException(jsonSchemaPath); + + var schema = JsonSchema.FromFile(jsonSchemaPath, DefaultSerializerOptions); + + var result = schema.Evaluate(jsonElement, DefaultEvaluationOptions); + return result; + } +} diff --git a/test/docfx.Tests/Utilities/PathHelper.cs b/test/docfx.Tests/Utilities/PathHelper.cs new file mode 100644 index 00000000000..d3f874cc3e9 --- /dev/null +++ b/test/docfx.Tests/Utilities/PathHelper.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; + +namespace Docfx.JsonSchemaGenerator.Tests; + +internal class PathHelper +{ + public static string GetSolutionFolder([CallerFilePath] string callerFilePath = "") + { + if (callerFilePath.StartsWith("/_/")) + { + // PathMap is rewritten on CI environment (`ContinuousIntegrationBuild=true`). + // So try to get workspace folder from GitHub Action environment variable. + var workspace = Environment.GetEnvironmentVariable("GITHUB_WORKSPACE"); + if (workspace != null) + return workspace; + } + + if (!File.Exists(callerFilePath)) + { + // CallerFilePath is resolved at build timing. + // If build/test is executed on separated machine. It failed to find file. + throw new Exception($"File is not found. callerFilePath: {callerFilePath}"); + } + + return FindSolutionFolder(callerFilePath, "docfx"); + } + + /// + /// Find docfx solution folder. + /// + private static string FindSolutionFolder(string callerFilePath, string solutionName) + { + var dir = new FileInfo(callerFilePath).Directory; + while (dir != null + && dir.Name != solutionName + && !dir.EnumerateFiles($"{solutionName}.sln").Any()) + { + dir = dir.Parent; + } + + if (dir == null) + throw new Exception("Failed to find solution folder."); + + return dir.FullName; + } +}