docs-util: add tool to generate mapping between JS SDK examples and routes (#11933)
* initial implementation * finish implementation * add js sdk key
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import { minimatch } from "minimatch"
|
||||
import AbstractGenerator from "./index.js"
|
||||
import getBasePath from "../../utils/get-base-path.js"
|
||||
import RouteExamplesKindGenerator from "../kinds/route-examples.js"
|
||||
import ts from "typescript"
|
||||
import type { RouteExamples } from "types"
|
||||
import getMonorepoRoot from "../../utils/get-monorepo-root.js"
|
||||
import path from "path"
|
||||
class RouteExamplesGenerator extends AbstractGenerator {
|
||||
protected routeExamplesKindGenerator?: RouteExamplesKindGenerator
|
||||
|
||||
async run() {
|
||||
this.init()
|
||||
|
||||
this.routeExamplesKindGenerator = new RouteExamplesKindGenerator({
|
||||
checker: this.checker!,
|
||||
generatorEventManager: this.generatorEventManager,
|
||||
})
|
||||
|
||||
let routeExamples: RouteExamples = {}
|
||||
|
||||
await Promise.all(
|
||||
this.program!.getSourceFiles().map(async (file) => {
|
||||
if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileNodes: ts.Node[] = [file]
|
||||
|
||||
console.log(`[Route Examples] 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.routeExamplesKindGenerator!.isAllowed(node) &&
|
||||
this.routeExamplesKindGenerator!.canDocumentNode(node)
|
||||
) {
|
||||
const result =
|
||||
await this.routeExamplesKindGenerator!.getDocBlock(node)
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
routeExamples = Object.assign(routeExamples, JSON.parse(result))
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
fileNodes.map(async (node) => await documentChild(node))
|
||||
)
|
||||
|
||||
if (!this.options.dryRun) {
|
||||
this.writeJson(routeExamples)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Route Examples generator can run on this file.
|
||||
*/
|
||||
isFileIncluded(fileName: string): boolean {
|
||||
return (
|
||||
super.isFileIncluded(fileName) &&
|
||||
minimatch(
|
||||
getBasePath(fileName),
|
||||
"packages/core/**/js-sdk/src/@(store|admin|auth)/**",
|
||||
{
|
||||
matchBase: true,
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the route examples to a JSON file.
|
||||
*
|
||||
* @param routeExamples - The route examples to write.
|
||||
*/
|
||||
writeJson(routeExamples: RouteExamples) {
|
||||
const filePath = path.join(
|
||||
getMonorepoRoot(),
|
||||
"www/utils/generated/route-examples-output/route-examples.json"
|
||||
)
|
||||
|
||||
const fileContent = JSON.stringify(routeExamples, null, 2)
|
||||
|
||||
ts.sys.writeFile(filePath, fileContent)
|
||||
}
|
||||
}
|
||||
|
||||
export default RouteExamplesGenerator
|
||||
@@ -239,7 +239,6 @@ class OasKindGenerator extends FunctionKindGenerator {
|
||||
node: ts.Node | FunctionOrVariableNode,
|
||||
options?: GetDocBlockOptions
|
||||
): Promise<string> {
|
||||
// TODO use AiGenerator to generate descriptions + examples
|
||||
if (!this.isAllowed(node)) {
|
||||
return await super.getDocBlock(node, options)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import ts from "typescript"
|
||||
import { SyntaxKind } from "typescript"
|
||||
import DefaultKindGenerator, { GetDocBlockOptions } from "./default.js"
|
||||
import { API_ROUTE_PARAM_REGEX } from "../../constants.js"
|
||||
import type { RouteExamples } from "types"
|
||||
const EXAMPLE_CODEBLOCK_REGEX = /```(ts|typescript)\s*([.\s\S]*?)\s*```/
|
||||
|
||||
type RouteData = {
|
||||
route: string
|
||||
method: string
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
class RouteExamplesKindGenerator extends DefaultKindGenerator<ts.MethodDeclaration> {
|
||||
public name = "route-examples"
|
||||
protected allowedKinds: SyntaxKind[] = [
|
||||
SyntaxKind.MethodDeclaration,
|
||||
SyntaxKind.ArrowFunction,
|
||||
]
|
||||
|
||||
/**
|
||||
* Gets the route examples from the specified node.
|
||||
*
|
||||
* @param node - The node to get the route examples from.
|
||||
* @param options - The options for the route examples.
|
||||
* @returns The route examples for the specified node.
|
||||
*/
|
||||
async getDocBlock(
|
||||
node: ts.MethodDeclaration,
|
||||
options?: GetDocBlockOptions
|
||||
): Promise<string> {
|
||||
if (!this.isAllowed(node)) {
|
||||
return await super.getDocBlock(node, options)
|
||||
}
|
||||
|
||||
// extract the route path from the node
|
||||
const routeData = this.findRoute(node.body as ts.Node)
|
||||
|
||||
if (!routeData.route) {
|
||||
return ""
|
||||
}
|
||||
if (!routeData.method) {
|
||||
routeData.method = "GET" // default method
|
||||
}
|
||||
|
||||
// get examples from the comments
|
||||
const example = ts
|
||||
.getJSDocTags(node)
|
||||
.find((tag) => tag.tagName.escapedText === "example")
|
||||
|
||||
if (!example || !example.comment) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const exampleText = this.getExampleText(
|
||||
typeof example.comment === "string"
|
||||
? example.comment
|
||||
: example.comment[example.comment.length - 1].text
|
||||
)
|
||||
|
||||
return JSON.stringify({
|
||||
[this.formatRouteData(routeData)]: {
|
||||
[this.getExampleType(node)]: exampleText,
|
||||
},
|
||||
} as RouteExamples)
|
||||
}
|
||||
|
||||
getExampleText(comment: string): string {
|
||||
// try to match the example codeblock first
|
||||
const match = comment.match(EXAMPLE_CODEBLOCK_REGEX)
|
||||
if (match) {
|
||||
// return the last match
|
||||
return match[match.length - 1]
|
||||
}
|
||||
|
||||
// consider the comment as the example text
|
||||
return comment
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this method later to support different example types.
|
||||
*
|
||||
* @param node - The node to get the example type for.
|
||||
* @returns The example type.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getExampleType(node: ts.MethodDeclaration): string {
|
||||
return "js-sdk"
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the route data from the specified node.
|
||||
*
|
||||
* @param node - The node to find the route from.
|
||||
* @returns The route data.
|
||||
*/
|
||||
findRoute(node: ts.Node): RouteData {
|
||||
const result = {
|
||||
route: "",
|
||||
method: "",
|
||||
}
|
||||
|
||||
if (
|
||||
node.kind === ts.SyntaxKind.StringLiteral ||
|
||||
node.kind === ts.SyntaxKind.NoSubstitutionTemplateLiteral ||
|
||||
node.kind === ts.SyntaxKind.TemplateExpression
|
||||
) {
|
||||
const str = node
|
||||
.getText()
|
||||
.replace(/^["'`]|["'`]$/g, "")
|
||||
.replace(API_ROUTE_PARAM_REGEX, `{$1}`)
|
||||
.toLowerCase()
|
||||
if (
|
||||
str.startsWith("/store") ||
|
||||
str.startsWith("/admin") ||
|
||||
str.startsWith("/auth")
|
||||
) {
|
||||
result.route = str
|
||||
} else if (
|
||||
str === "get" ||
|
||||
str === "post" ||
|
||||
str === "put" ||
|
||||
str === "delete"
|
||||
) {
|
||||
result.method = str.toUpperCase()
|
||||
}
|
||||
} else {
|
||||
node.forEachChild((child) => {
|
||||
if (result.route.length > 0 && result.method.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const childResult = this.findRoute(child)
|
||||
if (result.route.length === 0) {
|
||||
result.route = childResult.route
|
||||
}
|
||||
if (result.method.length === 0) {
|
||||
result.method = childResult.method
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the route data as a string.
|
||||
*
|
||||
* @param routeData - The route data to format.
|
||||
* @returns The formatted route data as a string.
|
||||
*/
|
||||
formatRouteData(routeData: RouteData): string {
|
||||
return `${routeData.method} ${routeData.route}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 has docblock
|
||||
return ts.getJSDocCommentsAndTags(node).length > 0 && !this.isPrivate(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a node is private.
|
||||
*
|
||||
* @param node - The node to check for.
|
||||
* @returns Whether the node is private.
|
||||
*/
|
||||
isPrivate(node: ts.Node): boolean {
|
||||
// Check for explicit private keyword
|
||||
if (ts.canHaveModifiers(node)) {
|
||||
const modifiers = ts.getModifiers(node)
|
||||
if (modifiers) {
|
||||
return modifiers.some(
|
||||
(modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for private class member
|
||||
return (node.flags & ts.ModifierFlags.Private) !== 0
|
||||
}
|
||||
}
|
||||
|
||||
export default RouteExamplesKindGenerator
|
||||
Reference in New Issue
Block a user