Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat: api page view source
  • Loading branch information
yufeih committed Oct 22, 2023
commit 23ce58333c500b4e686ca1c68ff1257c211ea2c7
28 changes: 18 additions & 10 deletions src/Docfx.Build/ApiPage/ApiPageHtmlTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,25 @@ public static HtmlTemplate Render(ApiPage page, Func<string, string> markup)
H6 h6 => Html($"<h6 class='section' id='{h6.id}'>{h6.h6}</h6>"),
};

HtmlTemplate Api(Api api) => api.Value switch
HtmlTemplate Api(Api api)
{
Api1 api1 => Html($"<h1 class='section api' {Attributes(api1.metadata)} id='{api1.id}'>{api1.api1}</h1>"),
Api2 api2 => Html($"<h2 class='section api' {Attributes(api2.metadata)} id='{api2.id}'>{api2.api2}</h2>"),
Api3 api3 => Html($"<h3 class='section api' {Attributes(api3.metadata)} id='{api3.id}'>{api3.api3}</h3>"),
Api4 api4 => Html($"<h4 class='section api' {Attributes(api4.metadata)} id='{api4.id}'>{api4.api4}</h4>"),
};

HtmlTemplate Attributes(Dictionary<string, string>? metadata) => metadata is null
? default
: UnsafeHtml(string.Join(" ", metadata.Select(m => $"data-{WebUtility.HtmlEncode(m.Key)}='{WebUtility.HtmlEncode(m.Value)}'")));
var value = (ApiBase)api.Value;
var attributes = value.metadata is null
? default
: UnsafeHtml(string.Join(" ", value.metadata.Select(m => $"data-{WebUtility.HtmlEncode(m.Key)}='{WebUtility.HtmlEncode(m.Value)}'")));

var src = string.IsNullOrEmpty(value.src)
? default
: Html($" <a class='header-action link-secondary' title='View source' href='{value.src}'><i class='bi bi-code-slash'></i></a>");

return api.Value switch
{
Api1 api1 => Html($"<h1 class='section api' {attributes} id='{value.id}'>{api1.api1}{src}</h1>"),
Api2 api2 => Html($"<h2 class='section api' {attributes} id='{value.id}'>{api2.api2}{src}</h2>"),
Api3 api3 => Html($"<h3 class='section api' {attributes} id='{value.id}'>{api3.api3}{src}</h3>"),
Api4 api4 => Html($"<h4 class='section api' {attributes} id='{value.id}'>{api4.api4}{src}</h4>"),
};
}

