diff --git a/.gitignore b/.gitignore index 1dd09a2..b2d314a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,7 @@ node_modules # OS files .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Automatically generated stuff +autodocs/**/* \ No newline at end of file diff --git a/scripts/generate-http-docs.js b/scripts/generate-http-docs.js new file mode 100644 index 0000000..cee0999 --- /dev/null +++ b/scripts/generate-http-docs.js @@ -0,0 +1,325 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +function findWorkspaceRoot(startDir) { + let current = startDir; + while (true) { + if (fs.existsSync(path.join(current, 'workspace.yml'))) { + return current; + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error('workspace.yml not found from the provided start directory'); + } + current = parent; + } +} + +function readText(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function stripQuotes(value) { + const trimmed = String(value).trim(); + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function parseWorkspace(workspacePath) { + const text = readText(workspacePath); + const lines = text.split(/\r?\n/); + const collections = []; + let inCollections = false; + let currentCollection = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (!inCollections && trimmed === 'collections:') { + inCollections = true; + continue; + } + + if (!inCollections) { + continue; + } + + if (!line.startsWith(' ') && !line.startsWith('\t') && trimmed) { + break; + } + + const nameMatch = line.match(/^\s*-\s+name:\s*(.+)$/); + if (nameMatch) { + currentCollection = { name: stripQuotes(nameMatch[1]) }; + collections.push(currentCollection); + continue; + } + + const pathMatch = line.match(/^\s*path:\s*(.+)$/); + if (pathMatch && currentCollection) { + currentCollection.path = stripQuotes(pathMatch[1]); + } + } + + return { collections }; +} + +function sanitizeVarName(value) { + return String(value) + .trim() + .replace(/[{}]/g, '') + .replace(/[^A-Za-z0-9_]/g, '_') + .replace(/^([0-9])/, '_$1') || 'value'; +} + +function collectPlaceholders(value) { + if (typeof value !== 'string') { + return []; + } + const placeholders = []; + const regex = /\{\{([^{}]+)\}\}/g; + let match; + while ((match = regex.exec(value)) !== null) { + placeholders.push(match[1]); + } + return placeholders; +} + +function findBlock(lines, keyName) { + for (let i = 0; i < lines.length; i += 1) { + const trimmed = lines[i].trim(); + if (trimmed !== keyName && !trimmed.startsWith(`${keyName}:`)) { + continue; + } + + const lineIndent = lines[i].match(/^\s*/)[0].length; + const block = []; + for (let j = i + 1; j < lines.length; j += 1) { + const currentLine = lines[j]; + const currentTrimmed = currentLine.trim(); + if (!currentTrimmed) { + block.push(currentLine); + continue; + } + const currentIndent = currentLine.match(/^\s*/)[0].length; + if (currentIndent <= lineIndent && !currentLine.startsWith(' ')) { + break; + } + if (currentIndent <= lineIndent && currentTrimmed.startsWith('#')) { + block.push(currentLine); + continue; + } + if (currentIndent <= lineIndent) { + break; + } + block.push(currentLine); + } + return block; + } + return []; +} + +function parseRequestInfo(text) { + const lines = text.split(/\r?\n/); + const infoLines = findBlock(lines, 'info'); + const nameMatch = infoLines.join('\n').match(/^\s*name:\s*(.+)$/m); + return nameMatch ? stripQuotes(nameMatch[1]) : ''; +} + +function parseHttpBlock(text) { + const lines = text.split(/\r?\n/); + const blockLines = findBlock(lines, 'http'); + const blockText = blockLines.join('\n'); + const methodMatch = blockText.match(/^\s*method:\s*(.+)$/m); + const urlMatch = blockText.match(/^\s*url:\s*(.+)$/m); + + const params = []; + const paramLines = blockLines.join('\n').split(/\r?\n/); + let inParamsBlock = false; + let paramsIndent = 0; + let currentParam = null; + + for (const line of paramLines) { + const trimmed = line.trim(); + if (!inParamsBlock) { + if (trimmed === 'params:') { + inParamsBlock = true; + paramsIndent = line.match(/^\s*/)[0].length; + } + continue; + } + + if (!trimmed) { + continue; + } + + const indent = line.match(/^\s*/)[0].length; + if (indent <= paramsIndent) { + break; + } + + const listItemMatch = line.match(/^\s*-\s+name:\s*(.+)$/); + if (listItemMatch) { + currentParam = { name: stripQuotes(listItemMatch[1]) }; + params.push(currentParam); + continue; + } + + if (!currentParam) { + continue; + } + + const propertyMatch = line.match(/^\s*([A-Za-z0-9_]+):\s*(.+)$/); + if (propertyMatch) { + const [, key, value] = propertyMatch; + currentParam[key] = stripQuotes(value); + } + } + + return { + method: methodMatch ? stripQuotes(methodMatch[1]) : 'GET', + url: urlMatch ? stripQuotes(urlMatch[1]) : '', + params, + }; +} + +function buildRequestContent(request, requestName) { + const lines = []; + const placeholders = new Set(); + + const addPlaceholders = (value) => { + for (const placeholder of collectPlaceholders(value)) { + placeholders.add(placeholder); + } + }; + + let url = request.url || ''; + if (url) { + addPlaceholders(url); + url = url.replace(/:([A-Za-z0-9_]+)/g, (_, name) => `{{${name}}}`); + } + + const queryParams = []; + const headers = []; + for (const param of request.params || []) { + const name = param.name || ''; + const value = param.value || ''; + const type = (param.type || 'query').toLowerCase(); + const disabled = String(param.disabled).toLowerCase() === 'true'; + + if (disabled) { + continue; + } + + addPlaceholders(value); + if (type === 'header') { + headers.push({ name, value }); + } else { + queryParams.push({ name, value }); + } + } + + if (placeholders.size > 0) { + lines.push(`# Variables for ${requestName}`); + for (const placeholder of [...placeholders].sort()) { + lines.push(`@${sanitizeVarName(placeholder)} = YOUR_VALUE_HERE`); + } + lines.push(''); + } + + const method = (request.method || 'GET').toUpperCase(); + let requestUrl = url; + for (const param of queryParams) { + if (!param.name) { + continue; + } + const separator = requestUrl.includes('?') ? '&' : '?'; + requestUrl = `${requestUrl}${separator}${param.name}=${param.value}`; + } + + lines.push(`${method} ${requestUrl}`); + for (const header of headers) { + lines.push(`${header.name}: ${header.value}`); + } + return lines.join('\n'); +} + +function ensureDir(dirPath) { + fs.mkdirSync(dirPath, { recursive: true }); +} + +function walkYamlFiles(rootDir) { + const results = []; + const entries = fs.readdirSync(rootDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') { + continue; + } + const fullPath = path.join(rootDir, entry.name); + if (entry.isDirectory()) { + results.push(...walkYamlFiles(fullPath)); + } else if (entry.isFile() && /\.ya?ml$/i.test(entry.name)) { + results.push(fullPath); + } + } + return results; +} + +function main() { + const workspaceRoot = findWorkspaceRoot(__dirname); + const workspaceFile = path.join(workspaceRoot, 'workspace.yml'); + const workspace = parseWorkspace(workspaceFile); + const collections = Array.isArray(workspace.collections) ? workspace.collections : []; + + if (collections.length === 0) { + throw new Error('No collections found in workspace.yml'); + } + + for (const collection of collections) { + if (!collection || !collection.name || !collection.path) { + continue; + } + + const sourceDir = path.join(workspaceRoot, collection.path); + if (!fs.existsSync(sourceDir)) { + console.warn(`Skipping missing collection path: ${collection.path}`); + continue; + } + + const outputRoot = path.join(workspaceRoot, 'autodocs', 'http', collection.name); + ensureDir(outputRoot); + + const yamlFiles = walkYamlFiles(sourceDir); + let processed = 0; + + for (const yamlFile of yamlFiles) { + const content = readText(yamlFile); + const requestName = parseRequestInfo(content) || path.basename(yamlFile, path.extname(yamlFile)); + const httpBlock = parseHttpBlock(content); + if (!httpBlock || !httpBlock.url) { + continue; + } + + const relativePath = path.relative(sourceDir, yamlFile); + const parsedPath = path.parse(relativePath); + const targetDir = path.join(outputRoot, parsedPath.dir); + ensureDir(targetDir); + + const outputFile = path.join(targetDir, `${parsedPath.name}.http`); + const requestContent = buildRequestContent(httpBlock, requestName); + fs.writeFileSync(outputFile, `${requestContent}\n`, 'utf8'); + processed += 1; + } + + console.log(`Generated ${processed} .http file(s) for ${collection.name}`); + } +} + +try { + main(); +} catch (error) { + console.error(error.message); + process.exit(1); +}