Last active
March 4, 2025 14:05
-
-
Save pcornier/dd7248cdd285308dd1074addd89705fc to your computer and use it in GitHub Desktop.
A small JS script to replace apidoc, it will create markdown files and html files
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env node | |
const fs = require('fs'); | |
const path = require('path'); | |
const marked = require('marked'); | |
// Add command-line argument parsing | |
const yargs = require('yargs/yargs'); | |
const { hideBin } = require('yargs/helpers'); | |
const argv = yargs(hideBin(process.argv)) | |
.option('source', { | |
alias: 's', | |
type: 'string', | |
description: 'Source directory containing API files', | |
default: './resources' | |
}) | |
.option('output', { | |
alias: 'o', | |
type: 'string', | |
description: 'Output directory for documentation', | |
default: './doc' | |
}) | |
.option('pattern', { | |
alias: 'p', | |
type: 'string', | |
description: 'File pattern to match for API files', | |
default: '/api/' | |
}) | |
.option('title', { | |
alias: 't', | |
type: 'string', | |
description: 'Title for the documentation', | |
default: 'API Documentation' | |
}) | |
.help() | |
.alias('help', 'h') | |
.argv; | |
// Ensure output directories exist | |
const DOC_DIR = path.resolve(process.cwd(), argv.output); | |
const MARKDOWN_DIR = path.join(DOC_DIR, 'markdown'); | |
const HTML_DIR = path.join(DOC_DIR, 'html'); | |
// Create directories if they don't exist | |
[DOC_DIR, MARKDOWN_DIR, HTML_DIR].forEach(dir => { | |
if (!fs.existsSync(dir)) { | |
fs.mkdirSync(dir, { recursive: true }); | |
} | |
}); | |
// Function to extract API documentation from comments | |
function extractApiDocs(fileContent) { | |
const apiDocRegexes = [ | |
// Regex for @api {method} endpoint description | |
/\/\*\*\s*@api\s*{(\w+)}\s*([^\s]+)\s*([^*]+).*?\*\//gs, | |
// More flexible regex to catch variations | |
/\/\*\*[\s\S]*?@api\s*{(\w+)}\s*([^\s]+)[\s\S]*?\*\//gs | |
]; | |
const matches = []; | |
for (const apiDocRegex of apiDocRegexes) { | |
const apiMatches = [...fileContent.matchAll(apiDocRegex)]; | |
for (const match of apiMatches) { | |
const [fullMatch, method, endpoint] = match; | |
// Extract additional tags | |
const groupMatch = fullMatch.match(/@apiGroup\s*([^\n]+)/); | |
const versionMatch = fullMatch.match(/@apiVersion\s*([^\n]+)/); | |
const descriptionMatch = fullMatch.match(/@apiDescription\s*([^*]+)/); | |
// Extract all @api prefixed tags | |
const apiTags = {}; | |
const apiTagRegex = /@api(\w+)\s*{([^}]+)}\s*([^\n]+)/g; | |
let tagMatch; | |
while ((tagMatch = apiTagRegex.exec(fullMatch)) !== null) { | |
const [, tagType, tagValue, tagDescription] = tagMatch; | |
if (!apiTags[tagType]) { | |
apiTags[tagType] = []; | |
} | |
apiTags[tagType].push({ | |
value: tagValue.trim(), | |
description: tagDescription.trim() | |
}); | |
} | |
// Extract success and error responses | |
const successResponses = []; | |
const successResponseRegex = /@apiSuccess\s*{([^}]+)}\s*([^\n]+)/g; | |
let successMatch; | |
while ((successMatch = successResponseRegex.exec(fullMatch)) !== null) { | |
successResponses.push({ | |
type: successMatch[1].trim(), | |
description: successMatch[2].trim() | |
}); | |
} | |
// Extract success examples with more robust regex | |
const successExamples = []; | |
const successExampleRegex = /@apiSuccessExample\s*{([^}]+)}\s*([\s\S]*?)(?=\*\s*@|\*\/)/g; | |
let successExampleMatch; | |
while ((successExampleMatch = successExampleRegex.exec(fullMatch)) !== null) { | |
successExamples.push({ | |
type: successExampleMatch[1].trim(), | |
example: successExampleMatch[2].trim() | |
}); | |
} | |
const errorResponses = []; | |
const errorResponseRegex = /@apiError\s*\(([^)]+)\)\s*{([^}]+)}\s*([^\n]+)/g; | |
let errorMatch; | |
while ((errorMatch = errorResponseRegex.exec(fullMatch)) !== null) { | |
errorResponses.push({ | |
code: errorMatch[1].trim(), | |
type: errorMatch[2].trim(), | |
description: errorMatch[3].trim() | |
}); | |
} | |
// Extract error examples with more robust regex | |
const errorExamples = []; | |
const errorExampleRegex = /@apiErrorExample\s*{([^}]+)}\s*([\s\S]*?)(?=\*\s*@|\*\/)/g; | |
let errorExampleMatch; | |
while ((errorExampleMatch = errorExampleRegex.exec(fullMatch)) !== null) { | |
errorExamples.push({ | |
type: errorExampleMatch[1].trim(), | |
example: errorExampleMatch[2].trim() | |
}); | |
} | |
// Extract general examples with more robust regex | |
const generalExamples = []; | |
const generalExampleRegex = /@apiExample\s*{([^}]+)}\s*([\s\S]*?)(?=\*\s*@|\*\/)/g; | |
let generalExampleMatch; | |
while ((generalExampleMatch = generalExampleRegex.exec(fullMatch)) !== null) { | |
generalExamples.push({ | |
type: generalExampleMatch[1].trim(), | |
example: generalExampleMatch[2].trim() | |
}); | |
} | |
const tags = { | |
method: { type: 'HTTP Method', value: method }, | |
group: { type: 'Group', value: groupMatch ? groupMatch[1].trim() : 'Uncategorized' }, | |
version: { type: 'Version', value: versionMatch ? versionMatch[1].trim() : '1.0.0' } | |
}; | |
matches.push({ | |
method: method.toLowerCase(), | |
endpoint, | |
description: (descriptionMatch ? descriptionMatch[1] : 'No description provided').trim(), | |
tags, | |
apiTags, | |
successResponses, | |
successExamples, | |
errorResponses, | |
errorExamples, | |
generalExamples | |
}); | |
} | |
} | |
return matches; | |
} | |
// Recursively find all JS files in source directory | |
function findApiFiles(dir, pattern) { | |
const apiFiles = []; | |
function traverse(currentPath) { | |
const files = fs.readdirSync(currentPath); | |
for (const file of files) { | |
const fullPath = path.join(currentPath, file); | |
const stat = fs.statSync(fullPath); | |
if (stat.isDirectory()) { | |
traverse(fullPath); | |
} else if (file.endsWith('.js') && fullPath.includes(pattern)) { | |
apiFiles.push(fullPath); | |
} | |
} | |
} | |
traverse(dir); | |
return apiFiles; | |
} | |
// Generate Markdown documentation | |
function generateDocs(apiFiles) { | |
const docSections = {}; | |
const navigationLinks = []; | |
// First pass: collect all group names | |
for (const file of apiFiles) { | |
const fileContent = fs.readFileSync(file, 'utf-8'); | |
const apiDocs = extractApiDocs(fileContent); | |
if (apiDocs.length > 0) { | |
for (const apiDoc of apiDocs) { | |
// Use the group from the API documentation, fallback to 'Uncategorized' | |
const groupName = apiDoc.tags.group.value || 'Uncategorized'; | |
if (!docSections[groupName]) { | |
docSections[groupName] = []; | |
// Add to navigation links if not already present | |
if (!navigationLinks.some(link => link.name.toLowerCase() === groupName.toLowerCase())) { | |
navigationLinks.push({ | |
name: groupName.charAt(0).toUpperCase() + groupName.slice(1), | |
file: `${groupName.toLowerCase().replace(/ /g, '-')}.html`, | |
endpoints: [] | |
}); | |
} | |
} | |
docSections[groupName].push(apiDoc); | |
} | |
} | |
} | |
// Sort navigation links alphabetically | |
navigationLinks.sort((a, b) => a.name.localeCompare(b.name)); | |
// Write Markdown files and generate HTML | |
for (const [group, endpoints] of Object.entries(docSections)) { | |
// Find the corresponding navigation link and add endpoints | |
const navLink = navigationLinks.find(link => | |
link.name.toLowerCase() === group.toLowerCase() | |
); | |
const markdownContent = [ | |
`# ${group.charAt(0).toUpperCase() + group.slice(1)} API Documentation\n`, | |
...endpoints.map((endpoint, index) => { | |
// Create a unique anchor for each endpoint | |
const endpointAnchor = `${endpoint.method}-${endpoint.endpoint.replace(/[^a-zA-Z0-9]/g, '-')}`.toLowerCase(); | |
// Add to navigation link's endpoints | |
if (navLink) { | |
navLink.endpoints.push({ | |
name: `${endpoint.method.toUpperCase()} ${endpoint.endpoint}`, | |
anchor: endpointAnchor | |
}); | |
} | |
let content = `## <a id="${endpointAnchor}">${endpoint.method.toUpperCase()} ${endpoint.endpoint}</a>\n` + | |
`**Description**: ${endpoint.description}\n\n` + | |
`**Basic Tags**:\n` + | |
Object.entries(endpoint.tags) | |
.map(([tagName, tag]) => `- \`${tagName}\`: \`${tag.type}\` ${tag.value}`) | |
.join('\n'); | |
// Add additional API tags | |
if (Object.keys(endpoint.apiTags).length > 0) { | |
content += `\n\n**Additional API Tags**:\n`; | |
for (const [tagType, tagValues] of Object.entries(endpoint.apiTags)) { | |
content += `### @api${tagType}\n`; | |
tagValues.forEach(tag => { | |
content += `- \`${tag.value}\`: ${tag.description}\n`; | |
}); | |
} | |
} | |
// Add Success Responses | |
if (endpoint.successResponses.length > 0) { | |
content += `\n**Success Responses**:\n`; | |
endpoint.successResponses.forEach(success => { | |
content += `- \`${success.type}\`: ${success.description}\n`; | |
}); | |
} | |
// Add Success Examples | |
if (endpoint.successExamples.length > 0) { | |
content += `\n**Success Examples**:\n`; | |
endpoint.successExamples.forEach(example => { | |
// Normalize language type (lowercase) | |
const lang = example.type.toLowerCase(); | |
content += `### ${example.type}\n`; | |
content += '```' + lang + '\n' + example.example + '\n```\n'; | |
}); | |
} | |
// Add Error Responses | |
if (endpoint.errorResponses.length > 0) { | |
content += `\n**Error Responses**:\n`; | |
endpoint.errorResponses.forEach(error => { | |
content += `- \`${error.code}\` \`${error.type}\`: ${error.description}\n`; | |
}); | |
} | |
// Add Error Examples | |
if (endpoint.errorExamples.length > 0) { | |
content += `\n**Error Examples**:\n`; | |
endpoint.errorExamples.forEach(example => { | |
// Normalize language type (lowercase) | |
const lang = example.type.toLowerCase(); | |
content += `### ${example.type}\n`; | |
content += '```' + lang + '\n' + example.example + '\n```\n'; | |
}); | |
} | |
// Add General Examples | |
if (endpoint.generalExamples.length > 0) { | |
content += `\n**General Examples**:\n`; | |
endpoint.generalExamples.forEach(example => { | |
// Normalize language type (lowercase) | |
const lang = example.type.toLowerCase(); | |
content += `### ${example.type}\n`; | |
content += '```' + lang + '\n' + example.example + '\n```\n'; | |
}); | |
} | |
return content + '\n\n---\n'; | |
}) | |
].join('\n'); | |
// Save Markdown file with group name in lowercase and spaces replaced with dashes | |
const markdownFilePath = path.join( | |
MARKDOWN_DIR, | |
`${group.toLowerCase().replace(/ /g, '-')}.md` | |
); | |
fs.writeFileSync(markdownFilePath, markdownContent); | |
// Convert Markdown to HTML | |
const htmlContent = marked.parse(markdownContent); | |
const htmlFilePath = path.join( | |
HTML_DIR, | |
`${group.toLowerCase().replace(/ /g, '-')}.html` | |
); | |
// Create a basic HTML template with sidebar | |
const fullHtmlContent = ` | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<title>${group.charAt(0).toUpperCase() + group.slice(1)} - ${argv.title}</title> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.1.0/github-markdown.min.css"> | |
<style> | |
/* Add this to prevent text selection during resize */ | |
.no-select { | |
user-select: none; | |
-webkit-user-select: none; | |
-moz-user-select: none; | |
-ms-user-select: none; | |
} | |
body { | |
margin: 0; | |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
} | |
.container { | |
display: flex; | |
min-width: 100vw; | |
} | |
.sidebar { | |
width: 250px; | |
min-width: 200px; | |
max-width: 400px; | |
background-color: #f6f8fa; | |
height: 100vh; | |
position: fixed; | |
left: 0; | |
top: 0; | |
overflow-y: auto; | |
padding: 20px; | |
box-sizing: border-box; | |
border-right: 1px solid #e1e4e8; | |
resize: horizontal; | |
overflow: auto; | |
} | |
.sidebar h2 { | |
margin-top: 0; | |
border-bottom: 1px solid #e1e4e8; | |
padding-bottom: 10px; | |
font-size: 1.1em; | |
} | |
.sidebar ul { | |
list-style-type: none; | |
padding: 0; | |
} | |
.sidebar ul li { | |
margin-bottom: 10px; | |
} | |
.sidebar ul li a { | |
text-decoration: none; | |
color: #24292e; | |
font-weight: 600; | |
font-size: 0.9em; | |
white-space: nowrap; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
display: block; | |
max-width: 100%; | |
} | |
.sidebar ul li a:hover { | |
color: #0366d6; | |
} | |
.sidebar ul li ul { | |
margin-left: 15px; | |
margin-top: 5px; | |
} | |
.sidebar ul li ul li a { | |
font-weight: 400; | |
font-size: 0.8em; | |
color: #586069; | |
white-space: nowrap; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
display: block; | |
max-width: 100%; | |
} | |
.content { | |
margin-left: 250px; | |
padding: 45px; | |
max-width: 980px; | |
width: calc(100% - 250px); | |
box-sizing: border-box; | |
transition: margin-left 0.2s; | |
} | |
.markdown-body { | |
max-width: 100%; | |
} | |
.resize-handle { | |
position: absolute; | |
right: 0; | |
top: 0; | |
bottom: 0; | |
width: 5px; | |
cursor: ew-resize; | |
background: transparent; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="sidebar"> | |
<div class="resize-handle"></div> | |
<h2>${argv.title}</h2> | |
<ul> | |
${navigationLinks.map(link => ` | |
<li> | |
<a href="${link.file}">${link.name}</a> | |
${link.name.toLowerCase() === group.toLowerCase() && link.endpoints && link.endpoints.length > 0 ? ` | |
<ul> | |
${link.endpoints.map(endpoint => | |
`<li><a href="${link.file}#${endpoint.anchor}">${endpoint.name}</a></li>` | |
).join('\n')} | |
</ul> | |
` : ''} | |
</li> | |
`).join('\n')} | |
</ul> | |
</div> | |
<div class="content"> | |
<div class="markdown-body"> | |
${htmlContent} | |
</div> | |
</div> | |
</div> | |
<script> | |
document.addEventListener('DOMContentLoaded', function() { | |
const sidebar = document.querySelector('.sidebar'); | |
const content = document.querySelector('.content'); | |
const resizeHandle = document.querySelector('.resize-handle'); | |
let isResizing = false; | |
resizeHandle.addEventListener('mousedown', function(e) { | |
isResizing = true; | |
document.body.classList.add('no-select'); // Add no-select class | |
document.addEventListener('mousemove', resize); | |
document.addEventListener('mouseup', stopResize); | |
}); | |
function resize(e) { | |
if (isResizing) { | |
const newWidth = e.clientX; | |
if (newWidth >= 200 && newWidth <= 400) { | |
sidebar.style.width = newWidth + 'px'; | |
content.style.marginLeft = newWidth + 'px'; | |
} | |
} | |
} | |
function stopResize() { | |
isResizing = false; | |
document.body.classList.remove('no-select'); // Remove no-select class | |
document.removeEventListener('mousemove', resize); | |
} | |
}); | |
</script> | |
</body> | |
</html> | |
`; | |
fs.writeFileSync(htmlFilePath, fullHtmlContent); | |
} | |
// Create index page | |
const indexHtmlContent = ` | |
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<title>${argv.title}</title> | |
<style> | |
body { | |
margin: 0; | |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; | |
} | |
.container { | |
display: flex; | |
} | |
.sidebar { | |
width: 250px; | |
background-color: #f6f8fa; | |
height: 100vh; | |
position: fixed; | |
left: 0; | |
top: 0; | |
overflow-y: auto; | |
padding: 20px; | |
box-sizing: border-box; | |
border-right: 1px solid #e1e4e8; | |
} | |
.sidebar h2 { | |
margin-top: 0; | |
border-bottom: 1px solid #e1e4e8; | |
padding-bottom: 10px; | |
} | |
.sidebar ul { | |
list-style-type: none; | |
padding: 0; | |
} | |
.sidebar ul li { | |
margin-bottom: 10px; | |
} | |
.sidebar ul li a { | |
text-decoration: none; | |
color: #24292e; | |
font-weight: 600; | |
} | |
.sidebar ul li a:hover { | |
color: #0366d6; | |
} | |
.sidebar ul li ul { | |
margin-left: 15px; | |
margin-top: 5px; | |
} | |
.sidebar ul li ul li a { | |
font-weight: 400; | |
font-size: 0.9em; | |
color: #586069; | |
} | |
.content { | |
margin-left: 250px; | |
padding: 45px; | |
max-width: 980px; | |
width: calc(100% - 250px); | |
box-sizing: border-box; | |
} | |
.markdown-body { | |
max-width: 100%; | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div class="sidebar"> | |
<h2>${argv.title}</h2> | |
<ul> | |
${navigationLinks.map(link => ` | |
<li> | |
<a href="${link.file}">${link.name}</a> | |
</li> | |
`).join('\n')} | |
</ul> | |
</div> | |
<div class="content"> | |
<h1>${argv.title}</h1> | |
<p>Select a section from the sidebar to view API details.</p> | |
</div> | |
</div> | |
</body> | |
</html> | |
`; | |
fs.writeFileSync(path.join(HTML_DIR, 'index.html'), indexHtmlContent); | |
} | |
// Main execution | |
const sourceDir = path.resolve(process.cwd(), argv.source); | |
const apiFiles = findApiFiles(sourceDir, argv.pattern); | |
generateDocs(apiFiles); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment