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:
Shahed Nasser
2025-03-21 15:34:52 +02:00
committed by GitHub
parent 834b309037
commit df5ae50612
11 changed files with 1025 additions and 3 deletions

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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