diff --git a/.github/workflows/article-api-docs.yml b/.github/workflows/article-api-docs.yml new file mode 100644 index 0000000000..286b65a14e --- /dev/null +++ b/.github/workflows/article-api-docs.yml @@ -0,0 +1,45 @@ +name: 'Check article-api docs' + +# **What it does**: Makes sure changes to the article api are documented. +# **Why we have it**: So what's documented doesn't fall behind +# **Who does it impact**: Docs engineering, CGS team + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/article-api/middleware/article.ts' + - 'src/article-api/middleware/pagelist.ts' + # Self-test + - .github/workflows/article-api-docs.yml + +permissions: + contents: read + +jobs: + check-content-linter-rules-docs: + runs-on: ${{ fromJSON('["ubuntu-latest", "ubuntu-20.04-xl"]')[github.repository == 'github/docs-internal'] }} + if: github.repository == 'github/docs-internal' || github.repository == 'github/docs' + steps: + - name: Checkout + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - uses: ./.github/actions/node-npm-setup + + - name: Check that src/article-api/README.md is up-to-date + run: npm run generate-article-api-docs + + - name: Fail if it isn't up-to-date + run: | + if [ -n "$(git status --porcelain)" ]; then + git status + git diff + + # Some whitespace for the sake of the message below + echo "" + echo "" + + echo "src/article-api/README.md is out of date." + echo "Please run 'npm run generate-article-api-docs' and commit the changes." + exit 1; + fi diff --git a/package.json b/package.json index cb010b4d98..3d4c405371 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "lint-content": "tsx src/content-linter/scripts/lint-content.js", "lint-translation": "vitest src/content-linter/tests/lint-files.js", "liquid-markdown-tables": "tsx src/tools/scripts/liquid-markdown-tables/index.ts", + "generate-article-api-docs": "tsx src/article-api/scripts/generate-api-docs.ts", "generate-code-scanning-query-list": "tsx src/code-scanning/scripts/generate-code-scanning-query-list.ts", "generate-content-linter-docs": "tsx src/content-linter/scripts/generate-docs.ts", "move-content": "tsx src/content-render/scripts/move-content.js", diff --git a/src/article-api/README.md b/src/article-api/README.md index 3ba4328224..00891b3d31 100644 --- a/src/article-api/README.md +++ b/src/article-api/README.md @@ -22,3 +22,128 @@ The `/api/article` endpoints return information about a page by `pathname`. For internal folks ask in the Docs Engineering slack channel. For open source folks, please open a discussion in the public repository. + + +## Reference: API endpoints + +### GET /api/article/ + +Get article metadata and content in a single object. Equivalent to calling `/article/meta` concatenated with `/article/body`. + +**Parameters**: +- **pathname** (string) - Article path (e.g. '/en/get-started/article-name') + +**Returns**: (object) - JSON object with article metadata and content (`meta` and `body` keys) + +**Throws**: +- (Error): 403 - If the article body cannot be retrieved. Reason is given in the error message. +- (Error): 400 - If pathname parameter is invalid. +- (Error): 404 - If the path is valid, but the page couldn't be resolved. + +**Example**: +``` +❯ curl -s "https://docs.github.com/api/article?pathname=/en/get-started/start-your-journey/about-github-and-git" +{ + "meta": { + "title": "About GitHub and Git", + "intro": "You can use GitHub and Git to collaborate on work.", + "product": "Get started" + }, + "body": "## About GitHub\n\nGitHub is a cloud-based platform where you can store, share, and work together with others to write code.\n\nStoring your code in a \"repository\" on GitHub allows you to:\n\n* **Showcase or share** your work.\n [...]" +} +``` + +--- + +### GET /api/article/body + +Get the contents of an article's body. + +**Parameters**: +- **pathname** (string) - Article path (e.g. '/en/get-started/article-name') + +**Returns**: (string) - Article body content in markdown format. + +**Throws**: +- (Error): 403 - If the article body cannot be retrieved. Reason is given in the error message. +- (Error): 400 - If pathname parameter is invalid. +- (Error): 404 - If the path is valid, but the page couldn't be resolved. + +**Example**: +``` +❯ curl -s https://docs.github.com/api/article/body\?pathname=/en/get-started/start-your-journey/about-github-and-git +## About GitHub + +GitHub is a cloud-based platform where you can store, share, and work together with others to write code. + +Storing your code in a "repository" on GitHub allows you to: +[...] +``` + +--- + +### GET /api/article/meta + +Get metadata about an article. + +**Parameters**: +- **pathname** (string) - Article path (e.g. '/en/get-started/article-name') + +**Returns**: (object) - JSON object containing article metadata with title, intro, and product information. + +**Throws**: +- (Error): 400 - If pathname parameter is invalid. +- (Error): 404 - If the path is valid, but the page couldn't be resolved. + +**Example**: +``` +❯ curl -s "https://docs.github.com/api/article/meta?pathname=/en/get-started/start-your-journey/about-github-and-git" +{ + "title": "About GitHub and Git", + "intro": "You can use GitHub and Git to collaborate on work.", + "product": "Get started", + "breadcrumbs": [ + { + "href": "/en/get-started", + "title": "Get started" + }, + { + "href": "/en/get-started/start-your-journey", + "title": "Start your journey" + }, + { + "href": "/en/get-started/start-your-journey/about-github-and-git", + "title": "About GitHub and Git" + } + ] +} +``` + +--- + +### GET /api/pagelist/:lang/:productVersion + +A list of pages available for a fully qualified path containing the target language and product version. + +**Parameters**: +- **lang** (string) - Path parameter for language code (e.g. 'en') +- **productVersion** (string) - Path parameter for product version (e.g. 'free-pro-team@latest') + +**Returns**: (string) - List of paths matching the language and version + +**Throws**: +- (Error): 400 - If language or version parameters are invalid. Reason is given in the error message. + +**Example**: +``` +❯ curl -s https://docs.github.com/api/pagelist/en/free-pro-team@latest +/en +/en/search +/en/get-started +/en/get-started/start-your-journey +/en/get-started/start-your-journey/about-github-and-git +[...] +``` + +--- + diff --git a/src/article-api/middleware/article.ts b/src/article-api/middleware/article.ts index 642420b737..ca2394ea86 100644 --- a/src/article-api/middleware/article.ts +++ b/src/article-api/middleware/article.ts @@ -20,6 +20,25 @@ const router = express.Router() // - pathValidationMiddleware ensures the path is properly structured and handles errors when it's not // - pageValidationMiddleware fetches the page from the pagelist, returns 404 to the user if not found +/** + * Get article metadata and content in a single object. Equivalent to calling `/article/meta` concatenated with `/article/body`. + * @route GET /api/article + * @param {string} pathname - Article path (e.g. '/en/get-started/article-name') + * @returns {object} JSON object with article metadata and content (`meta` and `body` keys) + * @throws {Error} 403 - If the article body cannot be retrieved. Reason is given in the error message. + * @throws {Error} 400 - If pathname parameter is invalid. + * @throws {Error} 404 - If the path is valid, but the page couldn't be resolved. + * @example + * ❯ curl -s "https://docs.github.com/api/article?pathname=/en/get-started/start-your-journey/about-github-and-git" + * { + * "meta": { + * "title": "About GitHub and Git", + * "intro": "You can use GitHub and Git to collaborate on work.", + * "product": "Get started" + * }, + * "body": "## About GitHub\n\nGitHub is a cloud-based platform where you can store, share, and work together with others to write code.\n\nStoring your code in a \"repository\" on GitHub allows you to:\n\n* **Showcase or share** your work.\n [...]" + * } + */ router.get( '/', pathValidationMiddleware as RequestHandler, @@ -43,6 +62,23 @@ router.get( }), ) +/** + * Get the contents of an article's body. + * @route GET /api/article/body + * @param {string} pathname - Article path (e.g. '/en/get-started/article-name') + * @returns {string} Article body content in markdown format. + * @throws {Error} 403 - If the article body cannot be retrieved. Reason is given in the error message. + * @throws {Error} 400 - If pathname parameter is invalid. + * @throws {Error} 404 - If the path is valid, but the page couldn't be resolved. + * @example + * ❯ curl -s https://docs.github.com/api/article/body\?pathname=/en/get-started/start-your-journey/about-github-and-git + * ## About GitHub + * + * GitHub is a cloud-based platform where you can store, share, and work together with others to write code. + * + * Storing your code in a "repository" on GitHub allows you to: + * [...] + */ router.get( '/body', pathValidationMiddleware as RequestHandler, @@ -62,6 +98,35 @@ router.get( }), ) +/** + * Get metadata about an article. + * @route GET /api/article/meta + * @param {string} pathname - Article path (e.g. '/en/get-started/article-name') + * @returns {object} JSON object containing article metadata with title, intro, and product information. + * @throws {Error} 400 - If pathname parameter is invalid. + * @throws {Error} 404 - If the path is valid, but the page couldn't be resolved. + * @example + * ❯ curl -s "https://docs.github.com/api/article/meta?pathname=/en/get-started/start-your-journey/about-github-and-git" + * { + * "title": "About GitHub and Git", + * "intro": "You can use GitHub and Git to collaborate on work.", + * "product": "Get started", + * "breadcrumbs": [ + * { + * "href": "/en/get-started", + * "title": "Get started" + * }, + * { + * "href": "/en/get-started/start-your-journey", + * "title": "Start your journey" + * }, + * { + * "href": "/en/get-started/start-your-journey/about-github-and-git", + * "title": "About GitHub and Git" + * } + * ] + * } + */ router.get( '/meta', pathValidationMiddleware as RequestHandler, diff --git a/src/article-api/middleware/pagelist.ts b/src/article-api/middleware/pagelist.ts index 4cb6c4613e..f8f5bd666c 100644 --- a/src/article-api/middleware/pagelist.ts +++ b/src/article-api/middleware/pagelist.ts @@ -44,7 +44,22 @@ router.get( }), ) -// for a fully qualified path with language and product version, we'll serve up the pagelist +/** + * A list of pages available for a fully qualified path containing the target language and product version. + * @route GET /api/pagelist + * @param {string} lang - Path parameter for language code (e.g. 'en') + * @param {string} productVersion - Path parameter for product version (e.g. 'free-pro-team@latest') + * @returns {string} List of paths matching the language and version + * @throws {Error} 400 - If language or version parameters are invalid. Reason is given in the error message. + * @example + * ❯ curl -s https://docs.github.com/api/pagelist/en/free-pro-team@latest + * /en + * /en/search + * /en/get-started + * /en/get-started/start-your-journey + * /en/get-started/start-your-journey/about-github-and-git + * [...] + */ router.get( '/:lang/:productVersion', pagelistValidationMiddleware as RequestHandler, diff --git a/src/article-api/scripts/generate-api-docs.ts b/src/article-api/scripts/generate-api-docs.ts new file mode 100644 index 0000000000..bddac9a9c1 --- /dev/null +++ b/src/article-api/scripts/generate-api-docs.ts @@ -0,0 +1,174 @@ +import { writeFileSync, readFileSync, existsSync } from 'fs' + +function main({ sources, outputPath }: { sources: string[]; outputPath: string }): void { + // Extract API documentation comments from all source files + const allDocs = sources.flatMap((sourcePath) => extractApiDocs(sourcePath)) + + // Generate markdown + const markdown = generateMarkdown(allDocs) + + // Update README + updateReadme(outputPath, markdown) + + console.log('API documentation generated successfully!') +} + +// Extract API docs from comments in the file +function extractApiDocs(file: string): string[] { + const apiDocs: any[] = [] + + // get the content from the api routes + const content = readFileSync(file, 'utf8') + + // Get the router method definitions with JSDOC-style comments + const routeRegex = + /\/\*\*\s*([\s\S]*?)\s*\*\/\s*router\.(get|post|put|delete)\s*\(\s*['"]([^'"]*)['"]/g + let match + + while ((match = routeRegex.exec(content)) !== null) { + const commentBlock = match[1] + const method = match[2] + const path = match[3] + + // The description is first line of the comment + const description = commentBlock + .trim() + .split('\n')[0] + .trim() + .replace(/^\*\s*/, '') + + // Grab the other elements from the comment + // we currently support: params, returns, examples, throws + const params = extractParams(commentBlock) + const returns = extractReturns(commentBlock) + const examples = extractExample(commentBlock) + const throws = extractThrows(commentBlock) + + apiDocs.push({ + method, // GET, POST, etc + path: file.includes('article.ts') ? `/api/article${path}` : `/api/pagelist${path}`, // Prepend base path + description, // defined in the top of the block comment + params, // defined from @params + returns, // defined from @returns + examples, // defined from @example + throws, // defined from @throws + }) + } + + return apiDocs +} + +function extractThrows(commentBlock: string): string[] { + const throwsRegex = /@throws\s+{([^}]+)}\s+([^\n]+)/g + const throws: string[] = [] + + let throwsMatch + while ((throwsMatch = throwsRegex.exec(commentBlock)) !== null) { + const type = throwsMatch[1] + const desc = throwsMatch[2].trim() + throws.push(`- (${type}): ${desc}`) + } + + return throws +} + +// Extract parameters from comment block +function extractParams(commentBlock: string): string[] { + const paramRegex = /@param\s+{([^}]+)}\s+([^\s]+)\s+([^\n]+)/g + const params: string[] = [] + + let paramMatch + while ((paramMatch = paramRegex.exec(commentBlock)) !== null) { + const type = paramMatch[1] + const name = paramMatch[2] + const desc = paramMatch[3].trim() + params.push(`- **${name}** (${type}) ${desc}`) + } + + return params +} + +// Extract return info from comment block +function extractReturns(commentBlock: string): string { + const returnMatch = commentBlock.match(/@returns\s+{([^}]+)}\s+([^\n]+)/) + if (returnMatch) { + const type = returnMatch[1] + const desc = returnMatch[2].trim() + return `**Returns**: (${type}) - ${desc}` + } + return '' +} + +// Extract example from comment block +function extractExample(commentBlock: string): string { + const exampleMatch = commentBlock.match(/@example\b([\s\S]*?)(?=\s*\*\s*@|\s*\*\/|$)/) + if (exampleMatch) { + // Clean up the example text by removing leading asterisks and spaces from each line, preserving tabs + return exampleMatch[1] + .split('\n') + .map((line) => line.replace(/^\s*\*\s?/, '')) + .join('\n') + .trim() + } + return '' +} + +// Generate markdown from parsed documentation +function generateMarkdown(apiDocs: any[]): string { + let markdown = '## Reference: API endpoints\n\n' + + apiDocs.forEach((doc) => { + markdown += `### ${doc.method.toUpperCase()} ${doc.path}\n\n` + markdown += `${doc.description}\n\n` + + if (doc.params.length > 0) { + markdown += '**Parameters**:\n' + markdown += doc.params.join('\n') + markdown += '\n\n' + } + + if (doc.returns) { + markdown += `${doc.returns}\n\n` + } + + if (doc.throws.length > 0) { + markdown += '**Throws**:\n' + markdown += doc.throws.join('\n') + markdown += '\n\n' + } + + if (doc.examples) { + markdown += `**Example**:\n\`\`\`\n${doc.examples}\n\`\`\`\n\n` + } + + markdown += '---\n\n' + }) + + return markdown +} + +// Update README with generated documentation +function updateReadme(readmePath: string, markdown: string): void { + if (existsSync(readmePath)) { + let readme = readFileSync(readmePath, 'utf8') + + const placeholderComment = `` + + // Replace API documentation section, or append to end + if (readme.includes(placeholderComment)) { + const pattern = new RegExp(placeholderComment + '[\\s\\S]*', 'g') + readme = readme.replace(pattern, placeholderComment + '\n' + markdown) + } else { + readme += '\n' + markdown + } + + writeFileSync(readmePath, readme) + } else { + writeFileSync(readmePath, markdown) + } +} + +main({ + sources: ['src/article-api/middleware/article.ts', 'src/article-api/middleware/pagelist.ts'], + outputPath: 'src/article-api/README.md', +})