HtmlTemplate Facts(Facts facts) => facts.facts.Length is 0 ? default : Html(
$"""
Expand Down
17 changes: 0 additions & 17 deletions src/Docfx.Common/Git/GitRepoInfo.cs

This file was deleted.

98 changes: 35 additions & 63 deletions src/Docfx.Common/Git/GitUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,16 @@

namespace Docfx.Common.Git;

public record GitSource(string Repo, string Branch, string Path, int Line);

public static class GitUtility
{
record Repo(string path, string url, string branch);

private static Regex GitHubRepoUrlRegex =
new(@"^((https|http):\/\/(.+@)?github\.com\/|git@github\.com:)(?<account>\S+)\/(?<repository>[A-Za-z0-9_.-]+)(\.git)?\/?$", RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.RightToLeft);

private static readonly Regex VsoGitRepoUrlRegex =
new(@"^(((https|http):\/\/(?<account>\S+))|((ssh:\/\/)(?<account>\S+)@(?:\S+)))\.visualstudio\.com(?<port>:\d+)?(?:\/DefaultCollection)?(\/(?<project>[^\/]+)(\/.*)*)*\/(?:_git|_ssh)\/(?<repository>([^._,]|[^._,][^@~;{}'+=,<>|\/\\?:&$*""#[\]]*[^.,]))$", RegexOptions.Compiled | RegexOptions.IgnoreCase);

private static readonly string GitHubNormalizedRepoUrlTemplate = "https://github.com/{0}/{1}";
private static readonly string VsoNormalizedRepoUrlTemplate = "https://{0}.visualstudio.com/DefaultCollection/{1}/_git/{2}";

private static readonly ConcurrentDictionary<string, Repo?> s_cache = new();

private static readonly string? s_branch =
Env("DOCFX_SOURCE_BRANCH_NAME") ??
Env("DOCFX_SOURCE_BRANCH_NAME") ??
Env("GITHUB_REF_NAME") ?? // GitHub Actions
Env("APPVEYOR_REPO_BRANCH") ?? // AppVeyor
Env("Git_Branch") ?? // Team City
Expand Down Expand Up @@ -57,16 +50,45 @@ record Repo(string path, string url, string branch);

public static string? RawContentUrlToContentUrl(string rawUrl)
{
if (EnvironmentContext.GitFeaturesDisabled)
return null;

// GitHub
return Regex.Replace(
rawUrl,
@"^https://raw\.githubusercontent\.com/([^/]+)/([^/]+)/([^/]+)/(.+)$",
string.IsNullOrEmpty(s_branch) ? "https://github.com/$1/$2/blob/$3/$4" : $"https://github.com/$1/$2/blob/{s_branch}/$4");
}

public static string? GetSourceUrl(GitSource source)
{
var repo = source.Repo.StartsWith("git") ? GitUrlToHttps(source.Repo) : source.Repo;
repo = repo.TrimEnd('/').TrimEnd(".git");

if (!Uri.TryCreate(repo, UriKind.Absolute, out var url))
return null;

var path = source.Path.Replace('\\', '/');

return url.Host switch
{
"github.com" => $"https://github.com{url.AbsolutePath}/blob/{source.Branch}/{path}{(source.Line > 0 ? $"#L{source.Line}" : null)}",
"bitbucket.org" => $"https://bitbucket.org{url.AbsolutePath}/src/{source.Branch}/{path}{(source.Line > 0 ? $"#lines-{source.Line}" : null)}",
_ when url.Host.EndsWith(".visualstudio.com") || url.Host == "dev.azure.com" =>
$"https://{url.Host}{url.AbsolutePath}?path={path}&version={(IsCommit(source.Branch) ? "GC" : "GB")}{source.Branch}{(source.Line > 0 ? $"&line={source.Line}" : null)}",
_ => null,
};

static bool IsCommit(string branch)
{
return branch.Length == 40 && branch.All(char.IsLetterOrDigit);
}

static string GitUrlToHttps(string url)
{
var pos = url.IndexOf('@');
if (pos == -1) return url;
return $"https://{url.Substring(pos + 1).Replace(":[0-9]+", "").Replace(':', '/')}";
}
}

private static Repo? GetRepoInfo(string? directory)
{
if (string.IsNullOrEmpty(directory))
Expand Down Expand Up @@ -117,54 +139,4 @@ static bool IsGitRoot(string directory)
return Directory.Exists(gitPath);
}
}

[Obsolete("Docfx parses repoUrl in template preprocessor. This method is never used.")]
public static GitRepoInfo Parse(string repoUrl)
{
#if NET7_0_OR_GREATER
ArgumentException.ThrowIfNullOrEmpty(repoUrl);
#else
if (string.IsNullOrEmpty(repoUrl))
{
throw new ArgumentNullException(nameof(repoUrl));
}
#endif

var githubMatch = GitHubRepoUrlRegex.Match(repoUrl);
if (githubMatch.Success)
{
var gitRepositoryAccount = githubMatch.Groups["account"].Value;
var gitRepositoryName = githubMatch.Groups["repository"].Value;
return new GitRepoInfo
{
RepoType = RepoType.GitHub,
RepoAccount = gitRepositoryAccount,
RepoName = gitRepositoryName,
RepoProject = null,
NormalizedRepoUrl = new Uri(string.Format(GitHubNormalizedRepoUrlTemplate, gitRepositoryAccount, gitRepositoryName))
};
}

var vsoMatch = VsoGitRepoUrlRegex.Match(repoUrl);
if (vsoMatch.Success)
{
var gitRepositoryAccount = vsoMatch.Groups["account"].Value;
var gitRepositoryName = vsoMatch.Groups["repository"].Value;

// VSO has this logic: if the project name and repository name are same, then VSO will return the url without project name.
// Sample: if you visit https://cpubwin.visualstudio.com/drivers/_git/drivers, it will return https://cpubwin.visualstudio.com/_git/drivers
// We need to normalize it to keep same behavior with other projects. Always return https://<account>.visualstudio.com/<collection>/<project>/_git/<repository>
var gitRepositoryProject = string.IsNullOrEmpty(vsoMatch.Groups["project"].Value) ? gitRepositoryName : vsoMatch.Groups["project"].Value;
return new GitRepoInfo
{
RepoType = RepoType.Vso,
RepoAccount = gitRepositoryAccount,
RepoName = Uri.UnescapeDataString(gitRepositoryName),
RepoProject = gitRepositoryProject,
NormalizedRepoUrl = new Uri(string.Format(VsoNormalizedRepoUrlTemplate, gitRepositoryAccount, gitRepositoryProject, gitRepositoryName))
};
}

throw new NotSupportedException($"'{repoUrl}' is not a valid Vso/GitHub repository url");
}
}
15 changes: 0 additions & 15 deletions src/Docfx.DataContracts.Common/SourceDetail.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,6 @@ public class SourceDetail
[JsonProperty("remote")]
public GitDetail Remote { get; set; }

[YamlMember(Alias = "base")]
[JsonProperty("base")]
public string BasePath { get; set; }

[YamlMember(Alias = "id")]
[JsonProperty("id")]
public string Name { get; set; }
Expand All @@ -42,15 +38,4 @@ public class SourceDetail
[YamlMember(Alias = "endLine")]
[JsonProperty("endLine")]
public int EndLine { get; set; }

[YamlMember(Alias = "content")]
[JsonProperty("content")]
public string Content { get; set; }

/// <summary>
/// The external path for current source if it is not locally available
/// </summary>
[YamlMember(Alias = "isExternal")]
[JsonProperty("isExternal")]
public bool IsExternalPath { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Text.RegularExpressions;
using Docfx.Build.ApiPage;
using Docfx.Common;
using Docfx.Common.Git;
using Docfx.DataContracts.ManagedReference;
using Docfx.Plugins;
using Microsoft.CodeAnalysis;
Expand Down Expand Up @@ -113,17 +114,22 @@ void Heading(int level, string title, string? id = null)
});
}

void Api(int level, string title, ISymbol symbol)
void Api(int level, string title, ISymbol symbol, Compilation compilation)
{
var uid = VisitorHelper.GetId(symbol);
var id = Regex.Replace(uid, @"\W", "_");
var commentId = VisitorHelper.GetCommentId(symbol);
var source = config.DisableGitFeatures ? null : VisitorHelper.GetSourceDetail(symbol, compilation);
var git = source is null || source.Remote is null ? null
: new GitSource(source.Remote.Repo, source.Remote.Branch, source.Remote.Path, source.StartLine + 1);
var src = git is null ? null : options.SourceUrl?.Invoke(git) ?? GitUtility.GetSourceUrl(git);

body.Add(level switch
{
1 => (Api)new Api1 { api1 = title, id = id, metadata = new() { ["uid"] = uid, ["commentId"] = commentId } },
2 => (Api)new Api2 { api2 = title, id = id, metadata = new() { ["uid"] = uid, ["commentId"] = commentId } },
3 => (Api)new Api3 { api3 = title, id = id, metadata = new() { ["uid"] = uid, ["commentId"] = commentId } },
4 => (Api)new Api4 { api4 = title, id = id, metadata = new() { ["uid"] = uid, ["commentId"] = commentId } },
1 => (Api)new Api1 { api1 = title, id = id, src = src, metadata = new() { ["uid"] = uid, ["commentId"] = commentId } },
2 => (Api)new Api2 { api2 = title, id = id, src = src, metadata = new() { ["uid"] = uid, ["commentId"] = commentId } },
3 => (Api)new Api3 { api3 = title, id = id, src = src, metadata = new() { ["uid"] = uid, ["commentId"] = commentId } },
4 => (Api)new Api4 { api4 = title, id = id, src = src, metadata = new() { ["uid"] = uid, ["commentId"] = commentId } },
});
}

Expand All @@ -135,7 +141,7 @@ from s in allSymbols
where s.symbol.Kind is SymbolKind.NamedType && namespaceSymbols.Contains(s.symbol.ContainingNamespace)
select (symbol: (INamedTypeSymbol)s.symbol, s.compilation)).ToList();

Api(1, title = $"Namespace {symbol}", symbol);
Api(1, title = $"Namespace {symbol}", symbol, compilation);

Summary(comment);
Namespaces();
Expand Down Expand Up @@ -173,7 +179,7 @@ void Types(Func<INamedTypeSymbol, bool> predicate, string headingText)

void Enum(INamedTypeSymbol type)
{
Api(1, title = $"Enum {SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp)}", symbol);
Api(1, title = $"Enum {SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp)}", symbol, compilation);

body.Add(new Facts { facts = Facts().ToArray() });
Summary(comment);
Expand All @@ -190,7 +196,7 @@ void Enum(INamedTypeSymbol type)

void Delegate(INamedTypeSymbol type)
{
Api(1, title = $"Delegate {SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp)}", symbol);
Api(1, title = $"Delegate {SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp)}", symbol, compilation);

body.Add(new Facts { facts = Facts().ToArray() });
Summary(comment);
Expand Down Expand Up @@ -218,7 +224,7 @@ void ClassLike(INamedTypeSymbol type)
_ => throw new InvalidOperationException(),
};

Api(1, title = $"{typeHeader} {SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp)}", symbol);
Api(1, title = $"{typeHeader} {SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp)}", symbol, compilation);

body.Add(new Facts { facts = Facts().ToArray() });
Summary(comment);
Expand Down Expand Up @@ -388,7 +394,7 @@ void InheritedMembers()

void MemberHeader(string headingText)
{
Api(1, title = $"{headingText} {SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp, overload: true)}", symbol);
Api(1, title = $"{headingText} {SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp, overload: true)}", symbol, compilation);
body.Add(new Facts { facts = Facts().ToArray() });
}

Expand Down Expand Up @@ -470,7 +476,7 @@ Parameter ToParameter(ITypeParameterSymbol param)

void Method(IMethodSymbol symbol, Compilation compilation, int headingLevel)
{
Api(headingLevel, SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp), symbol);
Api(headingLevel, SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp), symbol, compilation);

var comment = Comment(symbol, compilation);
Summary(comment);
Expand All @@ -488,7 +494,7 @@ void Method(IMethodSymbol symbol, Compilation compilation, int headingLevel)

void Field(IFieldSymbol symbol, Compilation compilation, int headingLevel)
{
Api(headingLevel, SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp), symbol);
Api(headingLevel, SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp), symbol, compilation);

var comment = Comment(symbol, compilation);
Summary(comment);
Expand All @@ -511,7 +517,7 @@ void Field(IFieldSymbol symbol, Compilation compilation, int headingLevel)

void Property(IPropertySymbol symbol, Compilation compilation, int headingLevel)
{
Api(headingLevel, SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp), symbol);
Api(headingLevel, SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp), symbol, compilation);

var comment = Comment(symbol, compilation);
Summary(comment);
Expand All @@ -534,7 +540,7 @@ void Property(IPropertySymbol symbol, Compilation compilation, int headingLevel)

void Event(IEventSymbol symbol, Compilation compilation, int headingLevel)
{
Api(headingLevel, SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp), symbol);
Api(headingLevel, SymbolFormatter.GetName(symbol, SyntaxLanguage.CSharp), symbol, compilation);

var comment = Comment(symbol, compilation);
Summary(comment);
Expand Down
1 change: 1 addition & 0 deletions src/Docfx.Dotnet/DotnetApiCatalog.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ private static ExtractMetadataConfig ConvertConfig(MetadataJsonItemConfig config
OutputFolder = outputFolder,
CodeSourceBasePath = configModel?.CodeSourceBasePath,
DisableDefaultFilter = configModel?.DisableDefaultFilter ?? false,
DisableGitFeatures = configModel?.DisableGitFeatures ?? false,
NoRestore = configModel?.NoRestore ?? false,
NamespaceLayout = configModel?.NamespaceLayout ?? default,
MemberLayout = configModel?.MemberLayout ?? default,
Expand Down
7 changes: 7 additions & 0 deletions src/Docfx.Dotnet/DotnetApiOptions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Docfx.Common.Git;
using Microsoft.CodeAnalysis;

#nullable enable
Expand Down Expand Up @@ -44,4 +45,10 @@ public class DotnetApiOptions
/// Excluding a parent symbol exclude all child symbols underneath it.
/// </summary>
public Func<ISymbol, SymbolIncludeState>? IncludeAttribute { get; init; }

/// <summary>
/// Customizes the view source URL for files in a git repository.
/// Returns `null` to use built-in support for GitHub, Azure Repos, etc.
/// </summary>
public Func<GitSource, string?>? SourceUrl { get; init; }
}
2 changes: 2 additions & 0 deletions src/Docfx.Dotnet/ManagedReference/ExtractMetadataConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ internal class ExtractMetadataConfig

public bool DisableDefaultFilter { get; init; }

public bool DisableGitFeatures { get; init; }

public bool NoRestore { get; init; }

public NamespaceLayout NamespaceLayout { get; init; }
Expand Down
6 changes: 6 additions & 0 deletions templates/modern/ApiPage.html.primary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

exports.transform = function (model) {
return model;
}
Loading