chore: move docs-util to www (#7232)

* reorganize docs apps

* add README

* fix directory

* add condition for old docs

* move docs-util to www

* remove remaining docs-util

* fixes of paths

* fix scripts

* path fixes

* fix github actions

* add build packages script
This commit is contained in:
Shahed Nasser
2024-05-06 09:13:50 +03:00
committed by GitHub
parent cf9605fe6f
commit b39de05535
607 changed files with 183 additions and 254 deletions

View File

@@ -0,0 +1,378 @@
import { faker } from "@faker-js/faker"
import { OpenAPIV3 } from "openapi-types"
import { API_ROUTE_PARAM_REGEX, OasArea } from "../kinds/oas.js"
import { CodeSample } from "../../types/index.js"
import { capitalize, kebabToCamel, wordsToCamel, wordsToKebab } from "utils"
type CodeSampleData = Omit<CodeSample, "source">
/**
* This class generates examples for OAS.
*/
class OasExamplesGenerator {
static JSCLIENT_CODESAMPLE_DATA: CodeSampleData = {
lang: "JavaScript",
label: "JS Client",
}
static CURL_CODESAMPLE_DATA: CodeSampleData = {
lang: "Shell",
label: "cURL",
}
static MEDUSAREACT_CODESAMPLE_DATA: CodeSampleData = {
lang: "tsx",
label: "Medusa React",
}
/**
* Generate JS client example for an OAS operation.
*
* @param param0 - The operation's details
* @returns The JS client example.
*/
generateJSClientExample({
area,
tag,
oasPath,
httpMethod,
isAdminAuthenticated,
isStoreAuthenticated,
parameters,
requestBody,
responseBody,
}: {
/**
* The area of the operation.
*/
area: OasArea
/**
* The tag this operation belongs to.
*/
tag: string
/**
* The API route's path.
*/
oasPath: string
/**
* The http method of the operation.
*/
httpMethod: string
/**
* Whether the operation requires admin authentication.
*/
isAdminAuthenticated?: boolean
/**
* Whether the operation requires customer authentication.
*/
isStoreAuthenticated?: boolean
/**
* The path parameters that can be sent in the request, if any.
*/
parameters?: OpenAPIV3.ParameterObject[]
/**
* The request body's schema, if any.
*/
requestBody?: OpenAPIV3.SchemaObject
/**
* The response body's schema, if any.
*/
responseBody?: OpenAPIV3.SchemaObject
}) {
const exampleArr = [
`import Medusa from "@medusajs/medusa-js"`,
`const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })`,
]
if (isAdminAuthenticated) {
exampleArr.push(`// must be previously logged in or use api token`)
} else if (isStoreAuthenticated) {
exampleArr.push(`// must be previously logged in.`)
}
// infer JS method name
// reset regex manually
API_ROUTE_PARAM_REGEX.lastIndex = 0
const isForSingleEntity = API_ROUTE_PARAM_REGEX.test(oasPath)
let jsMethod = `{methodName}`
if (isForSingleEntity) {
const splitOasPath = oasPath
.replaceAll(API_ROUTE_PARAM_REGEX, "")
.replace(/\/(batch)*$/, "")
.split("/")
const isBulk = oasPath.endsWith("/batch")
const isOperationOnDifferentEntity =
wordsToKebab(tag) !== splitOasPath[splitOasPath.length - 1]
if (isBulk || isOperationOnDifferentEntity) {
const endingEntityName = capitalize(
isBulk &&
API_ROUTE_PARAM_REGEX.test(splitOasPath[splitOasPath.length - 1])
? wordsToCamel(tag)
: kebabToCamel(splitOasPath[splitOasPath.length - 1])
)
jsMethod =
httpMethod === "get"
? `list${endingEntityName}`
: httpMethod === "post"
? `add${endingEntityName}`
: `remove${endingEntityName}`
} else {
jsMethod =
httpMethod === "get"
? "retrieve"
: httpMethod === "post"
? "update"
: "delete"
}
} else {
jsMethod =
httpMethod === "get"
? "list"
: httpMethod === "post"
? "create"
: "delete"
}
// collect the path/request parameters to be passed to the request.
const parametersArr: string[] =
parameters?.map((parameter) => parameter.name) || []
const requestData = requestBody
? this.getSchemaRequiredData(requestBody)
: {}
// assemble the method-call line of format `medusa.{admin?}.{methodName}({...parameters,} {requestBodyDataObj})`
exampleArr.push(
`medusa${area === "admin" ? `.${area}` : ""}.${wordsToCamel(
tag
)}.${jsMethod}(${parametersArr.join(", ")}${
Object.keys(requestData).length
? `${parametersArr.length ? ", " : ""}${JSON.stringify(
requestData,
undefined,
2
)}`
: ""
})`
)
// assemble then lines with response data, if any
const responseData = responseBody
? this.getSchemaRequiredData(responseBody)
: {}
const responseRequiredItems = Object.keys(responseData)
const responseRequiredItemsStr = responseRequiredItems.length
? `{ ${responseRequiredItems.join(", ")} }`
: ""
exampleArr.push(
`.then((${responseRequiredItemsStr}) => {\n\t\t${
responseRequiredItemsStr.length
? `console.log(${responseRequiredItemsStr})`
: "// Success"
}\n})`
)
return exampleArr.join("\n")
}
/**
* Generate cURL examples for an OAS operation.
*
* @param param0 - The operation's details.
* @returns The cURL example.
*/
generateCurlExample({
method,
path,
isAdminAuthenticated,
isStoreAuthenticated,
requestSchema,
}: {
/**
* The HTTP method.
*/
method: string
/**
* The API Route's path.
*/
path: string
/**
* Whether the route requires admin authentication.
*/
isAdminAuthenticated?: boolean
/**
* Whether the route requires customer authentication.
*/
isStoreAuthenticated?: boolean
/**
* The schema of the request body, if any.
*/
requestSchema?: OpenAPIV3.SchemaObject
}): string {
const exampleArr = [
`curl${
method.toLowerCase() !== "get" ? ` -X ${method.toUpperCase()}` : ""
} '{backend_url}${path}'`,
]
if (isAdminAuthenticated) {
exampleArr.push(`-H 'x-medusa-access-token: {api_token}'`)
} else if (isStoreAuthenticated) {
exampleArr.push(`-H 'Authorization: Bearer {access_token}'`)
}
if (requestSchema) {
const requestData = this.getSchemaRequiredData(requestSchema)
if (Object.keys(requestData).length > 0) {
exampleArr.push(`-H 'Content-Type: application/json'`)
exampleArr.push(
`--data-raw '${JSON.stringify(requestData, undefined, 2)}'`
)
}
}
return exampleArr.join(` \\\n`)
}
/**
* Retrieves data object from a schema object. Only retrieves the required fields.
*
* @param schema - The schema to retrieve its required data object.
* @returns An object of required data and their fake values.
*/
getSchemaRequiredData(
schema: OpenAPIV3.SchemaObject
): Record<string, unknown> {
const data: Record<string, unknown> = {}
if (schema.required?.length && schema.properties) {
schema.required.forEach((propertyName) => {
// extract property and its type
const property = schema.properties![
propertyName
] as OpenAPIV3.SchemaObject
let value: unknown
if (property.type === "object") {
const typedValue: Record<string, unknown> = {}
// get the fake value of every property in the object
if (property.properties) {
Object.entries(property.properties).forEach(
([childName, childProp]) => {
const typedChildProp = childProp as OpenAPIV3.SchemaObject
if (!typedChildProp.type) {
return
}
// if the property is an object, get its data object
// otherwise, get its fake value
typedValue[childName] =
typedChildProp.type === "object"
? this.getSchemaRequiredData(
typedChildProp as OpenAPIV3.SchemaObject
)
: this.getFakeValue({
name: childName,
type: typedChildProp.type,
format: typedChildProp.format,
})
}
)
}
value = typedValue
} else if (property.type === "array") {
// if the type of the array's items is an object, retrieve
// its data object. Otherwise, retrieve its fake value.
const propertyItems = property.items as OpenAPIV3.SchemaObject
if (!propertyItems.type) {
value = []
} else {
value = [
propertyItems.type === "object"
? this.getSchemaRequiredData(
property.items as OpenAPIV3.SchemaObject
)
: this.getFakeValue({
name: propertyName,
type: propertyItems.type,
format: propertyItems.format,
}),
]
}
} else if (property.type) {
// retrieve fake value for all other types
value = this.getFakeValue({
name: propertyName,
type: property.type,
format: property.format,
})
}
if (value !== undefined) {
data[propertyName] = value
}
})
}
return data
}
/**
* Retrieve the fake value of a property. The value is used in examples.
*
* @param param0 - The property's details
* @returns The fake value
*/
getFakeValue({
name,
type,
format,
}: {
/**
* The name of the property. It can help when generating the fake value.
* For example, if the name is `id`, the fake value generated will be of the format `id_<randomstring>`.
*/
name: string
/**
* The type of the property.
*/
type: OpenAPIV3.NonArraySchemaObjectType | "array"
/**
* The OAS format of the property. For example, `date-time`.
*/
format?: string
}): unknown {
let value: unknown
switch (true) {
case type === "string" && format === "date-time":
value = faker.date.future().toISOString()
break
case type === "boolean":
value = faker.datatype.boolean()
break
case type === "integer" || type === "number":
value = faker.number.int()
break
case type === "array":
value = []
break
case type === "string":
value = faker.helpers
.mustache(`{{${name}}}`, {
id: () =>
`id_${faker.string.alphanumeric({
length: { min: 10, max: 20 },
})}`,
name: () => faker.person.firstName(),
email: () => faker.internet.email(),
password: () => faker.internet.password({ length: 8 }),
currency: () => faker.finance.currencyCode(),
})
.replace(`{{${name}}}`, "{value}")
}
return value !== undefined ? value : "{value}"
}
}
export default OasExamplesGenerator

View File

@@ -0,0 +1,174 @@
/* eslint-disable no-case-declarations */
import ts from "typescript"
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
import AbstractGenerator from "./index.js"
import { minimatch } from "minimatch"
import AiGenerator from "../helpers/ai-generator.js"
/**
* A class used to generate docblock for one or multiple file paths.
*/
class DocblockGenerator extends AbstractGenerator {
/**
* Generate docblocks for the files in the `options`.
*/
async run() {
this.init()
const printer = ts.createPrinter({
removeComments: false,
})
const documentSourceFile = async (file: ts.SourceFile) => {
// Ignore .d.ts files
if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) {
return
}
let aiGenerator: AiGenerator | undefined
console.log(`[Docblock] Generating for ${file.fileName}...`)
let fileContent = file.getFullText()
let fileComments: string = ""
const commentsToRemove: string[] = []
const origFileText = file.getFullText().trim()
const fileNodes: ts.Node[] = [file]
if (this.options.generateExamples) {
aiGenerator = new AiGenerator()
}
// since typescript's compiler API doesn't support
// async processes, we have to retrieve the nodes first then
// traverse them separately.
const pushNodesToArr = (node: ts.Node) => {
fileNodes.push(node)
ts.forEachChild(node, pushNodesToArr)
}
ts.forEachChild(file, pushNodesToArr)
const documentNode = async (node: ts.Node) => {
const isSourceFile = ts.isSourceFile(node)
const nodeKindGenerator = this.kindsRegistry?.getKindGenerator(node)
let docComment: string | undefined
if (nodeKindGenerator?.canDocumentNode(node)) {
if (aiGenerator) {
const nodeFiles = aiGenerator.getNodeFiles(file)
await aiGenerator.initAssistant(nodeFiles)
}
// initialize assistant only when needed
// if previously initialized, calling the method does nothing
docComment = await nodeKindGenerator.getDocBlock(node, {
aiGenerator,
addEnd: true,
})
if (docComment.length) {
const existingComments =
nodeKindGenerator.getNodeCommentsFromRange(node)
if (existingComments?.length) {
commentsToRemove.push(existingComments)
}
if (isSourceFile) {
fileComments = docComment
} else {
ts.addSyntheticLeadingComment(
node,
ts.SyntaxKind.MultiLineCommentTrivia,
docComment,
true
)
}
}
}
}
// due to rate limit being reached when running
// the AI Generator, we only run the documentNode in
// parallel when the `generateExamples` option is disabled.
if (this.options.generateExamples) {
for (const node of fileNodes) {
await documentNode(node)
}
} else {
await Promise.all(
fileNodes.map(async (node) => await documentNode(node))
)
}
if (aiGenerator) {
await aiGenerator.destroyAssistant()
}
// add comments to file
const newNodeText = printer.printNode(ts.EmitHint.Unspecified, file, file)
// if file's text changed, replace it.
if (newNodeText !== origFileText) {
fileContent = fileContent.replace(origFileText, newNodeText)
}
if (!this.options.dryRun) {
if (commentsToRemove.length) {
let formatted = this.formatter.addCommentsToSourceFile(
fileComments,
await this.formatter.formatStr(fileContent, file.fileName)
)
commentsToRemove.forEach((commentToRemove) => {
formatted = formatted.replace(commentToRemove, "")
})
ts.sys.writeFile(
file.fileName,
await this.formatter.formatStr(formatted, file.fileName)
)
} else {
ts.sys.writeFile(
file.fileName,
this.formatter.addCommentsToSourceFile(
fileComments,
await this.formatter.formatStr(fileContent, file.fileName)
)
)
}
}
console.log(
`[Docblock] Finished generating docblock for ${file.fileName}.`
)
}
if (this.options.generateExamples) {
for (const file of this.program!.getSourceFiles()) {
await documentSourceFile(file)
}
} else {
await Promise.all(
this.program!.getSourceFiles().map(
async (file) => await documentSourceFile(file)
)
)
}
this.generatorEventManager.emit(GeneratorEvent.FINISHED_GENERATE_EVENT)
this.reset()
}
/**
* Checks whether the specified file path is included in the program
* and isn't an API file.
*
* @param fileName - The file path to check
* @returns Whether the docblock generator can run on this file.
*/
isFileIncluded(fileName: string): boolean {
return (
super.isFileIncluded(fileName) &&
!minimatch(this.getBasePath(fileName), "packages/medusa/**/api**/**", {
matchBase: true,
})
)
}
}
export default DocblockGenerator

