storybook-addon-jsdoc-to-mdx
Version:
Storybook addon that automatically generates MDX documentation from JSDoc comments in your TypeScript and JavaScript files. Supports HTML tags in comments, complex TypeScript types, and integrates seamlessly with Storybook 7.x and 8.x.
132 lines (131 loc) • 5.53 kB
JavaScript
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.removeCommentsFromCode = removeCommentsFromCode;
exports.findBasePath = findBasePath;
exports.getPathName = getPathName;
exports.extractMethodFromCode = extractMethodFromCode;
exports.formatJsDocComment = formatJsDocComment;
const path_1 = __importDefault(require("path"));
const typescript_1 = __importDefault(require("typescript"));
const comment_parser_1 = require("comment-parser");
function removeCommentsFromCode(code) {
const printer = typescript_1.default.createPrinter({ removeComments: true });
const sourceFile = typescript_1.default.createSourceFile("temp.ts", code, typescript_1.default.ScriptTarget.Latest, true, typescript_1.default.ScriptKind.TS);
return printer.printFile(sourceFile);
}
function findBasePath(filePath, folderPaths) {
for (const folderPath of folderPaths) {
if (filePath.startsWith(folderPath)) {
return folderPath;
}
}
return "";
}
function getPathName(filePath, baseDir) {
const relativePath = path_1.default.relative(baseDir, filePath);
const dirName = path_1.default.dirname(relativePath);
const extenstion = path_1.default.extname(filePath);
const baseName = path_1.default.basename(relativePath, extenstion);
if (baseName === "index") {
return dirName.replace(/\\/g, "/");
}
return path_1.default.join(dirName, baseName).replace(/\\/g, "/");
}
function extractMethodFromCode(code, methodName) {
const sourceFile = typescript_1.default.createSourceFile("temp.ts", code, typescript_1.default.ScriptTarget.Latest, true, typescript_1.default.ScriptKind.TS);
let methodNode;
function visit(node) {
if (typescript_1.default.isMethodDeclaration(node) &&
node.name &&
node.name.getText() === methodName) {
methodNode = node;
return;
}
typescript_1.default.forEachChild(node, visit);
}
visit(sourceFile);
if (!methodNode) {
return code;
}
const start = methodNode.getStart(sourceFile);
const end = methodNode.end;
return code.slice(start, end).trim();
}
// Escape HTML tags to prevent MDX parsing errors
function escapeHtmlTags(str) {
return str
.replace(/[-`]/g, '\\$&') // \- and \`
.replace(/[{}]/g, '\\$&') // \{ and \}
.replace(/</g, '<') // <
.replace(/>/g, '>'); // >
}
function formatJsDocComment(raw) {
var _a;
const trimmedRaw = raw.trim();
// Return plain strings unchanged (non JSDoc comments)
if (!trimmedRaw.startsWith('/**')) {
return trimmedRaw;
}
// Parse the raw JSDoc comment
const { description = '', tags = [] } = ((_a = (0, comment_parser_1.parse)(trimmedRaw)) === null || _a === void 0 ? void 0 : _a[0]) || {};
// Group tags by their `tag` field
const grouped = tags.reduce((acc, t) => {
var _a;
(acc[t.tag] = (_a = acc[t.tag]) !== null && _a !== void 0 ? _a : []).push(t);
return acc;
}, {});
// Render the JSDoc description as a Markdown block-quote
const quotedDescription = description
.trim()
.split('\n')
.map(line => `> ${escapeHtmlTags(line)}`)
.join('\n');
let mdx = quotedDescription + '\n\n';
// Parameters
if (grouped.param) {
const paramsBlock = grouped.param
.map(t => `- \`${t.name}\` ${t.type ? `*${escapeHtmlTags(t.type)}*` : ''} — ${escapeHtmlTags(t.description.trim().replace(/^-/g, '').trim())}`)
.join('\n');
mdx += `#### Parameter:\n\n${paramsBlock}\n`;
}
// Returns
const returnsGroup = grouped.returns || grouped.return;
if (returnsGroup) {
const { type = '', description: retDesc = '' } = returnsGroup[0];
mdx += `#### Returns:\n${type ? `\`${escapeHtmlTags(type)}\`` : ''} ${escapeHtmlTags(retDesc)}\n`;
}
// Example
if (grouped.example && grouped.example.length > 0) {
mdx += `#### Example:\n`;
grouped.example.forEach(e => {
const { description: exDesc = '', name: exName = '', source = [] } = e;
// Prefer raw source tokens because they keep the original line breaks
const rawLines = source
.filter(line => typeof line.source === 'string')
.slice(1, -1)
.map(line => line.source.replace(/^\s*\*\s?/, '')); // strip leading "* "
// Drop the first line that still contains "@example"
const codeLines = rawLines.length > 1
? rawLines // use tokens if present
: exDesc.split('\n'); // fallback to description
const exampleCode = codeLines.join('\n');
mdx += `\`\`\`ts\n${exampleCode}\n\`\`\`\n`;
});
}
// Fallback for other tags
Object.entries(grouped).forEach(([tagName, tagList]) => {
if (['param', 'return', 'returns', 'example'].includes(tagName))
return;
const heading = tagName.charAt(0).toUpperCase() + tagName.slice(1);
const lines = tagList
.map(t => t.name
? `- \`${t.name}\`${t.type ? ` *${t.type}*` : ''} — ${escapeHtmlTags(t.description)}`
: escapeHtmlTags(t.description))
.join('\n');
mdx += `#### ${heading}:\n${lines}\n`;
});
return mdx.trim();
}
;