Skip to content

Instantly share code, notes, and snippets.

@pcornier
Last active March 4, 2025 14:05
Show Gist options
  • Save pcornier/dd7248cdd285308dd1074addd89705fc to your computer and use it in GitHub Desktop.
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
#!/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