View File

@@ -0,0 +1,110 @@
import ts from "typescript"
import Formatter from "../helpers/formatter.js"
import KindsRegistry from "../kinds/registry.js"
import GeneratorEventManager from "../helpers/generator-event-manager.js"
import { CommonCliOptions } from "../../types/index.js"
import { existsSync, readdirSync, statSync } from "node:fs"
import path from "node:path"
export type Options = {
paths: string[]
dryRun?: boolean
} & Pick<CommonCliOptions, "generateExamples">
abstract class AbstractGenerator {
protected options: Options
protected program?: ts.Program
protected checker?: ts.TypeChecker
protected formatter: Formatter
protected kindsRegistry?: KindsRegistry
protected generatorEventManager: GeneratorEventManager
constructor(options: Options) {
this.options = options
this.formatter = new Formatter()
this.generatorEventManager = new GeneratorEventManager()
}
init() {
const files: string[] = []
this.options.paths.forEach((optionPath) => {
if (!existsSync(optionPath)) {
return
}
if (!statSync(optionPath).isDirectory()) {
files.push(optionPath)
return
}
// read files recursively from directory
files.push(
...readdirSync(optionPath, {
recursive: true,
encoding: "utf-8",
})
.map((filePath) => path.join(optionPath, filePath))
.filter((filePath) => !statSync(filePath).isDirectory())
)
})
this.program = ts.createProgram(files, {})
this.checker = this.program.getTypeChecker()
const { generateExamples } = this.options
this.kindsRegistry = new KindsRegistry({
checker: this.checker,
generatorEventManager: this.generatorEventManager,
additionalOptions: {
generateExamples,
},
})
}
/**
* Generate the docblock for the paths specified in the {@link options} class property.
*/
abstract run(): void
/**
* Checks whether a file is included in the specified files.
*
* @param {string} fileName - The file to check for.
* @returns {boolean} Whether the file can have docblocks generated for it.
*/
isFileIncluded(fileName: string): boolean {
const baseFilePath = this.getBasePath(fileName)
return this.options.paths.some((path) =>
baseFilePath.startsWith(this.getBasePath(path))
)
}
/**
* Retrieve the pathname of a file without the relative part before `packages/`
*
* @param fileName - The file name/path
* @returns The path without the relative part.
*/
getBasePath(fileName: string) {
let basePath = fileName
const packageIndex = fileName.indexOf("packages/")
if (packageIndex) {
basePath = basePath.substring(packageIndex)
}
return basePath
}
/**
* Reset the generator's properties for new usage.
*/
reset() {
this.program = undefined
this.checker = undefined
}
}
export default AbstractGenerator

View File

@@ -0,0 +1,92 @@
import { minimatch } from "minimatch"
import AbstractGenerator from "./index.js"
import ts from "typescript"
import OasKindGenerator from "../kinds/oas.js"
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
/**
* A class used to generate OAS yaml comments. The comments are written
* in different files than the specified files.
*/
class OasGenerator extends AbstractGenerator {
protected oasKindGenerator?: OasKindGenerator
async run() {
this.init()
const { generateExamples } = this.options
this.oasKindGenerator = new OasKindGenerator({
checker: this.checker!,
generatorEventManager: this.generatorEventManager,
additionalOptions: {
generateExamples,
},
})
await Promise.all(
this.program!.getSourceFiles().map(async (file) => {
// Ignore .d.ts files
if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) {
return
}
const fileNodes: ts.Node[] = [file]
console.log(`[OAS] Generating for ${file.fileName}...`)
// since typescript's compiler API doesn't support
// async processes, we have to retrieve the nodes first then
// traverse them separately.
const pushNodesToArr = (node: ts.Node) => {
fileNodes.push(node)
ts.forEachChild(node, pushNodesToArr)
}
ts.forEachChild(file, pushNodesToArr)
const documentChild = async (node: ts.Node) => {
if (
this.oasKindGenerator!.isAllowed(node) &&
this.oasKindGenerator!.canDocumentNode(node)
) {
const oas = await this.oasKindGenerator!.getDocBlock(node)
if (!this.options.dryRun) {
const filename =
this.oasKindGenerator!.getAssociatedFileName(node)
ts.sys.writeFile(
filename,
this.formatter.addCommentsToSourceFile(oas, "")
)
}
}
}
await Promise.all(
fileNodes.map(async (node) => await documentChild(node))
)
this.generatorEventManager.emit(GeneratorEvent.FINISHED_GENERATE_EVENT)
console.log(`[OAS] Finished generating OAS for ${file.fileName}.`)
})
)
}
/**
* Checks whether the specified file path is included in the program
* and is an API file.
*
* @param fileName - The file path to check
* @returns Whether the OAS generator can run on this file.
*/
isFileIncluded(fileName: string): boolean {
return (
super.isFileIncluded(fileName) &&
minimatch(this.getBasePath(fileName), "packages/medusa/**/api**/**", {
matchBase: true,
})
)
}
}
export default OasGenerator

View File

@@ -0,0 +1,330 @@
import { createReadStream, existsSync } from "fs"
import OpenAI from "openai"
import path from "path"
import ts from "typescript"
import { ReadableStreamDefaultReadResult } from "stream/web"
import { DOCBLOCK_NEW_LINE } from "../../constants.js"
import { AssistantStreamEvent } from "openai/resources/beta/index.mjs"
import { pascalToCamel } from "utils"
type GenerateExampleOptions = {
className?: string
functionName: string
signature?: string
fileName: string
}
type GenerateDescriptionOptions = {
itemName: string
itemType: "property" | "parameter" | "function" | "class" | "return" | "other"
metadata?: {
parentName?: string
parentType?: string
functionSignature?: string
fileName?: string
}
}
const CODE_REGEX = /(?<code>```[\s\S.]*```)/g
class AiGenerator {
private openAi: OpenAI
private assistant?: OpenAI.Beta.Assistants.Assistant
private fileMap: Map<string, string>
constructor() {
this.openAi = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
maxRetries: 10,
})
this.fileMap = new Map()
}
/**
* Initialize OpenAI assistant and upload files.
*
* @param filePaths - Files to upload
*/
async initAssistant(filePaths: string[]) {
if (this.assistant) {
return
}
this.fileMap = new Map()
const files: OpenAI.Files.FileObject[] = []
// upload the files to openai
await Promise.all(
filePaths.map(async (filePath) => {
const openAiFile = await this.openAi.files.create({
file: createReadStream(filePath),
purpose: "assistants",
})
files.push(openAiFile)
this.fileMap.set(filePath, openAiFile.id)
})
)
// create assistant
this.assistant = await this.openAi.beta.assistants.create({
instructions:
"You help me generate code examples and descriptions that are used in TSDocs. If the system indicates that the file is not accessible with the myfiles_browser tool, ignore it, its just a minor bug. You are capable of opening and analyzing the file, remember that. And carry out the requested task. Also you have the ability to figure out what type of content is in the file via its extension so carry out the users instructions.",
model: "gpt-4-turbo-preview",
tools: [{ type: "retrieval" }],
file_ids: files.map((file) => file.id),
})
}
/**
* Deletes an assistant and its files from OpenAI.
*/
async destroyAssistant() {
if (!this.assistant) {
return
}
// delete files of assistant
await Promise.all(
this.assistant.file_ids.map(async (fileId) => {
try {
await this.openAi.files.del(fileId)
} catch (e) {
console.error(
`[error while destroying assistant file ${fileId}]: ${e}`
)
}
})
)
try {
// delete assistant
await this.openAi.beta.assistants.del(this.assistant.id)
} catch (e) {
console.error(
`[error while destroying assistant ${this.assistant!.id}]: ${e}`
)
}
this.assistant = undefined
this.fileMap = new Map()
}
/**
* Generates an example code block wrapped in backticks. The comment includes astrix by default.
*
* @param param0 - Options to generate the example code based on.
* @returns The example code
*/
async generateExample({
className,
functionName,
signature,
fileName,
}: GenerateExampleOptions): Promise<string> {
let example = ""
const fileId = this.fileMap.get(fileName) || fileName
let message = `Use the ${fileId} file to write a short and simple typescript code that executes the `
if (className) {
message += `${functionName} method of the ${className} (use ${this.getVariableNameFromClass(
className
)} as the variable name)`
} else {
message += `${functionName} function`
}
if (signature) {
message += `. The ${
className ? "method" : "function"
} has the signature ${signature}`
}
message += `. Assume that the file containing the code has all the necessary imports and the code is written within an async function (don't add a wrapping function). Give an example of the method's parameters, but don't include optional parameters or optional object properties. Infer types from other files. Provide the code without an explanation.`
const messageResult = await this.retrieveAiResponse(message)
const matchedCode = CODE_REGEX.exec(messageResult)
if (matchedCode?.groups?.code) {
example = matchedCode.groups.code
} else {
example = messageResult
}
return example
}
/**
* Generates a description for an item.
*
* @param param0 - Options to generate the description based on.
* @returns The generated description.
*/
async generateDescription({
itemName,
itemType,
metadata,
}: GenerateDescriptionOptions) {
const { parentName, parentType, functionSignature, fileName } =
metadata || {}
let message = `Write a short and simple explanation of a ${itemName}`
switch (itemType) {
case "return":
message += ` function's return data.`
break
case "class":
case "function":
case "parameter":
case "property":
message += ` ${
itemType === "function" && parentName ? "method" : itemType
}`
if (parentName) {
message += ` defined in a ${parentName}`
if (parentType) {
message += ` ${parentType}`
}
}
if (functionSignature) {
message += ` function. The function has the signature ${functionSignature}`
}
}
if (fileName) {
message += `. Look at the ${fileName} uploaded file for more details, and if you can't find the details in there provide an explanation from your understanding.`
}
message +=
". The explanation must be one sentence shorter than 10 words. Don't provide anything else in the response."
return await this.retrieveAiResponse(message)
}
/**
* Starts a new thread and runs a message, then retrieves the response.
*
* @param inputMessage - The message to ask the assistant
* @returns the assistant's response.
*/
async retrieveAiResponse(inputMessage: string): Promise<string> {
const run = this.openAi.beta.threads.createAndRunStream({
assistant_id: this.assistant!.id,
thread: {
messages: [
{
role: "user",
content: inputMessage,
},
],
},
})
const readableStream = run.toReadableStream().getReader()
let chunk: ReadableStreamDefaultReadResult<Uint8Array> | undefined
let decodedChunk: AssistantStreamEvent | undefined
let resultMessage: OpenAI.Beta.Threads.Messages.Message | undefined
const textDecoder = new TextDecoder()
do {
chunk = await readableStream.read()
const decodedValue = textDecoder.decode(chunk.value)
if (decodedValue.length) {
decodedChunk = JSON.parse(
textDecoder.decode(chunk.value)
) as AssistantStreamEvent
if (
decodedChunk.event === "thread.message.completed" &&
decodedChunk.data.object === "thread.message"
) {
resultMessage = decodedChunk.data
} else if (
decodedChunk.event === "thread.run.failed" &&
decodedChunk.data.last_error?.code === "server_error"
) {
// retry
return this.retrieveAiResponse(inputMessage)
}
}
console.log(decodedValue, resultMessage)
} while (
!resultMessage &&
// a run may fail if the rate limit is reached
decodedChunk?.event !== "thread.run.failed" &&
decodedChunk?.event !== "thread.run.step.failed" &&
decodedChunk?.event !== "thread.message.completed" &&
decodedChunk?.event !== "thread.run.completed"
)
if (!resultMessage) {
return ""
}
return resultMessage.content
.map((item) => {
return item.type === "text" ? item.text.value : ""
})
.join(" ")
.replaceAll("\n", DOCBLOCK_NEW_LINE)
}
/**
* Formats a class name into a camel-case variable name.
*
* @param className - The class name to format.
* @returns The variable name.
*/
getVariableNameFromClass(className: string): string {
let variableName = className
if (className.startsWith("I") && /[A-Z]/.test(className.charAt(1))) {
variableName = variableName.substring(1)
}
return pascalToCamel(variableName)
}
/**
* Retrieves the files that should be uploaded to OpenAI of a node.
*
* @param node - The node to retrieve its files.
* @returns the list of file paths.
*/
getNodeFiles(node: ts.Node): string[] {
const sourceFile = node.getSourceFile()
const files: string[] = [sourceFile.fileName]
if ("imports" in sourceFile) {
;(sourceFile.imports as ts.StringLiteral[]).forEach((importedFile) => {
if (importedFile.text.startsWith(".")) {
// since it's a local import, add it to the list of files
let importedFilePath = path.resolve(
sourceFile.fileName,
"..",
importedFile.text
)
if (!path.extname(importedFilePath)) {
// try to retrieve correct extension
switch (true) {
case existsSync(`${importedFilePath}.ts`):
importedFilePath += `.ts`
break
case existsSync(`${importedFilePath}.js`):
importedFilePath += `.js`
break
case existsSync(`${importedFilePath}.tsx`):
importedFilePath += `.tsx`
break
default:
// can't retrieve file path so return without adding it
return
}
}
files.push(importedFilePath)
}
})
}
return [...new Set(files)]
}
}
export default AiGenerator

