Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[docfx] adding --watch option to build command #10010

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion src/docfx/Models/BuildCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public override int Execute(CommandContext context, BuildCommandOptions settings
});
}

internal static void MergeOptionsToConfig(BuildCommandOptions options, BuildJsonConfig config, string configDirectory)
internal static void MergeOptionsToConfig(DefaultBuildCommandOptions options, BuildJsonConfig config, string configDirectory)
{
// base directory for content from command line is current directory
// e.g. C:\folder1>docfx build folder2\docfx.json --content "*.cs"
Expand Down
86 changes: 1 addition & 85 deletions src/docfx/Models/BuildCommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,97 +7,13 @@
namespace Docfx;

[Description("Generate client-only website combining API in YAML files and conceptual files")]
internal class BuildCommandOptions : LogOptions
internal class BuildCommandOptions : DefaultBuildCommandOptions
{
[Description("Specify the output base directory")]
[CommandOption("-o|--output")]
public string OutputFolder { get; set; }

[Description("Path to docfx.json")]
[CommandArgument(0, "[config]")]
public string ConfigFile { get; set; }

[Description("Specify a list of global metadata in key value pairs (e.g. --metadata _appTitle=\"My App\" --metadata _disableContribution)")]
[CommandOption("-m|--metadata")]
public string[] Metadata { get; set; }

[Description("Specify the urls of xrefmap used by content files.")]
[CommandOption("-x|--xref")]
[TypeConverter(typeof(ArrayOptionConverter))]
public IEnumerable<string> XRefMaps { get; set; }

[Description("Specify the template name to apply to. If not specified, output YAML file will not be transformed.")]
[CommandOption("-t|--template")]
[TypeConverter(typeof(ArrayOptionConverter))]
public IEnumerable<string> Templates { get; set; }

[Description("Specify which theme to use. By default 'default' theme is offered.")]
[CommandOption("--theme")]
[TypeConverter(typeof(ArrayOptionConverter))]
public IEnumerable<string> Themes { get; set; }

[Description("Host the generated documentation to a website")]
[CommandOption("-s|--serve")]
public bool Serve { get; set; }

[Description("Specify the hostname of the hosted website (e.g., 'localhost' or '*')")]
[CommandOption("-n|--hostname")]
public string Host { get; set; }

[Description("Specify the port of the hosted website")]
[CommandOption("-p|--port")]
public int? Port { get; set; }

[Description("Open a web browser when the hosted website starts.")]
[CommandOption("--open-browser")]
public bool OpenBrowser { get; set; }

[Description("Open a file in a web browser when the hosted website starts.")]
[CommandOption("--open-file <RELATIVE_PATH>")]
public string OpenFile { get; set; }

[Description("Run in debug mode. With debug mode, raw model and view model will be exported automatically when it encounters error when applying templates. If not specified, it is false.")]
[CommandOption("--debug")]
public bool EnableDebugMode { get; set; }

[Description("The output folder for files generated for debugging purpose when in debug mode. If not specified, it is ${TempPath}/docfx")]
[CommandOption("--debugOutput")]
public string OutputFolderForDebugFiles { get; set; }

[Description("If set to true, data model to run template script will be extracted in .raw.model.json extension")]
[CommandOption("--exportRawModel")]
public bool ExportRawModel { get; set; }

[Description("Specify the output folder for the raw model. If not set, the raw model will be generated to the same folder as the output documentation")]
[CommandOption("--rawModelOutputFolder")]
public string RawModelOutputFolder { get; set; }

[Description("Specify the output folder for the view model. If not set, the view model will be generated to the same folder as the output documentation")]
[CommandOption("--viewModelOutputFolder")]
public string ViewModelOutputFolder { get; set; }

[Description("If set to true, data model to apply template will be extracted in .view.model.json extension")]
[CommandOption("--exportViewModel")]
public bool ExportViewModel { get; set; }

[Description("If set to true, template will not be actually applied to the documents. This option is always used with --exportRawModel or --exportViewModel is set so that only raw model files or view model files are generated.")]
[CommandOption("--dryRun")]
public bool DryRun { get; set; }

[Description("Set the max parallelism, 0 is auto.")]
[CommandOption("--maxParallelism")]
public int? MaxParallelism { get; set; }

[Description("Set the parameters for markdown engine, value should be a JSON string.")]
[CommandOption("--markdownEngineProperties")]
public string MarkdownEngineProperties { get; set; }

[Description("Set the order of post processors in plugins")]
[CommandOption("--postProcessors")]
[TypeConverter(typeof(ArrayOptionConverter))]
public IEnumerable<string> PostProcessors { get; set; }

[Description("Disable fetching Git related information for articles. By default it is enabled and may have side effect on performance when the repo is large.")]
[CommandOption("--disableGitFeatures")]
public bool DisableGitFeatures { get; set; }
}
94 changes: 94 additions & 0 deletions src/docfx/Models/DefaultBuildCommandOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel;
using Spectre.Console.Cli;

namespace Docfx;

internal class DefaultBuildCommandOptions : LogOptions
{
[Description("Specify the output base directory")]
[CommandOption("-o|--output")]
public string OutputFolder { get; set; }

[Description("Path to docfx.json")]
[CommandArgument(0, "[config]")]
public string ConfigFile { get; set; }

[Description("Specify a list of global metadata in key value pairs (e.g. --metadata _appTitle=\"My App\" --metadata _disableContribution)")]
[CommandOption("-m|--metadata")]
public string[] Metadata { get; set; }

[Description("Specify the urls of xrefmap used by content files.")]
[CommandOption("-x|--xref")]
[TypeConverter(typeof(ArrayOptionConverter))]
public IEnumerable<string> XRefMaps { get; set; }

[Description("Specify the template name to apply to. If not specified, output YAML file will not be transformed.")]
[CommandOption("-t|--template")]
[TypeConverter(typeof(ArrayOptionConverter))]
public IEnumerable<string> Templates { get; set; }

[Description("Specify which theme to use. By default 'default' theme is offered.")]
[CommandOption("--theme")]
[TypeConverter(typeof(ArrayOptionConverter))]
public IEnumerable<string> Themes { get; set; }

[Description("Specify the hostname of the hosted website (e.g., 'localhost' or '*')")]
[CommandOption("-n|--hostname")]
public string Host { get; set; }

[Description("Specify the port of the hosted website")]
[CommandOption("-p|--port")]
public int? Port { get; set; }

[Description("Open a file in a web browser when the hosted website starts.")]
[CommandOption("--open-file <RELATIVE_PATH>")]
public string OpenFile { get; set; }

[Description("Run in debug mode. With debug mode, raw model and view model will be exported automatically when it encounters error when applying templates. If not specified, it is false.")]
[CommandOption("--debug")]
public bool EnableDebugMode { get; set; }

[Description("The output folder for files generated for debugging purpose when in debug mode. If not specified, it is ${TempPath}/docfx")]
[CommandOption("--debugOutput")]
public string OutputFolderForDebugFiles { get; set; }

[Description("If set to true, data model to run template script will be extracted in .raw.model.json extension")]
[CommandOption("--exportRawModel")]
public bool ExportRawModel { get; set; }

[Description("Specify the output folder for the raw model. If not set, the raw model will be generated to the same folder as the output documentation")]
[CommandOption("--rawModelOutputFolder")]
public string RawModelOutputFolder { get; set; }

[Description("Specify the output folder for the view model. If not set, the view model will be generated to the same folder as the output documentation")]
[CommandOption("--viewModelOutputFolder")]
public string ViewModelOutputFolder { get; set; }

[Description("If set to true, data model to apply template will be extracted in .view.model.json extension")]
[CommandOption("--exportViewModel")]
public bool ExportViewModel { get; set; }

[Description("If set to true, template will not be actually applied to the documents. This option is always used with --exportRawModel or --exportViewModel is set so that only raw model files or view model files are generated.")]
[CommandOption("--dryRun")]
public bool DryRun { get; set; }

[Description("Set the max parallelism, 0 is auto.")]
[CommandOption("--maxParallelism")]
public int? MaxParallelism { get; set; }

[Description("Set the parameters for markdown engine, value should be a JSON string.")]
[CommandOption("--markdownEngineProperties")]
public string MarkdownEngineProperties { get; set; }

[Description("Set the order of post processors in plugins")]
[CommandOption("--postProcessors")]
[TypeConverter(typeof(ArrayOptionConverter))]
public IEnumerable<string> PostProcessors { get; set; }

[Description("Disable fetching Git related information for articles. By default it is enabled and may have side effect on performance when the repo is large.")]
[CommandOption("--disableGitFeatures")]
public bool DisableGitFeatures { get; set; }
}
174 changes: 174 additions & 0 deletions src/docfx/Models/WatchCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// 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;
using Spectre.Console.Cli;

namespace Docfx;

internal class WatchCommand : Command<WatchCommandOptions>
{
public override int Execute(CommandContext context, WatchCommandOptions settings)
{
return CommandHelper.Run(settings, () =>
{
var (config, baseDirectory) = Docset.GetConfig(settings.ConfigFile);
BuildCommand.MergeOptionsToConfig(settings, config.build, baseDirectory);
var conf = new BuildOptions();
var serveDirectory = RunBuild.Exec(config.build, conf, baseDirectory, settings.OutputFolder);

void onChange()
{
RunBuild.Exec(config.build, conf, baseDirectory, settings.OutputFolder);
}

// always do an initial rendering to start from something
onChange();

if (!settings.NoServe)
{
using var watcher = Watch(baseDirectory, config.build, onChange);
Serve(serveDirectory, settings.Host, settings.Port, settings.OpenBrowser, settings.OpenFile);
}
else
{
using var watcher = Watch(baseDirectory, config.build, onChange);

// just block but here we can't use the host mecanism
// since we didn't start the server so use console one
using var canceller = new CancellationTokenSource();
Console.CancelKeyPress += (sender, args) => canceller.Cancel();
Task.Delay(Timeout.Infinite, canceller.Token).Wait();
}
});
}

internal void Serve(string serveDirectory, string host, int? port, bool openBrowser, string openFile) {
if (CommandHelper.IsTcpPortAlreadyUsed(host, port))
{
Logger.LogError($"Serve option specified. But TCP port {port ?? 8080} is already being in use.");
return;
}
RunServe.Exec(serveDirectory, host, port, openBrowser, openFile);
}

// For now it is a simplistic implementation, in particular on the glob to filter mappping
// but it should be sufficient for most cases.
internal static IDisposable Watch(string baseDir, BuildJsonConfig config, Action onChange)
{
FileSystemWatcher watcher = new(baseDir)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.Attributes | NotifyFilters.Size | NotifyFilters.FileName |
NotifyFilters.DirectoryName | NotifyFilters.LastWrite
};

if (WatchAll(config))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can start with a simple deterministic watch implementation that watches all files changes in the docfx.json directory, except for the output directory (_site)

{
watcher.Filters.Add("*.*");
}
else
{
RegisterFiles(watcher, config.Content);
RegisterFiles(watcher, config.Resource);

IEnumerable<string> forcedFiles = ["docfx.json", "*.md", "toc.yml"];
foreach (var forcedFile in forcedFiles)
{
if (!watcher.Filters.Any(f => f == forcedFile))
{
watcher.Filters.Add(forcedFile);
}
}
}

// avoid to call onChange() in chain so await "last" event before re-rendering
var cancellation = new CancellationTokenSource[] { null };
async void debounce()
{
var token = new CancellationTokenSource();
lock (cancellation)
{
ResetToken(cancellation);
cancellation[0] = token;
}

await Task.Delay(100, token.Token);
if (!token.IsCancellationRequested)
{
onChange();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the build invoked by onChange method takes a minute to finish and files changes each second, would we trigger multiple build or wait for the last build to complete before triggering the next?

}
}

watcher.Changed += (_, _) => debounce();
watcher.Created += (_, _) => debounce();
watcher.Deleted += (_, _) => debounce();
watcher.Renamed += (_, _) => debounce();
watcher.EnableRaisingEvents = true;

return new DisposableAction(() =>
{
watcher.Dispose();
lock (cancellation)
{
ResetToken(cancellation);
}
});
}

private static void ResetToken(CancellationTokenSource[] cancellation)
{
var token = cancellation[0];
if (token is not null && !token.IsCancellationRequested)
{
token.Cancel();
token.Dispose();
}
}

internal static bool WatchAll(BuildJsonConfig config)
{
return ((IEnumerable<FileMapping>)[config.Resource, config.Content])
.Where(it => it is not null)
.SelectMany(it => it.Items)
.SelectMany(it => it.Files)
.Any(it => it.EndsWith("**"));
}

internal static void RegisterFiles(FileSystemWatcher watcher, FileMapping content)
{
foreach (var pattern in content?
.Items?
.SelectMany(it => it.Files)
.SelectMany(SanitizePatternForWatcher)
.Distinct()
.ToList())
{
watcher.Filters.Add(pattern);
}
}

// as of now it can list too much files but will less hurt to render more often with deboucning
// than not rendering when needed.
internal static IEnumerable<string> SanitizePatternForWatcher(string file)
{
var name = file[(file.LastIndexOf('.') + 1)..]; // "**/images/**/*.png" => "*.png"
if (name.EndsWith('}')) // "**/*.{md,yml}" => "*.md" and "*.yml"
{
var start = name.IndexOf('{');
if (start > 0)
{
var prefix = file[0..start];
return file[(start + 1)..^1]
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(extension => $"{prefix}{extension}");
}
}
return [name];
}

internal class DisposableAction(Action action) : IDisposable
{
public void Dispose() => action();
}
}
21 changes: 21 additions & 0 deletions src/docfx/Models/WatchCommandOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.ComponentModel;
using Spectre.Console.Cli;

namespace Docfx;

[Description("Generate client-only website combining API in YAML files and conceptual files and watch them for changes")]
internal class WatchCommandOptions : DefaultBuildCommandOptions
{
[Description("Host the generated documentation to a website")]
[CommandOption("--no-serve")]
[DefaultValue("true")]
public bool NoServe { get; set; }

[Description("Open a web browser when the hosted website starts.")]
[CommandOption("--open-browser")]
[DefaultValue("false")]
public bool OpenBrowser { get; set; }
}
Loading
Loading