Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Copy this file to .env and fill in the values.
# .env is gitignored and will never be committed or affect the published site.

# Option 1: Skip all GitHub API calls entirely (fastest local builds, no contributor/commit data).
SKIP_CONTRIBUTORS=true

# Option 2: Use a GitHub Personal Access Token (PAT) for full local builds with real data.
# Generate one at https://github.com/settings/tokens (read:public_repo scope is enough).
# Comment out SKIP_CONTRIBUTORS above and uncomment the line below.
# ACCESS_TOKEN=ghp_your_personal_access_token_here
8 changes: 7 additions & 1 deletion .nuke/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"Deploy",
"PullDnnPackages",
"Restore",
"Serve"
"Serve",
"ValidateGitHubToken"
]
},
"Verbosity": {
Expand Down Expand Up @@ -117,6 +118,11 @@
"type": "string",
"description": "Github Token"
},
"Port": {
"type": "integer",
"description": "Port to serve the docs on locally (default: 8085, auto-increments if already in use)",
"format": "int32"
},
"Solution": {
"type": "string",
"description": "Path to a solution file that is automatically loaded"
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,25 @@ Unless you are part of the DNNDocs core team, you will only have read access (yo
### .NET Framework Prerequisites
You should ensure that you already have [.NET 5.0](https://dotnet.microsoft.com/download/dotnet) (used by the `build` project) and the "Developer Pack" for [.NET Framework 4.6.2](https://dotnet.microsoft.com/download/dotnet-framework/net462) (used by the custom `DocFx` plugins) installed on your machine before trying the build process.

### Environment Variables

The build uses a `.env` file in the root of the repository for local configuration. This file is gitignored so it will never be committed. Copy `.env.example` to get started:

```
copy .env.example .env
```

Open `.env` and choose one of two modes:

| Mode | When to use |
|------|-------------|
| `SKIP_CONTRIBUTORS=true` | **Recommended for most contributors.** Skips all GitHub API calls, making local builds much faster. Contributor and last-updated data will be absent from the local preview. |
| `ACCESS_TOKEN=ghp_...` | Use when you need to verify contributor/commit data locally. Generate a token at [github.com/settings/tokens](https://github.com/settings/tokens) with `read:public_repo` scope. |

> **Note:** Without a token and without `SKIP_CONTRIBUTORS=true`, the build will still work but may be slow or hit the GitHub anonymous rate limit.

### Running the build

You should now be able to run the development version of the docs locally with the following command:

Windows Powershell:
Expand Down
85 changes: 84 additions & 1 deletion build/Build.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Sockets;
using System.Threading.Tasks;
using Nuke.Common;
using Nuke.Common.CI.GitHubActions;
using Nuke.Common.IO;
Expand Down Expand Up @@ -53,6 +58,9 @@ class Build : NukeBuild
[Parameter("Github Token")]
readonly string GithubToken;

[Parameter("Port to serve the docs on locally (default: 8085, auto-increments if already in use)")]
readonly int Port = 8085;

// Nuke features injection.
[Solution] readonly Solution Solution;
[GitRepository] readonly GitRepository gitRepository;
Expand Down Expand Up @@ -132,12 +140,86 @@ class Build : NukeBuild
});

Target Serve => _ => _
.DependsOn(ValidateGitHubToken)
.DependsOn(Clean)
.DependsOn(Restore)
.DependsOn(BuildPlugins)
.Executes(() =>
{
DnnDocFX?.Invoke("--serve --open-browser", RootDirectory);
DnnDocFX?.Invoke($"--serve --open-browser --port {FindFreePort(Port)}", RootDirectory);
});

static int FindFreePort(int startPort)
{
for (var port = startPort; port < startPort + 20; port++)
{
try
{
var listener = new TcpListener(IPAddress.Loopback, port);
listener.Start();
listener.Stop();
return port;
}
catch (SocketException) { }
}
throw new Exception($"Could not find a free port in range {startPort}-{startPort + 19}.");
}

Target ValidateGitHubToken => _ => _
.Executes(async () =>
{
// Load .env file manually (DotNetEnv is not a build project dependency)
var envFile = RootDirectory / ".env";
if (envFile.FileExists())
{
foreach (var line in System.IO.File.ReadAllLines(envFile))
{
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) continue;
var parts = line.Split('=', 2);
if (parts.Length == 2)
Environment.SetEnvironmentVariable(parts[0].Trim(), parts[1].Trim());
}
}

var token = Environment.GetEnvironmentVariable("ACCESS_TOKEN");
if (string.IsNullOrEmpty(token))
token = GithubToken;

if (string.IsNullOrEmpty(token))
{
Serilog.Log.Warning("No GitHub token found. Contributor and commit data will be skipped.");
Serilog.Log.Warning("To enable it, set ACCESS_TOKEN in your .env file (see .env.example).");
Environment.SetEnvironmentVariable("SKIP_CONTRIBUTORS", "true");
return;
}

using var http = new HttpClient();
http.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("DNNDocs-Build", "1.0"));
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);

var response = await http.GetAsync("https://api.github.com/user");

if (response.StatusCode == HttpStatusCode.Unauthorized)
Assert.Fail("GitHub token is invalid or has expired. Generate a new one at https://github.com/settings/tokens");

if (!response.IsSuccessStatusCode)
Assert.Fail($"GitHub token validation returned an unexpected status: {(int)response.StatusCode} {response.ReasonPhrase}");

// Check that the token has the required scope
response.Headers.TryGetValues("X-OAuth-Scopes", out var scopeValues);
var scopes = (scopeValues?.FirstOrDefault() ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);

if (scopes.Length > 0 && !scopes.Contains("public_repo") && !scopes.Contains("repo"))
Assert.Fail($"GitHub token is missing the 'public_repo' scope. Current scopes: {string.Join(", ", scopes)}. " +
"Regenerate it at https://github.com/settings/tokens");

var login = await response.Content.ReadAsStringAsync();
var loginStart = login.IndexOf("\"login\":\"") + 9;
var loginEnd = login.IndexOf('"', loginStart);
var username = loginStart > 8 ? login[loginStart..loginEnd] : "unknown";

Serilog.Log.Information("GitHub token is valid. Authenticated as: {Username}", username);
});

