Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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; }
}
179 changes: 179 additions & 0 deletions src/docfx/Models/WatchCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// 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);
}

if (settings is { Serve: true, Watch: true })
{
using var watcher = Watch(baseDirectory, config.build, onChange);
Serve(serveDirectory, settings.Host, settings.Port, settings.OpenBrowser, settings.OpenFile);
}
else if (settings.Watch)
{
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();
}
else if (settings.Serve)
{
RunServe.Exec(serveDirectory, settings.Host, settings.Port, settings.OpenBrowser, settings.OpenFile);
}
else
{
onChange();
}
});
}

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();
}
}
Loading