View File

@@ -0,0 +1,264 @@
import getMonorepoRoot from "../../utils/get-monorepo-root.js"
import { ESLint, Linter } from "eslint"
import path from "path"
import dirname from "../../utils/dirname.js"
import { minimatch } from "minimatch"
import { existsSync } from "fs"
import * as prettier from "prettier"
import getRelativePaths from "../../utils/get-relative-paths.js"
/**
* A class used to apply formatting to files using ESLint and other formatting options.
*/
class Formatter {
protected cwd: string
protected eslintConfig?: Linter.Config
protected generalESLintConfig?: Linter.ConfigOverride<Linter.RulesRecord>
protected configForFile: Map<
string,
Linter.ConfigOverride<Linter.RulesRecord>
>
constructor() {
this.cwd = getMonorepoRoot()
this.configForFile = new Map()
}
/**
* Adds new lines before and after a comment if it's preceeded/followed immediately by a word (not by an empty line).
*
* @param {string} content - The content to format.
* @returns {string} The returned formatted content.
*/
normalizeCommentNewLine(content: string): string {
return content
.replaceAll(/(.)\n(\s*)\/\*\*/g, "$1\n\n$2/**")
.replaceAll(/\*\/\s*(.)/g, "*/\n$1")
}
/**
* Normalizes an ESLint overrides configuration object. If a file name is specified, the configuration are normalized to
* include the `tsconfig` related to the file. If a file name isn't specified, the tsconfig file path names
* in the `parserConfig.project` array are normalized to have a full relative path (as that is required by ESLint).
*
* @param {Linter.ConfigOverride<Linter.RulesRecord>} config - The original configuration object.
* @param {string} fileName - The file name that
* @returns {Linter.ConfigOverride<Linter.RulesRecord>} The normalized and cloned configuration object.
*/
normalizeOverridesConfigObject(
config: Linter.ConfigOverride<Linter.RulesRecord>,
fileName?: string
): Linter.ConfigOverride<Linter.RulesRecord> {
// clone config
const newConfig = structuredClone(config)
if (!newConfig.parserOptions) {
return newConfig
}
if (fileName) {
const packagePattern = /^(?<packagePath>.*\/packages\/[^/]*).*$/
// try to manually set the project of the parser options
const matchFilePackage = packagePattern.exec(fileName)
if (matchFilePackage?.groups?.packagePath) {
const tsConfigPath = path.join(
matchFilePackage.groups.packagePath,
"tsconfig.json"
)
const tsConfigSpecPath = path.join(
matchFilePackage.groups.packagePath,
"tsconfig.spec.json"
)
newConfig.parserOptions.project = [
existsSync(tsConfigPath)
? tsConfigPath
: existsSync(tsConfigSpecPath)
? tsConfigSpecPath
: [
...getRelativePaths(
newConfig.parserOptions.project || [],
this.cwd
),
],
]
}
} else if (newConfig.parserOptions.project?.length) {
// fix parser projects paths to be relative to this script
newConfig.parserOptions.project = getRelativePaths(
newConfig.parserOptions.project as string[],
this.cwd
)
}
return newConfig
}
/**
* Retrieves the general ESLint configuration and sets it to the `eslintConfig` class property, if it's not already set.
* It also tries to set the `generalESLintConfig` class property to the override configuration in the `eslintConfig`
* whose `files` array includes `*.ts`.
*/
async getESLintConfig() {
if (this.eslintConfig) {
return
}
this.eslintConfig = (
await import(
path.relative(dirname(), path.join(this.cwd, ".eslintrc.js"))
)
).default as Linter.Config
this.generalESLintConfig = this.eslintConfig!.overrides?.find((item) =>
item.files.includes("*.ts")
)
if (this.generalESLintConfig) {
this.generalESLintConfig = this.normalizeOverridesConfigObject(
this.generalESLintConfig
)
}
}
/**
* Retrieves the normalized ESLint overrides configuration for a specific file.
*
* @param {string} filePath - The file's path.
* @returns {Promise<Linter.ConfigOverride<Linter.RulesRecord> | undefined>} The normalized configuration object or `undefined` if not found.
*/
async getESLintOverridesConfigForFile(
filePath: string
): Promise<Linter.ConfigOverride<Linter.RulesRecord> | undefined> {
await this.getESLintConfig()
if (this.configForFile.has(filePath)) {
return this.configForFile.get(filePath)!
}
let relevantConfig = this.eslintConfig!.overrides?.find((item) => {
if (typeof item.files === "string") {
return minimatch(filePath, item.files)
}
return item.files.some((file) => minimatch(filePath, file))
})
if (!relevantConfig && !this.generalESLintConfig) {
return undefined
}
relevantConfig = this.normalizeOverridesConfigObject(
structuredClone(relevantConfig || this.generalESLintConfig!),
filePath
)
relevantConfig!.files = [path.relative(this.cwd, filePath)]
this.configForFile.set(filePath, relevantConfig)
return relevantConfig
}
/**
* Formats a string with ESLint.
*
* @param {string} content - The content to format.
* @param {string} fileName - The path to the file that the content belongs to.
* @returns {Promise<string>} The formatted content.
*/
async formatStrWithEslint(
content: string,
fileName: string
): Promise<string> {
const prettifiedContent = await this.formatStrWithPrettier(
content,
fileName
)
const relevantConfig = await this.getESLintOverridesConfigForFile(fileName)
const eslint = new ESLint({
overrideConfig: {
...this.eslintConfig,
overrides: relevantConfig ? [relevantConfig] : undefined,
},
cwd: this.cwd,
resolvePluginsRelativeTo: this.cwd,
fix: true,
ignore: false,
})
let newContent = prettifiedContent
const result = await eslint.lintText(prettifiedContent, {
filePath: fileName,
})
if (result.length) {
newContent = result[0].output || newContent
}
return newContent
}
/**
* Format a file's content with prettier.
*
* @param content - The content to format.
* @param fileName - The name of the file the content belongs to.
* @returns The formatted content
*/
async formatStrWithPrettier(
content: string,
fileName: string
): Promise<string> {
// load config of the file
const prettierConfig = (await prettier.resolveConfig(fileName)) || undefined
if (prettierConfig && !prettierConfig.parser) {
prettierConfig.parser = "babel-ts"
}
return await prettier.format(content, prettierConfig)
}
/**
* Applies all formatting types to a string.
*
* @param {string} content - The content to format.
* @param {string} fileName - The path to the file that holds the content.
* @returns {Promise<string>} The formatted content.
*/
async formatStr(content: string, fileName: string): Promise<string> {
const newContent = await this.formatStrWithEslint(content, fileName)
let normalizedContent = this.normalizeCommentNewLine(newContent)
if (normalizedContent !== newContent) {
/**
* Since adding the new lines after comments as done in {@link normalizeCommentNewLine} method may lead to linting errors,
* we have to rerun the {@link formatStrWithEslint}. It's not possible to run {@link normalizeCommentNewLine} the first time
* and provide the expected result.
*/
normalizedContent = await this.formatStrWithEslint(
normalizedContent,
fileName
)
}
return normalizedContent
}
/**
* Adds comments of a source file to the top of the file's content. It should have additional extra line after the comment.
* If the comment's length is 0, the `content` is returned as is.
*
* @param {string} comment - The comments of the source file.
* @param {string} content - The source file's comments.
* @returns {string} The full content with the comments.
*/
addCommentsToSourceFile(comment: string, content: string): string {
return comment.length ? `/**\n ${comment}*/\n\n${content}` : content
}
}
export default Formatter

View File

@@ -0,0 +1,37 @@
import EventEmitter from "events"
export enum GeneratorEvent {
FINISHED_GENERATE_EVENT = "finished_generate",
}
/**
* A class used to emit events during the lifecycle of the generator.
*/
class GeneratorEventManager {
private eventEmitter: EventEmitter
constructor() {
this.eventEmitter = new EventEmitter()
}
/**
* Emit an event to listeners.
*
* @param event - The event to emit.
*/
emit(event: GeneratorEvent) {
this.eventEmitter.emit(event)
}
/**
* Add a listener to an event.
*
* @param event - The event to add a listener for.
* @param handler - The handler of the event.
*/
listen(event: GeneratorEvent, handler: () => void) {
this.eventEmitter.on(event, handler)
}
}
export default GeneratorEventManager

View File

@@ -0,0 +1,115 @@
import { Octokit } from "octokit"
import promiseExec from "../../utils/promise-exec.js"
import getMonorepoRoot from "../../utils/get-monorepo-root.js"
import filterFiles from "../../utils/filter-files.js"
type Options = {
owner?: string
repo?: string
authToken?: string
}
export class GitManager {
private owner: string
private repo: string
private authToken: string
private octokit: Octokit
private gitApiVersion = "2022-11-28"
constructor(options?: Options) {
this.owner = options?.owner || process.env.GIT_OWNER || ""
this.repo = options?.repo || process.env.GIT_REPO || ""
this.authToken = options?.authToken || process.env.GITHUB_TOKEN || ""
this.octokit = new Octokit({
auth: this.authToken,
})
}
async getCommitFilesSinceRelease(tagName: string) {
const { data: release } = await this.octokit.request(
"GET /repos/{owner}/{repo}/releases/tags/{tag}",
{
owner: this.owner,
repo: this.repo,
tag: tagName,
headers: {
"X-GitHub-Api-Version": this.gitApiVersion,
},
}
)
return this.getCommitsFiles(release.published_at)
}
async getCommitFilesSinceLastRelease() {
// list releases to get the latest two releases
const { data: release } = await this.octokit.request(
"GET /repos/{owner}/{repo}/releases/latest",
{
owner: this.owner,
repo: this.repo,
headers: {
"X-GitHub-Api-Version": this.gitApiVersion,
},
}
)
return this.getCommitsFiles(release.published_at)
}
async getCommitsFiles(date?: string | null) {
// get commits between the last two releases
const commits = await this.octokit.paginate(
"GET /repos/{owner}/{repo}/commits",
{
owner: this.owner,
repo: this.repo,
since: date || undefined,
per_page: 100,
}
)
// get files of each of the commits
const files = new Set<string>()
await Promise.all(
commits.map(async (commit) => {
const commitFiles = await this.getCommitFiles(commit.sha)
commitFiles?.forEach((commitFile) => files.add(commitFile.filename))
})
)
return [...files]
}
async getDiffFiles(): Promise<string[]> {
const childProcess = await promiseExec(
`git diff --name-only -- "packages/**/**.ts" "packages/**/*.js" "packages/**/*.tsx" "packages/**/*.jsx"`,
{
cwd: getMonorepoRoot(),
}
)
return filterFiles(
childProcess.stdout.toString().split("\n").filter(Boolean)
)
}
async getCommitFiles(commitSha: string) {
const {
data: { files },
} = await this.octokit.request("GET /repos/{owner}/{repo}/commits/{ref}", {
owner: "medusajs",
repo: "medusa",
ref: commitSha,
headers: {
"X-GitHub-Api-Version": "2022-11-28",
},
per_page: 3000,
})
return files
}
}

