diff --git a/docs/index.md b/docs/index.md index 819493d0f04..f8c81d39793 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,13 +19,13 @@ dotnet tool update -g docfx To create a new docset, run: ```bash -docfx init --quiet +docfx init ``` -This command creates a new docset under the `docfx_project` directory. To build the docset, run: +This command walks you through creating a new docfx project under the current working directory. To build the docset, run: ```bash -docfx docfx_project/docfx.json --serve +docfx docfx.json --serve ``` Now you can preview the website on . @@ -33,7 +33,7 @@ Now you can preview the website on . To preview your local changes, save changes then run this command in a new terminal to rebuild the website: ```bash -docfx docfx_project/docfx.json +docfx docfx.json ``` ## Publish to GitHub Pages diff --git a/src/docfx/Models/InitCommand.cs b/src/docfx/Models/InitCommand.cs index 51673d9e9ba..340055e101e 100644 --- a/src/docfx/Models/InitCommand.cs +++ b/src/docfx/Models/InitCommand.cs @@ -2,569 +2,160 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Text.Json; using System.Text.Json.Serialization; -using Docfx.Common; -using Newtonsoft.Json; +using Spectre.Console; using Spectre.Console.Cli; +using static Spectre.Console.AnsiConsole; + namespace Docfx; internal class InitCommand : Command { - private const string ConfigName = DataContracts.Common.Constants.ConfigFileName; - private const string DefaultOutputFolder = "docfx_project"; - private const string DefaultMetadataOutputFolder = "api"; + public override int Execute([NotNull] CommandContext context, [NotNull] InitCommandOptions options) + { + WriteLine( + """ + This utility will walk you through creating a docfx project. + It only covers the most common items, and tries to guess sensible defaults. - private InitCommandOptions _options; - private IEnumerable _metadataQuestions; + """); - private IEnumerable _buildQuestions; + var name = options.Yes ? "" : Ask("Name", "mysite"); + var dotnetApi = options.Yes ? true : Confirm("Generate .NET API documentation?"); + var csprojLocation = options.Yes || !dotnetApi ? "src" : Ask(".NET projects location", "src"); + var docsLocation = options.Yes ? "docs" : Ask("Markdown docs location", "docs"); + var search = options.Yes ? true : Confirm("Enable site search?", true); + var pdf = options.Yes ? true : Confirm("Enable PDF?", true); - private IEnumerable _selectorQuestions; + var outdir = Path.GetFullPath(options.OutputFolder ?? "."); - public override int Execute([NotNull] CommandContext context, [NotNull] InitCommandOptions options) - { - _options = options; - _metadataQuestions = new IQuestion[] + var docfx = new { - new MultiAnswerQuestion( - "Where is your .NET assemblies or projects?", - (s, m, c) => + metadata = dotnetApi ? new[] + { + new { - if (s != null) + src = new[] { - var item = new FileMapping(new FileMappingItem(s)); - m.Metadata.Add(new MetadataJsonItemConfig - { - Src = item, - Dest = DefaultMetadataOutputFolder, - }); - m.Build.Content = new FileMapping(new FileMappingItem("api/**.yml", "api/index.md")); - } - }, - new string[] { "bin/**/*.dll" }) + new { src = $"../{csprojLocation}", files = new[] { "**/*.csproj" } } + }, + dest = "api" + } + } : null, + build = new + { + content = new[] { - Descriptions = new string[] - { - "Supports assemblies, projects, solutions, or source code files", - Hints.Glob, - Hints.Enter, - } + new { files = new[] { "**/*.{md,yml}" }, exclude = new[] { "_site/**" } } }, - }; - _buildQuestions = new IQuestion[] - { - // TODO: Check if the input glob pattern matches any files - // IF no matching: WARN [init]: There is no file matching this pattern. - new MultiAnswerQuestion( - "What are the locations of your conceptual files?", - (s, m, _) => + resources = new[] { - if (s != null) - { - if (m.Build.Content == null) - { - m.Build.Content = new FileMapping(); - } - - m.Build.Content.Add(new FileMappingItem(s)); - } + new { files = new[] { "images/**" } } }, - new string[] { "articles/**.md", "articles/**/toc.yml", "toc.yml", "*.md" }) - { - Descriptions = new string[] - { - "Supported conceptual files could be any text files. Markdown format is also supported.", - Hints.Glob, - Hints.Enter, - } - }, - new MultiAnswerQuestion( - "What are the locations of your resource files?", - (s, m, _) => + output = "_site", + template = new[] { "default", "modern" }, + globalMetadata = new { - if (s != null) - { - m.Build.Resource = new FileMapping(new FileMappingItem(s)); - } + _appName = name, + _appTitle = name, + pdf, }, - new string[] { "images/**" }) - { - Descriptions = new string[] - { - "The resource files which conceptual files are referencing, e.g. images.", - Hints.Glob, - Hints.Enter, - } + postProcessors = search ? new[] { "ExtractSearchIndex" } : null, } - }; - _selectorQuestions = new IQuestion[] - { - new YesOrNoQuestion( - "Does the website contain .NET API documentation?", (s, m, c) => - { - m.Build = new BuildJsonConfig { - Output = "_site", - }; - m.Build.Template.Add("default"); - m.Build.Template.Add("modern"); - if (s) - { - m.Metadata = new MetadataJsonConfig(); - c.ContainsMetadata = true; - } - else - { - c.ContainsMetadata = false; - } - }), - }; - - string outputFolder = null; - var config = new DefaultConfigModel(); - var questionContext = new QuestionContext - { - Quiet = _options.Quiet }; - foreach (var question in _selectorQuestions) - { - question.Process(config, questionContext); - } - if (questionContext.ContainsMetadata) - { - foreach (var question in _metadataQuestions) - { - question.Process(config, questionContext); - } - } - - foreach (var question in _buildQuestions) - { - question.Process(config, questionContext); - } - - if (_options.OnlyConfigFile) - { - GenerateConfigFile(_options.OutputFolder, config, _options.Quiet, _options.Overwrite); - } - else + var files = new Dictionary { - outputFolder = Path.GetFullPath(string.IsNullOrEmpty(_options.OutputFolder) ? DefaultOutputFolder : _options.OutputFolder).ToDisplayPath(); - GenerateSeedProject(outputFolder, config, _options.Quiet, _options.Overwrite); - } - - return 0; - } - - private static void GenerateConfigFile(string outputFolder, object config, bool quiet, bool overwrite) - { - var path = Path.Combine(outputFolder ?? string.Empty, ConfigName).ToDisplayPath(); - if (File.Exists(path)) - { - if (!ProcessOverwriteQuestion($"Config file \"{path}\" already exists, do you want to overwrite this file?", quiet, overwrite)) - { - return; - } - } - - SaveConfigFile(path, config); - $"Successfully generated default docfx config file to {path}".WriteLineToConsole(ConsoleColor.Green); - } - - private static void GenerateSeedProject(string outputFolder, DefaultConfigModel config, bool quiet, bool overwrite) - { - if (Directory.Exists(outputFolder)) - { - if (!ProcessOverwriteQuestion($"Output folder \"{outputFolder}\" already exists. Do you still want to generate files into this folder? You can use -o command option to specify the folder name", quiet, true)) - { - return; - } - } - else - { - Directory.CreateDirectory(outputFolder); - } - - // 1. Create default files - var srcFolder = Path.Combine(outputFolder, "src"); - var apiFolder = Path.Combine(outputFolder, "api"); - var apidocFolder = Path.Combine(outputFolder, "apidoc"); - var articleFolder = Path.Combine(outputFolder, "articles"); - var imageFolder = Path.Combine(outputFolder, "images"); - var folders = new string[] { srcFolder, apiFolder, apidocFolder, articleFolder, imageFolder }; - foreach (var folder in folders) - { - if (!Directory.Exists(folder)) - { - Directory.CreateDirectory(folder); - $"Created folder {folder.ToDisplayPath()}".WriteLineToConsole(ConsoleColor.Gray); - } - } - - // 2. Create default files - // a. toc.yml - // b. index.md - // c. articles/toc.yml - // d. articles/index.md - // e. .gitignore - // f. api/.gitignore - // TODO: move api/index.md out to some other folder - var tocYaml = Tuple.Create("toc.yml", @"- name: Articles - href: articles/ -- name: Api Documentation - href: api/ - homepage: api/index.md -"); - var indexMarkdownFile = Tuple.Create("index.md", @"# This is the **HOMEPAGE**. -Refer to [Markdown](http://daringfireball.net/projects/markdown/) for how to write markdown files. -## Quick Start Notes: -1. Add images to the *images* folder if the file is referencing an image. -"); - var apiTocFile = Tuple.Create("api/toc.yml", @"- name: TO BE REPLACED - href: index.md -"); - var apiIndexFile = Tuple.Create("api/index.md", @"# PLACEHOLDER -TODO: Add .NET projects to the *src* folder and run `docfx` to generate **REAL** *API Documentation*! -"); - - var articleTocFile = Tuple.Create("articles/toc.yml", @"- name: Introduction - href: intro.md -"); - var articleMarkdownFile = Tuple.Create("articles/intro.md", @"# Add your introductions here! -"); - var gitignore = Tuple.Create(".gitignore", $@"############### -# folder # -############### -/**/DROP/ -/**/TEMP/ -/**/packages/ -/**/bin/ -/**/obj/ -{config.Build.Output} -"); - var apiGitignore = Tuple.Create("api/.gitignore", @"############### -# temp file # -############### -*.yml -.manifest -"); - var files = new Tuple[] { tocYaml, indexMarkdownFile, apiTocFile, apiIndexFile, articleTocFile, articleMarkdownFile, gitignore, apiGitignore }; - foreach (var file in files) - { - var filePath = Path.Combine(outputFolder, file.Item1); - - if (overwrite || !File.Exists(filePath)) - { - var content = file.Item2; - var dir = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dir)) + ["docfx.json"] = JsonSerializer.Serialize(docfx, new JsonSerializerOptions() { - Directory.CreateDirectory(dir); - } + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }), - File.WriteAllText(filePath, content); - $"Created File {filePath.ToDisplayPath()}".WriteLineToConsole(ConsoleColor.Gray); - } - } + ["toc.yml"] = dotnetApi ? + $""" + - name: Docs + href: {docsLocation}/ + - name: API + href: api/ + """ : + $""" + - name: Docs + href: {docsLocation}/ + """, + + ["index.md"] = + """ + --- + _layout: landing + --- + + # This is the **HOMEPAGE**. + + Refer to [Markdown](http://daringfireball.net/projects/markdown/) for how to write markdown files. + + ## Quick Start Notes: + + 1. Add images to the *images* folder if the file is referencing an image. + """, + + [$"{docsLocation}/introduction.md"] = + """ + # Introduction + """, + + [$"{docsLocation}/getting-started.md"] = + """ + # Getting Started + """, + + [$"{docsLocation}/toc.yml"] = + """ + - name: Introduction + href: introduction.md + - name: Getting Started + href: getting-started.md + """, + }; - // 2. Create docfx.json - var path = Path.Combine(outputFolder ?? string.Empty, ConfigName); - if (overwrite || !File.Exists(path)) + foreach (var (key, value) in files) { - SaveConfigFile(path, config); - $"Created config file {path.ToDisplayPath()}".WriteLineToConsole(ConsoleColor.Gray); - } + var path = Path.GetFullPath(Path.Combine(outdir, key)); + var confirm = File.Exists(path) + ? $"About to overwrite existing file [yellow]{path.EscapeMarkup()}[/] with:" + : key.Contains("docfx.json") ? $"About to write to [yellow]{path.EscapeMarkup()}[/]:" : null; - $"Successfully generated default docfx project to {outputFolder.ToDisplayPath()}".WriteLineToConsole(ConsoleColor.Green); - "Please run:".WriteLineToConsole(ConsoleColor.Gray); - $"\tdocfx \"{path.ToDisplayPath()}\" --serve".WriteLineToConsole(ConsoleColor.White); - "To generate a default docfx website.".WriteLineToConsole(ConsoleColor.Gray); - } - - private static void SaveConfigFile(string path, object config) - { - JsonUtility.Serialize(path, config, Formatting.Indented); - } - - private static bool ProcessOverwriteQuestion(string message, bool quiet, bool overwriteResult) - { - bool overwritten = true; - - IQuestion overwriteQuestion; - if (overwriteResult) - { - overwriteQuestion = new YesOrNoQuestion( - message, - (s, m, c) => - { - if (!s) - { - overwritten = false; - } - }); - } - else - { - overwriteQuestion = new NoOrYesQuestion( - message, - (s, m, c) => + if (!options.Yes && confirm is not null) { - if (!s) - { - overwritten = false; - } - }); - } - - overwriteQuestion.Process(null, new QuestionContext { NeedWarning = overwritten, Quiet = quiet }); - - return overwritten; - } + WriteLine(); + if (!Confirm( + $""" + {confirm} - #region Question classes + {value.EscapeMarkup()} - private static class YesOrNoOption - { - public const string YesAnswer = "Yes"; - public const string NoAnswer = "No"; - } - - /// - /// the default option is Yes - /// - private sealed class YesOrNoQuestion : SingleChoiceQuestion - { - private static readonly string[] YesOrNoAnswer = { YesOrNoOption.YesAnswer, YesOrNoOption.NoAnswer }; - public YesOrNoQuestion(string content, Action setter) : base(content, setter, Converter, YesOrNoAnswer) - { - } - - private static bool Converter(string input) - { - return input == YesOrNoOption.YesAnswer; - } - } - - /// - /// the default option is No - /// - private sealed class NoOrYesQuestion : SingleChoiceQuestion - { - private static readonly string[] NoOrYesAnswer = { YesOrNoOption.NoAnswer, YesOrNoOption.YesAnswer }; - - public NoOrYesQuestion(string content, Action setter) : base(content, setter, Converter, NoOrYesAnswer) - { - } - - private static bool Converter(string input) - { - return input == YesOrNoOption.YesAnswer; - } - } - - private class SingleChoiceQuestion : Question - { - private readonly Func _converter; - /// - /// Options, the first one as the default one - /// - public string[] Options { get; set; } - - public SingleChoiceQuestion(string content, Action setter, Func converter, params string[] options) - : base(content, setter) - { - ArgumentNullException.ThrowIfNull(options); - ArgumentNullException.ThrowIfNull(converter); - - if (options.Length == 0) throw new ArgumentOutOfRangeException(nameof(options)); - - _converter = converter; - Options = options; - DefaultAnswer = options[0]; - DefaultValue = converter(DefaultAnswer); - } - - protected override T GetAnswer() - { - var options = Options; - Console.Write("Choose Answer ({0}): ", string.Join("/", options)); - Console.Write(DefaultAnswer[0]); - Console.SetCursorPosition(Console.CursorLeft - 1, Console.CursorTop); - string matched = null; - - var line = Console.ReadLine(); - while (!string.IsNullOrEmpty(line)) - { - matched = GetMatchedOption(options, line); - if (matched == null) + Is this OK? + """)) { - Console.Write("Invalid Answer, please reenter: "); + WriteLine("Aborted."); + return -1; } - else - { - return _converter(matched); - } - - line = Console.ReadLine(); - } - - return DefaultValue; - } - - private static string GetMatchedOption(string[] options, string input) - { - return options.FirstOrDefault(s => s.Equals(input, StringComparison.OrdinalIgnoreCase) || s.Substring(0, 1).Equals(input, StringComparison.OrdinalIgnoreCase)); - } - } - - private sealed class MultiAnswerQuestion : Question - { - public MultiAnswerQuestion(string content, Action setter, string[] defaultValue = null) - : base(content, setter) - { - DefaultValue = defaultValue; - DefaultAnswer = ConvertToString(defaultValue); - } - - protected override string[] GetAnswer() - { - var line = Console.ReadLine(); - List answers = new(); - while (!string.IsNullOrEmpty(line)) - { - answers.Add(line); - line = Console.ReadLine(); - } - - if (answers.Count > 0) - { - return answers.ToArray(); - } - else - { - return DefaultValue; - } - } - - private static string ConvertToString(string[] array) - { - if (array == null) return null; - return string.Join(",", array); - } - } - - private sealed class SingleAnswerQuestion : Question - { - public SingleAnswerQuestion(string content, Action setter, string defaultAnswer = null) - : base(content, setter) - { - DefaultValue = defaultAnswer; - DefaultAnswer = defaultAnswer; - } - - protected override string GetAnswer() - { - var line = Console.ReadLine(); - if (!string.IsNullOrEmpty(line)) - { - return line; } - else - { - return DefaultValue; - } - } - } - - private abstract class Question : IQuestion - { - private Action _setter { get; } - - public string Content { get; } - - /// - /// Each string stands for one line - /// - public string[] Descriptions { get; set; } - - public T DefaultValue { get; protected set; } - public string DefaultAnswer { get; protected set; } - public Question(string content, Action setter) - { - ArgumentNullException.ThrowIfNull(setter); - - Content = content; - _setter = setter; + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, value); } - public void Process(DefaultConfigModel model, QuestionContext context) - { - if (context.Quiet) - { - _setter(DefaultValue, model, context); - } - else - { - WriteQuestion(context); - var value = GetAnswer(); - _setter(value, model, context); - } - } - - protected abstract T GetAnswer(); - - private void WriteQuestion(QuestionContext context) - { - Content.WriteToConsole(context.NeedWarning ? ConsoleColor.Yellow : ConsoleColor.White); - WriteDefaultAnswer(); - Descriptions.WriteLinesToConsole(ConsoleColor.Gray); - } - - private void WriteDefaultAnswer() - { - if (DefaultAnswer == null) - { - Console.WriteLine(); - return; - } - - " (Default: ".WriteToConsole(ConsoleColor.Gray); - DefaultAnswer.WriteToConsole(ConsoleColor.Green); - ")".WriteLineToConsole(ConsoleColor.Gray); - } - } - - private interface IQuestion - { - void Process(DefaultConfigModel model, QuestionContext context); - } - - private sealed class QuestionContext - { - public bool Quiet { get; set; } - public bool ContainsMetadata { get; set; } - public bool NeedWarning { get; set; } - } - - #endregion + MarkupLineInterpolated( + $""" - private static class Hints - { - public const string Tab = "Press TAB to list possible options."; - public const string Enter = "Press ENTER to move to the next question."; - public const string Glob = "You can use glob patterns, e.g. src/**"; - } + [green]Project created at {Path.Combine(outdir)}[/] - private class DefaultConfigModel - { - [JsonProperty("metadata")] - [JsonPropertyName("metadata")] - public MetadataJsonConfig Metadata { get; set; } + Run [yellow]docfx {Path.Combine(outdir, "docfx.json")} --serve[/] to launch the site. + """); - [JsonProperty("build")] - [JsonPropertyName("build")] - public BuildJsonConfig Build { get; set; } + return 0; } } diff --git a/src/docfx/Models/InitCommandOptions.cs b/src/docfx/Models/InitCommandOptions.cs index c14754bd9c6..8be84910959 100644 --- a/src/docfx/Models/InitCommandOptions.cs +++ b/src/docfx/Models/InitCommandOptions.cs @@ -4,24 +4,18 @@ using System.ComponentModel; using Spectre.Console.Cli; +#nullable enable + namespace Docfx; [Description("Generate an initial docfx.json following the instructions")] internal class InitCommandOptions : CommandSettings { - [Description("Quietly generate the default docfx.json")] - [CommandOption("-q|--quiet")] - public bool Quiet { get; set; } - - [Description("Specify if the current file will be overwritten if it exists")] - [CommandOption("--overwrite")] - public bool Overwrite { get; set; } + [Description("Yes to all questions")] + [CommandOption("-y|--yes")] + public bool Yes { get; set; } - [Description("Specify the output folder of the config file. If not specified, the config file will be saved to a new folder docfx_project")] + [Description("Specify the output directory of the generated files")] [CommandOption("-o|--output")] - public string OutputFolder { get; set; } - - [Description("Generate config file docfx.json only, no project folder will be generated")] - [CommandOption("-f|--file")] - public bool OnlyConfigFile { get; set; } + public string? OutputFolder { get; set; } } diff --git a/src/docfx/Properties/launchSettings.json b/src/docfx/Properties/launchSettings.json index ece6f7f4a84..4f841b9ba24 100644 --- a/src/docfx/Properties/launchSettings.json +++ b/src/docfx/Properties/launchSettings.json @@ -2,6 +2,14 @@ // Uncomment next line to enable intellisense. // "$schema": "https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/launchsettings.json", "profiles": { + // Run `docfx init` command. + "docfx init": { + "commandName": "Project", + "commandLineArgs": "init --output bin/init", + "workingDirectory": ".", + "environmentVariables": { + } + }, // Run `docfx build` command. "docfx build": { "commandName": "Project",