diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d8d5a3bc7..f0bc523fd 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -6,7 +6,15 @@ "version": "5.1.250801", "commands": [ "dotnet-format" - ] + ], + "rollForward": false + }, + "docfx": { + "version": "2.78.2", + "commands": [ + "docfx" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index aec314b89..1cf8b19a6 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -4,6 +4,7 @@ on: push: paths: - 'docs/**' + - 'Consul/**' pull_request: paths: - 'docs/**' @@ -77,6 +78,16 @@ jobs: run: yarn install working-directory: ./docs + - name: Setup .NET + uses: actions/setup-dotnet@v4 + + - name: Restore tool + run: dotnet tool restore + + - name: Generate API reference using DocFX + run: yarn run api:generate + working-directory: ./docs + - name: Build run: yarn run build working-directory: ./docs diff --git a/docs/README.md b/docs/README.md index 569ac7e93..238baa1ff 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,28 @@ yarn start This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. +### Generating the "API Reference" section + +You can generate an API Reference section automatically based on the source code +using [DocFX](https://dotnet.github.io/docfx/) by running the following commands: + +```shell +# restore the DocFX tool +dotnet tool restore + +# generate markdown files using DocFX +yarn run generate-api-folder + +# transform the generated markdown files +yarn run transform-api-folder + +# or using one command that does both +yarn run api:generate + +# to clean the generated files +yarn run api:clean +``` + ## Build ```shell diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 000000000..7847112ec --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,17 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "../Consul", + "files": [ + "**/*.csproj" + ] + } + ], + "dest": "api", + "output": ".", + "outputFormat": "markdown" + } + ] +} diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 36b6d0d0b..eca9fd219 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -1,6 +1,7 @@ // @ts-check // Note: type annotations allow type checking and IDEs autocompletion +const fs = require('node:fs'); const lightCodeTheme = require('prism-react-renderer/themes/github'); const darkCodeTheme = require('prism-react-renderer/themes/dracula'); @@ -12,6 +13,7 @@ const consulDotNetVersion = clean_version(process.env.CONSUL_DOT_NET_VERSION || const consulAPIVersion = clean_version(extract_consul_version(consulDotNetVersion)); const url = process.env.URL || `https://consuldot.net`; const baseUrl = process.env.BASE_URL || `/`; +const shouldIncludeApiReference = fs.existsSync('./api'); function clean_version(version) { if (version) { @@ -83,6 +85,18 @@ const config = { ], ], + plugins: [ + shouldIncludeApiReference && [ + '@docusaurus/plugin-content-docs', + { + id: 'api', + path: 'api', + routeBasePath: 'api', + sidebarPath: require.resolve('./sidebars.js'), + }, + ], + ], + themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ ({ @@ -101,6 +115,13 @@ const config = { label: 'Docs', position: 'left', }, + shouldIncludeApiReference && { + docsPluginId: 'api', + docId: 'Consul/README', + type: 'doc', + label: 'API Reference', + position: 'left', + }, { to: '/docs/category/contributing', label: 'Contribute', @@ -126,7 +147,7 @@ const config = { className: 'header-twitter-link', 'aria-label': 'Twitter', }, - ], + ].filter(Boolean), }, footer: { links: [ diff --git a/docs/package.json b/docs/package.json index d05f1ec61..0e148b30b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -13,6 +13,10 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", + "generate-api-folder": "dotnet tool run docfx metadata docfx.json", + "transform-api-folder": "node src/scripts/transformApiFolder.js --path=api", + "api:generate": "yarn generate-api-folder && yarn transform-api-folder", + "api:clean": "rimraf api", "typecheck": "tsc" }, "dependencies": { @@ -28,6 +32,7 @@ "@docusaurus/module-type-aliases": "^2.3.1", "@tsconfig/docusaurus": "^1.0.5", "dotenv": "^16.0.3", + "js-yaml": "^4.1.0", "typescript": "^4.7.4" }, "browserslist": { diff --git a/docs/src/scripts/transformApiFolder.js b/docs/src/scripts/transformApiFolder.js new file mode 100644 index 000000000..5f8bb94ce --- /dev/null +++ b/docs/src/scripts/transformApiFolder.js @@ -0,0 +1,238 @@ +/** + * This script transforms the API reference folder generated by docfx into a Docusaurus-compatible folder with better navigation and fixed markdown. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + + +/** + * Load the content of a YAML file. + * @param {string} filePath - The path of the YAML file to load. + */ +function loadYamlFile(filePath) { + try { + const fileContent = fs.readFileSync(filePath, 'utf-8'); + return yaml.load(fileContent, {}); + } catch (e) { + console.error(`Error reading YAML file "${filePath}": ${e}`); + process.exit(1); + } +} + + +/** + * Singularize a section. + * @param {string} word - The word to singularize. + */ +function singularizeWord(word) { + const mapping = { + "Namespaces": "Namespace", + "Classes": "Class", + "Enums": "Enum", + "Interfaces": "Interface", + "Structs": "Struct", + } + return mapping[word] || word.slice(0, -1); +} + + +/** + * Build the navigation structure of the API reference. + * @param tocContent - The table of content. + */ +function buildNavigation(tocContent) { + const navigation = {}; // [oldHref]: {href, title, sidebar_position} + let parentIndex = 0; + const parentSection = `Namespaces`; + const parentPrefix = `${singularizeWord(parentSection)} `; + + // iterate over root items (namespaces) + for (const rootItem of tocContent) { + if (!rootItem.href) { + console.warn(`Root item ${rootItem.name} has no href.`); + continue; + } + const rootItemStem = rootItem.href.split('.').slice(0, -1).join('.'); + const rootItemBreadcrumb = [rootItemStem, 'README.md']; + + // add index document + navigation[rootItem.href] = { + href: rootItemBreadcrumb.join('/'), + href_levels: rootItemBreadcrumb.length, + title: `${parentPrefix}${rootItem.name}`, + sidebar_position: ++parentIndex, + } + + // add children + if (rootItem.items) { + let childIndex = 0; + let childSection = ''; + let childPrefix = ''; + for (const childItem of rootItem.items) { + if (!childItem.href) { + childSection = childItem.name; + childPrefix = `${singularizeWord(childSection)} `; + continue; + } + const childItemStem = childItem.href.replace(rootItemStem, '').split('.').filter(Boolean); + const childItemName = childItemStem.join('.'); + const childBreadcrumb = [rootItemStem, childSection, childItemName].filter(Boolean); + navigation[childItem.href] = { + href: childBreadcrumb.join('/'), + href_levels: childBreadcrumb.length, + title: `${childPrefix}${childItem.name}`, + sidebar_position: ++childIndex, + } + } + } + } + return navigation; +} + +/** + * Update file content according to new navigation. + * @param {string} file - The file to update. + * @param {string} content - The content of the file to update. + * @param navigation - The new navigation. + */ +function updateFileContent(file, content, navigation) { + let updatedContent = content; + const fileNavigation = navigation[file]; + const linkRegex = /(?.)?\[(?[^\]]+)]\((?(?:\\\)|[^)])+)\)(?.)?/g; + + // fix broken links and special characters escaping in links + updatedContent = updatedContent.replace(linkRegex, (match, previous, text, url, next) => { + const newText = text; // keep original text (special chars escaped) + let newUrl = url.replaceAll(/\\(?[^)(])/g, '$'); // escape parentheses only in url + let fragment = ''; + const newUrlParts = newUrl.split('#'); + if (newUrlParts.length > 1) { + newUrl = newUrlParts[0]; + fragment = `#${newUrlParts[1]}`; + } + if (newUrl in navigation) { + const linkNavigation = navigation[newUrl]; + newUrl = '../'.repeat(fileNavigation.href_levels - 1) + linkNavigation.href; + } + const newPrevious = previous?.trim()? `${previous} ` : (previous || ''); + const newNext = next?.trim()? ` ${next}` : (next || ''); + return `${newPrevious}[${newText}](${newUrl}${fragment})${newNext}`; + }); + + return updatedContent; +} + + +/** + * Extract title from the content of a file. + * @param {string} content - The content of the file. + */ +function extractTitleFromContent(content) { + const lines = content.split('\n'); + if (!lines?.length) return ''; + const firstLine = lines[0]; + const firstLineParts = firstLine.split(' '); + if (!firstLineParts?.length) return ''; + return firstLineParts[firstLineParts.length - 1] + .replaceAll("\\", "") + .trim(); +} + + +/** + * Transforms the API reference folder by processing each file. + * @param {string} relativePath - The relative path of the API folder to process. + */ +function transformApiReferenceFolder(relativePath) { + const folderPath = path.resolve(relativePath); + + // Check if the folder exists + if (!fs.existsSync(folderPath)) { + console.error(`The folder "${folderPath}" does not exist.`); + process.exit(1); + } + + // check if toc.yml file exists + const tocPath = path.join(folderPath, 'toc.yml'); + if (!fs.existsSync(tocPath)) { + console.error(`The table of content file "${tocPath}" does not exist.`); + process.exit(1); + } + + // load table of content + const tocContent = loadYamlFile(tocPath); + console.log('Table of content:'); + console.log(JSON.stringify(tocContent, null, 2)); + + // remove toc.yml file + fs.unlinkSync(tocPath); + + // build navigation + console.log('Navigation:'); + const navigation = buildNavigation(tocContent); + console.log(navigation); + + // Read all files in the folder + const files = fs.readdirSync(folderPath); + + // Process each file + files.forEach((file) => { + const filePath = path.join(folderPath, file); + if (file.endsWith('.md')) { + if (!(file in navigation)) { + console.error(`File ${file} is not in the navigation.`); + process.exit(1); + } + console.log(`Processing file: ${file}`); + const fileNavigation = navigation[file]; + + // Update content + const updatedContent = updateFileContent(file, fs.readFileSync(filePath, 'utf-8'), navigation); + const frontMatter = `---\ntitle: "${extractTitleFromContent(updatedContent) || fileNavigation.title}"\nsidebar_position: ${fileNavigation.sidebar_position}\n---\n\n`; + + // Prepare new file + const newPath = path.join(folderPath, fileNavigation.href); + const newContent = frontMatter + updatedContent; + + // create parent folder if not exists + const parentFolder = path.dirname(newPath); + if (!fs.existsSync(parentFolder)) { + fs.mkdirSync(parentFolder, {recursive: true}); + } + + // write new file + fs.writeFileSync(newPath, newContent, 'utf-8'); + + // remove old file + fs.unlinkSync(filePath); + } + }); +} + +/** + * Entry point of the script. + * @param argv + */ +function main(argv) { + // Parse the path argument + const args = argv.slice(2); + const pathArg = args.find(arg => arg.startsWith('--path=')); + if (!pathArg) { + console.error('Usage: yarn run transform-api-folder --path='); + process.exit(1); + } + const pathParts = pathArg.split('='); + if (pathParts?.length !== 2) { + console.error('Error: Invalid "--path" value.'); + process.exit(1); + } + const relativePath = pathParts[1]; + + // Transform the API reference folder + transformApiReferenceFolder(relativePath); +} + +// Run the main function +main(process.argv); diff --git a/docs/yarn.lock b/docs/yarn.lock index 4c726fc8f..496ba28e7 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -2905,15 +2905,10 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001426: - version "1.0.30001447" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001447.tgz#ef1f39ae38d839d7176713735a8e467a0a2523bd" - integrity sha512-bdKU1BQDPeEXe9A39xJnGtY0uRq/z5osrnXUw0TcK+EYno45Y+U7QU9HhHEyzvMDffpYadFXi3idnSNkcwLkTw== - -caniuse-lite@^1.0.30001646: - version "1.0.30001663" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001663.tgz#1529a723505e429fdfd49532e9fc42273ba7fed7" - integrity sha512-o9C3X27GLKbLeTYZ6HBOLU1tsAcBZsLis28wrVzddShCS16RujjHp9GDHKZqrB3meE0YjhawvMFsGb/igqiPzA== +caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001400, caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001646: + version "1.0.30001687" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001687.tgz" + integrity sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ== ccount@^1.0.0: version "1.1.0"