View File

@@ -0,0 +1,553 @@
import ts from "typescript"
import { DOCBLOCK_DOUBLE_LINES, DOCBLOCK_NEW_LINE } from "../../constants.js"
import pluralize from "pluralize"
import { camelToTitle, camelToWords, snakeToWords } from "utils"
import { normalizeName } from "../../utils/str-formatting.js"
type TemplateOptions = {
pluralIndicatorStr?: string
parentName?: string
rawParentName?: string
returnTypeName?: string
}
type KnowledgeBase = {
startsWith?: string
endsWith?: string
exact?: string
pattern?: RegExp
template:
| string
| ((str: string, options?: TemplateOptions) => string | undefined)
kind?: ts.SyntaxKind[]
}
export type RetrieveOptions = {
/**
* A name that can be of a function, type, etc...
*/
str: string
/**
* Options to pass to the `template` function of a
* knowledge base item.
*/
templateOptions?: TemplateOptions
/**
* The kind of the associated node.
*/
kind?: ts.SyntaxKind
}
type RetrieveSymbolOptions = Omit<RetrieveOptions, "str"> & {
/**
* The symbol to retrieve the item from the knowledge base.
*/
symbol: ts.Symbol
}
/**
* A class that holds common Medusa patterns and acts as a knowledge base for possible summaries/examples/general templates.
*/
class KnowledgeBaseFactory {
private TYPE_PLACEHOLDER = `{type name}`
private summaryKnowledgeBase: KnowledgeBase[] = [
{
startsWith: "FindConfig",
template: (str) => {
const typeArgs = str
.replace("FindConfig<", "")
.replace(/>$/, "")
.split(",")
.map((part) => camelToWords(normalizeName(part.trim())))
const typeName =
typeArgs.length > 0 && typeArgs[0].length > 0
? typeArgs[0]
: this.TYPE_PLACEHOLDER
return `The configurations determining how the ${typeName} is retrieved. Its properties, such as \`select\` or \`relations\`, accept the ${DOCBLOCK_NEW_LINE}attributes or relations associated with a ${typeName}.`
},
},
{
startsWith: "Filterable",
endsWith: "Props",
template: (str) => {
return `The filters to apply on the retrieved ${camelToWords(
normalizeName(str)
)}s.`
},
},
{
startsWith: "Create",
endsWith: "DTO",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The ${camelToWords(normalizeName(str))}${
isPlural ? "s" : ""
} to be created.`
},
},
{
startsWith: "Update",
endsWith: "DTO",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The attributes to update in the ${camelToWords(
normalizeName(str)
)}${isPlural ? "s" : ""}.`
},
},
{
endsWith: "UpdatableFields",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The attributes to update in the ${camelToWords(
normalizeName(str)
)}${isPlural ? "s" : ""}.`
},
},
{
startsWith: "Upsert",
endsWith: "DTO",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The attributes in the ${camelToWords(normalizeName(str))}${
isPlural ? "s" : ""
} to be created or updated.`
},
},
{
startsWith: "RestoreReturn",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`Configurations determining which relations to restore along with each of the ${this.TYPE_PLACEHOLDER}. You can pass to its \`returnLinkableKeys\` ${DOCBLOCK_NEW_LINE}property any of the ${this.TYPE_PLACEHOLDER}'s relation attribute names, such as \`{type relation name}\`.`,
options
)
},
},
{
endsWith: "DTO",
template: (str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return `The ${camelToWords(normalizeName(str))}${
isPlural ? "s" : ""
} details.`
},
},
{
endsWith: "_id",
template: (str: string): string => {
const formatted = str.replace(/_id$/, "").split("_").join(" ")
return `The associated ${formatted}'s ID.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
endsWith: "Id",
template: (str: string): string => {
const formatted = camelToWords(str.replace(/Id$/, ""))
return `The ${formatted}'s ID.`
},
kind: [
ts.SyntaxKind.PropertySignature,
ts.SyntaxKind.PropertyDeclaration,
ts.SyntaxKind.Parameter,
],
},
{
exact: "id",
template: (str, options) => {
if (options?.rawParentName?.startsWith("Filterable")) {
return `The IDs to filter the ${options?.parentName || `{name}`}s by.`
}
const parentName = options?.parentName
? options.parentName
: options?.rawParentName
? camelToWords(normalizeName(options.rawParentName))
: `{name}`
return `The ID of the ${parentName}.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "ids",
template: (str, options) => {
if (options?.rawParentName?.startsWith("Filterable")) {
return `The IDs to filter the ${options?.parentName || `{name}`} by.`
}
const parentName = options?.parentName
? options.parentName
: options?.rawParentName
? camelToWords(normalizeName(options.rawParentName))
: `{name}`
return `The IDs of the ${parentName}.`
},
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "metadata",
template: "Holds custom data in key-value pairs.",
kind: [ts.SyntaxKind.PropertySignature],
},
{
exact: "customHeaders",
template: "Custom headers to attach to the request.",
},
{
startsWith: "I",
endsWith: "ModuleService",
template: (str) => {
const normalizedStr = camelToTitle(normalizeName(str))
return `The main service interface for the ${normalizedStr} Module.`
},
},
]
private functionSummaryKnowledgeBase: KnowledgeBase[] = [
{
startsWith: "listAndCount",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`retrieves a paginated list of ${this.TYPE_PLACEHOLDER}s along with the total count of available ${this.TYPE_PLACEHOLDER}s satisfying the provided filters.`,
options
)
},
},
{
startsWith: "list",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`retrieves a paginated list of ${this.TYPE_PLACEHOLDER}s based on optional filters and configuration.`,
options
)
},
},
{
startsWith: "retrieve",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`retrieves a ${this.TYPE_PLACEHOLDER} by its ID.`,
options
)
},
},
{
startsWith: "create",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`creates${!isPlural ? " a" : ""} ${this.TYPE_PLACEHOLDER}${
isPlural ? "s" : ""
}.`,
options
)
},
},
{
startsWith: "delete",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`deletes${!isPlural ? " a" : ""} ${this.TYPE_PLACEHOLDER} by ${
isPlural ? "their" : "its"
} ID${isPlural ? "s" : ""}.`,
options
)
},
},
{
startsWith: "update",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`updates${!isPlural ? " an" : ""} existing ${this.TYPE_PLACEHOLDER}${
isPlural ? "s" : ""
}.`,
options
)
},
},
{
startsWith: "softDelete",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`soft deletes${!isPlural ? " a" : ""} ${this.TYPE_PLACEHOLDER}${
isPlural ? "s" : ""
} by ${isPlural ? "their" : "its"} IDs.`,
options
)
},
},
{
startsWith: "restore",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`restores${!isPlural ? " a" : ""} soft deleted ${
this.TYPE_PLACEHOLDER
}${isPlural ? "s" : ""} by ${isPlural ? "their" : "its"} IDs.`,
options
)
},
},
{
startsWith: "upsert",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`updates or creates${!isPlural ? " a" : ""} ${this.TYPE_PLACEHOLDER}${
isPlural ? "s" : ""
} if ${isPlural ? "they don't" : "it doesn't"} exist.`,
options
)
},
},
]
private functionReturnKnowledgeBase: KnowledgeBase[] = [
{
startsWith: "listAndCount",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`The list of ${this.TYPE_PLACEHOLDER}s along with their total count.`,
options
)
},
},
{
startsWith: "list",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`The list of ${this.TYPE_PLACEHOLDER}s.`,
options
)
},
},
{
startsWith: "retrieve",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`The retrieved ${this.TYPE_PLACEHOLDER}.`,
options
)
},
},
{
startsWith: "create",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`The created ${this.TYPE_PLACEHOLDER}${isPlural ? "s" : ""}.`,
options
)
},
},
{
startsWith: "update",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`The updated ${this.TYPE_PLACEHOLDER}${isPlural ? "s" : ""}.`,
options
)
},
},
{
startsWith: "upsert",
template: (_str, options) => {
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
return this.replaceTypePlaceholder(
`The created or updated ${this.TYPE_PLACEHOLDER}${
isPlural ? "s" : ""
}.`,
options
)
},
},
{
startsWith: "softDelete",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`An object that includes the IDs of related records that were also soft deleted, such as the ID of the associated {related entity name}. ${DOCBLOCK_NEW_LINE}The object's keys are the ID attribute names of the ${this.TYPE_PLACEHOLDER} entity's relations, such as \`{relation ID field name}\`, and its value is an array of strings, each being the ID of a record associated ${DOCBLOCK_NEW_LINE}with the ${this.TYPE_PLACEHOLDER} through this relation, such as the IDs of associated {related entity name}.${DOCBLOCK_DOUBLE_LINES}If there are no related records, the promise resolves to \`void\`.`,
options
)
},
},
{
startsWith: "restore",
template: (_str, options) => {
return this.replaceTypePlaceholder(
`An object that includes the IDs of related records that were restored, such as the ID of associated {relation name}. ${DOCBLOCK_NEW_LINE}The object's keys are the ID attribute names of the ${this.TYPE_PLACEHOLDER} entity's relations, such as \`{relation ID field name}\`, ${DOCBLOCK_NEW_LINE}and its value is an array of strings, each being the ID of the record associated with the ${this.TYPE_PLACEHOLDER} through this relation, ${DOCBLOCK_NEW_LINE}such as the IDs of associated {relation name}.${DOCBLOCK_DOUBLE_LINES}If there are no related records restored, the promise resolves to \`void\`.`,
options
)
},
},
]
private oasDescriptionKnowledgeBase: KnowledgeBase[] = [
{
pattern: /.*/,
template(str, options) {
if (!options?.parentName) {
return
}
const formattedName = str === "id" ? "ID" : snakeToWords(str)
const formattedParentName = pluralize.singular(
snakeToWords(options.parentName)
)
if (formattedName === formattedParentName) {
return `The ${formattedParentName}'s details.`
}
return `The ${formattedParentName}'s ${formattedName}.`
},
},
]
/**
* Tries to find in a specified knowledge base a template relevant to the specified name.
*
* @returns {string | undefined} The matching knowledge base template, if found.
*/
private tryToFindInKnowledgeBase({
str,
knowledgeBase,
templateOptions,
kind,
}: RetrieveOptions & {
/**
* A knowledge base to search in.
*/
knowledgeBase: KnowledgeBase[]
}): string | undefined {
const foundItem = knowledgeBase.find((item) => {
if (item.exact) {
return str === item.exact
}
if (item.pattern) {
return item.pattern.test(str)
}
if (item.kind?.length && (!kind || !item.kind.includes(kind))) {
return false
}
if (item.startsWith && item.endsWith) {
return str.startsWith(item.startsWith) && str.endsWith(item.endsWith)
}
if (item.startsWith) {
return str.startsWith(item.startsWith)
}
return item.endsWith ? str.endsWith(item.endsWith) : false
})
if (!foundItem) {
return
}
return typeof foundItem.template === "string"
? foundItem?.template
: foundItem?.template(str, templateOptions)
}
/**
* This method replaces uses of {@link TYPE_PLACEHOLDER} with the normalized parent name, if provided.
*
* @param str - The string to normalize
* @param options - The template options
* @returns The normalized string
*/
private replaceTypePlaceholder(
str: string,
options?: TemplateOptions
): string {
const typeName = options?.rawParentName
? camelToWords(normalizeName(options.rawParentName))
: this.TYPE_PLACEHOLDER
return str.replaceAll(this.TYPE_PLACEHOLDER, typeName)
}
/**
* Checks whether a type should be handled as a plural. Typically used with {@link TemplateOptions.pluralIndicatorStr}.
*
* @param str - The type string to check.
* @returns Whether the type is handled as a plural.
*/
private isTypePlural(str: string | undefined): boolean {
return str?.endsWith("[]") || false
}
/**
* Tries to retrieve the summary template of a specified type from the {@link summaryKnowledgeBase}.
*
* @returns {string | undefined} The matching knowledge base template, if found.
*/
tryToGetSummary({ str, ...options }: RetrieveOptions): string | undefined {
const normalizedTypeStr = str.replaceAll("[]", "")
return this.tryToFindInKnowledgeBase({
...options,
str: normalizedTypeStr,
knowledgeBase: this.summaryKnowledgeBase,
templateOptions: {
pluralIndicatorStr: str,
...options.templateOptions,
},
})
}
/**
* Tries to retrieve the summary template of a function's symbol from the {@link functionSummaryKnowledgeBase}.
*
* @returns {string | undefined} The matching knowledge base template, if found.
*/
tryToGetFunctionSummary({
symbol,
...options
}: RetrieveSymbolOptions): string | undefined {
return this.tryToFindInKnowledgeBase({
...options,
str: symbol.getName(),
knowledgeBase: this.functionSummaryKnowledgeBase,
})
}
/**
* Tries to retrieve the return template of a function's symbol from the {@link functionReturnKnowledgeBase}.
*
* @returns {string | undefined} The matching knowledge base template, if found.
*/
tryToGetFunctionReturns({
symbol,
...options
}: RetrieveSymbolOptions): string | undefined {
return this.tryToFindInKnowledgeBase({
...options,
str: symbol.getName(),
knowledgeBase: this.functionReturnKnowledgeBase,
})
}
/**
* Tries to retrieve the description template of an OAS property from the {@link oasDescriptionKnowledgeBase}.
*
* @returns {string | undefined} The matching knowledgebase template, if found.
*/
tryToGetOasDescription({
str,
...options
}: RetrieveOptions): string | undefined {
const normalizedTypeStr = str.replaceAll("[]", "")
return this.tryToFindInKnowledgeBase({
...options,
str: normalizedTypeStr,
knowledgeBase: this.oasDescriptionKnowledgeBase,
})
}
}
export default KnowledgeBaseFactory