Target CreateDeployBranch => _ => _
Expand All @@ -151,6 +233,7 @@ class Build : NukeBuild

Target Deploy => _ => _
.OnlyWhenDynamic(() => gitRepository.ToString() == $"https://github.com/{organizationName}/{repositoryName}")
.DependsOn(ValidateGitHubToken)
.DependsOn(CreateDeployBranch)
.DependsOn(Compile)
.Executes(() => {
Expand Down
6 changes: 6 additions & 0 deletions plugins/DNNCommunity.DNNDocs.Plugins/Models/Contributor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,11 @@ public class Contributor

[JsonProperty(PropertyName = "contributions")]
public string Contributions { get; set; }

/// <summary>Number of commits by this author in the sampled window.</summary>
public int Total { get; set; }

/// <summary>Date of the most recent commit by this author.</summary>
public DateTimeOffset LatestCommitDate { get; set; }
}
}
10 changes: 4 additions & 6 deletions plugins/DNNCommunity.DNNDocs.Plugins/MoreLinksBuildStep.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Docfx.Plugins;
using Docfx.Common;
using Docfx.Plugins;
using System.Collections.Immutable;
using System.Composition;
using System.Text.RegularExpressions;
Expand All @@ -14,7 +15,7 @@ public class MoreLinksBuildStep : IDocumentBuildStep

public MoreLinksBuildStep()
{
Console.WriteLine($"[PLUGIN] {nameof(MoreLinksBuildStep)} loaded");
Logger.LogInfo($"{nameof(MoreLinksBuildStep)} loaded.");
}

public class Link
Expand Down Expand Up @@ -58,10 +59,7 @@ public void Postbuild(ImmutableList<FileModel> models, IHostService host)
}
if (newLinks.Count > 0)
{
foreach (var link in newLinks)
{
Console.WriteLine($"[PLUGIN] {nameof(MoreLinksBuildStep)}: {link.Url} related topic added for {model.File}");
}
Logger.LogVerbose($"{nameof(MoreLinksBuildStep)}: {newLinks.Count} related topic(s) added for {model.File}");
AddLinksDivToContents(contents, "related-topics", newLinks);
}
}
Expand Down
43 changes: 22 additions & 21 deletions plugins/DNNCommunity.DNNDocs.Plugins/PageStatsBuildStep.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Immutable;
using System.Composition;
using System.Diagnostics;
using Docfx.Common;
using DNNCommunity.DNNDocs.Plugins.Models;
using DNNCommunity.DNNDocs.Plugins.Providers;
using Docfx.Plugins;
Expand All @@ -16,40 +17,44 @@ public class PageStatsBuildStep : IDocumentBuildStep

