docs-util: rename docblock-generator to docs-generator (#8331)
* docs-util: rename docblock-generator to docs-generator * change program name * fix action
This commit is contained in:
379
www/utils/packages/docs-generator/src/classes/examples/oas.ts
Normal file
379
www/utils/packages/docs-generator/src/classes/examples/oas.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { faker } from "@faker-js/faker"
|
||||
import { OpenAPIV3 } from "openapi-types"
|
||||
import { OasArea } from "../kinds/oas.js"
|
||||
import { CodeSample } from "../../types/index.js"
|
||||
import { capitalize, kebabToCamel, wordsToCamel, wordsToKebab } from "utils"
|
||||
import { API_ROUTE_PARAM_REGEX } from "../../constants.js"
|
||||
|
||||
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
|
||||
111
www/utils/packages/docs-generator/src/classes/generators/dml.ts
Normal file
111
www/utils/packages/docs-generator/src/classes/generators/dml.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import ts from "typescript"
|
||||
import DmlKindGenerator from "../kinds/dml.js"
|
||||
import AbstractGenerator from "./index.js"
|
||||
import { GeneratorEvent } from "../helpers/generator-event-manager.js"
|
||||
import { minimatch } from "minimatch"
|
||||
import getBasePath from "../../utils/get-base-path.js"
|
||||
import toJsonFormatted from "../../utils/to-json-formatted.js"
|
||||
import { DmlFile } from "types"
|
||||
|
||||
/**
|
||||
* A class used to generate DML JSON files with descriptions of properties.
|
||||
*/
|
||||
class DmlGenerator extends AbstractGenerator {
|
||||
protected dmlKindGenerator?: DmlKindGenerator
|
||||
|
||||
async run() {
|
||||
this.init()
|
||||
|
||||
this.dmlKindGenerator = new DmlKindGenerator({
|
||||
checker: this.checker!,
|
||||
generatorEventManager: this.generatorEventManager,
|
||||
})
|
||||
|
||||
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(`[DML] 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.dmlKindGenerator!.isAllowed(node) &&
|
||||
this.dmlKindGenerator!.canDocumentNode(node)
|
||||
) {
|
||||
const dmlJson = await this.dmlKindGenerator!.getDocBlock(node)
|
||||
|
||||
if (!this.options.dryRun) {
|
||||
const filePath =
|
||||
this.dmlKindGenerator!.getAssociatedFileName(node)
|
||||
this.writeJson(filePath, dmlJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(getBasePath(fileName), "packages/modules/**/models/**", {
|
||||
matchBase: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method writes the DML JSON file. If the file already exists, it only updates
|
||||
* the data model's object in the JSON file.
|
||||
*
|
||||
* @param filePath - The path of the file to write the DML JSON to.
|
||||
* @param dataModelJson - The DML JSON.
|
||||
*/
|
||||
writeJson(filePath: string, dataModelJson: string) {
|
||||
let moduleJson = ts.sys.readFile(filePath)
|
||||
if (!moduleJson) {
|
||||
moduleJson = dataModelJson
|
||||
} else {
|
||||
// parse the JSON and replace the data model's JSON
|
||||
// with the new one
|
||||
const parsedModuleJson = JSON.parse(moduleJson) as DmlFile
|
||||
const parsedDataModelJson = JSON.parse(dataModelJson) as DmlFile
|
||||
const dataModelName = Object.keys(parsedDataModelJson)[0]
|
||||
parsedModuleJson[dataModelName] = parsedDataModelJson[dataModelName]
|
||||
|
||||
moduleJson = toJsonFormatted(parsedModuleJson)
|
||||
}
|
||||
|
||||
ts.sys.writeFile(filePath, moduleJson)
|
||||
}
|
||||
}
|
||||
|
||||
export default DmlGenerator
|
||||
@@ -0,0 +1,178 @@
|
||||
/* 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"
|
||||
import getBasePath from "../../utils/get-base-path.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(getBasePath(fileName), "packages/medusa/**/api**/**", {
|
||||
matchBase: true,
|
||||
}) &&
|
||||
!minimatch(getBasePath(fileName), "packages/modules/**/models/**", {
|
||||
matchBase: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default DocblockGenerator
|
||||
@@ -0,0 +1,95 @@
|
||||
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"
|
||||
import getBasePath from "../../utils/get-base-path.js"
|
||||
|
||||
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 = getBasePath(fileName)
|
||||
return this.options.paths.some((path) =>
|
||||
baseFilePath.startsWith(getBasePath(path))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the generator's properties for new usage.
|
||||
*/
|
||||
reset() {
|
||||
this.program = undefined
|
||||
this.checker = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export default AbstractGenerator
|
||||
@@ -0,0 +1,93 @@
|
||||
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"
|
||||
import getBasePath from "../../utils/get-base-path.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(getBasePath(fileName), "packages/medusa/**/api**/**", {
|
||||
matchBase: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default OasGenerator
|
||||
@@ -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, it’s 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
|
||||
@@ -0,0 +1,267 @@
|
||||
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(import.meta.url),
|
||||
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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,902 @@
|
||||
import ts from "typescript"
|
||||
import {
|
||||
API_ROUTE_PARAM_REGEX,
|
||||
DOCBLOCK_NEW_LINE,
|
||||
SUMMARY_PLACEHOLDER,
|
||||
} from "../../constants.js"
|
||||
import pluralize from "pluralize"
|
||||
import {
|
||||
camelToTitle,
|
||||
camelToWords,
|
||||
kebabToTitle,
|
||||
snakeToWords,
|
||||
wordsToKebab,
|
||||
} from "utils"
|
||||
import { normalizeName } from "../../utils/str-formatting.js"
|
||||
|
||||
const singular = pluralize.singular
|
||||
|
||||
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.`
|
||||
},
|
||||
},
|
||||
{
|
||||
exact: "$eq",
|
||||
template: () => {
|
||||
return `Find records whose property exactly matches this value.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$ne",
|
||||
template: () => {
|
||||
return `Find records whose property doesn't matches this value.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$in",
|
||||
template: () => {
|
||||
return `Find records whose property is within the specified values.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$nin",
|
||||
template: () => {
|
||||
return `Find records whose property isn't within the specified values.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$like",
|
||||
template: () => {
|
||||
return `Find records whose property satisfies this like filter. For example, \`My%\`.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$ilike",
|
||||
template: () => {
|
||||
return `Find records whose property satisfies this [ilike filter](https://www.postgresql.org/docs/current/functions-matching.html). For example, \`My%\`.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$re",
|
||||
template: () => {
|
||||
return `Find records whose property matches this regular expression pattern.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$contains",
|
||||
template: () => {
|
||||
return `Find records whose property is an array that has one or more items.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$gt",
|
||||
template: () => {
|
||||
return `Find records whose property's value is greater than the specified number or date.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$gte",
|
||||
template: () => {
|
||||
return `Find records whose property's value is greater than or equal to the specified number or date.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$lt",
|
||||
template: () => {
|
||||
return `Find records whose property's value is less than the specified number or date.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
{
|
||||
exact: "$lte",
|
||||
template: () => {
|
||||
return `Find records whose property's value is less than or equal to the specified number or date.`
|
||||
},
|
||||
kind: [ts.SyntaxKind.PropertySignature],
|
||||
},
|
||||
]
|
||||
private functionSummaryKnowledgeBase: KnowledgeBase[] = [
|
||||
{
|
||||
startsWith: "listAndCount",
|
||||
template: (str) => {
|
||||
const { pluralName } = this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "listAndCount",
|
||||
})
|
||||
return `retrieves a paginated list of ${pluralName} along with the total count of available ${pluralName} satisfying the provided filters.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "list",
|
||||
template: (str) => {
|
||||
const { pluralName } = this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "list",
|
||||
})
|
||||
return `retrieves a paginated list of ${pluralName} based on optional filters and configuration.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "retrieve",
|
||||
template: (str) => {
|
||||
const { singularName } = this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "retrieve",
|
||||
})
|
||||
return `retrieves a ${singularName} by its ID.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "create",
|
||||
template: (str, options) => {
|
||||
const { article, isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "create",
|
||||
options,
|
||||
})
|
||||
return `creates${article} ${isPlural ? pluralName : singularName}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "delete",
|
||||
template: (str, options) => {
|
||||
const { article, isPlural, pronoun, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "delete",
|
||||
options,
|
||||
})
|
||||
return `deletes${article} ${
|
||||
isPlural ? pluralName : singularName
|
||||
} by ${pronoun} ID${isPlural ? "s" : ""}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "update",
|
||||
template: (str, options) => {
|
||||
const { article, isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "update",
|
||||
options,
|
||||
})
|
||||
return `updates${article} existing ${
|
||||
isPlural ? pluralName : singularName
|
||||
}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "softDelete",
|
||||
template: (str, options) => {
|
||||
const { article, pronoun, isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "softDelete",
|
||||
options,
|
||||
})
|
||||
return `soft deletes${article} ${
|
||||
isPlural ? pluralName : singularName
|
||||
} by ${pronoun} IDs.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "restore",
|
||||
template: (str, options) => {
|
||||
const { article, pronoun, isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "restore",
|
||||
options,
|
||||
})
|
||||
return `restores${article} soft deleted ${
|
||||
isPlural ? pluralName : singularName
|
||||
} by ${pronoun} ID${isPlural ? "s" : ""}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "upsert",
|
||||
template: (str, options) => {
|
||||
const { article, isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "upsert",
|
||||
options,
|
||||
})
|
||||
return `updates or creates${article} ${
|
||||
isPlural ? pluralName : singularName
|
||||
} if ${isPlural ? "they don't" : "it doesn't"} exist.`
|
||||
},
|
||||
},
|
||||
]
|
||||
private functionReturnKnowledgeBase: KnowledgeBase[] = [
|
||||
{
|
||||
startsWith: "listAndCount",
|
||||
template: (str, options) => {
|
||||
const { isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "upsert",
|
||||
options,
|
||||
})
|
||||
return `The list of ${
|
||||
isPlural ? pluralName : singularName
|
||||
} along with their total count.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "list",
|
||||
template: (str, options) => {
|
||||
const { isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "upsert",
|
||||
options,
|
||||
})
|
||||
return `The list of ${isPlural ? pluralName : singularName}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "retrieve",
|
||||
template: (str, options) => {
|
||||
const { isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "upsert",
|
||||
options,
|
||||
})
|
||||
return `The retrieved ${isPlural ? pluralName : singularName}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "create",
|
||||
template: (str, options) => {
|
||||
const { isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "upsert",
|
||||
options,
|
||||
})
|
||||
return `The created ${isPlural ? pluralName : singularName}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "update",
|
||||
template: (str, options) => {
|
||||
const { isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "upsert",
|
||||
options,
|
||||
})
|
||||
return `The updated ${isPlural ? pluralName : singularName}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "upsert",
|
||||
template: (str, options) => {
|
||||
const { isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "upsert",
|
||||
options,
|
||||
})
|
||||
return `The created or updated ${isPlural ? pluralName : singularName}.`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "softDelete",
|
||||
template: (str, options) => {
|
||||
const { isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "softDelete",
|
||||
options,
|
||||
})
|
||||
return `An object whose keys are of the format \`{camel_case_data_model}_id\` and values are arrays of IDs of soft-deleted ${
|
||||
isPlural ? pluralName : singularName
|
||||
}`
|
||||
},
|
||||
},
|
||||
{
|
||||
startsWith: "restore",
|
||||
template: (str, options) => {
|
||||
const { isPlural, singularName, pluralName } =
|
||||
this.getPluralConfigForFunction({
|
||||
str,
|
||||
replacement: "restore",
|
||||
options,
|
||||
})
|
||||
return `An object whose keys are of the format \`{camel_case_data_model}_id\` and values are arrays of IDs of restored ${
|
||||
isPlural ? pluralName : singularName
|
||||
}`
|
||||
},
|
||||
},
|
||||
]
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* This method retrieves plural configuration for Medusa service's functions.
|
||||
*
|
||||
* @param param0 - The function's input.
|
||||
* @returns The plural configurations.
|
||||
*/
|
||||
private getPluralConfigForFunction({
|
||||
str,
|
||||
replacement,
|
||||
options,
|
||||
}: {
|
||||
str: string
|
||||
replacement: string
|
||||
options?: TemplateOptions
|
||||
}): {
|
||||
isPlural: boolean
|
||||
typeName: string
|
||||
singularName: string
|
||||
pluralName: string
|
||||
article: string
|
||||
pronoun: string
|
||||
} {
|
||||
const isPlural = this.isTypePlural(options?.pluralIndicatorStr)
|
||||
const typeName = camelToWords(str.replace(replacement, ""))
|
||||
const singularName = singular(typeName)
|
||||
const pluralName = pluralize(typeName)
|
||||
const article = isPlural ? "" : " a"
|
||||
const pronoun = isPlural ? "their" : "its"
|
||||
|
||||
return {
|
||||
isPlural,
|
||||
typeName,
|
||||
singularName,
|
||||
pluralName,
|
||||
article,
|
||||
pronoun,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
tryToGetOasSchemaDescription({
|
||||
str,
|
||||
...options
|
||||
}: RetrieveOptions): string | undefined {
|
||||
const normalizedTypeStr = str.replaceAll("[]", "")
|
||||
return this.tryToFindInKnowledgeBase({
|
||||
...options,
|
||||
str: normalizedTypeStr,
|
||||
knowledgeBase: this.oasDescriptionKnowledgeBase,
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Retrieve the summary and description of the OAS.
|
||||
*
|
||||
* @param param0 - The OAS operation's details.
|
||||
* @returns The summary and description.
|
||||
*/
|
||||
tryToGetOasMethodSummaryAndDescription({
|
||||
oasPath,
|
||||
httpMethod,
|
||||
tag,
|
||||
}: {
|
||||
/**
|
||||
* The OAS path.
|
||||
*/
|
||||
oasPath: string
|
||||
/**
|
||||
* The HTTP method name.
|
||||
*/
|
||||
httpMethod: string
|
||||
/**
|
||||
* The OAS tag name.
|
||||
*/
|
||||
tag: string
|
||||
}): {
|
||||
/**
|
||||
* The OAS's summary
|
||||
*/
|
||||
summary: string
|
||||
/**
|
||||
* The OAS's description.
|
||||
*/
|
||||
description: string
|
||||
} {
|
||||
// reset regex manually
|
||||
API_ROUTE_PARAM_REGEX.lastIndex = 0
|
||||
const result = {
|
||||
summary: SUMMARY_PLACEHOLDER,
|
||||
description: SUMMARY_PLACEHOLDER,
|
||||
}
|
||||
// retrieve different variations of the tag to include in the summary/description
|
||||
const lowerTag = tag.toLowerCase()
|
||||
const singularLowerTag = pluralize.singular(lowerTag)
|
||||
const singularTag = pluralize.singular(tag)
|
||||
|
||||
// check if the OAS operation is performed on a single entity or
|
||||
// general entities. If the operation has a path parameter, then it's
|
||||
// considered for a single entity.
|
||||
const isForSingleEntity = API_ROUTE_PARAM_REGEX.test(oasPath)
|
||||
|
||||
if (isForSingleEntity) {
|
||||
// Check whether the OAS operation is applied on a different entity.
|
||||
// If the OAS path ends with /batch or a different entity
|
||||
// name than the tag name, then it's performed on an entity other than the
|
||||
// main entity (the one indicated by the tag), so the summary/description vary
|
||||
// slightly.
|
||||
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) {
|
||||
// if the operation is a bulk operation and it ends with a path parameter (after removing the `/batch` part)
|
||||
// then the tag name is the targeted entity. Else, it's the last part of the OAS path (after removing the `/batch` part).
|
||||
const endingEntityName =
|
||||
isBulk &&
|
||||
API_ROUTE_PARAM_REGEX.test(splitOasPath[splitOasPath.length - 1])
|
||||
? tag
|
||||
: kebabToTitle(splitOasPath[splitOasPath.length - 1])
|
||||
// retrieve different formatted versions of the entity name for the summary/description
|
||||
const pluralEndingEntityName = pluralize.plural(endingEntityName)
|
||||
const lowerEndingEntityName = pluralEndingEntityName.toLowerCase()
|
||||
const singularLowerEndingEntityName =
|
||||
pluralize.singular(endingEntityName)
|
||||
|
||||
// set the summary/description based on the HTTP method
|
||||
if (httpMethod === "get") {
|
||||
result.summary = `List ${pluralEndingEntityName}`
|
||||
result.description = `Retrieve a list of ${lowerEndingEntityName} in a ${singularLowerTag}. The ${lowerEndingEntityName} can be filtered by fields like FILTER FIELDS. The ${lowerEndingEntityName} can also be paginated.`
|
||||
} else if (httpMethod === "post") {
|
||||
result.summary = `Add ${pluralEndingEntityName} to ${singularTag}`
|
||||
result.description = `Add a list of ${lowerEndingEntityName} to a ${singularLowerTag}.`
|
||||
} else {
|
||||
result.summary = `Remove ${pluralEndingEntityName} from ${singularTag}`
|
||||
result.description = `Remove a list of ${lowerEndingEntityName} from a ${singularLowerTag}. This doesn't delete the ${singularLowerEndingEntityName}, only the association between the ${singularLowerEndingEntityName} and the ${singularLowerTag}.`
|
||||
}
|
||||
} else {
|
||||
// the OAS operation is applied on a single entity that is the main entity (denoted by the tag).
|
||||
// retrieve the summary/description based on the HTTP method.
|
||||
if (httpMethod === "get") {
|
||||
result.summary = `Get a ${singularTag}`
|
||||
result.description = `Retrieve a ${singularLowerTag} by its ID. You can expand the ${singularLowerTag}'s relations or select the fields that should be returned.`
|
||||
} else if (httpMethod === "post") {
|
||||
result.summary = `Update a ${singularTag}`
|
||||
result.description = `Update a ${singularLowerTag}'s details.`
|
||||
} else {
|
||||
result.summary = `Delete a ${singularTag}`
|
||||
result.description = `Delete a ${singularLowerTag}.`
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// the OAS operation is applied on all entities of the tag in general.
|
||||
// retrieve the summary/description based on the HTTP method.
|
||||
if (httpMethod === "get") {
|
||||
result.summary = `List ${tag}`
|
||||
result.description = `Retrieve a list of ${lowerTag}. The ${lowerTag} can be filtered by fields such as \`id\`. The ${lowerTag} can also be sorted or paginated.`
|
||||
} else if (httpMethod === "post") {
|
||||
result.summary = `Create ${singularTag}`
|
||||
result.description = `Create a ${singularLowerTag}.`
|
||||
} else {
|
||||
result.summary = `Delete ${tag}`
|
||||
result.description = `Delete ${tag}`
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve summary and description for a property of an object, interface, or type.
|
||||
*
|
||||
* @param param0 - The property's details.
|
||||
* @returns The property's summary.
|
||||
*/
|
||||
tryToGetObjectPropertySummary({
|
||||
retrieveOptions,
|
||||
propertyDetails: { isClassOrInterface, isBoolean, classOrInterfaceName },
|
||||
}: {
|
||||
/**
|
||||
* The options used to retrieve the summary from the knowledge base, if available.
|
||||
*/
|
||||
retrieveOptions: RetrieveOptions
|
||||
/**
|
||||
* The details of the property.
|
||||
*/
|
||||
propertyDetails: {
|
||||
/**
|
||||
* Whether the property's value is a class or interface. This applies to all
|
||||
* object-like types.
|
||||
*
|
||||
* If `true`, the property is considered to represent a relationship to another
|
||||
* class / interface.
|
||||
*/
|
||||
isClassOrInterface: boolean
|
||||
/**
|
||||
* Whether the property's value is a boolean
|
||||
*/
|
||||
isBoolean: boolean
|
||||
/**
|
||||
* The name of the class / interface this property's value is associated to.
|
||||
* This is only used if {@link isClassOrInterface} is `true`.
|
||||
*/
|
||||
classOrInterfaceName?: string
|
||||
}
|
||||
}): string {
|
||||
let summary = this.tryToGetSummary(retrieveOptions)
|
||||
|
||||
if (summary) {
|
||||
return summary
|
||||
}
|
||||
|
||||
if (isClassOrInterface) {
|
||||
summary = `The associated ${classOrInterfaceName}.`
|
||||
} else if (isBoolean) {
|
||||
summary = `Whether the ${retrieveOptions.templateOptions
|
||||
?.parentName} ${snakeToWords(retrieveOptions.str)}.`
|
||||
} else {
|
||||
summary = `The ${snakeToWords(
|
||||
retrieveOptions.str
|
||||
)} of the ${retrieveOptions.templateOptions?.parentName}`
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
||||
export default KnowledgeBaseFactory
|
||||
@@ -0,0 +1,339 @@
|
||||
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-output-base-paths.js"
|
||||
import { parse } from "yaml"
|
||||
import formatOas from "../../utils/format-oas.js"
|
||||
import pluralize from "pluralize"
|
||||
import { capitalize, wordsToPascal } from "utils"
|
||||
import { OasArea } from "../kinds/oas.js"
|
||||
|
||||
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
|
||||
private MAX_LEVEL = 4
|
||||
/**
|
||||
* 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.
|
||||
* @param level - The current depth level. Used to avoid maximum call stack size exceeded.
|
||||
* @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.
|
||||
*/
|
||||
namedSchemaToReference(
|
||||
schema: OpenApiSchema,
|
||||
level = 0
|
||||
): OpenAPIV3.ReferenceObject | undefined {
|
||||
if (level > this.MAX_LEVEL) {
|
||||
return
|
||||
}
|
||||
|
||||
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.namedSchemaToReference(propertySchema.items, level + 1) ||
|
||||
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.namedSchemaToReference(item, level + 1) || item
|
||||
})
|
||||
}
|
||||
|
||||
schema.properties![property] =
|
||||
this.namedSchemaToReference(
|
||||
propertySchema as OpenApiSchema,
|
||||
level + 1
|
||||
) || propertySchema
|
||||
})
|
||||
}
|
||||
|
||||
this.schemas.set(schema["x-schemaName"], schema)
|
||||
|
||||
return {
|
||||
$ref: this.constructSchemaReference(schema["x-schemaName"]),
|
||||
}
|
||||
}
|
||||
|
||||
schemaChildrenToRefs(schema: OpenApiSchema, level = 0): OpenApiSchema {
|
||||
if (level > this.MAX_LEVEL) {
|
||||
return schema
|
||||
}
|
||||
|
||||
const clonedSchema = Object.assign({}, schema)
|
||||
|
||||
if (clonedSchema.allOf) {
|
||||
clonedSchema.allOf = clonedSchema.allOf.map((item) => {
|
||||
if (this.isRefObject(item)) {
|
||||
return item
|
||||
}
|
||||
|
||||
const transformChildItems = this.schemaChildrenToRefs(item, level + 1)
|
||||
return (
|
||||
this.namedSchemaToReference(transformChildItems) ||
|
||||
transformChildItems
|
||||
)
|
||||
})
|
||||
} else if (clonedSchema.oneOf) {
|
||||
clonedSchema.oneOf = clonedSchema.oneOf.map((item) => {
|
||||
if (this.isRefObject(item)) {
|
||||
return item
|
||||
}
|
||||
|
||||
const transformChildItems = this.schemaChildrenToRefs(item, level + 1)
|
||||
return (
|
||||
this.namedSchemaToReference(transformChildItems) ||
|
||||
transformChildItems
|
||||
)
|
||||
})
|
||||
} else if (
|
||||
clonedSchema.type === "array" &&
|
||||
!this.isRefObject(clonedSchema.items)
|
||||
) {
|
||||
const transformedChildItems = this.schemaChildrenToRefs(
|
||||
clonedSchema.items,
|
||||
level
|
||||
)
|
||||
clonedSchema.items =
|
||||
this.namedSchemaToReference(transformedChildItems) ||
|
||||
transformedChildItems
|
||||
} else if (clonedSchema.properties && !clonedSchema["x-schemaName"]) {
|
||||
Object.entries(clonedSchema.properties).forEach(([key, property]) => {
|
||||
if (this.isRefObject(property)) {
|
||||
return
|
||||
}
|
||||
|
||||
const transformedProperty = this.schemaChildrenToRefs(
|
||||
property,
|
||||
level + 1
|
||||
)
|
||||
schema.properties![key] =
|
||||
this.namedSchemaToReference(transformedProperty) ||
|
||||
transformedProperty
|
||||
})
|
||||
}
|
||||
|
||||
return clonedSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the expected file name of the schema.
|
||||
*
|
||||
* @param name - The schema's name
|
||||
* @returns The schema's file name
|
||||
*/
|
||||
getSchemaFileName(name: string, shouldNormalizeName = true): string {
|
||||
return join(
|
||||
this.baseOutputPath,
|
||||
"schemas",
|
||||
`${shouldNormalizeName ? this.normalizeSchemaName(name) : 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,
|
||||
shouldNormalizeName = true
|
||||
): ParsedSchema | undefined {
|
||||
const schemaName = shouldNormalizeName
|
||||
? this.normalizeSchemaName(name)
|
||||
: 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, shouldNormalizeName)
|
||||
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, "")
|
||||
.replace(/(?<!Type)Type$/, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 names of the associated schema.
|
||||
*/
|
||||
tagNameToSchemaName(tagName: string, area: OasArea): string {
|
||||
const mainSchemaName = wordsToPascal(pluralize.singular(tagName))
|
||||
return `${capitalize(area)}${mainSchemaName}`
|
||||
}
|
||||
}
|
||||
|
||||
export default OasSchemaHelper
|
||||
@@ -0,0 +1,57 @@
|
||||
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",
|
||||
},
|
||||
created_at: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
updated_at: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
deleted_at: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
600
www/utils/packages/docs-generator/src/classes/kinds/default.ts
Normal file
600
www/utils/packages/docs-generator/src/classes/kinds/default.ts
Normal file
@@ -0,0 +1,600 @@
|
||||
import ts from "typescript"
|
||||
import {
|
||||
DOCBLOCK_START,
|
||||
DOCBLOCK_END_LINE,
|
||||
DOCBLOCK_DOUBLE_LINES,
|
||||
DOCBLOCK_NEW_LINE,
|
||||
SUMMARY_PLACEHOLDER,
|
||||
} 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 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 : SUMMARY_PLACEHOLDER
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) || SUMMARY_PLACEHOLDER}`
|
||||
}
|
||||
|
||||
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(SUMMARY_PLACEHOLDER)
|
||||
}
|
||||
|
||||
// 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
|
||||
277
www/utils/packages/docs-generator/src/classes/kinds/dml.ts
Normal file
277
www/utils/packages/docs-generator/src/classes/kinds/dml.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import ts from "typescript"
|
||||
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
|
||||
import getBasePath from "../../utils/get-base-path.js"
|
||||
import { getDmlOutputBasePath } from "../../utils/get-output-base-paths.js"
|
||||
import path from "path"
|
||||
import { camelToWords, RELATION_NAMES, snakeToPascal } from "utils"
|
||||
import toJsonFormatted from "../../utils/to-json-formatted.js"
|
||||
import { DmlFile, DmlObject } from "types"
|
||||
|
||||
/**
|
||||
* DML generator for data models created with DML.
|
||||
*/
|
||||
class DmlKindGenerator extends DefaultKindGenerator<ts.CallExpression> {
|
||||
protected allowedKinds: ts.SyntaxKind[] = [ts.SyntaxKind.CallExpression]
|
||||
public name = "dml"
|
||||
|
||||
/**
|
||||
* Checks whether the node is a call expression, is `model.define`, and has at least
|
||||
* two arguments.
|
||||
*
|
||||
* @param node - The node to check
|
||||
* @returns Whether this kind generator can be used for this node.
|
||||
*/
|
||||
isAllowed(node: ts.Node): node is ts.CallExpression {
|
||||
if (!super.isAllowed(node)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const isModelUtility =
|
||||
"expression" in node.expression &&
|
||||
(node.expression.expression as ts.Node).getText() === "model"
|
||||
const isDefineMethod =
|
||||
"name" in node.expression &&
|
||||
(node.expression.name as ts.Identifier).getText() === "define"
|
||||
const hasAllArguments = node.arguments.length >= 2
|
||||
|
||||
return isModelUtility && isDefineMethod && hasAllArguments
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the DML object from the node's associated file, if it exists.
|
||||
*
|
||||
* @param node - The node to retrieve its DML object.
|
||||
* @param dataModelName - The data model's name
|
||||
* @returns The DML object, if available.
|
||||
*/
|
||||
getExistingDmlObjectFromFile(
|
||||
node: ts.CallExpression,
|
||||
dataModelName: string
|
||||
): DmlObject | undefined {
|
||||
const filePath = this.getAssociatedFileName(node)
|
||||
const existingJson = ts.sys.readFile(filePath)
|
||||
|
||||
if (!existingJson) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsedJson = JSON.parse(existingJson) as DmlFile
|
||||
if (!Object.hasOwn(parsedJson, dataModelName)) {
|
||||
return
|
||||
}
|
||||
|
||||
return parsedJson[dataModelName].properties
|
||||
}
|
||||
|
||||
/**
|
||||
* Get new or updated JSON dml object of the data model.
|
||||
*
|
||||
* @param node - The node to get its JSON DML object.
|
||||
* @param options - General options
|
||||
* @returns The JSON dml object.
|
||||
*/
|
||||
async getDocBlock(
|
||||
node: ts.CallExpression | ts.Node,
|
||||
options?: GetDocBlockOptions
|
||||
): Promise<string> {
|
||||
if (!this.isAllowed(node)) {
|
||||
return await super.getDocBlock(node, options)
|
||||
}
|
||||
const dataModelName = this.getDataModelName(node.arguments[0])
|
||||
|
||||
const existingDmlObject = this.getExistingDmlObjectFromFile(
|
||||
node,
|
||||
dataModelName
|
||||
)
|
||||
|
||||
const properties = existingDmlObject
|
||||
? this.updateExistingDmlObject({
|
||||
node,
|
||||
dmlObject: existingDmlObject,
|
||||
dataModelName,
|
||||
})
|
||||
: this.getNewDmlObject({
|
||||
node,
|
||||
dataModelName,
|
||||
})
|
||||
|
||||
const dmlFile: DmlFile = {
|
||||
[dataModelName]: {
|
||||
filePath: getBasePath(node.getSourceFile().fileName),
|
||||
properties,
|
||||
},
|
||||
}
|
||||
|
||||
return toJsonFormatted(dmlFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new DML object for a node.
|
||||
*
|
||||
* @param param0 - The node and data model's details
|
||||
* @returns The DML object.
|
||||
*/
|
||||
getNewDmlObject({
|
||||
node,
|
||||
dataModelName,
|
||||
}: {
|
||||
node: ts.CallExpression
|
||||
dataModelName: string
|
||||
}): DmlObject {
|
||||
return this.getPropertiesFromNode(node, dataModelName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing dml object by removing properties no longer
|
||||
* available and adding new ones.
|
||||
*
|
||||
* @param param0 - The node and data model's details.
|
||||
* @returns The updated DML object.
|
||||
*/
|
||||
updateExistingDmlObject({
|
||||
node,
|
||||
dmlObject,
|
||||
dataModelName,
|
||||
}: {
|
||||
node: ts.CallExpression
|
||||
dmlObject: DmlObject
|
||||
dataModelName: string
|
||||
}): DmlObject {
|
||||
const newDmlObject = Object.assign({}, dmlObject)
|
||||
const newProperties = this.getPropertiesFromNode(node, dataModelName)
|
||||
|
||||
const newPropertyNames = Object.keys(newProperties)
|
||||
const oldPropertyNames = Object.keys(newDmlObject)
|
||||
|
||||
// delete properties not available anymore
|
||||
oldPropertyNames.forEach((oldPropertyName) => {
|
||||
if (!newPropertyNames.includes(oldPropertyName)) {
|
||||
delete newDmlObject[oldPropertyName]
|
||||
}
|
||||
})
|
||||
|
||||
// add new properties
|
||||
newPropertyNames.forEach((newPropertyName) => {
|
||||
if (oldPropertyNames.includes(newPropertyName)) {
|
||||
return
|
||||
}
|
||||
|
||||
newDmlObject[newPropertyName] = newProperties[newPropertyName]
|
||||
})
|
||||
|
||||
return newDmlObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the data model's name. It's either a string passed to `model.define`, or
|
||||
* in the `name` property of the object passed as a parameter to `model.define`.
|
||||
*
|
||||
* @param node - The node to retrieve its data model name.
|
||||
* @returns The data model's name.
|
||||
*/
|
||||
getDataModelName(node: ts.Node): string {
|
||||
let name = node.getText()
|
||||
if (ts.isObjectLiteralExpression(node)) {
|
||||
const nameProperty = node.properties.find((propertyNode) => {
|
||||
return (
|
||||
propertyNode.name?.getText() === "name" &&
|
||||
"initializer" in propertyNode
|
||||
)
|
||||
}) as ts.ObjectLiteralElementLike & {
|
||||
initializer: ts.Node
|
||||
}
|
||||
|
||||
if (nameProperty) {
|
||||
name = nameProperty.initializer.getText()
|
||||
}
|
||||
}
|
||||
|
||||
return snakeToPascal(name.replace(/^"/, "").replace(/"$/, ""))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the properties of a node.
|
||||
*
|
||||
* @param node - The node to get its properties.
|
||||
* @param dataModelName - The data model's name.
|
||||
* @returns The properties and their description.
|
||||
*/
|
||||
getPropertiesFromNode(
|
||||
node: ts.CallExpression,
|
||||
dataModelName: string
|
||||
): DmlObject {
|
||||
const formattedDataModelName = this.formatDataModelName(dataModelName)
|
||||
const propertyNodes =
|
||||
"properties" in node.arguments[1]
|
||||
? (node.arguments[1].properties as ts.Node[])
|
||||
: []
|
||||
|
||||
const properties: DmlObject = {}
|
||||
|
||||
propertyNodes.forEach((propertyNode) => {
|
||||
const propertyName =
|
||||
"name" in propertyNode
|
||||
? (propertyNode.name as ts.Identifier).getText()
|
||||
: propertyNode.getText()
|
||||
const propertyType = this.checker.getTypeAtLocation(propertyNode)
|
||||
const propertyTypeStr = this.checker.typeToString(propertyType)
|
||||
|
||||
const isRelation = RELATION_NAMES.some((relationName) =>
|
||||
propertyTypeStr.includes(relationName)
|
||||
)
|
||||
const isBoolean = propertyTypeStr.includes("BooleanProperty")
|
||||
const relationName = isRelation ? camelToWords(propertyName) : undefined
|
||||
|
||||
let propertyDescription =
|
||||
this.knowledgeBaseFactory.tryToGetObjectPropertySummary({
|
||||
retrieveOptions: {
|
||||
str: propertyName,
|
||||
kind: propertyNode.kind,
|
||||
templateOptions: {
|
||||
parentName: formattedDataModelName,
|
||||
rawParentName: dataModelName,
|
||||
},
|
||||
},
|
||||
propertyDetails: {
|
||||
isClassOrInterface: isRelation,
|
||||
isBoolean,
|
||||
classOrInterfaceName: relationName,
|
||||
},
|
||||
})
|
||||
|
||||
if (isRelation) {
|
||||
propertyDescription += `\n\n@expandable`
|
||||
}
|
||||
|
||||
properties[propertyName] = propertyDescription
|
||||
})
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the output JSON file associated with a node.
|
||||
*
|
||||
* @param node - The node to get its file name.
|
||||
* @returns The file name.
|
||||
*/
|
||||
getAssociatedFileName(node: ts.Node): string {
|
||||
const filePath = getBasePath(node.getSourceFile().fileName).split("/")
|
||||
// since modules are at packages/modules/<name>, the name should be at index 2
|
||||
const moduleName = filePath[2]
|
||||
|
||||
return path.join(getDmlOutputBasePath(), `${moduleName}.json`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the data model name for presentation purposes.
|
||||
*
|
||||
* @param name - The raw data model's name.
|
||||
* @returns The formatted name.
|
||||
*/
|
||||
formatDataModelName(name: string): string {
|
||||
return camelToWords(name)
|
||||
}
|
||||
}
|
||||
|
||||
export default DmlKindGenerator
|
||||
@@ -0,0 +1,98 @@
|
||||
import ts from "typescript"
|
||||
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
|
||||
import { DOCBLOCK_END_LINE, DOCBLOCK_START } from "../../constants.js"
|
||||
import { camelToWords } 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)
|
||||
// check if the property's type is interface/type/class
|
||||
const propertyType = this.checker.getTypeAtLocation(node)
|
||||
const isPropertyClassOrInterface = propertyType.isClassOrInterface()
|
||||
|
||||
// try first to retrieve the summary from the knowledge base if it exists.
|
||||
const summary = this.knowledgeBaseFactory.tryToGetObjectPropertySummary({
|
||||
retrieveOptions: {
|
||||
str: node.name.getText(),
|
||||
kind: node.kind,
|
||||
templateOptions: {
|
||||
rawParentName,
|
||||
parentName,
|
||||
},
|
||||
},
|
||||
propertyDetails: {
|
||||
isClassOrInterface: isPropertyClassOrInterface,
|
||||
isBoolean:
|
||||
"intrinsicName" in propertyType &&
|
||||
propertyType.intrinsicName === "boolean",
|
||||
classOrInterfaceName: isPropertyClassOrInterface
|
||||
? this.formatInterfaceName(this.checker.typeToString(propertyType))
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
str += summary
|
||||
|
||||
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
|
||||
386
www/utils/packages/docs-generator/src/classes/kinds/function.ts
Normal file
386
www/utils/packages/docs-generator/src/classes/kinds/function.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import ts from "typescript"
|
||||
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
|
||||
import {
|
||||
DOCBLOCK_NEW_LINE,
|
||||
DOCBLOCK_END_LINE,
|
||||
DOCBLOCK_START,
|
||||
DOCBLOCK_DOUBLE_LINES,
|
||||
SUMMARY_PLACEHOLDER,
|
||||
} 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 ${SUMMARY_PLACEHOLDER}`
|
||||
: 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
|
||||
@@ -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
|
||||
1897
www/utils/packages/docs-generator/src/classes/kinds/oas.ts
Normal file
1897
www/utils/packages/docs-generator/src/classes/kinds/oas.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
||||
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"
|
||||
import DmlKindGenerator from "./dml.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 DmlKindGenerator(options),
|
||||
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
|
||||
@@ -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
|
||||
43
www/utils/packages/docs-generator/src/commands/clean-dml.ts
Normal file
43
www/utils/packages/docs-generator/src/commands/clean-dml.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { readdirSync, existsSync, readFileSync, writeFileSync } from "fs"
|
||||
import { getDmlOutputBasePath } from "../utils/get-output-base-paths.js"
|
||||
import path from "path"
|
||||
import getMonorepoRoot from "../utils/get-monorepo-root.js"
|
||||
import { DmlFile } from "types"
|
||||
import toJsonFormatted from "../utils/to-json-formatted.js"
|
||||
|
||||
export default async function () {
|
||||
console.log("Cleaning DML JSON files...")
|
||||
|
||||
const dmlOutputPath = getDmlOutputBasePath()
|
||||
const monorepoRoot = getMonorepoRoot()
|
||||
|
||||
const jsonFiles = readdirSync(dmlOutputPath)
|
||||
|
||||
jsonFiles.forEach((jsonFile) => {
|
||||
const jsonFilePath = path.join(dmlOutputPath, jsonFile)
|
||||
const parsedJson = JSON.parse(
|
||||
readFileSync(jsonFilePath, "utf-8")
|
||||
) as DmlFile
|
||||
|
||||
const dataModelKeys = Object.keys(parsedJson)
|
||||
let dataUpdated = false
|
||||
|
||||
dataModelKeys.forEach((dataModelName) => {
|
||||
const { filePath } = parsedJson[dataModelName]
|
||||
|
||||
const fullFilePath = path.join(monorepoRoot, filePath)
|
||||
|
||||
if (existsSync(fullFilePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
// delete data model from json object
|
||||
delete parsedJson[dataModelName]
|
||||
dataUpdated = true
|
||||
})
|
||||
|
||||
if (dataUpdated) {
|
||||
writeFileSync(jsonFilePath, toJsonFormatted(parsedJson))
|
||||
}
|
||||
})
|
||||
}
|
||||
284
www/utils/packages/docs-generator/src/commands/clean-oas.ts
Normal file
284
www/utils/packages/docs-generator/src/commands/clean-oas.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "fs"
|
||||
import { OpenAPIV3 } from "openapi-types"
|
||||
import path from "path"
|
||||
import ts from "typescript"
|
||||
import { parse, stringify } from "yaml"
|
||||
import GeneratorEventManager from "../classes/helpers/generator-event-manager.js"
|
||||
import OasSchemaHelper from "../classes/helpers/oas-schema.js"
|
||||
import OasKindGenerator, { OasArea } from "../classes/kinds/oas.js"
|
||||
import { DEFAULT_OAS_RESPONSES } from "../constants.js"
|
||||
import { OpenApiDocument, OpenApiSchema } from "../types/index.js"
|
||||
import getMonorepoRoot from "../utils/get-monorepo-root.js"
|
||||
import { getOasOutputBasePath } from "../utils/get-output-base-paths.js"
|
||||
import parseOas from "../utils/parse-oas.js"
|
||||
|
||||
const OAS_PREFIX_REGEX = /@oas \[(?<method>(get|post|delete))\] (?<path>.+)/
|
||||
|
||||
export default async function () {
|
||||
const oasOutputBasePath = getOasOutputBasePath()
|
||||
const oasOperationsPath = path.join(oasOutputBasePath, "operations")
|
||||
const apiRoutesPath = path.join(
|
||||
getMonorepoRoot(),
|
||||
"packages",
|
||||
"medusa",
|
||||
"src",
|
||||
"api"
|
||||
)
|
||||
const areas: OasArea[] = ["admin", "store"]
|
||||
const tags: Map<OasArea, Set<string>> = new Map()
|
||||
const oasSchemaHelper = new OasSchemaHelper()
|
||||
const referencedSchemas: Set<string> = new Set()
|
||||
const allSchemas: Set<string> = new Set()
|
||||
areas.forEach((area) => {
|
||||
tags.set(area, new Set<string>())
|
||||
})
|
||||
|
||||
const testAndFindReferenceSchema = (
|
||||
nestedSchema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
|
||||
) => {
|
||||
if (oasSchemaHelper.isRefObject(nestedSchema)) {
|
||||
referencedSchemas.add(
|
||||
oasSchemaHelper.normalizeSchemaName(nestedSchema.$ref)
|
||||
)
|
||||
} else {
|
||||
findReferencedSchemas(nestedSchema)
|
||||
}
|
||||
}
|
||||
|
||||
const findReferencedSchemas = (schema: OpenApiSchema) => {
|
||||
if (schema.properties) {
|
||||
Object.values(schema.properties).forEach(testAndFindReferenceSchema)
|
||||
} else if (schema.oneOf || schema.allOf || schema.anyOf) {
|
||||
Object.values((schema.oneOf || schema.allOf || schema.anyOf)!).forEach(
|
||||
testAndFindReferenceSchema
|
||||
)
|
||||
} else if (schema.type === "array") {
|
||||
testAndFindReferenceSchema(schema.items)
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Cleaning OAS files...")
|
||||
|
||||
// read files under the operations/{area} directory
|
||||
areas.forEach((area) => {
|
||||
const areaPath = path.join(oasOperationsPath, area)
|
||||
if (!existsSync(areaPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
readdirSync(areaPath, {
|
||||
recursive: true,
|
||||
encoding: "utf-8",
|
||||
}).forEach((oasFile) => {
|
||||
const filePath = path.join(areaPath, oasFile)
|
||||
const { oas, oasPrefix } = parseOas(readFileSync(filePath, "utf-8")) || {}
|
||||
|
||||
if (!oas || !oasPrefix) {
|
||||
return
|
||||
}
|
||||
|
||||
// decode oasPrefix
|
||||
const matchOasPrefix = OAS_PREFIX_REGEX.exec(oasPrefix)
|
||||
if (!matchOasPrefix?.groups?.method || !matchOasPrefix.groups.path) {
|
||||
return
|
||||
}
|
||||
const splitPath = matchOasPrefix.groups.path.substring(1).split("/")
|
||||
|
||||
// normalize path by replacing {paramName} with [paramName]
|
||||
const normalizedOasPrefix = splitPath
|
||||
.map((item) => item.replace(/^\{(.+)\}$/, "[$1]"))
|
||||
.join("/")
|
||||
const sourceFilePath = path.join(
|
||||
apiRoutesPath,
|
||||
normalizedOasPrefix,
|
||||
"route.ts"
|
||||
)
|
||||
|
||||
// check if a route exists for the path
|
||||
if (!existsSync(sourceFilePath)) {
|
||||
// remove OAS file
|
||||
rmSync(filePath, {
|
||||
force: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// check if method exists in the file
|
||||
let exists = false
|
||||
const program = ts.createProgram([sourceFilePath], {})
|
||||
|
||||
const oasKindGenerator = new OasKindGenerator({
|
||||
checker: program.getTypeChecker(),
|
||||
generatorEventManager: new GeneratorEventManager(),
|
||||
additionalOptions: {},
|
||||
})
|
||||
const sourceFile = program.getSourceFile(sourceFilePath)
|
||||
|
||||
if (!sourceFile) {
|
||||
// remove file
|
||||
rmSync(filePath, {
|
||||
force: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const visitChildren = (node: ts.Node) => {
|
||||
if (
|
||||
!exists &&
|
||||
oasKindGenerator.isAllowed(node) &&
|
||||
oasKindGenerator.canDocumentNode(node) &&
|
||||
oasKindGenerator.getHTTPMethodName(node) ===
|
||||
matchOasPrefix.groups!.method
|
||||
) {
|
||||
exists = true
|
||||
} else if (!exists) {
|
||||
ts.forEachChild(node, visitChildren)
|
||||
}
|
||||
}
|
||||
|
||||
ts.forEachChild(sourceFile, visitChildren)
|
||||
|
||||
if (!exists) {
|
||||
// remove OAS file
|
||||
rmSync(filePath, {
|
||||
force: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// collect tags
|
||||
oas.tags?.forEach((tag) => {
|
||||
const areaTags = tags.get(area as OasArea)
|
||||
areaTags?.add(tag)
|
||||
})
|
||||
|
||||
// collect schemas
|
||||
if (oas.requestBody) {
|
||||
if (oasSchemaHelper.isRefObject(oas.requestBody)) {
|
||||
referencedSchemas.add(
|
||||
oasSchemaHelper.normalizeSchemaName(oas.requestBody.$ref)
|
||||
)
|
||||
} else {
|
||||
const requestBodySchema =
|
||||
oas.requestBody.content[Object.keys(oas.requestBody.content)[0]]
|
||||
.schema
|
||||
if (requestBodySchema) {
|
||||
testAndFindReferenceSchema(requestBodySchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (oas.responses) {
|
||||
const successResponseKey = Object.keys(oas.responses)[0]
|
||||
if (!Object.keys(DEFAULT_OAS_RESPONSES).includes(successResponseKey)) {
|
||||
const responseObj = oas.responses[successResponseKey]
|
||||
if (oasSchemaHelper.isRefObject(responseObj)) {
|
||||
referencedSchemas.add(
|
||||
oasSchemaHelper.normalizeSchemaName(responseObj.$ref)
|
||||
)
|
||||
} else if (responseObj.content) {
|
||||
const responseBodySchema =
|
||||
responseObj.content[Object.keys(responseObj.content)[0]].schema
|
||||
if (responseBodySchema) {
|
||||
testAndFindReferenceSchema(responseBodySchema)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
console.log("Clean tags...")
|
||||
|
||||
// check if any tags should be removed
|
||||
const oasBasePath = path.join(oasOutputBasePath, "base")
|
||||
readdirSync(oasBasePath, {
|
||||
recursive: true,
|
||||
encoding: "utf-8",
|
||||
}).forEach((baseYaml) => {
|
||||
const baseYamlPath = path.join(oasBasePath, baseYaml)
|
||||
const parsedBaseYaml = parse(
|
||||
readFileSync(baseYamlPath, "utf-8")
|
||||
) as OpenApiDocument
|
||||
|
||||
const area = path.basename(baseYaml).split(".")[0] as OasArea
|
||||
const areaTags = tags.get(area)
|
||||
if (!areaTags) {
|
||||
return
|
||||
}
|
||||
const lengthBefore = parsedBaseYaml.tags?.length || 0
|
||||
|
||||
parsedBaseYaml.tags = parsedBaseYaml.tags?.filter((tag) =>
|
||||
areaTags.has(tag.name)
|
||||
)
|
||||
|
||||
if (lengthBefore !== (parsedBaseYaml.tags?.length || 0)) {
|
||||
// sort alphabetically
|
||||
parsedBaseYaml.tags?.sort((tagA, tagB) => {
|
||||
return tagA.name.localeCompare(tagB.name)
|
||||
})
|
||||
// write to the file
|
||||
writeFileSync(baseYamlPath, stringify(parsedBaseYaml))
|
||||
}
|
||||
|
||||
// collect referenced schemas
|
||||
parsedBaseYaml.tags?.forEach((tag) => {
|
||||
if (tag["x-associatedSchema"]) {
|
||||
referencedSchemas.add(
|
||||
oasSchemaHelper.normalizeSchemaName(tag["x-associatedSchema"].$ref)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
console.log("Clean schemas...")
|
||||
|
||||
// check if any schemas should be removed
|
||||
// a schema is removed if no other schemas/operations reference it
|
||||
const oasSchemasPath = path.join(oasOutputBasePath, "schemas")
|
||||
readdirSync(oasSchemasPath, {
|
||||
recursive: true,
|
||||
encoding: "utf-8",
|
||||
}).forEach((schemaYaml) => {
|
||||
const schemaPath = path.join(oasSchemasPath, schemaYaml)
|
||||
const parsedSchema = oasSchemaHelper.parseSchema(
|
||||
readFileSync(schemaPath, "utf-8")
|
||||
)
|
||||
|
||||
if (!parsedSchema) {
|
||||
// remove file
|
||||
rmSync(schemaPath, {
|
||||
force: true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// add schema to all schemas
|
||||
if (parsedSchema.schema["x-schemaName"]) {
|
||||
allSchemas.add(parsedSchema.schema["x-schemaName"])
|
||||
}
|
||||
|
||||
// collect referenced schemas
|
||||
findReferencedSchemas(parsedSchema.schema)
|
||||
})
|
||||
|
||||
// clean up schemas
|
||||
allSchemas.forEach((schemaName) => {
|
||||
if (referencedSchemas.has(schemaName)) {
|
||||
return
|
||||
}
|
||||
|
||||
// schema isn't referenced anywhere, so remove it
|
||||
rmSync(path.join(oasSchemasPath, `${schemaName}.ts`), {
|
||||
force: true,
|
||||
})
|
||||
})
|
||||
|
||||
console.log("Finished clean up")
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import path from "path"
|
||||
import DocblockGenerator from "../classes/generators/docblock.js"
|
||||
import getMonorepoRoot from "../utils/get-monorepo-root.js"
|
||||
import { GitManager } from "../classes/helpers/git-manager.js"
|
||||
import { CommonCliOptions } from "../types/index.js"
|
||||
import OasGenerator from "../classes/generators/oas.js"
|
||||
import DmlGenerator from "../classes/generators/dml.js"
|
||||
|
||||
export default async function runGitChanges({
|
||||
type,
|
||||
...options
|
||||
}: CommonCliOptions) {
|
||||
const monorepoPath = getMonorepoRoot()
|
||||
// retrieve the changed files under `packages` in the monorepo root.
|
||||
const gitManager = new GitManager()
|
||||
let files = await gitManager.getDiffFiles()
|
||||
|
||||
if (!files.length) {
|
||||
console.log(`No file changes detected.`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${files.length} files have changed. Running generator on them...`
|
||||
)
|
||||
|
||||
files = files.map((filePath) => path.resolve(monorepoPath, filePath))
|
||||
|
||||
if (type === "all" || type === "docs") {
|
||||
const docblockGenerator = new DocblockGenerator({
|
||||
paths: files,
|
||||
...options,
|
||||
})
|
||||
|
||||
await docblockGenerator.run()
|
||||
}
|
||||
|
||||
if (type === "all" || type === "oas") {
|
||||
const oasGenerator = new OasGenerator({
|
||||
paths: files,
|
||||
...options,
|
||||
})
|
||||
|
||||
await oasGenerator.run()
|
||||
}
|
||||
|
||||
if (type === "all" || type === "dml") {
|
||||
const dmlGenerator = new DmlGenerator({
|
||||
paths: files,
|
||||
...options,
|
||||
})
|
||||
|
||||
await dmlGenerator.run()
|
||||
}
|
||||
|
||||
console.log(`Finished generating docs for ${files.length} files.`)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import filterFiles from "../utils/filter-files.js"
|
||||
import path from "path"
|
||||
import getMonorepoRoot from "../utils/get-monorepo-root.js"
|
||||
import DocblockGenerator from "../classes/generators/docblock.js"
|
||||
import OasGenerator from "../classes/generators/oas.js"
|
||||
import { CommonCliOptions } from "../types/index.js"
|
||||
import { GitManager } from "../classes/helpers/git-manager.js"
|
||||
import DmlGenerator from "../classes/generators/dml.js"
|
||||
|
||||
export default async function (
|
||||
commitSha: string,
|
||||
{ type, ...options }: CommonCliOptions
|
||||
) {
|
||||
const monorepoPath = getMonorepoRoot()
|
||||
// retrieve the files changed in the commit
|
||||
const gitManager = new GitManager()
|
||||
|
||||
const files = await gitManager.getCommitFiles(commitSha)
|
||||
|
||||
// filter changed files
|
||||
let filteredFiles = filterFiles(files?.map((file) => file.filename) || [])
|
||||
|
||||
if (!filteredFiles.length) {
|
||||
console.log("No applicable files changed. Canceling...")
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${filteredFiles.length} files have changed. Running generator on them...`
|
||||
)
|
||||
|
||||
filteredFiles = filteredFiles.map((filePath) =>
|
||||
path.resolve(monorepoPath, filePath)
|
||||
)
|
||||
|
||||
// generate docblocks for each of the files.
|
||||
if (type === "all" || type === "docs") {
|
||||
const docblockGenerator = new DocblockGenerator({
|
||||
paths: filteredFiles,
|
||||
...options,
|
||||
})
|
||||
|
||||
await docblockGenerator.run()
|
||||
}
|
||||
|
||||
if (type === "all" || type === "oas") {
|
||||
const oasGenerator = new OasGenerator({
|
||||
paths: filteredFiles,
|
||||
...options,
|
||||
})
|
||||
|
||||
await oasGenerator.run()
|
||||
}
|
||||
|
||||
if (type === "all" || type === "dml") {
|
||||
const dmlGenerator = new DmlGenerator({
|
||||
paths: filteredFiles,
|
||||
...options,
|
||||
})
|
||||
|
||||
await dmlGenerator.run()
|
||||
}
|
||||
|
||||
console.log(`Finished generating docs for ${filteredFiles.length} files.`)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import filterFiles from "../utils/filter-files.js"
|
||||
import path from "path"
|
||||
import DocblockGenerator from "../classes/generators/docblock.js"
|
||||
import getMonorepoRoot from "../utils/get-monorepo-root.js"
|
||||
import { GitManager } from "../classes/helpers/git-manager.js"
|
||||
import OasGenerator from "../classes/generators/oas.js"
|
||||
import { CommonCliOptions } from "../types/index.js"
|
||||
import DmlGenerator from "../classes/generators/dml.js"
|
||||
|
||||
export default async function ({ type, tag, ...options }: CommonCliOptions) {
|
||||
const gitManager = new GitManager()
|
||||
|
||||
console.log(`Get files in commits since ${tag || "last release"}`)
|
||||
|
||||
const files = tag
|
||||
? await gitManager.getCommitFilesSinceRelease(tag)
|
||||
: await gitManager.getCommitFilesSinceLastRelease()
|
||||
|
||||
// filter changed files
|
||||
let filteredFiles = filterFiles(files)
|
||||
|
||||
if (!filteredFiles.length) {
|
||||
console.log("No applicable files changed. Canceling...")
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${filteredFiles.length} files have changed. Running generator on them...`
|
||||
)
|
||||
|
||||
filteredFiles = filteredFiles.map((filePath) =>
|
||||
path.resolve(getMonorepoRoot(), filePath)
|
||||
)
|
||||
|
||||
if (type === "all" || type === "docs") {
|
||||
const docblockGenerator = new DocblockGenerator({
|
||||
paths: filteredFiles,
|
||||
...options,
|
||||
})
|
||||
|
||||
await docblockGenerator.run()
|
||||
}
|
||||
|
||||
if (type === "all" || type === "oas") {
|
||||
const oasGenerator = new OasGenerator({
|
||||
paths: filteredFiles,
|
||||
...options,
|
||||
})
|
||||
|
||||
await oasGenerator.run()
|
||||
}
|
||||
|
||||
if (type === "all" || type === "dml") {
|
||||
const dmlGenerator = new DmlGenerator({
|
||||
paths: filteredFiles,
|
||||
...options,
|
||||
})
|
||||
|
||||
await dmlGenerator.run()
|
||||
}
|
||||
|
||||
console.log(`Finished generating docs for ${filteredFiles.length} files.`)
|
||||
}
|
||||
41
www/utils/packages/docs-generator/src/commands/run.ts
Normal file
41
www/utils/packages/docs-generator/src/commands/run.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import DmlGenerator from "../classes/generators/dml.js"
|
||||
import DocblockGenerator from "../classes/generators/docblock.js"
|
||||
import { Options } from "../classes/generators/index.js"
|
||||
import OasGenerator from "../classes/generators/oas.js"
|
||||
import { CommonCliOptions } from "../types/index.js"
|
||||
|
||||
export default async function run(
|
||||
paths: string[],
|
||||
{ type, ...options }: Omit<Options, "paths"> & CommonCliOptions
|
||||
) {
|
||||
console.log("Running...")
|
||||
|
||||
if (type === "all" || type === "docs") {
|
||||
const docblockGenerator = new DocblockGenerator({
|
||||
paths,
|
||||
...options,
|
||||
})
|
||||
|
||||
await docblockGenerator.run()
|
||||
}
|
||||
|
||||
if (type === "all" || type === "oas") {
|
||||
const oasGenerator = new OasGenerator({
|
||||
paths,
|
||||
...options,
|
||||
})
|
||||
|
||||
await oasGenerator.run()
|
||||
}
|
||||
|
||||
if (type === "all" || type === "dml") {
|
||||
const dmlGenerator = new DmlGenerator({
|
||||
paths,
|
||||
...options,
|
||||
})
|
||||
|
||||
await dmlGenerator.run()
|
||||
}
|
||||
|
||||
console.log(`Finished running.`)
|
||||
}
|
||||
35
www/utils/packages/docs-generator/src/constants.ts
Normal file
35
www/utils/packages/docs-generator/src/constants.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { OpenAPIV3 } from "openapi-types"
|
||||
|
||||
export const DOCBLOCK_LINE_ASTRIX = " * "
|
||||
export const DOCBLOCK_NEW_LINE = `\n${DOCBLOCK_LINE_ASTRIX}`
|
||||
export const DOCBLOCK_START = `*${DOCBLOCK_NEW_LINE}`
|
||||
export const DOCBLOCK_END_LINE = "\n"
|
||||
export const DOCBLOCK_DOUBLE_LINES = `${DOCBLOCK_NEW_LINE}${DOCBLOCK_NEW_LINE}`
|
||||
export const DEFAULT_OAS_RESPONSES: {
|
||||
[k: string]: OpenAPIV3.ReferenceObject
|
||||
} = {
|
||||
"400": {
|
||||
$ref: "#/components/responses/400_error",
|
||||
},
|
||||
"401": {
|
||||
$ref: "#/components/responses/unauthorized",
|
||||
},
|
||||
"404": {
|
||||
$ref: "#/components/responses/not_found_error",
|
||||
},
|
||||
"409": {
|
||||
$ref: "#/components/responses/invalid_state_error",
|
||||
},
|
||||
"422": {
|
||||
$ref: "#/components/responses/invalid_request_error",
|
||||
},
|
||||
"500": {
|
||||
$ref: "#/components/responses/500_error",
|
||||
},
|
||||
}
|
||||
export const API_ROUTE_PARAM_REGEX = /\[(.+?)\]/g
|
||||
// we can't use `{summary}` because it causes an MDX error
|
||||
// when we finally render the summary. We can alternatively
|
||||
// use `\{summary\}` but it wouldn't look pretty in the OAS,
|
||||
// so doing this for now.
|
||||
export const SUMMARY_PLACEHOLDER = "SUMMARY"
|
||||
79
www/utils/packages/docs-generator/src/index.ts
Normal file
79
www/utils/packages/docs-generator/src/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
import "dotenv/config"
|
||||
import { Command, Option } from "commander"
|
||||
import run from "./commands/run.js"
|
||||
import runGitChanges from "./commands/run-git-changes.js"
|
||||
import runGitCommit from "./commands/run-git-commit.js"
|
||||
import runRelease from "./commands/run-release.js"
|
||||
import cleanOas from "./commands/clean-oas.js"
|
||||
import cleanDml from "./commands/clean-dml.js"
|
||||
|
||||
const program = new Command()
|
||||
|
||||
program.name("docs-generator").description("Generate TSDoc doc-blocks")
|
||||
|
||||
// define common options
|
||||
const typeOption = new Option("--type <type>", "The type of docs to generate.")
|
||||
.choices(["all", "docs", "oas", "dml"])
|
||||
.default("all")
|
||||
|
||||
const generateExamplesOption = new Option(
|
||||
"--generate-examples",
|
||||
"Whether to generate examples"
|
||||
).default(false)
|
||||
|
||||
program
|
||||
.command("run")
|
||||
.description("Generate TSDoc doc-blocks for specified files.")
|
||||
.argument("<files...>", "One or more TypeScript file or directory paths.")
|
||||
.option(
|
||||
"--dry-run",
|
||||
"Whether to run the command without writing the changes."
|
||||
)
|
||||
.addOption(typeOption)
|
||||
.addOption(generateExamplesOption)
|
||||
.action(run)
|
||||
|
||||
program
|
||||
.command("run:changes")
|
||||
.description("Generate TSDoc doc-blocks for changed files in git.")
|
||||
.addOption(typeOption)
|
||||
.addOption(generateExamplesOption)
|
||||
.action(runGitChanges)
|
||||
|
||||
program
|
||||
.command("run:commit")
|
||||
.description("Generate TSDoc doc-blocks for changed files in a commit.")
|
||||
.argument("<commitSha>", "The SHA of a commit.")
|
||||
.addOption(typeOption)
|
||||
.addOption(generateExamplesOption)
|
||||
.action(runGitCommit)
|
||||
|
||||
program
|
||||
.command("run:release")
|
||||
.description(
|
||||
"Generate TSDoc doc-blocks for files part of the latest release. It will retrieve the files of commits between the latest two releases."
|
||||
)
|
||||
.addOption(typeOption)
|
||||
.addOption(generateExamplesOption)
|
||||
.option(
|
||||
"--tag <tag>",
|
||||
"Specify a release tag to use rather than the latest release."
|
||||
)
|
||||
.action(runRelease)
|
||||
|
||||
program
|
||||
.command("clean:oas")
|
||||
.description(
|
||||
"Check generated OAS under the `oas-output/operations` directory and remove any OAS that no longer exists."
|
||||
)
|
||||
.action(cleanOas)
|
||||
|
||||
program
|
||||
.command("clean:dml")
|
||||
.description(
|
||||
"Check generated DML files under the `dml-output` directory and remove any data models that no longer exists."
|
||||
)
|
||||
.action(cleanDml)
|
||||
|
||||
program.parse()
|
||||
39
www/utils/packages/docs-generator/src/types/index.d.ts
vendored
Normal file
39
www/utils/packages/docs-generator/src/types/index.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import { OpenAPIV3 } from "openapi-types"
|
||||
|
||||
declare type CodeSample = {
|
||||
lang: string
|
||||
label: string
|
||||
source: string
|
||||
}
|
||||
|
||||
export declare type OpenApiOperation = Partial<OpenAPIV3.OperationObject> & {
|
||||
"x-authenticated"?: boolean
|
||||
"x-codeSamples"?: CodeSample[]
|
||||
}
|
||||
|
||||
export declare type CommonCliOptions = {
|
||||
type: "all" | "oas" | "docs" | "dml"
|
||||
generateExamples?: boolean
|
||||
tag?: string
|
||||
}
|
||||
|
||||
export declare type OpenApiSchema = OpenAPIV3.SchemaObject & {
|
||||
"x-schemaName"?: string
|
||||
}
|
||||
|
||||
export declare interface OpenApiTagObject extends OpenAPIV3.TagObject {
|
||||
"x-associatedSchema"?: OpenAPIV3.ReferenceObject
|
||||
}
|
||||
|
||||
export declare interface OpenApiDocument extends OpenAPIV3.Document {
|
||||
tags?: OpenApiTagObject[]
|
||||
}
|
||||
|
||||
export declare type DmlObject = Record<string, string>
|
||||
|
||||
export declare type DmlFile = {
|
||||
[k: string]: {
|
||||
filePath: string
|
||||
properties: DmlObject
|
||||
}
|
||||
}
|
||||
8
www/utils/packages/docs-generator/src/utils/dirname.ts
Normal file
8
www/utils/packages/docs-generator/src/utils/dirname.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
export default function dirname(fileUrl: string) {
|
||||
const __filename = fileURLToPath(fileUrl)
|
||||
|
||||
return path.dirname(__filename)
|
||||
}
|
||||
13
www/utils/packages/docs-generator/src/utils/filter-files.ts
Normal file
13
www/utils/packages/docs-generator/src/utils/filter-files.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { minimatch } from "minimatch"
|
||||
|
||||
export default function (files: string[]): string[] {
|
||||
return files.filter((file) =>
|
||||
minimatch(
|
||||
file,
|
||||
"**/packages/@(medusa|core/types|medusa-js|medusa-react)/src/**/*.@(ts|tsx|js|jsx)",
|
||||
{
|
||||
matchBase: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
20
www/utils/packages/docs-generator/src/utils/format-oas.ts
Normal file
20
www/utils/packages/docs-generator/src/utils/format-oas.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { stringify } from "yaml"
|
||||
import { DOCBLOCK_END_LINE, DOCBLOCK_NEW_LINE } from "../constants.js"
|
||||
import { OpenApiOperation, OpenApiSchema } from "../types/index.js"
|
||||
|
||||
/**
|
||||
* Retrieve the OAS as a formatted string that can be used as a comment.
|
||||
*
|
||||
* @param oas - The OAS operation to format.
|
||||
* @param oasPrefix - The OAS prefix that's used before the OAS operation.
|
||||
* @returns The formatted OAS comment.
|
||||
*/
|
||||
export default function formatOas(
|
||||
oas: OpenApiOperation | OpenApiSchema,
|
||||
oasPrefix: string
|
||||
) {
|
||||
return `* ${oasPrefix}${DOCBLOCK_NEW_LINE}${stringify(oas).replaceAll(
|
||||
"\n",
|
||||
DOCBLOCK_NEW_LINE
|
||||
)}${DOCBLOCK_END_LINE}`
|
||||
}
|
||||
15
www/utils/packages/docs-generator/src/utils/get-base-path.ts
Normal file
15
www/utils/packages/docs-generator/src/utils/get-base-path.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function getBasePath(fileName: string) {
|
||||
let basePath = fileName
|
||||
const packageIndex = fileName.indexOf("packages/")
|
||||
if (packageIndex) {
|
||||
basePath = basePath.substring(packageIndex)
|
||||
}
|
||||
|
||||
return basePath
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Due to some types using Zod in their declaration
|
||||
// The type name isn't picked properly by typescript
|
||||
|
||||
import ts from "typescript"
|
||||
import isZodObject from "./is-zod-object.js"
|
||||
|
||||
// this ensures that the correct type name is used.
|
||||
export default function getCorrectZodTypeName({
|
||||
typeReferenceNode,
|
||||
itemType,
|
||||
}: {
|
||||
typeReferenceNode: ts.TypeReferenceNode
|
||||
itemType: ts.Type
|
||||
}): string | undefined {
|
||||
if (!isZodObject(itemType)) {
|
||||
return
|
||||
}
|
||||
|
||||
return typeReferenceNode.typeArguments?.[0] &&
|
||||
"typeName" in typeReferenceNode.typeArguments[0]
|
||||
? (typeReferenceNode.typeArguments?.[0].typeName as ts.Identifier).getText()
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "path"
|
||||
import dirname from "./dirname.js"
|
||||
|
||||
/**
|
||||
* Retrieves the monorepo root either from the `MONOREPO_ROOT_PATH` environment
|
||||
* variable, or inferring it from the path.
|
||||
*
|
||||
* @returns {string} The absolute path to the monorepository.
|
||||
*/
|
||||
export default function getMonorepoRoot(): string {
|
||||
return (
|
||||
process.env.MONOREPO_ROOT_PATH ||
|
||||
path.join(dirname(import.meta.url), "..", "..", "..", "..", "..", "..")
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import path from "path"
|
||||
import getMonorepoRoot from "./get-monorepo-root.js"
|
||||
|
||||
/**
|
||||
* Retrieves the base path to the `oas-output` directory.
|
||||
*/
|
||||
export function getOasOutputBasePath() {
|
||||
return path.join(getMonorepoRoot(), "www", "utils", "generated", "oas-output")
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the base path to the `dml-output` directory
|
||||
*/
|
||||
export function getDmlOutputBasePath() {
|
||||
return path.join(getMonorepoRoot(), "www", "utils", "generated", "dml-output")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import path from "path"
|
||||
|
||||
/**
|
||||
* Get relative path of multiple file paths to a specified path.
|
||||
*
|
||||
* @param {string[]} filePaths - The file paths to retrieve their relative path.
|
||||
* @param {string} pathPrefix - The path to retrieve paths relative to.
|
||||
* @returns {string[]} The relative file paths.
|
||||
*/
|
||||
export default function getRelativePaths(
|
||||
filePaths: string[],
|
||||
pathPrefix: string
|
||||
): string[] {
|
||||
return filePaths.map((filePath) => path.resolve(pathPrefix, filePath))
|
||||
}
|
||||
24
www/utils/packages/docs-generator/src/utils/get-symbol.ts
Normal file
24
www/utils/packages/docs-generator/src/utils/get-symbol.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import ts from "typescript"
|
||||
|
||||
/**
|
||||
* Retrieves the symbol of a node.
|
||||
*
|
||||
* @param {ts.Node} node - The node to retrieve its symbol.
|
||||
* @param {ts.TypeChecker} checker - The type checker of the TypeScript program the symbol is in.
|
||||
* @returns {ts.Symbol | undefined} The symbol if found.
|
||||
*/
|
||||
export default function getSymbol(
|
||||
node: ts.Node,
|
||||
checker: ts.TypeChecker
|
||||
): ts.Symbol | undefined {
|
||||
if (
|
||||
ts.isVariableStatement(node) &&
|
||||
node.declarationList.declarations.length
|
||||
) {
|
||||
return getSymbol(node.declarationList.declarations[0], checker)
|
||||
}
|
||||
|
||||
return "symbol" in node && node.symbol
|
||||
? (node.symbol as ts.Symbol)
|
||||
: undefined
|
||||
}
|
||||
15
www/utils/packages/docs-generator/src/utils/is-zod-object.ts
Normal file
15
www/utils/packages/docs-generator/src/utils/is-zod-object.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import ts from "typescript"
|
||||
|
||||
export default function isZodObject(itemType: ts.Type): boolean {
|
||||
if (!itemType.symbol?.declarations?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parent = itemType.symbol.declarations[0].parent
|
||||
|
||||
if (!("typeName" in parent)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (parent.typeName as ts.Identifier).getText().includes("ZodObject")
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import path from "path"
|
||||
import getMonorepoRoot from "./get-monorepo-root.js"
|
||||
import ts from "typescript"
|
||||
import { minimatch } from "minimatch"
|
||||
import { capitalize } from "utils"
|
||||
|
||||
export const kindsCanHaveNamespace = [
|
||||
ts.SyntaxKind.SourceFile,
|
||||
ts.SyntaxKind.ClassDeclaration,
|
||||
ts.SyntaxKind.EnumDeclaration,
|
||||
ts.SyntaxKind.ModuleDeclaration,
|
||||
ts.SyntaxKind.InterfaceDeclaration,
|
||||
ts.SyntaxKind.TypeAliasDeclaration,
|
||||
ts.SyntaxKind.MethodDeclaration,
|
||||
ts.SyntaxKind.MethodSignature,
|
||||
ts.SyntaxKind.FunctionDeclaration,
|
||||
ts.SyntaxKind.ArrowFunction,
|
||||
ts.SyntaxKind.VariableStatement,
|
||||
]
|
||||
|
||||
export const pathsHavingCustomNamespace = [
|
||||
"**/packages/medusa\\-react/src/hooks/**/index.ts",
|
||||
"**/packages/medusa\\-react/src/@(helpers|contexts)/**/*.@(tsx|ts)",
|
||||
]
|
||||
|
||||
export const CUSTOM_NAMESPACE_TAG = "@customNamespace"
|
||||
|
||||
/**
|
||||
* Get the path used with the {@link CUSTOM_NAMESPACE_TAG}.
|
||||
*
|
||||
* @param {ts.Node} node - The node to retrieve its custom namespace path.
|
||||
* @returns {string} The namespace path.
|
||||
*/
|
||||
export function getNamespacePath(node: ts.Node): string {
|
||||
const packagePathPrefix = `${path.resolve(
|
||||
getMonorepoRoot(),
|
||||
"packages/medusa-react/src"
|
||||
)}/`
|
||||
|
||||
const sourceFile = node.getSourceFile()
|
||||
|
||||
let hookPath = path
|
||||
.dirname(sourceFile.fileName)
|
||||
.replace(packagePathPrefix, "")
|
||||
|
||||
const fileName = path.basename(sourceFile.fileName)
|
||||
|
||||
if (
|
||||
!fileName.startsWith("index") &&
|
||||
!fileName.startsWith("mutations") &&
|
||||
!fileName.startsWith("queries")
|
||||
) {
|
||||
hookPath += `/${fileName.replace(path.extname(fileName), "")}`
|
||||
}
|
||||
|
||||
return hookPath
|
||||
.split("/")
|
||||
.map((pathItem, index) => {
|
||||
if (index === 0) {
|
||||
pathItem = pathItem
|
||||
.replace("contexts", "providers")
|
||||
.replace("helpers", "utilities")
|
||||
}
|
||||
|
||||
return pathItem
|
||||
.split("-")
|
||||
.map((item) => capitalize(item))
|
||||
.join(" ")
|
||||
})
|
||||
.join(".")
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the full tag of the custom namespace with its value.
|
||||
*
|
||||
* @param {ts.Node} node - The node to retrieve its custom namespace path.
|
||||
* @returns {string} The custom namespace tag and value.
|
||||
*/
|
||||
export function getCustomNamespaceTag(node: ts.Node): string {
|
||||
return `${CUSTOM_NAMESPACE_TAG} ${getNamespacePath(node)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a node should have a custom namespace path.
|
||||
*
|
||||
* @param {ts.Node} node - The node to check.
|
||||
* @returns {boolean} Whether the node should have a custom namespace.
|
||||
*/
|
||||
export function shouldHaveCustomNamespace(node: ts.Node): boolean {
|
||||
if (!kindsCanHaveNamespace.includes(node.kind)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const fileName = node.getSourceFile().fileName
|
||||
|
||||
return pathsHavingCustomNamespace.some((pattern) =>
|
||||
minimatch(fileName, pattern, {
|
||||
matchBase: true,
|
||||
})
|
||||
)
|
||||
}
|
||||
42
www/utils/packages/docs-generator/src/utils/parse-oas.ts
Normal file
42
www/utils/packages/docs-generator/src/utils/parse-oas.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { parse } from "yaml"
|
||||
import { OpenApiOperation } from "../types/index.js"
|
||||
import { DOCBLOCK_LINE_ASTRIX } from "../constants.js"
|
||||
|
||||
export type ExistingOas = {
|
||||
oas: OpenApiOperation
|
||||
oasPrefix: string
|
||||
}
|
||||
|
||||
export default function parseOas(content: string): ExistingOas | undefined {
|
||||
content = content
|
||||
.replace(`/**\n`, "")
|
||||
.replaceAll(DOCBLOCK_LINE_ASTRIX, "")
|
||||
.replaceAll("*/", "")
|
||||
.trim()
|
||||
|
||||
if (!content.startsWith("@oas")) {
|
||||
// the file is of an invalid format.
|
||||
return
|
||||
}
|
||||
|
||||
// extract oas prefix line
|
||||
const splitNodeComments = content.split("\n")
|
||||
const oasPrefix = content.split("\n")[0]
|
||||
content = splitNodeComments.slice(1).join("\n")
|
||||
|
||||
let oas: OpenApiOperation | undefined
|
||||
|
||||
try {
|
||||
oas = parse(content) as OpenApiOperation
|
||||
} catch (e) {
|
||||
// couldn't parse the OAS, so consider it
|
||||
// not existent
|
||||
}
|
||||
|
||||
return oas
|
||||
? {
|
||||
oas,
|
||||
oasPrefix,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import util from "node:util"
|
||||
import { exec } from "child_process"
|
||||
|
||||
export default util.promisify(exec)
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Remove parts of the name such as DTO, Filterable, etc...
|
||||
*
|
||||
* @param {string} str - The name to format.
|
||||
* @returns {string} The normalized name.
|
||||
*/
|
||||
export function normalizeName(str: string): string {
|
||||
return str
|
||||
.replace(/^(create|update|delete|upsert)/i, "")
|
||||
.replace(/DTO$/, "")
|
||||
.replace(/^Filterable/, "")
|
||||
.replace(/Props$/, "")
|
||||
.replace(/^I([A-Z])/, "$1")
|
||||
.replace(/ModuleService$/, "")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Retrieves the stringified JSON of a variable formatted.
|
||||
*
|
||||
* @param item The item to stringify
|
||||
* @returns The formatted JSON string
|
||||
*/
|
||||
export default function toJsonFormatted(item: unknown): string {
|
||||
return JSON.stringify(item, undefined, "\t")
|
||||
}
|
||||
Reference in New Issue
Block a user