View File

@@ -0,0 +1,257 @@
import { OpenAPIV3 } from "openapi-types"
import { OpenApiSchema } from "../../types/index.js"
import Formatter from "./formatter.js"
import { join } from "path"
import { DOCBLOCK_LINE_ASTRIX } from "../../constants.js"
import ts from "typescript"
import getOasOutputBasePath from "../../utils/get-oas-output-base-path.js"
import { parse } from "yaml"
import formatOas from "../../utils/format-oas.js"
import pluralize from "pluralize"
import { wordsToPascal } from "utils"
export type ParsedSchema = {
schema: OpenApiSchema
schemaPrefix: string
}
/**
* Class providing helper methods for OAS Schemas
*/
class OasSchemaHelper {
/**
* This map collects schemas created while generating the OAS, then, once the generation process
* finishes, it checks if it should be added to the base OAS document.
*/
private schemas: Map<string, OpenApiSchema>
protected schemaRefPrefix = "#/components/schemas/"
protected formatter: Formatter
/**
* The path to the directory holding the base YAML files.
*/
protected baseOutputPath: string
constructor() {
this.schemas = new Map()
this.formatter = new Formatter()
this.baseOutputPath = getOasOutputBasePath()
}
/**
* Initialize the {@link schemas} property. Helpful when resetting the property.
*/
init() {
this.schemas = new Map()
}
/**
* Retrieve schema as a reference object and add the schema to the {@link schemas} property.
*
* @param schema - The schema to convert and add to the schemas property.
* @returns The schema as a reference. If the schema doesn't have the x-schemaName property set,
* the schema isn't converted and `undefined` is returned.
*/
schemaToReference(
schema: OpenApiSchema
): OpenAPIV3.ReferenceObject | undefined {
if (!schema["x-schemaName"]) {
return
}
schema["x-schemaName"] = this.normalizeSchemaName(schema["x-schemaName"])
// check if schema has child schemas
// and convert those
if (schema.properties) {
Object.keys(schema.properties).forEach((property) => {
const propertySchema = schema.properties![property]
if ("$ref" in propertySchema) {
return
}
// if the property is an array, possibly convert its items schema
// to a reference.
if (
propertySchema.type === "array" &&
propertySchema.items &&
!("$ref" in propertySchema.items)
) {
propertySchema.items =
this.schemaToReference(propertySchema.items) || propertySchema.items
} else if (
propertySchema.oneOf ||
propertySchema.allOf ||
propertySchema.anyOf
) {
// if the property is a combination of types, go through each of
// the types and try to convert them to references.
const schemaTarget =
propertySchema.oneOf || propertySchema.allOf || propertySchema.anyOf
schemaTarget!.forEach((item, index) => {
if ("$ref" in item) {
return
}
schemaTarget![index] = this.schemaToReference(item) || item
})
}
schema.properties![property] =
this.schemaToReference(propertySchema as OpenApiSchema) ||
propertySchema
})
}
this.schemas.set(schema["x-schemaName"], schema)
return {
$ref: this.constructSchemaReference(schema["x-schemaName"]),
}
}
/**
* Retrieve the expected file name of the schema.
*
* @param name - The schema's name
* @returns The schema's file name
*/
getSchemaFileName(name: string): string {
return join(
this.baseOutputPath,
"schemas",
`${this.normalizeSchemaName(name)}.ts`
)
}
/**
* Retrieve the schema by its name. If the schema is in the {@link schemas} map, it'll be retrieved from
* there. Otherwise, the method will try to retrieve it from an outputted schema file, if available.
*
* @param name - The schema's name.
* @returns The parsed schema, if found.
*/
getSchemaByName(name: string): ParsedSchema | undefined {
const schemaName = this.normalizeSchemaName(name)
// check if it already exists in the schemas map
if (this.schemas.has(schemaName)) {
return {
schema: this.schemas.get(schemaName)!,
schemaPrefix: `@schema ${schemaName}`,
}
}
const schemaFile = this.getSchemaFileName(schemaName)
const schemaFileContent = ts.sys.readFile(schemaFile)
if (!schemaFileContent) {
return
}
return this.parseSchema(schemaFileContent)
}
/**
* Parses a schema comment string.
*
* @param content - The schema comment string
* @returns If the schema is valid and parsed successfully, the schema and its prefix are retrieved.
*/
parseSchema(content: string): ParsedSchema | undefined {
const schemaFileContent = content
.replace(`/**\n`, "")
.replaceAll(DOCBLOCK_LINE_ASTRIX, "")
.replaceAll("*/", "")
.trim()
if (!schemaFileContent.startsWith("@schema")) {
return
}
const splitContent = schemaFileContent.split("\n")
const schemaPrefix = splitContent[0]
let schema: OpenApiSchema | undefined
try {
schema = parse(splitContent.slice(1).join("\n"))
} catch (e) {
// couldn't parse the OAS, so consider it
// not existent
}
return schema
? {
schema,
schemaPrefix,
}
: undefined
}
/**
* Retrieve the normalized schema name. A schema's name must be normalized before saved.
*
* @param name - The original name.
* @returns The normalized name.
*/
normalizeSchemaName(name: string): string {
return name.replace("DTO", "").replace(this.schemaRefPrefix, "")
}
/**
* Construct a reference string to a schema.
*
* @param name - The name of the schema. For cautionary reasons, the name is normalized using the {@link normalizeSchemaName} method.
* @returns The schema reference.
*/
constructSchemaReference(name: string): string {
return `${this.schemaRefPrefix}${this.normalizeSchemaName(name)}`
}
/**
* Writes schemas in the {@link schemas} property to the file path retrieved using the {@link getSchemaFileName} method.
*/
writeNewSchemas() {
this.schemas.forEach((schema) => {
if (!schema["x-schemaName"]) {
return
}
const normalizedName = this.normalizeSchemaName(schema["x-schemaName"])
const schemaFileName = this.getSchemaFileName(normalizedName)
ts.sys.writeFile(
schemaFileName,
this.formatter.addCommentsToSourceFile(
formatOas(schema, `@schema ${normalizedName}`),
""
)
)
})
}
/**
* Checks whether an object is a reference object.
*
* @param schema - The schema object to check.
* @returns Whether the object is a reference object.
*/
isRefObject(
schema:
| OpenAPIV3.ReferenceObject
| OpenApiSchema
| OpenAPIV3.RequestBodyObject
| OpenAPIV3.ResponseObject
| undefined
): schema is OpenAPIV3.ReferenceObject {
return schema !== undefined && "$ref" in schema
}
/**
* Converts a tag name to a schema name. Can be used to try and retrieve the schema
* associated with a tag.
*
* @param tagName - The name of the tag.
* @returns The possible name of the associated schema.
*/
tagNameToSchemaName(tagName: string): string {
return wordsToPascal(pluralize.singular(tagName))
}
}
export default OasSchemaHelper

View File

@@ -0,0 +1,45 @@
import { OpenApiSchema } from "../../types/index.js"
/**
* This class has predefined OAS schemas for some types. It's used to bypass
* the logic of creating a schema for certain types.
*/
class SchemaFactory {
/**
* The pre-defined schemas.
*/
private schemas: Record<string, OpenApiSchema> = {
BigNumberInput: {
type: "string",
},
BigNumber: {
type: "string",
},
}
/**
* Try to retrieve the pre-defined schema of a type name.
*
* @param name - the name of the type.
* @param additionalData - Additional data to pass along/override in the predefined schema. For example, a description.
* @returns The schema, if found.
*/
public tryGetSchema(
name: string,
additionalData?: Partial<OpenApiSchema>
): OpenApiSchema | undefined {
if (!Object.hasOwn(this.schemas, name)) {
return
}
let schema = Object.assign({}, this.schemas[name])
if (additionalData) {
schema = Object.assign(schema, additionalData)
}
return schema
}
}
export default SchemaFactory

View File