public PageStatsBuildStep()
{
Console.WriteLine($"[PLUGIN] {nameof(PageStatsBuildStep)} loaded.");
Logger.LogInfo($"{nameof(PageStatsBuildStep)} loaded.");
}

public void Build(FileModel model, IHostService host)
{
}

public void Postbuild(ImmutableList<FileModel> models, IHostService host)
{
=> PostbuildAsync(models, host).GetAwaiter().GetResult();

private async Task PostbuildAsync(ImmutableList<FileModel> models, IHostService host)
{
if (models == null || models.Count == 0)
{
Console.WriteLine("[PLUGIN] No models to process.");
Logger.LogWarning($"{nameof(PageStatsBuildStep)}: No models to process.");
return;
}

Console.WriteLine($"[PLUGIN] {nameof(PageStatsBuildStep)} Processing {models.Count} models");
var articleModels = models
.Where(m => m.Type == DocumentType.Article)
.Where(m => !m.LocalPathFromRoot.StartsWith("content/reference/", StringComparison.OrdinalIgnoreCase))
.ToList();
Logger.LogInfo($"{nameof(PageStatsBuildStep)}: Processing {articleModels.Count} article models (reference pages excluded).");

var gitHubApi = new GitHubApi(); // Optionally pass token
var gitHubApi = new GitHubApi();
var totalSw = Stopwatch.StartNew();

foreach (var model in models)
foreach (var item in articleModels.Select((value, index) => new { value, index }))
{
if ((item.index + 1) % 100 == 0 || item.index + 1 == articleModels.Count)
{
Logger.LogInfo($"{nameof(PageStatsBuildStep)}: Processing model {item.index + 1}/{articleModels.Count} ({totalSw.Elapsed.TotalSeconds:F1}s elapsed).");
}

if (model.Type != DocumentType.Article) continue;

// Build the relative path for the GitHub API query
string transformedFilePath = model.LocalPathFromRoot.Replace("/", "%2F");

// Get commits for the specific page/file
var gitCommits = gitHubApi.GetCommitsAsync(model.LocalPathFromRoot).GetAwaiter().GetResult();
var gitCommits = await gitHubApi.GetCommitsAsync(item.value.LocalPathFromRoot);

if (gitCommits != null && gitCommits.Count > 0)
{
// Group by author login and order by number of commits
var topContributors = gitCommits
.Where(c => c.Author != null && !string.IsNullOrEmpty(c.Author.Login))
.GroupBy(c => c.Author.Login)
Expand All @@ -58,27 +63,23 @@ public void Postbuild(ImmutableList<FileModel> models, IHostService host)
.Take(5)
.ToList();

var content = (Dictionary<string, object>)model.Content;
var content = (Dictionary<string, object>)item.value.Content;

var contributorsList = new List<string>();
int index = 1;
foreach (var commit in topContributors)
{
content[$"gitPageContributor{index}Login"] = commit.Author.Login;
content[$"gitPageContributor{index}AvatarUrl"] = commit.Author.AvatarUrl;
content[$"gitPageContributor{index}HtmlUrl"] = commit.Author.HtmlUrl;

contributorsList.Add(commit.Author.Login);
index++;
}

// Set the page last updated date based on the latest commit
var lastUpdated = gitCommits[0].Commit.Author.Date.DateTime.ToShortDateString();
content["gitPageDate"] = lastUpdated;

Console.WriteLine($"[PLUGIN] {nameof(PageStatsBuildStep)} processed {model.File} | Contributors: {string.Join(", ", contributorsList)} | Last Updated: {lastUpdated}");
}
}

Logger.LogInfo($"{nameof(PageStatsBuildStep)}: Finished in {totalSw.Elapsed.TotalSeconds:F1}s.");
}


Expand Down
Loading
Loading