import { buildDocumentation, documentationToMarkdown } from "tsdoc-markdown"; import type { DocEntry } from "tsdoc-markdown"; import fs from "fs/promises"; import path from "path"; import { getDir } from "#package/utils/node/getDir.ts"; import { getFilesWithFS } from "#package/utils/node/getFilesWithFS.ts"; interface IFileConfig { files: string; name: string; fileFilter?: (filePath: string) => boolean; isUseFileNameForDefaultExport?: boolean; } type TJsDocTag = NonNullable[number]; type TDisplayPart = { text: string; }; type TJsDocTagWithText = TJsDocTag & { text: TDisplayPart[]; }; type TMarkdownDocument = { content: string; filePath: string; }; type TMarkdownFence = { character: "`" | "~"; length: number; line: number; }; type TMarkdownValidationIssue = { line?: number; message: string; }; const CWD: string = getDir(import.meta.url); const docsPath: string = path.resolve(CWD, "../../storybook/src/docs"); const ext = ".md"; const files: IFileConfig[] = [ { files: "src/package/utils", name: "utils", }, { files: "src/package/utils/react", name: "reactUtils", fileFilter: (filePath) => !path.basename(filePath).startsWith("use"), }, { files: "src/package/utils/react", name: "reactHooks", fileFilter: (filePath) => path.basename(filePath).startsWith("use"), }, { files: "src/package/utils/webpack", name: "tools", fileFilter: (filePath) => ![ "FinishPlugin.ts", "index.ts" ].includes(path.basename(filePath)), isUseFileNameForDefaultExport: true, }, { files: "src/package/plugins", name: "plugins", fileFilter: (filePath) => ![ "_" ].includes(path.basename(filePath)), isUseFileNameForDefaultExport: true, }, ]; const isValidInputFile = (filePath: string): boolean => { switch (true) { case !/\.(ts|tsx)$/.test(filePath): case /\.d\.ts$/.test(filePath): case /\.test\.(ts|tsx)$/.test(filePath): case /\.stories\.(ts|tsx)$/.test(filePath): return false; default: return true; } }; const getCodeLanguage = (content: string): "html" | "css" | "tsx" | "ts" => { const rawSource = content.trim(); let source = rawSource; let declaredLang = ""; if (rawSource.startsWith("```")) { const firstLineEnd = rawSource.indexOf("\n"); const lastFenceStart = rawSource.lastIndexOf("\n```"); if (firstLineEnd > 2 && lastFenceStart > firstLineEnd) { declaredLang = rawSource.slice(3, firstLineEnd).trim().toLowerCase(); source = rawSource.slice(firstLineEnd + 1, lastFenceStart).trim(); } } const decoded = source .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("&", "&"); const lower = decoded.toLowerCase(); const hasJsxLikeTag = decoded.includes("") || decoded.includes("<>") || decoded.includes("") || decoded.includes("<") && decoded.includes(">") && decoded.includes("{") && decoded.includes("}"); if (declaredLang === "tsx" || declaredLang === "jsx") { return "tsx"; } if (declaredLang === "html" || declaredLang === "xml" || declaredLang === "svg") { return "html"; } if (declaredLang === "css" || declaredLang === "scss" || declaredLang === "less") { return "css"; } if ((declaredLang === "ts" || declaredLang === "typescript") && hasJsxLikeTag) { return "tsx"; } const isHtml = decoded.startsWith("<") && decoded.includes(">") && (decoded.includes("") || lower.includes(""); if (isTsx) { return "tsx"; } const isCss = decoded.includes("{") && decoded.includes("}") && decoded.includes(":") && decoded.includes(";") && !decoded.includes("=>") || lower.includes("@media") || lower.includes("@keyframes"); if (isCss) { return "css"; } return "ts"; }; const getCodeMarkdown = (content: string): string => { const lang = getCodeLanguage(content); return `\`\`\`${lang}\n${content}\n\`\`\``; }; const getLocalizedHeadings = (content: string): string => { return content .replace(/^(\#{1,6}\s+)Functions(\s*)$/gm, "$1Функции$2") .replace(/^(\#{1,6}\s+)Static Methods(\s*)$/gm, "$1Статические методы класса$2") .replace(/^(\#{1,6}\s+)Static Properties(\s*)$/gm, "$1Статические поля класса$2") .replace(/^(\#{1,6}\s+)Properties(\s*)$/gm, "$1Поля класса$2") .replace(/^(\#{1,6}\s+)Types(\s*)$/gm, "$1Типы$2") .replace(/^(\#{1,6}\s+)Interfaces(\s*)$/gm, "$1Интерфейсы$2") .replace(/^(\#{1,6}\s+)Methods(\s*)$/gm, "$1Методы$2") .replace(/^(\#{1,6}\s+)Constants(\s*)$/gm, "$1Константы$2") .replace(/^Examples:\s*$/gm, "Примеры:") .replace(/^Example:\s*$/gm, "Примеры:") .replace(/^Parameters:\s*$/gm, "Параметры:") .replace(/^Returns:\s*$/gm, "Возвращает:") .replace(/^References:\s*$/gm, "Референсы:"); }; const getMarkdownValidationIssues = (content: string): TMarkdownValidationIssue[] => { if (!content.trim()) { return [ { message: "document is empty" } ]; } const issues: TMarkdownValidationIssue[] = []; const lines = content.split(/\r?\n/); let headingCount = 0; let previousHeadingLevel: number | undefined; let openFence: TMarkdownFence | undefined; lines.forEach((line, index) => { const lineNumber = index + 1; if (line.includes("\0")) { issues.push({ line: lineNumber, message: "contains a null character" }); } const fenceMatch = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line); if (openFence) { if (fenceMatch) { const [ , marker, suffix ] = fenceMatch; if ( marker && suffix !== undefined && marker[0] === openFence.character && marker.length >= openFence.length && !suffix.trim() ) { openFence = undefined; } } return; } if (fenceMatch) { const marker = fenceMatch[1]; const character = marker?.[0]; if (marker && (character === "`" || character === "~")) { openFence = { character, length: marker.length, line: lineNumber, }; } return; } const headingMatch = /^\s{0,3}(#{1,6})(?:[\t ]+(.*)|[\t ]*)$/.exec(line); if (!headingMatch) { return; } const [ , marker, rawHeading = "" ] = headingMatch; const heading = rawHeading.replace(/[\t ]+#+[\t ]*$/, "").trim(); if (!marker || !heading) { issues.push({ line: lineNumber, message: "heading has no text" }); return; } const headingLevel = marker.length; headingCount += 1; if (previousHeadingLevel !== undefined && headingLevel > previousHeadingLevel + 1) { issues.push({ line: lineNumber, message: `heading level jumps from ${previousHeadingLevel} to ${headingLevel}`, }); } previousHeadingLevel = headingLevel; }); if (!headingCount) { issues.push({ message: "document has no headings" }); } if (openFence) { issues.push({ line: openFence.line, message: "fenced code block is not closed" }); } return issues; }; const validateMarkdownDocuments = (documents: TMarkdownDocument[]): void => { if (!documents.length) { throw new Error("Markdown validation failed: no documents were generated"); } const errors = documents.flatMap(({ content, filePath }) => { const relativeFilePath = path.relative(process.cwd(), filePath); return getMarkdownValidationIssues(content).map(({ line, message }) => { const location = line ? `${relativeFilePath}:${line}` : relativeFilePath; return `${location}: ${message}`; }); }); if (errors.length) { throw new Error(`Markdown validation failed:\n${errors.map((error) => `- ${error}`).join("\n")}`); } }; const hasDisplayPartsText = (tag: TJsDocTag): tag is TJsDocTagWithText => { return Array.isArray(tag.text) && tag.text.length > 0 && tag.text.every((part: unknown) => { return typeof part === "object" && !!part && "text" in part && typeof (part as TDisplayPart).text === "string"; }); }; const patchExamples = (entries: DocEntry[]): void => { entries .map(({ jsDocs }) => jsDocs ?? []) .flat() .filter((tag) => tag.name === "example") .filter(hasDisplayPartsText) .forEach((tag) => { const sourceText = tag.text.map((part: TDisplayPart) => part.text).join(""); const [ firstPart ] = tag.text; if (!firstPart) { return; } tag.text = [ { ...firstPart, text: getCodeMarkdown(sourceText), } ]; }); }; const patchDefaultExportNames = (entries: DocEntry[]): void => { entries .filter((entry) => entry.name === "default") .forEach((entry) => { if (!entry.fileName) { return; } const fileName = path.parse(entry.fileName).name; if (fileName && fileName !== "index") { entry.name = fileName; } }); }; const isDirectoryExists = async (dirPath: string): Promise => { try { await fs.access(dirPath); return true; } catch { return false; } }; const getMDDocs = async ({ files: source, name, fileFilter, isUseFileNameForDefaultExport, }: IFileConfig): Promise<{ content: string; name: string; } | undefined> => { const sourceDir = path.resolve(path.join(".", source)); console.debug(`[getMDDocs] Collect files from "${sourceDir}"`); if (!(await isDirectoryExists(sourceDir))) { console.debug(`[getMDDocs] Directory not found: "${sourceDir}"`); return undefined; } try { const inputFiles = await getFilesWithFS(sourceDir, (filePath: string) => { if (!isValidInputFile(filePath)) { return false; } return typeof fileFilter === "function" ? fileFilter(filePath) : true; }); if (!inputFiles.length) { console.debug(`[getMDDocs] Files not found for "${sourceDir}"`); return undefined; } const sourceDirPath = path.normalize(sourceDir).toLowerCase(); const filteredEntries: DocEntry[] = buildDocumentation({ inputFiles, options: { explore: true, types: true, }, }).filter(({ fileName }: DocEntry) => { if (!fileName) { return true; } return path .normalize(fileName) .toLowerCase() .startsWith(sourceDirPath); }); if (isUseFileNameForDefaultExport) { patchDefaultExportNames(filteredEntries); } patchExamples(filteredEntries); const content = getLocalizedHeadings(documentationToMarkdown({ entries: filteredEntries, options: { headingLevel: "##", emoji: null, }, })); return { content, name }; } catch (err) { const error = err as Error; console.debug(`[getMDDocs] Failed to render files from "${sourceDir}"`); console.error(error.message, error.cause); return undefined; } }; try { // eslint-disable-next-line security/detect-non-literal-fs-filename await fs.mkdir(docsPath, { recursive: true }); const renderedDocs = await Promise.all(files.map((item) => getMDDocs(item))); const markdownDocuments: TMarkdownDocument[] = renderedDocs .filter((item): item is { content: string; name: string; } => !!item) .map(({ content, name }) => ({ content, filePath: path.join(docsPath, `${name}${ext}`), })); validateMarkdownDocuments(markdownDocuments); const results = await Promise.all( markdownDocuments.map(async ({ content, filePath }) => { console.debug(`[getMDDocs] Writing file: "${filePath}"`); // eslint-disable-next-line security/detect-non-literal-fs-filename await fs.writeFile(filePath, content); return filePath; }) ); results.forEach((filePath) => console.debug(`[getMDDocs] Generated file: "${filePath}"`)); } catch (err) { console.error(err); process.exitCode = 1; }