@@ -0,0 +1,600 @@
import ts from "typescript"
import {
DOCBLOCK_START,
DOCBLOCK_END_LINE,
DOCBLOCK_DOUBLE_LINES,
DOCBLOCK_NEW_LINE,
} from "../../constants.js"
import getSymbol from "../../utils/get-symbol.js"
import KnowledgeBaseFactory, {
RetrieveOptions,
} from "../helpers/knowledge-base-factory.js"
import {
getCustomNamespaceTag,
shouldHaveCustomNamespace,
} from "../../utils/medusa-react-utils.js"
import GeneratorEventManager from "../helpers/generator-event-manager.js"
import { CommonCliOptions } from "../../types/index.js"
import AiGenerator from "../helpers/ai-generator.js"
import { camelToWords, capitalize } from "utils"
import { normalizeName } from "../../utils/str-formatting.js"
export type GeneratorOptions = {
checker: ts.TypeChecker
kinds?: ts.SyntaxKind[]
generatorEventManager: GeneratorEventManager
additionalOptions: Pick<CommonCliOptions, "generateExamples">
}
export type GetDocBlockOptions = {
addEnd?: boolean
summaryPrefix?: string
aiGenerator?: AiGenerator
}
type CommonDocsOptions = {
addDefaultSummary?: boolean
prefixWithLineBreaks?: boolean
}
/**
* Class used to generate docblocks for basic kinds. It can be
* extended for kinds requiring more elaborate TSDocs.
*/
class DefaultKindGenerator<T extends ts.Node = ts.Node> {
static DEFAULT_ALLOWED_NODE_KINDS = [
ts.SyntaxKind.SourceFile,
ts.SyntaxKind.ClassDeclaration,
ts.SyntaxKind.EnumDeclaration,
ts.SyntaxKind.EnumMember,
ts.SyntaxKind.ModuleDeclaration,
ts.SyntaxKind.PropertyDeclaration,
ts.SyntaxKind.InterfaceDeclaration,
ts.SyntaxKind.TypeAliasDeclaration,
ts.SyntaxKind.PropertySignature,
]
public name = "default"
protected allowedKinds: ts.SyntaxKind[]
protected checker: ts.TypeChecker
protected defaultSummary = "{summary}"
protected knowledgeBaseFactory: KnowledgeBaseFactory
protected generatorEventManager: GeneratorEventManager
protected options: Pick<CommonCliOptions, "generateExamples">
constructor({
checker,
kinds,
generatorEventManager,
additionalOptions,
}: GeneratorOptions) {
this.allowedKinds = kinds || DefaultKindGenerator.DEFAULT_ALLOWED_NODE_KINDS
this.checker = checker
this.knowledgeBaseFactory = new KnowledgeBaseFactory()
this.generatorEventManager = generatorEventManager
this.options = additionalOptions
}
/**
* @returns the kinds that are handled by this generator.
*/
getAllowedKinds(): ts.SyntaxKind[] {
return this.allowedKinds
}
/**
* Check whether this generator can be used for a node based on the node's kind.
*
* @param {ts.Node} node - The node to check for.
* @returns {boolean} Whether this generator can be used with the specified node.
*/
isAllowed(node: ts.Node): node is T {
return this.allowedKinds.includes(node.kind)
}
/**
* Retrieve the doc block for the passed node.
*
* @param {T | ts.Node} node - The node to retrieve the docblock for.
* @param {GetDocBlockOptions} options - Options useful for children classes of this class to specify the formatting of the docblock.
* @returns {string} The node's docblock.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getDocBlock(
node: T | ts.Node,
options: GetDocBlockOptions = { addEnd: true }
): Promise<string> {
let str = DOCBLOCK_START
const summary = this.getNodeSummary({ node })
switch (node.kind) {
case ts.SyntaxKind.EnumDeclaration:
str += `@enum${DOCBLOCK_DOUBLE_LINES}${summary}`
break
case ts.SyntaxKind.TypeAliasDeclaration:
str += `@interface${DOCBLOCK_DOUBLE_LINES}${summary}`
break
default:
str += summary
}
str += this.getCommonDocs(node, {
prefixWithLineBreaks: true,
})
return `${str}${options.addEnd ? DOCBLOCK_END_LINE : ""}`
}
/**
* Retrieves the summary comment of a node. It gives precedense to the node's symbol if it's provided/retrieved and if it's available using the {@link getSymbolDocBlock}.
* Otherwise, it retrieves the comments of the type using the {@link getTypeDocBlock}
* @returns {string} The summary comment.
*/
getNodeSummary({
node,
symbol,
nodeType,
knowledgeBaseOptions: overrideOptions,
}: {
/**
* The node to retrieve the summary comment for.
*/
node: T | ts.Node
/**
* Optionally provide the node's symbol. If not provided, the
* method will try to retrieve it.
*/
symbol?: ts.Symbol
/**
* Optionally provide the node's type. If not provided, the method
* will try to retrieve it.
*/
nodeType?: ts.Type
/**
* Override any of the default knowledge base options
* inferred using the {@link getKnowledgeBaseOptions} method
*/
knowledgeBaseOptions?: Partial<RetrieveOptions>
}): string {
const syntheticComments = ts.getSyntheticLeadingComments(node)
if (syntheticComments?.length) {
return syntheticComments.map((comment) => comment.text).join(" ")
}
const knowledgeBaseOptions = {
...this.getKnowledgeBaseOptions(node),
...overrideOptions,
}
if (!nodeType) {
nodeType =
"type" in node && node.type && ts.isTypeNode(node.type as ts.Node)
? this.checker.getTypeFromTypeNode(node.type as ts.TypeNode)
: symbol
? this.checker.getTypeOfSymbolAtLocation(symbol, node)
: this.checker.getTypeAtLocation(node)
}
if (!symbol) {
symbol = getSymbol(node, this.checker)
}
let summary = ""
if (symbol) {
summary = this.getSymbolDocBlock(symbol, knowledgeBaseOptions)
}
if (!summary.length) {
summary = this.getTypeDocBlock(nodeType, knowledgeBaseOptions)
}
return summary.length > 0 ? summary : this.defaultSummary
}
/**
* Retrieves the summary comment of a type. It tries to retrieve from the alias symbol, type arguments, or {@link KnowledgeBaseFactory}.
* If no summary comments are found, the {@link defaultSummary} is used.
*
* @param {ts.Type} nodeType - The type of a node.
* @returns {string} The summary comment.
*/
protected getTypeDocBlock(
nodeType: ts.Type,
knowledgeBaseOptions?: Partial<RetrieveOptions>
): string {
if (nodeType.aliasSymbol || nodeType.symbol) {
const symbolDoc = this.getSymbolDocBlock(
nodeType.aliasSymbol || nodeType.symbol
)
if (symbolDoc.length) {
return symbolDoc
}
}
const typeArguments = this.checker.getTypeArguments(
nodeType as ts.TypeReference
)
if (typeArguments.length) {
// take only the first type argument to account
const typeArgumentDoc = this.getTypeDocBlock(typeArguments[0])
if (!typeArgumentDoc.length) {
const tryKnowledgeSummary = this.knowledgeBaseFactory.tryToGetSummary({
...knowledgeBaseOptions,
str: this.checker.typeToString(nodeType),
})
if (tryKnowledgeSummary?.length) {
return tryKnowledgeSummary
}
}
if (!this.checker.isArrayType(nodeType)) {
return typeArgumentDoc
}
// do some formatting if the encapsulating type is an array
return `The list of ${capitalize(typeArgumentDoc) || this.defaultSummary}`
}
return (
this.knowledgeBaseFactory.tryToGetSummary({
...knowledgeBaseOptions,
str: this.checker.typeToString(nodeType),
}) || ""
)
}
/**
* Retrieves the docblock of a symbol. It tries to retrieve it using the symbol's `getDocumentationComment` and `getJsDocTags`
* methods. If both methods don't return any comments, it tries to get the comments from the {@link KnowledgeBaseFactory}.
*
* @param {ts.Symbol} symbol - The symbol to retrieve its docblock.
* @returns {string} The symbol's docblock.
*/
protected getSymbolDocBlock(
symbol: ts.Symbol,
knowledgeBaseOptions?: Partial<RetrieveOptions>
): string {
const commentDisplayParts = symbol.getDocumentationComment(this.checker)
if (!commentDisplayParts.length) {
// try to get description from the first JSDoc comment
const jsdocComments = symbol.getJsDocTags(this.checker)
if (jsdocComments.length) {
jsdocComments
.find((tag) => (tag.text?.length || 0) > 0)
?.text!.forEach((tagText) => {
commentDisplayParts.push(tagText)
})
}
}
if (!commentDisplayParts.length) {
return (
this.knowledgeBaseFactory.tryToGetSummary({
...knowledgeBaseOptions,
str: this.checker.typeToString(this.checker.getTypeOfSymbol(symbol)),
}) ||
this.knowledgeBaseFactory.tryToGetSummary({
...knowledgeBaseOptions,
str: symbol.name,
}) ||
""
)
}
return ts
.displayPartsToString(commentDisplayParts)
.replaceAll("\n", DOCBLOCK_NEW_LINE)
}
/**
* Retrieves docblocks based on decorators used on a symbol.
*
* @param {ts.Symbol} symbol - The symbol to retrieve its decorators docblock.
* @returns {string} The symbol's decorators docblock.
*/
getDecoratorDocs(symbol: ts.Symbol): string {
let str = ""
symbol.declarations?.forEach((declaration) => {
const modifiers =
"modifiers" in declaration && declaration.modifiers
? (declaration.modifiers as ts.NodeArray<ts.Modifier>)
: []
modifiers.forEach((modifier) => {
if (!ts.isDecorator(modifier)) {
return
}
// check for decorator text
;(modifier as ts.Decorator).forEachChild((childNode) => {
if (ts.isCallExpression(childNode)) {
const childNodeExpression = (childNode as ts.CallExpression)
.expression
if (ts.isIdentifier(childNodeExpression)) {
switch (childNodeExpression.escapedText) {
case "FeatureFlagEntity":
// add the `@featureFlag` tag.
str += `${DOCBLOCK_DOUBLE_LINES}@featureFlag [flag_name]`
break
case "BeforeInsert":
case "BeforeLoad":
case "AfterLoad":
// add `@apiIgnore` tag
str += `${DOCBLOCK_DOUBLE_LINES}@apiIgnore`
}
}
}
})
})
})
return str
}
/**
* Retrieve docblocks that are common to all nodes, despite their kind.
*
* @param {T | ts.Node} node - The node to retrieve its common doc blocks.
* @param {CommonDocsOptions} options - Formatting options.
* @returns {string} The common docblocks.
*/
getCommonDocs(
node: T | ts.Node,
options: CommonDocsOptions = { addDefaultSummary: false }
): string {
const tags = new Set<string>()
const symbol = getSymbol(node, this.checker)
if (!symbol) {
return ""
}
if (ts.isSourceFile(node)) {
// comments for source files must start with this tag
tags.add(`@packageDocumentation`)
}
if (options.addDefaultSummary) {
tags.add(this.defaultSummary)
}
// check for private or protected modifiers
// and if found, add the `@ignore` tag.
symbol.declarations?.some((declaration) => {
if (!("modifiers" in declaration) || !declaration.modifiers) {
return false
}
const hasPrivateOrProtected = (
declaration.modifiers as ts.NodeArray<ts.Modifier>
).find((modifier) => {
modifier.kind === ts.SyntaxKind.PrivateKeyword ||
modifier.kind === ts.SyntaxKind.ProtectedKeyword
})
if (!hasPrivateOrProtected) {
return false
}
tags.add("@ignore")
return true
})
// if a symbol's name starts with `_` then we
// should add the `@ignore` tag
if (symbol.getName().startsWith("_")) {
tags.add("@ignore")
}
// check if any docs can be added for the symbol's
// decorators
this.getDecoratorDocs(symbol)
.split(`${DOCBLOCK_DOUBLE_LINES}`)
.filter((docItem) => docItem.length > 0)
.forEach((docItem) => tags.add(docItem))
// add `@expandable` tag if the resource is
if (ts.isPropertyDeclaration(node)) {
const symbolType = this.checker.getTypeOfSymbol(symbol)
if (
symbolType.symbol?.declarations?.length &&
ts.isClassDeclaration(symbolType.symbol?.declarations[0]) &&
this.isEntity({
heritageClauses: (
symbolType.symbol?.declarations[0] as ts.ClassDeclaration
).heritageClauses,
node: symbolType.symbol?.declarations[0],
})
) {
tags.add(`@expandable`)
}
}
// check if custom namespace should be added
if (shouldHaveCustomNamespace(node)) {
tags.add(getCustomNamespaceTag(node))
}
// check for default value
const defaultValue = this.getDefaultValue(node)
if (defaultValue?.length) {
tags.add(`@defaultValue ${defaultValue}`)
}
let str = ""
tags.forEach((tag) => {
if (str.length > 0) {
str += `${DOCBLOCK_DOUBLE_LINES}`
}
str += `${tag}`
})
if (str.length && options.prefixWithLineBreaks) {
str = `${DOCBLOCK_DOUBLE_LINES}${str}`
}
return str
}
/**
* Check if a node is a Medusa entity.
* @returns {boolean} Whether the node is a Medusa entity.
*/
isEntity({
/**
* The inherit/extend keywords of the node.
*/
heritageClauses,
/**
* Optionally provide the node to accurately retrieve its type name.
*/
node,
}: {
heritageClauses?: ts.NodeArray<ts.HeritageClause>
node?: ts.Node
}): boolean {
return (
heritageClauses?.some((heritageClause) => {
return heritageClause.types.some((heritageClauseType) => {
const symbolType = this.checker.getTypeAtLocation(
heritageClauseType.expression
)
if (
this.checker
.typeToString(symbolType, node, undefined)
.includes("BaseEntity")
) {
return true
}
if (
symbolType.symbol.valueDeclaration &&
"heritageClauses" in symbolType.symbol.valueDeclaration
) {
return this.isEntity({
heritageClauses: symbolType.symbol.valueDeclaration
.heritageClauses as ts.NodeArray<ts.HeritageClause>,
node,
})
}
return false
})
}) || false
)
}
/**
* Get knowledge base options for a specified node.
*
* @param node - The node to retrieve its knowledge base options.
* @returns The knowledge base options.
*/
getKnowledgeBaseOptions(node: ts.Node): Partial<RetrieveOptions> {
const rawParentName =
"name" in node.parent &&
node.parent.name &&
ts.isIdentifier(node.parent.name as ts.Node)
? (node.parent.name as ts.Identifier).getText()
: undefined
return {
kind: node.kind,
templateOptions: {
rawParentName,
parentName: rawParentName
? camelToWords(normalizeName(rawParentName))
: undefined,
},
}
}
/**
* Get the default value of a node.
*
* @param node - The node to get its default value.
* @returns The default value, if any.
*/
getDefaultValue(node: ts.Node): string | undefined {
if (
"initializer" in node &&
node.initializer &&
ts.isExpression(node.initializer as ts.Node)
) {
const initializer = node.initializer as ts.Expression
// retrieve default value only if the value is numeric, string, or boolean
const defaultValue =
ts.isNumericLiteral(initializer) || ts.isStringLiteral(initializer)
? initializer.getText()
: initializer.kind === ts.SyntaxKind.FalseKeyword
? "false"
: initializer.kind === ts.SyntaxKind.TrueKeyword
? "true"
: ""
if (defaultValue.length) {
return defaultValue
}
}
}
/**
* Checks whether a node can be documented.
*
* @param {ts.Node} node - The node to check for.
* @returns {boolean} Whether the node can be documented.
*/
canDocumentNode(node: ts.Node): boolean {
// check if node already has docblock
return !this.nodeHasComments(node)
}
/**
* Get the comments range of a node.
* @param node - The node to get its comment range.
* @returns The comment range of the node if available.
*/
getNodeCommentsRange(node: ts.Node): ts.CommentRange[] | undefined {
return ts.getLeadingCommentRanges(
node.getSourceFile().getFullText(),
node.getFullStart()
)
}
/**
* Get a node's comment from its range.
*
* @param node - The node to get its comment range.
* @returns The comment if available.
*/
getNodeCommentsFromRange(node: ts.Node): string | undefined {
const commentRange = this.getNodeCommentsRange(node)
if (!commentRange?.length) {
return
}
return node
.getSourceFile()
.getFullText()
.slice(commentRange[0].pos, commentRange[0].end)
}
/**
* Check whether a node has comments.
*
* @param node - The node to check.
* @returns Whether the node has comments.
*/
nodeHasComments(node: ts.Node): boolean {
return this.getNodeCommentsFromRange(node) !== undefined
}
}
export default DefaultKindGenerator

View File

@@ -0,0 +1,102 @@
import ts from "typescript"
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
import { DOCBLOCK_END_LINE, DOCBLOCK_START } from "../../constants.js"
import { camelToWords, snakeToWords } from "utils"
import { normalizeName } from "../../utils/str-formatting.js"
/**
* A class that generates doc blocks for properties in a DTO interface/type.
*/
class DTOPropertyGenerator extends DefaultKindGenerator<ts.PropertySignature> {
protected allowedKinds: ts.SyntaxKind[] = [ts.SyntaxKind.PropertySignature]
public name = "dto-property"
/**
* Check that the generator can handle generating for the node.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether the generator can handle generating for the node.
*/
isAllowed(node: ts.Node): node is ts.PropertySignature {
if (!super.isAllowed(node)) {
return false
}
return (
this.getParentName((node as ts.PropertySignature).parent).endsWith(
"DTO"
) || false
)
}
async getDocBlock(
node: ts.PropertyDeclaration | ts.Node,
options?: GetDocBlockOptions
): Promise<string> {
if (!this.isAllowed(node)) {
return await super.getDocBlock(node, options)
}
let str = DOCBLOCK_START
const rawParentName = this.getParentName(node.parent)
const parentName = this.formatInterfaceName(rawParentName)
// try first to retrieve the summary from the knowledge base if it exists.
const summary = this.knowledgeBaseFactory.tryToGetSummary({
str: node.name.getText(),
kind: node.kind,
templateOptions: {
rawParentName,
parentName,
},
})
if (summary) {
str += summary
} else {
// check if the property's type is interface/type/class
const propertyType = this.checker.getTypeAtLocation(node)
if (propertyType.isClassOrInterface()) {
str += `The associated ${this.formatInterfaceName(
this.checker.typeToString(propertyType)
)}.`
} else if (
"intrinsicName" in propertyType &&
propertyType.intrinsicName === "boolean"
) {
str += `Whether the ${parentName} ${snakeToWords(node.name.getText())}.`
} else {
// format summary
str += `The ${snakeToWords(node.name.getText())} of the ${parentName}.`
}
}
return `${str}${DOCBLOCK_END_LINE}`
}
/**
* Format the name of the interface/type.
*
* @param {string} name - The name to format.
* @returns {string} The formatted name.
*/
formatInterfaceName(name: string): string {
return camelToWords(normalizeName(name))
}
/**
* Get the name of the parent interface/type.
*
* @param {ts.InterfaceDeclaration | ts.TypeLiteralNode} parent - The parent node.
* @returns {string} The name of the parent.
*/
getParentName(parent: ts.InterfaceDeclaration | ts.TypeLiteralNode): string {
if (ts.isInterfaceDeclaration(parent)) {
return parent.name.getText()
}
return this.checker.typeToString(this.checker.getTypeFromTypeNode(parent))
}
}
export default DTOPropertyGenerator

View File

@@ -0,0 +1,385 @@
import ts from "typescript"
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
import {
DOCBLOCK_NEW_LINE,
DOCBLOCK_END_LINE,
DOCBLOCK_START,
DOCBLOCK_DOUBLE_LINES,
} from "../../constants.js"
import getSymbol from "../../utils/get-symbol.js"
import AiGenerator from "../helpers/ai-generator.js"
import path from "path"
export type FunctionNode =
| ts.MethodDeclaration
| ts.MethodSignature
| ts.FunctionDeclaration
| ts.ArrowFunction
export type VariableNode = ts.VariableDeclaration | ts.VariableStatement
export type FunctionOrVariableNode = FunctionNode | ts.VariableStatement
/**
* Docblock generator for functions.
*/
// eslint-disable-next-line max-len
class FunctionKindGenerator extends DefaultKindGenerator<FunctionOrVariableNode> {
protected methodKinds: ts.SyntaxKind[] = [
ts.SyntaxKind.MethodDeclaration,
ts.SyntaxKind.MethodSignature,
]
protected functionKinds: ts.SyntaxKind[] = [ts.SyntaxKind.FunctionDeclaration]
protected allowedKinds: ts.SyntaxKind[] = [
...this.methodKinds,
...this.functionKinds,
]
public name = "function"
static EXAMPLE_PLACEHOLDER = `{example-code}`
protected aiParameterExceptions = ["sharedContext"]
/**
* Checks whether a node is considered a function node. A node is considered a function node if:
*
* 1. It is a method declaration (typically in classes), a method signature (typically in interfaces), or a function declaration.
* 2. An arrow function. However, for better docblock placement and formatting, we detect the variable statement surrounding the arrow function
* rather than the arrow function itself.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether the node is a function node and can be handled by this generator.
*/
isAllowed(node: ts.Node): node is FunctionOrVariableNode {
if (!super.isAllowed(node)) {
return ts.isVariableStatement(node) && this.isFunctionVariable(node)
}
return true
}
/**
* Checks whether a node is a variable statement/declaration with underlying node function
* using the {@link extractFunctionNode} method.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether the node is a variable statement/declaration with underlying node function.
*/
isFunctionVariable(node: ts.Node): node is VariableNode {
if (ts.isVariableStatement(node)) {
return node.declarationList.declarations.some((declaration) => {
return this.isFunctionVariable(declaration)
})
} else if (ts.isVariableDeclaration(node)) {
return this.extractFunctionNode(node) !== undefined
}
return false
}
/**
* Retrieves the underlying function/method/arrow function of a variable statement or declaration.
*
* @param {ts.Node} node - The variable statement/declaration to retrieve the function/method from.
* @returns The function/method if found.
*/
extractFunctionNode(node: VariableNode): FunctionNode | undefined {
if (ts.isVariableStatement(node)) {
const variableDeclaration = node.declarationList.declarations.find(
(declaration) => ts.isVariableDeclaration(declaration)
)
return variableDeclaration
? this.extractFunctionNode(variableDeclaration)
: undefined
} else if (
node.initializer &&
(this.isAllowed(node.initializer) || ts.isArrowFunction(node.initializer))
) {
return node.initializer
}
}
/**
* Check whether a node refers to a method.
*
* @param {FunctionNode} node - The node to check.
* @returns {boolean} Whether the node is a method.
*/
isMethod(
node: FunctionNode
): node is ts.MethodDeclaration | ts.MethodSignature {
return this.methodKinds.includes(node.kind)
}
/**
* Checks whether a type, typically the type of a function's signature, has return data.
*
* @param {string} typeStr - The type's string representation.
* @returns {boolean} Whether the type has return data.
*/
hasReturnData(typeStr: string): boolean {
return (
typeStr !== "void" &&
typeStr !== "never" &&
typeStr !== "Promise<void>" &&
typeStr !== "Promise<never>"
)
}
/**
* Retrieves the return type of a function.
*
* @param {FunctionNode} node - The function's node.
* @returns {ts.Type} The function's return type.
*/
getReturnType(node: FunctionNode): ts.Type {
return node.type
? this.checker.getTypeFromTypeNode(node.type)
: this.checker.getTypeAtLocation(node)
}
/**
* Retrieves the summary comment of a function.
*
* @param {FunctionNode} node - The function's options.
* @returns {string} The function's summary comment.
*/
getFunctionSummary({
node,
symbol,
parentSymbol,
returnType,
}: {
/**
* The node's function.
*/
node: FunctionNode
/**
* The node's symbol. If provided, the method will try to retrieve the summary from the {@link KnowledgeBaseFactory}.
*/
symbol?: ts.Symbol
/**
* The node's parent symbol. This is useful to pass along the parent name to the knowledge base.
*/
parentSymbol?: ts.Symbol
/**
* The node's return type. Useful for the {@link KnowledgeBaseFactory}
*/
returnType?: string
}): string {
return symbol
? this.knowledgeBaseFactory.tryToGetFunctionSummary({
symbol: symbol,
kind: node.kind,
templateOptions: {
rawParentName: parentSymbol?.getName(),
pluralIndicatorStr: returnType,
},
}) || this.getNodeSummary({ node, symbol })
: this.getNodeSummary({ node, symbol })
}
/**
* Retrieve the function's example comment.
*
* @param {ts.Symbol} symbol - The function's symbol. If provided, the method will try to retrieve the example from the {@link KnowledgeBaseFactory}.
* @returns {string} The function's example comment.
*/
getFunctionPlaceholderExample(): string {
return this.formatExample(FunctionKindGenerator.EXAMPLE_PLACEHOLDER)
}
/**
* Retrieves a function's example using the AiGenerator
*
* @param node - The function's node.
* @param aiGenerator - An instance of the AiGenerator
* @returns the example code
*/
async getFunctionExampleAi(
node: FunctionOrVariableNode,
aiGenerator: AiGenerator,
withTag = true
): Promise<string> {
const actualNode = ts.isVariableStatement(node)
? this.extractFunctionNode(node)
: node
if (!actualNode) {
return ""
}
const symbol = getSymbol(node, this.checker)
const example = await aiGenerator.generateExample({
className: this.isMethod(actualNode)
? getSymbol(node.parent, this.checker)?.name
: undefined,
functionName: symbol?.name || "",
signature: node.getText(),
fileName: path.basename(node.getSourceFile().fileName),
})
return this.formatExample(
example.length
? `${example}${DOCBLOCK_NEW_LINE}`
: FunctionKindGenerator.EXAMPLE_PLACEHOLDER,
withTag
)
}
formatExample(example: string, withTag = true): string {
return `${
withTag ? `${DOCBLOCK_DOUBLE_LINES}@example${DOCBLOCK_NEW_LINE}` : ""
}${example}`
}
/**
* Retrieve the full docblock of a function.
*
* @param {FunctionOrVariableNode | ts.Node} node - The function node. If a variable statement is provided, the underlying function is retrieved.
* If a different node type is provided, the parent generator is used to retrieve the docblock comment.
* @param {GetDocBlockOptions} options - Formatting options.
* @returns {string} The function's docblock.
*/
async getDocBlock(
node: FunctionOrVariableNode | ts.Node,
options: GetDocBlockOptions = { addEnd: true }
): Promise<string> {
if (!this.isAllowed(node)) {
return await super.getDocBlock(node, options)
}
const actualNode = ts.isVariableStatement(node)
? this.extractFunctionNode(node)
: node
if (!actualNode) {
return await super.getDocBlock(node, options)
}
let existingComments = this.getNodeCommentsFromRange(node)
if (existingComments?.includes(FunctionKindGenerator.EXAMPLE_PLACEHOLDER)) {
// just replace the existing comment and return it
if (options.aiGenerator) {
existingComments = existingComments.replace(
FunctionKindGenerator.EXAMPLE_PLACEHOLDER,
await this.getFunctionExampleAi(
actualNode,
options.aiGenerator,
false
)
)
}
return existingComments.replace("/*", "").replace("*/", "")
}
const nodeSymbol = getSymbol(node, this.checker)
const nodeParentSymbol = getSymbol(node.parent, this.checker)
const nodeType = this.getReturnType(actualNode)
const returnTypeStr = this.checker.typeToString(nodeType)
const normalizedTypeStr = returnTypeStr.startsWith("Promise<")
? returnTypeStr.replace(/^Promise</, "").replace(/>$/, "")
: returnTypeStr
let str = DOCBLOCK_START
// add summary
str += `${
options.summaryPrefix ||
(this.isMethod(actualNode) ? `This method` : `This function`)
} ${this.getFunctionSummary({
node: actualNode,
symbol: nodeSymbol,
parentSymbol: nodeParentSymbol,
returnType: normalizedTypeStr,
})}${DOCBLOCK_NEW_LINE}`
actualNode.parameters.map((parameterNode) => {
const symbol = getSymbol(parameterNode, this.checker)
if (!symbol) {
return
}
const symbolType = this.checker.getTypeOfSymbolAtLocation(
symbol,
parameterNode
)
const parameterName = symbol.getName()
const parameterSummary = this.getNodeSummary({
node: parameterNode,
symbol,
nodeType: symbolType,
knowledgeBaseOptions: {
templateOptions: {
rawParentName: nodeParentSymbol?.getName(),
pluralIndicatorStr: this.checker.typeToString(symbolType),
},
},
})
str += `${DOCBLOCK_NEW_LINE}@param {${this.checker.typeToString(
symbolType
)}} ${parameterName} - ${parameterSummary}`
})
// add returns
const possibleReturnSummary = !this.hasReturnData(returnTypeStr)
? `Resolves when ${this.defaultSummary}`
: this.getNodeSummary({
node: actualNode,
nodeType,
})
str += `${DOCBLOCK_NEW_LINE}@returns {${returnTypeStr}} ${
nodeSymbol
? this.knowledgeBaseFactory.tryToGetFunctionReturns({
symbol: nodeSymbol,
kind: actualNode.kind,
templateOptions: {
rawParentName: nodeParentSymbol?.getName(),
pluralIndicatorStr: normalizedTypeStr,
},
}) || possibleReturnSummary
: possibleReturnSummary
}`
// add example
if (!options.aiGenerator) {
str += this.getFunctionPlaceholderExample()
} else {
str += await this.getFunctionExampleAi(actualNode, options.aiGenerator)
}
// add common docs
str += this.getCommonDocs(node, {
prefixWithLineBreaks: true,
})
if (options.addEnd) {
str += DOCBLOCK_END_LINE
}
return str
}
/**
* Allows documenting (updating) a node if it has the example placeholder.
*
* @param node - The node to document.
* @returns Whether the node can be documented.
*/
canDocumentNode(node: ts.Node): boolean {
const comments = this.getNodeCommentsFromRange(node)
return (
!comments ||
comments?.includes(FunctionKindGenerator.EXAMPLE_PLACEHOLDER) ||
false
)
}
}
export default FunctionKindGenerator

View File

@@ -0,0 +1,202 @@
import ts from "typescript"
import FunctionKindGenerator, {
FunctionNode,
FunctionOrVariableNode,
} from "./function.js"
import {
DOCBLOCK_NEW_LINE,
DOCBLOCK_END_LINE,
DOCBLOCK_START,
DOCBLOCK_DOUBLE_LINES,
} from "../../constants.js"
import {
CUSTOM_NAMESPACE_TAG,
getCustomNamespaceTag,
} from "../../utils/medusa-react-utils.js"
/**
* Docblock generate for medusa-react hooks. Since hooks are essentially functions,
* it extends the {@link FunctionKindGenerator} class.
*/
class MedusaReactHooksKindGenerator extends FunctionKindGenerator {
public name = "medusa-react"
/**
* Checks whether the generator can retrieve the docblock of the specified node. It uses the parent generator
* to check that the node is a function, then checks if the function is a mutation using the {@link isMutation} method,
* or a query using the {@link isQuery} method.
*
* @param {ts.Node} node - The node to check.
* @returns {boolean} Whether this generator can be used on this node.
*/
isAllowed(node: ts.Node): node is FunctionOrVariableNode {
if (!super.isAllowed(node)) {
return false
}
const actualNode = ts.isVariableStatement(node)
? this.extractFunctionNode(node)
: node
return (
actualNode !== undefined &&
(this.isMutation(actualNode) || this.isQuery(actualNode))
)
}
/**
* Checks whether a function node is a mutation.
*
* @param {FunctionNode} node - The function node to check.
* @returns {boolean} Whether the node is a mutation.
*/
isMutation(node: FunctionNode): boolean {
const nodeType = this.getReturnType(node)
const callSignatures = nodeType.getCallSignatures()
return (
callSignatures.length > 0 &&
this.checker
.typeToString(this.checker.getReturnTypeOfSignature(callSignatures[0]))
.startsWith("UseMutationResult")
)
}
/**
* Checks whether a function node is a query.
*
* @param {FunctionNode} node - The function node to check.
* @returns {boolean} Whether the node is a query.
*/
isQuery(node: FunctionNode): boolean {
return node.parameters.some(
(parameter) =>
parameter.type?.getText().startsWith("UseQueryOptionsWrapper")
)
}
/**
* Retrieves the docblock of the medusa-react hook or mutation.
*
* @param {FunctionNode & ts.VariableDeclaration} node - The node to retrieve its docblock.
* @returns {string} The node's docblock.
*/
async getDocBlock(
node: FunctionNode & ts.VariableDeclaration
): Promise<string> {
// TODO use the AiGenerator to generate summary + examples
if (!this.isAllowed(node)) {
return await super.getDocBlock(node)
}
const actualNode = ts.isVariableStatement(node)
? this.extractFunctionNode(node)
: node
if (!actualNode) {
return await super.getDocBlock(node)
}
const isMutation = this.isMutation(actualNode)
let str = `${DOCBLOCK_START}This hook ${this.getFunctionSummary({
node,
})}`
// add example
str += this.getFunctionPlaceholderExample()
// loop over parameters that aren't query/mutation parameters
// and add docblock to them
await Promise.all(
this.getActualParameters(actualNode).map(async (parameter) => {
ts.addSyntheticLeadingComment(
parameter,
ts.SyntaxKind.MultiLineCommentTrivia,
await super.getDocBlock(parameter),
true
)
})
)
// check if mutation parameter is an intrinsic type and, if so, add the `@typeParamDefinition`
// tag to the hook
if (isMutation) {
const typeArg = this.getMutationRequestTypeArg(actualNode)
if (typeArg) {
str += `${DOCBLOCK_DOUBLE_LINES}@typeParamDefinition ${this.checker.typeToString(
typeArg
)} - {summary}`
}
}
// add common docs
str += this.getCommonDocs(node, {
prefixWithLineBreaks: true,
})
// add namespace in case it's not added
if (!str.includes(CUSTOM_NAMESPACE_TAG)) {
str += `${DOCBLOCK_DOUBLE_LINES}${getCustomNamespaceTag(actualNode)}`
}
// add the category
str += `${DOCBLOCK_NEW_LINE}@category ${
isMutation ? "Mutations" : "Queries"
}`
return `${str}${DOCBLOCK_END_LINE}`
}
/**
* Retrieves the parameters of a function node that aren't query/mutation options.
*
* @param {FunctionNode} node - The function node to retrieve its parameters.
* @returns {ts.ParameterDeclaration[]} - The function's actual parameters.
*/
getActualParameters(node: FunctionNode): ts.ParameterDeclaration[] {
return node.parameters.filter((parameter) => {
const parameterTypeStr = parameter.type?.getText()
return (
!parameterTypeStr?.startsWith("UseQueryOptionsWrapper") &&
!parameterTypeStr?.startsWith("UseMutationOptions") &&
!this.nodeHasComments(parameter)
)
})
}
/**
* Retreives a mutation's intrinsic request type, if available, which is specified as the third type argument of `UseMutationOptions`.
*
* @param {FunctionNode} node - The function node to retrieve its request type.
* @returns {ts.Type | undefined} The mutation's request type, if available.
*/
getMutationRequestTypeArg(node: FunctionNode): ts.Type | undefined {
const parameter = node.parameters.find(
(parameter) => parameter.type?.getText().startsWith("UseMutationOptions")
)
if (!parameter) {
return
}
const parameterType = this.checker.getTypeFromTypeNode(parameter.type!)
const typeArgs =
parameterType.aliasTypeArguments ||
("resolvedTypeArguments" in parameterType
? (parameterType.resolvedTypeArguments as ts.Type[])
: [])
if (
!typeArgs ||
typeArgs.length < 3 ||
!("intrinsicName" in typeArgs[2]) ||
["void", "unknown"].includes(typeArgs[2].intrinsicName as string)
) {
return
}
// find request in third type argument
return typeArgs[2]
}
}
export default MedusaReactHooksKindGenerator

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
import ts from "typescript"
import FunctionKindGenerator from "./function.js"
import DefaultKindGenerator, { GeneratorOptions } from "./default.js"
import MedusaReactHooksKindGenerator from "./medusa-react-hooks.js"
import SourceFileKindGenerator from "./source-file.js"
import DTOPropertyGenerator from "./dto-property.js"
import OasKindGenerator from "./oas.js"
/**
* A class that is used as a registry for the kind generators.
*/
class KindsRegistry {
protected kindInstances: DefaultKindGenerator[]
protected defaultKindGenerator: DefaultKindGenerator
constructor(
options: Pick<
GeneratorOptions,
"checker" | "generatorEventManager" | "additionalOptions"
>
) {
this.kindInstances = [
new OasKindGenerator(options),
new MedusaReactHooksKindGenerator(options),
new FunctionKindGenerator(options),
new SourceFileKindGenerator(options),
new DTOPropertyGenerator(options),
]
this.defaultKindGenerator = new DefaultKindGenerator(options)
}
/**
* Retrieve the generator for a node based on its kind, if any.
*
* @param {ts.Node} node - The node to retrieve its docblock generator.
* @returns {DefaultKindGenerator | undefined} The generator that can handle the node's kind, if any.
*/
getKindGenerator(node: ts.Node): DefaultKindGenerator | undefined {
return (
this.kindInstances.find((generator) => generator.isAllowed(node)) ||
(this.defaultKindGenerator.isAllowed(node)
? this.defaultKindGenerator
: undefined)
)
}
/**
* Checks whether a node has a kind generator.
*
* @param {ts.Node} node - The node to check for.
* @returns {boolean} Whether the node has a kind generator.
*/
hasGenerator(node: ts.Node): boolean {
return this.getKindGenerator(node) !== undefined
}
/**
* Retrieves a generator by its name attribute.
*
* @param name - The name of the generator to retrieve.
* @returns The generator, if exists.
*/
getKindGeneratorByName(name: string): DefaultKindGenerator | undefined {
return this.kindInstances.find((generator) => generator.name === name)
}
}
export default KindsRegistry

View File

@@ -0,0 +1,38 @@
import ts from "typescript"
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
import { DOCBLOCK_END_LINE, DOCBLOCK_START } from "../../constants.js"
import { shouldHaveCustomNamespace } from "../../utils/medusa-react-utils.js"
/**
* A generator used to retrieve doc blocks for a source file.
*/
class SourceFileKindGenerator extends DefaultKindGenerator<ts.SourceFile> {
protected allowedKinds: ts.SyntaxKind[] = [ts.SyntaxKind.SourceFile]
public name = "source-file"
/**
* Retrieve the docblock of a source file.
*
* @param {ts.SourceFile | ts.Node} node - The node to retrieve its docblocks.
* @param {GetDocBlockOptions} options - The formatting options.
* @returns {string} The node's docblock.
*/
async getDocBlock(
node: ts.SourceFile | ts.Node,
options?: GetDocBlockOptions
): Promise<string> {
if (!this.isAllowed(node)) {
return await super.getDocBlock(node, options)
}
if (shouldHaveCustomNamespace(node)) {
return `${DOCBLOCK_START}${this.getCommonDocs(node, {
addDefaultSummary: true,
})}${DOCBLOCK_END_LINE}`
}
return ""
}
}
export default SourceFileKindGenerator