import { UIDLDataSourceItemNode, UIDLDataSourceListNode, ChunkDefinition, GeneratorOptions, FileType, UIDLDataSource, DataSourceType, ChunkType, UIDLEcommerceCategory, } from '@teleporthq/teleport-types' import * as types from '@babel/types' import { ASTUtils } from '@teleporthq/teleport-plugin-common' import { StringUtils } from '@teleporthq/teleport-shared' import { generateDataSourceFetcherWithCore } from './data-source-fetchers' const VALID_DATA_SOURCE_TYPES: DataSourceType[] = [ 'rest-api', 'teleport', 'postgresql', 'mysql', 'mariadb', 'amazon-redshift', 'mongodb', 'cockroachdb', 'tidb', 'redis', 'firestore', 'clickhouse', 'airtable', 'supabase', 'turso', 'javascript', 'google-sheets', 'csv-file', ] export const sanitizeFileName = (input: string): string => { if (!input || typeof input !== 'string') { return 'unknown' } return ( input // Remove path traversal attempts .replace(/\.\./g, '') .replace(/[\/\\]/g, '-') // Remove invalid filename characters .replace(/[<>:"|?*\x00-\x1F]/g, '') // Replace spaces with dashes .replace(/\s+/g, '-') // Remove leading/trailing dashes and dots .replace(/^[-_.]+|[-_.]+$/g, '') // Limit length to prevent filesystem issues .substring(0, 200) || 'unknown' ) } export const isNonEmptyString = (value: unknown): value is string => { return typeof value === 'string' && value.trim().length > 0 } export const hoistLoadingFromRepeater = (dataSourceNode: any): void => { if (!dataSourceNode || !dataSourceNode.content) { return } const children = dataSourceNode.children || [] for (const child of children) { if (child.type === 'cms-list-repeater' && child.content?.nodes?.loading) { if (!dataSourceNode.content.nodes) { dataSourceNode.content.nodes = {} } if ( !dataSourceNode.content.nodes.loading && child.content.nodes.loading.content?.children?.length > 0 ) { dataSourceNode.content.nodes.loading = child.content.nodes.loading } break } } if (dataSourceNode.content.nodes?.success?.content?.children) { for (const child of dataSourceNode.content.nodes.success.content.children) { if (child.type === 'cms-list-repeater' && child.content?.nodes?.loading) { if ( !dataSourceNode.content.nodes.loading && child.content.nodes.loading.content?.children?.length > 0 ) { dataSourceNode.content.nodes.loading = child.content.nodes.loading } break } } } } // tslint:disable-next-line:no-any export const validateResourceDefinition = ( resourceDefinition: any ): { isValid: boolean error?: string } => { if (!resourceDefinition || typeof resourceDefinition !== 'object') { return { isValid: false, error: 'Resource definition is missing or invalid' } } const { dataSourceId, tableName, dataSourceType } = resourceDefinition if (!isNonEmptyString(dataSourceId)) { return { isValid: false, error: 'Data source ID is missing or invalid' } } if (!isNonEmptyString(dataSourceType)) { return { isValid: false, error: 'Data source type is missing or invalid' } } if (!VALID_DATA_SOURCE_TYPES.includes(dataSourceType as DataSourceType)) { return { isValid: false, error: `Invalid data source type: ${dataSourceType}` } } // tableName is optional for some data sources (like REST API, JavaScript, etc.) if (tableName !== undefined && !isNonEmptyString(tableName)) { return { isValid: false, error: 'Table name must be a non-empty string when provided' } } return { isValid: true } } // tslint:disable-next-line:no-any export const validateNodeContent = (content: any): { isValid: boolean; error?: string } => { if (!content || typeof content !== 'object') { return { isValid: false, error: 'Node content is missing or invalid' } } if (!content.resourceDefinition) { return { isValid: false, error: 'Resource definition is missing from node content' } } // key is optional - we can generate one from the resource definition if missing return { isValid: true } } export const validateDataSourceConfig = ( dataSource: UIDLDataSource ): { isValid: boolean; error?: string } => { if (!dataSource || typeof dataSource !== 'object') { return { isValid: false, error: 'Data source is missing or invalid' } } if (!isNonEmptyString(dataSource.id)) { return { isValid: false, error: 'Data source ID is missing' } } if (!isNonEmptyString(dataSource.type)) { return { isValid: false, error: 'Data source type is missing' } } if (!dataSource.config || typeof dataSource.config !== 'object') { return { isValid: false, error: 'Data source config is missing or invalid' } } return { isValid: true } } export const generateSafeFileName = ( dataSourceType: string, tableName: string, dataSourceId: string ): string => { const sanitizedType = sanitizeFileName(dataSourceType) const sanitizedTable = sanitizeFileName(tableName || 'data') const sanitizedId = sanitizeFileName(dataSourceId) // Create a unique identifier using parts of the dataSourceId const shortId = sanitizedId.substring(0, 8) const baseName = `${sanitizedType}-${sanitizedTable}-${shortId}` return StringUtils.camelCaseToDashCase(baseName) } export const extractDataSourceIntoNextAPIFolder = ( node: UIDLDataSourceItemNode | UIDLDataSourceListNode, dataSources: Record, componentChunk: ChunkDefinition, extractedResources: GeneratorOptions['extractedResources'], categories?: UIDLEcommerceCategory[] ) => { try { // Validate node content structure const contentValidation = validateNodeContent(node.content) if (!contentValidation.isValid) { return } const { resourceDefinition } = node.content // Validate resource definition const resourceValidation = validateResourceDefinition(resourceDefinition) if (!resourceValidation.isValid) { return } const { dataSourceId, tableName, dataSourceType } = resourceDefinition // Check if dataSources object exists if (!dataSources || typeof dataSources !== 'object') { return } // Check if data source exists const dataSource = dataSources[dataSourceId] if (!dataSource) { return } // Validate data source configuration const configValidation = validateDataSourceConfig(dataSource) if (!configValidation.isValid) { return } // Check if component chunk has meta and nodesLookup if (!componentChunk.meta || !componentChunk.meta.nodesLookup) { return } // Generate safe file name const fileName = generateSafeFileName(dataSourceType, tableName || 'data', dataSourceId) // Check if file name is valid if (!fileName || fileName === 'unknown') { return } // Find JSX node by searching through nodesLookup // The JSX generator creates keys like: ds-{dataSourceId}-{timestamp} // We need to find the node that matches our resourceDefinition // IMPORTANT: Match by both dataSourceId AND renderPropIdentifier to handle multiple DataProviders with same data source let jsxNode: types.JSXElement | null = null const targetRenderProp = node.content?.renderPropIdentifier // tslint:disable-next-line:no-any for (const jsxElement of Object.values(componentChunk.meta.nodesLookup)) { // tslint:disable-next-line:no-any if ((jsxElement as any).type === 'JSXElement') { const attrs = (jsxElement as types.JSXElement).openingElement.attributes // Look for resourceDefinition attribute // tslint:disable-next-line:no-any const resourceDefAttr = attrs.find( (attr) => (attr as any).type === 'JSXAttribute' && (attr as types.JSXAttribute).name.name === 'resourceDefinition' ) as types.JSXAttribute | undefined // Look for name attribute to match with renderPropIdentifier // tslint:disable-next-line:no-any const nameAttr = attrs.find( (attr) => (attr as any).type === 'JSXAttribute' && (attr as types.JSXAttribute).name.name === 'name' ) as types.JSXAttribute | undefined if ( resourceDefAttr && resourceDefAttr.value && resourceDefAttr.value.type === 'JSXExpressionContainer' ) { const expr = resourceDefAttr.value.expression if (expr.type === 'ObjectExpression') { // Check if this matches our resourceDefinition const props = expr.properties as types.ObjectProperty[] // tslint:disable-next-line:no-any const idProp = props.find((p: any) => p.key?.value === 'dataSourceId') // tslint:disable-next-line:no-any const idValue = (idProp as any)?.value?.value // Match by dataSourceId const dataSourceMatches = idValue === dataSourceId // Also check name prop matches renderPropIdentifier let renderPropMatches = !targetRenderProp // If no targetRenderProp, don't check it if (targetRenderProp && nameAttr && nameAttr.value) { // The name attribute value is usually a StringLiteral or JSXExpressionContainer if (nameAttr.value.type === 'StringLiteral') { if (nameAttr.value.value === targetRenderProp) { renderPropMatches = true } } else if (nameAttr.value.type === 'JSXExpressionContainer') { const nameExpr = nameAttr.value.expression if (nameExpr.type === 'StringLiteral' && nameExpr.value === targetRenderProp) { renderPropMatches = true } } } // Before matching, check if this node already has fetchData - if so, skip it // This is crucial for handling multiple DataProviders with same dataSourceId and name const alreadyHasFetchData = attrs.some( (attr) => (attr as types.JSXAttribute).name?.name === 'fetchData' ) if (dataSourceMatches && renderPropMatches && !alreadyHasFetchData) { jsxNode = jsxElement as types.JSXElement break } else if (dataSourceMatches && renderPropMatches && alreadyHasFetchData) { // Continue searching for the next matching node without fetchData } } } } } if (!jsxNode || jsxNode.type !== 'JSXElement') { return } // Ensure opening element and attributes exist if (!jsxNode.openingElement || !Array.isArray(jsxNode.openingElement.attributes)) { return } // Check if this node has already been processed (has fetchData attribute) const existingFetchData = jsxNode.openingElement.attributes.find( (attr) => (attr as types.JSXAttribute).name?.name === 'fetchData' ) if (existingFetchData) { return } // Check if there are resource params const resourceParams = node.content.resource?.params || {} const hasParams = Object.keys(resourceParams).length > 0 // Build resource path - use template literal if params exist let resourcePath: types.StringLiteral | types.TemplateLiteral if (hasParams) { resourcePath = types.templateLiteral( [ types.templateElement({ raw: `/api/${fileName}?`, cooked: `/api/${fileName}?` }), types.templateElement({ raw: '', cooked: '' }), ], [types.newExpression(types.identifier('URLSearchParams'), [types.identifier('params')])] ) } else { resourcePath = types.stringLiteral(`/api/${fileName}`) } const fetchAST = types.callExpression( types.memberExpression( types.callExpression(types.identifier('fetch'), [ resourcePath, types.objectExpression([ types.objectProperty( types.identifier('headers'), types.objectExpression([ types.objectProperty( types.stringLiteral('Content-Type'), types.stringLiteral('application/json') ), ]) ), ]), ]), types.identifier('then') ), [ types.arrowFunctionExpression( [types.identifier('res')], types.callExpression( types.memberExpression(types.identifier('res'), types.identifier('json')), [] ) ), ] ) const dataExpression = ASTUtils.generateMemberExpressionASTFromPath([ 'response', 'data', ]) as types.OptionalMemberExpression // If there are params, the arrow function should accept params const resourceAST = types.arrowFunctionExpression( hasParams ? [types.identifier('params')] : [], types.callExpression(types.memberExpression(fetchAST, types.identifier('then'), false), [ types.arrowFunctionExpression([types.identifier('response')], dataExpression), ]) ) jsxNode.openingElement.attributes.unshift( types.jSXAttribute( types.jsxIdentifier('fetchData'), types.jsxExpressionContainer(resourceAST) ) ) const hasPersistDataAttr = jsxNode.openingElement.attributes.some( (attr) => attr.type === 'JSXAttribute' && (attr as types.JSXAttribute).name?.name === 'persistDataDuringLoading' ) if (!hasPersistDataAttr) { jsxNode.openingElement.attributes.push( types.jsxAttribute( types.jsxIdentifier('persistDataDuringLoading'), types.jsxExpressionContainer(types.booleanLiteral(true)) ) ) } // Ensure extracted resources object exists if (!extractedResources || typeof extractedResources !== 'object') { return } // Check if a utils file already exists for this data source (from getStaticProps) // If so, create an API route that re-exports handler from it to avoid code duplication if (extractedResources[`utils/${fileName}`]) { const apiRouteCode = `import dataSourceModule from '../../utils/data-sources/${fileName}' export default dataSourceModule.handler ` extractedResources[`api/${fileName}`] = { fileName, fileType: FileType.JS, path: ['pages', 'api'], content: apiRouteCode, } return } // Generate fetcher code for API route (exports just the handler) let fetcherCode: string try { fetcherCode = generateDataSourceFetcherWithCore(dataSource, tableName || '', true, categories) } catch (error) { return } extractedResources[`api/${fileName}`] = { fileName, fileType: FileType.JS, path: ['pages', 'api'], content: fetcherCode, } } catch (error) { // Catch any unexpected errors to prevent plugin from crashing } } export const isEmbeddedDataSource = (dataSourceType: string): boolean => { return ['javascript', 'csv-file'].includes(dataSourceType) } export const replaceSecretReference = ( value: unknown, options: { templateLiteral?: boolean } = {} ): string => { const { templateLiteral = false } = options // Handle null and undefined if (value === null) { return 'null' } if (value === undefined) { return 'undefined' } // Handle non-string values if (typeof value !== 'string') { try { return JSON.stringify(value) } catch (error) { // Handle circular references or non-serializable values return 'null' } } // Check if it's a secret reference if (value.startsWith('teleporthq.secrets.')) { const envVar = value.replace('teleporthq.secrets.', '') // Validate environment variable name // Must be alphanumeric, underscores, and start with letter or underscore if (/^[A-Z_][A-Z0-9_]*$/i.test(envVar)) { return templateLiteral ? `\${process.env.${envVar}}` : `process.env.${envVar}` } else { // Invalid env var name, use as regular string return JSON.stringify(value) } } // Regular string value try { return JSON.stringify(value) } catch (error) { // Fallback for any serialization issues return '""' } } /** * Emit runtime helpers that every SQL-based fetcher needs to safely * apply the `{ query, queryColumns }` search contract: * * - `SEARCH_LIKE_ESCAPE_CHAR` — the char we register via `ESCAPE '|'` * in LIKE / ILIKE expressions. `|` is not special in any SQL * dialect's string-literal grammar, so the emitted SQL parses * unambiguously across Postgres, MySQL (regardless of * `NO_BACKSLASH_ESCAPES`), and SQLite. * * - `escapeLikePattern(term)` — prefixes `|` to `|`, `%`, `_` so a * user typing `50%` matches a literal `%` rather than any-chars. * * - `sanitizeSearchIdentifier(name)` — validates column identifiers * against `[A-Za-z_][A-Za-z0-9_]*` and throws on anything else. The * caller is responsible for wrapping in the dialect's quote chars. * * Kept as a single snippet so every fetcher imports the exact same * contract and the canvas preview (data-fetcher-worker / * database-provider-worker) and the generated Next.js app emit * byte-identical search SQL. */ export const generateSearchEscapeHelpersCode = (): string => { return `const SEARCH_LIKE_ESCAPE_CHAR = '|' const escapeLikePattern = (term) => { return String(term == null ? '' : term).replace(/[|%_]/g, (m) => SEARCH_LIKE_ESCAPE_CHAR + m) } const sanitizeSearchIdentifier = (name) => { if (typeof name !== 'string' || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { throw new Error('Invalid search column identifier: ' + String(name)) } return name }` } export const generateSafeJSONParseCode = (): string => { return `const safeJSONParse = (value) => { if (!value) return value if (typeof value === 'object') return value try { return JSON.parse(value) } catch (e) { console.warn('Failed to parse JSON:', e) return value } }` } export const generateDateFormatterCode = (): string => { return `const formatDateValue = (date) => { const options = { year: 'numeric', month: 'short', day: 'numeric', } const timeOptions = { hour: '2-digit', minute: '2-digit', } const hasTime = date.getHours() !== 0 || date.getMinutes() !== 0 || date.getSeconds() !== 0 if (hasTime) { return date.toLocaleString('en-US', { ...options, ...timeOptions }) } return date.toLocaleDateString('en-US', options) } const dateReplacer = (key, value) => { if (value instanceof Date) { return formatDateValue(value) } return value }` } export const generateSortFilterHelperCode = (): string => { return `function getNestedValue(obj, path) { if (!obj || typeof obj !== 'object') return undefined const keys = path.split('.') let value = obj for (const key of keys) { if (value && typeof value === 'object' && key in value) { value = value[key] } else { return undefined } } return value } function compareValues(value, target, operand) { if (value === null || value === undefined || target === null || target === undefined) { switch (operand) { case '=': return value == target case '!=': return value != target default: return false } } if (typeof value === 'number' && typeof target === 'number') { switch (operand) { case '=': return value === target case '!=': return value !== target case '>': return value > target case '>=': return value >= target case '<': return value < target case '<=': return value <= target default: return true } } if (typeof value === 'string' && typeof target === 'string') { switch (operand) { case '=': return value === target case '!=': return value !== target case '>': return value > target case '>=': return value >= target case '<': return value < target case '<=': return value <= target default: return true } } const valueDate = value instanceof Date ? value : (typeof value === 'string' ? new Date(value) : null) const targetDate = target instanceof Date ? target : (typeof target === 'string' ? new Date(target) : null) if (valueDate && targetDate && !isNaN(valueDate.getTime()) && !isNaN(targetDate.getTime())) { const valueTime = valueDate.getTime() const targetTime = targetDate.getTime() switch (operand) { case '=': return valueTime === targetTime case '!=': return valueTime !== targetTime case '>': return valueTime > targetTime case '>=': return valueTime >= targetTime case '<': return valueTime < targetTime case '<=': return valueTime <= targetTime default: return true } } if (Array.isArray(value) && Array.isArray(target)) { switch (operand) { case '=': return JSON.stringify(value) === JSON.stringify(target) case '!=': return JSON.stringify(value) !== JSON.stringify(target) case '>': return value.length > target.length case '>=': return value.length >= target.length case '<': return value.length < target.length case '<=': return value.length <= target.length default: return true } } if (Array.isArray(value) && !Array.isArray(target)) { switch (operand) { case '=': return value.includes(target) case '!=': return !value.includes(target) default: return false } } if (typeof value === 'object' && typeof target === 'object') { switch (operand) { case '=': return JSON.stringify(value) === JSON.stringify(target) case '!=': return JSON.stringify(value) !== JSON.stringify(target) default: return false } } switch (operand) { case '=': return value == target case '!=': return value != target case '>': return Number(value) > Number(target) case '>=': return Number(value) >= Number(target) case '<': return Number(value) < Number(target) case '<=': return Number(value) <= Number(target) default: return true } }` } export const sanitizeNumericParam = (value: unknown, defaultValue: number = 0): number => { if (typeof value === 'number' && !isNaN(value) && isFinite(value)) { return Math.max(0, Math.floor(value)) } if (typeof value === 'string') { const parsed = parseInt(value, 10) if (!isNaN(parsed) && isFinite(parsed)) { return Math.max(0, parsed) } } return defaultValue } export const sanitizePort = (port: unknown, defaultPort: number): number => { const sanitized = sanitizeNumericParam(port, defaultPort) // Ensure port is in valid range (1-65535) if (sanitized < 1 || sanitized > 65535) { return defaultPort } return sanitized } export const isValidUrl = (url: unknown): boolean => { if (typeof url !== 'string' || !url) { return false } try { const parsed = new URL(url) // Check for valid protocols return ['http:', 'https:', 'mongodb:', 'redis:', 'postgresql:', 'mysql:'].includes( parsed.protocol ) } catch { return false } } export const sanitizeIdentifier = (identifier: unknown): string => { if (typeof identifier !== 'string' || !identifier) { return '' } // Remove dangerous characters, only allow safe ones return identifier.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 64) } /** * Checks if a dynamic param is resolvable from the route's dynamic attribute * (i.e. available via context.params in getStaticProps/getServerSideProps). */ const isDynamicParamRouteResolvable = ( param: { type: string; content?: { referenceType?: string; id?: string } }, dynamicRouteAttr: string ): boolean => { if (param.type !== 'dynamic') { return false } return param.content?.referenceType === 'prop' && param.content?.id === dynamicRouteAttr } /** * Checks if a filter destination (within a static filters array) is resolvable * from the route's dynamic attribute. */ const isFilterDestinationRouteResolvable = ( destination: unknown, dynamicRouteAttr: string ): boolean => { if (!ASTUtils.isUIDLDynamicReference(destination)) { return false } const content = (destination as { content: { referenceType: string; id: string } }).content return content.referenceType === 'prop' && content.id === dynamicRouteAttr } /** * Determines if any resource params have dynamic values that cannot be resolved * server-side. Params whose dynamic references match the page's dynamicRouteAttribute * can be resolved from context.params and are NOT considered unresolvable. */ // tslint:disable-next-line:no-any export const hasUnresolvableDynamicParams = ( params: Record, dynamicRouteAttr?: string ): boolean => { return Object.values(params).some((param: any) => { if (param.type === 'expr') { return true } if (param.type === 'dynamic') { if (dynamicRouteAttr && isDynamicParamRouteResolvable(param, dynamicRouteAttr)) { return false } return true } if (param.type === 'static' && Array.isArray(param.content)) { const hasDynamicDestinations = param.content.some( (item: any) => item && typeof item === 'object' && ASTUtils.isUIDLDynamicReference(item?.destination) ) if (hasDynamicDestinations && dynamicRouteAttr) { const allResolvable = param.content.every((item: any) => { if (!item || typeof item !== 'object') { return true } if (!ASTUtils.isUIDLDynamicReference(item.destination)) { return true } return isFilterDestinationRouteResolvable(item.destination, dynamicRouteAttr) }) return !allResolvable } return hasDynamicDestinations } return false }) } const isValidJSIdentifier = (name: string): boolean => /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) /** * Generates context.params.attr or context.params['attr'] depending on * whether the attribute name is a valid JS identifier. */ const createContextParamsAccess = (attr: string): types.MemberExpression => { const contextParams = types.memberExpression( types.identifier('context'), types.identifier('params') ) return isValidJSIdentifier(attr) ? types.memberExpression(contextParams, types.identifier(attr)) : types.memberExpression(contextParams, types.stringLiteral(attr), true) } /** * Builds an AST ObjectExpression for a filter item, resolving dynamic destinations * that reference the route attribute to context.params[attr]. */ const buildFilterObjectAST = ( filterItem: Record, dynamicRouteAttr?: string ): types.ObjectExpression => { const properties: types.ObjectProperty[] = [] for (const [key, value] of Object.entries(filterItem)) { if (value === undefined) { continue } if (key === 'destination' && dynamicRouteAttr && ASTUtils.isUIDLDynamicReference(value)) { const content = (value as { content: { id: string } }).content properties.push( types.objectProperty(types.stringLiteral(key), createContextParamsAccess(content.id)) ) } else if (typeof value === 'string') { properties.push(types.objectProperty(types.stringLiteral(key), types.stringLiteral(value))) } else if (typeof value === 'number') { properties.push(types.objectProperty(types.stringLiteral(key), types.numericLiteral(value))) } else if (typeof value === 'boolean') { properties.push(types.objectProperty(types.stringLiteral(key), types.booleanLiteral(value))) } else if (value === null) { properties.push(types.objectProperty(types.stringLiteral(key), types.nullLiteral())) } else if (Array.isArray(value)) { properties.push( types.objectProperty( types.stringLiteral(key), types.arrayExpression(value.map((v) => ASTUtils.convertValueToLiteral(v))) ) ) } else if (typeof value === 'object') { properties.push( types.objectProperty( types.stringLiteral(key), ASTUtils.objectToObjectExpression(value as Record) ) ) } } return types.objectExpression(properties) } // tslint:disable-next-line:no-any export const extractDataSourceIntoGetStaticProps = ( node: UIDLDataSourceItemNode | UIDLDataSourceListNode, dataSources: Record, componentChunk: ChunkDefinition, getStaticPropsChunk: any, chunks: any[], extractedResources: GeneratorOptions['extractedResources'], dependencies: Record, dynamicRouteAttr?: string, folderPath?: string[], categories?: UIDLEcommerceCategory[] ): { success: boolean; chunk?: any } => { try { // Validate node content const contentValidation = validateNodeContent(node.content) if (!contentValidation.isValid) { return { success: false } } const { resourceDefinition } = node.content const resourceValidation = validateResourceDefinition(resourceDefinition) if (!resourceValidation.isValid) { return { success: false } } const { dataSourceId, tableName, dataSourceType } = resourceDefinition if (!dataSources || typeof dataSources !== 'object') { return { success: false } } const dataSource = dataSources[dataSourceId] if (!dataSource) { return { success: false } } const configValidation = validateDataSourceConfig(dataSource) if (!configValidation.isValid) { return { success: false } } if (!componentChunk.meta || !componentChunk.meta.nodesLookup) { return { success: false } } // Generate prop key using renderPropIdentifier and resource ID for uniqueness // This ensures each DataProvider instance gets its own fetch and initialData even when // multiple instances share the same renderPropIdentifier but have different params (sorts, filters) const renderPropIdentifier = node.content?.renderPropIdentifier // tslint:disable-next-line:no-any const resourceId = (node.content?.resource as any)?.id // Always generate a unique base key from dataSourceId and tableName const sanitizedDsName = StringUtils.dashCaseToCamelCase( sanitizeFileName(dataSource.name || dataSourceId) ) const sanitizedTableName = StringUtils.dashCaseToCamelCase( sanitizeFileName(tableName || 'data') ) const baseKey = `${sanitizedDsName}_${sanitizedTableName}_data` let propKey: string if (renderPropIdentifier && resourceId) { // Include resource ID to differentiate between same renderProp but different params const sanitizedResourceId = StringUtils.dashCaseToCamelCase( sanitizeFileName(resourceId).replace(/^TQ_/, '') ) propKey = `${renderPropIdentifier}_${sanitizedResourceId}` } else { // Use base key for uniqueness (even if renderPropIdentifier exists, base key ensures uniqueness across different data sources) propKey = baseKey } // Find matching JSX nodes for this data source // Strategy depends on whether we have a unique resource ID: // - With resource ID: each UIDL node updates ONE JSX element (unique data per element) // - Without resource ID: one UIDL node can update ALL matching JSX elements (shared data) const hasUniqueResourceId = !!resourceId const matchingJsxNodes: types.JSXElement[] = [] // Helper function to recursively traverse the AST and find matching JSX elements const traverseAST = (astNode: any) => { // Stop all traversal once we found a match with unique resource ID if (hasUniqueResourceId && matchingJsxNodes.length > 0) { return } if (!astNode || typeof astNode !== 'object') { return } if (astNode.type === 'JSXElement') { const jsxElement = astNode as types.JSXElement const attrs = jsxElement.openingElement.attributes const resourceDefAttr = attrs.find( (attr) => (attr as any).type === 'JSXAttribute' && (attr as types.JSXAttribute).name.name === 'resourceDefinition' ) as types.JSXAttribute | undefined const nameAttr = attrs.find( (attr) => (attr as any).type === 'JSXAttribute' && (attr as types.JSXAttribute).name.name === 'name' ) as types.JSXAttribute | undefined const paramsAttr = attrs.find( (attr) => (attr as any).type === 'JSXAttribute' && (attr as types.JSXAttribute).name.name === 'params' ) as types.JSXAttribute | undefined // Check if this node already has initialData - if so, skip it const hasInitialData = attrs.some( (attr) => (attr as types.JSXAttribute).name?.name === 'initialData' ) if ( !hasInitialData && resourceDefAttr && resourceDefAttr.value && resourceDefAttr.value.type === 'JSXExpressionContainer' ) { const expr = resourceDefAttr.value.expression if (expr.type === 'ObjectExpression') { const props = expr.properties as types.ObjectProperty[] // tslint:disable-next-line:no-any const idProp = props.find((p: any) => p.key?.value === 'dataSourceId') // tslint:disable-next-line:no-any const idValue = (idProp as any)?.value?.value // tslint:disable-next-line:no-any const tableNameProp = props.find((p: any) => p.key?.value === 'tableName') // tslint:disable-next-line:no-any const tableNameValue = (tableNameProp as any)?.value?.value // Match by name attribute to ensure we get the right renderPropIdentifier let nameMatches = true if (renderPropIdentifier && nameAttr && nameAttr.value) { nameMatches = false if (nameAttr.value.type === 'StringLiteral') { nameMatches = nameAttr.value.value === renderPropIdentifier } else if (nameAttr.value.type === 'JSXExpressionContainer') { const nameExpr = nameAttr.value.expression if (nameExpr.type === 'StringLiteral') { nameMatches = nameExpr.value === renderPropIdentifier } } } // Check if params match (for nodes with the same renderPropIdentifier but different params) // We only compare simple numeric params that affect data fetching (limit, page, perPage) // Other params like queryColumns are handled by pagination plugin and don't affect matching // tslint:disable-next-line:no-any const nodeResourceParams = (node.content as any).resource?.params || {} let paramsMatch = true // Extract numeric params from JSX that affect data fetching const jsxLimitParams: Record = {} let jsxHasParams = false if ( paramsAttr && paramsAttr.value && paramsAttr.value.type === 'JSXExpressionContainer' ) { jsxHasParams = true const paramsExpr = paramsAttr.value.expression let objExpr: types.ObjectExpression | null = null // Handle useMemo wrapper if ( paramsExpr.type === 'CallExpression' && (paramsExpr.callee as any).name === 'useMemo' ) { const memoArgs = paramsExpr.arguments if (memoArgs.length > 0 && memoArgs[0].type === 'ArrowFunctionExpression') { const arrowFunc = memoArgs[0] as types.ArrowFunctionExpression if (arrowFunc.body.type === 'ObjectExpression') { objExpr = arrowFunc.body as types.ObjectExpression } } } else if (paramsExpr.type === 'ObjectExpression') { // Direct object expression (before useMemo wrapping) objExpr = paramsExpr as types.ObjectExpression } if (objExpr) { objExpr.properties.forEach((prop: any) => { if (prop.type === 'ObjectProperty') { let key: string | undefined if (prop.key.type === 'Identifier') { key = prop.key.name } else if (prop.key.type === 'StringLiteral') { key = prop.key.value } // Only extract limit param - this is what differentiates data fetches if (key === 'limit' && prop.value.type === 'NumericLiteral') { jsxLimitParams[key] = prop.value.value } } }) } } // Extract limit from resource params const resourceLimit = nodeResourceParams.limit?.type === 'static' ? nodeResourceParams.limit.content : undefined // Compare limit params - this determines if data fetches are equivalent const jsxLimit = jsxLimitParams.limit if (jsxLimit !== undefined || resourceLimit !== undefined) { // If either has limit, both must have the same limit value paramsMatch = jsxLimit === resourceLimit } else if (!jsxHasParams && Object.keys(nodeResourceParams).length > 0) { // JSX has no params but resource has params - check if resource only has non-limit params // If resource has limit, no match; otherwise match paramsMatch = resourceLimit === undefined } else if (jsxHasParams && Object.keys(nodeResourceParams).length === 0) { // JSX has params but resource doesn't - check if JSX only has non-limit params // If JSX has limit, no match; otherwise match paramsMatch = jsxLimit === undefined } // else: both don't have params or both have equivalent limit - match // Collect matching nodes that don't have initialData yet and have matching params if ( idValue === dataSourceId && tableNameValue === tableName && nameMatches && paramsMatch ) { matchingJsxNodes.push(jsxElement) // If we have a unique resource ID, stop after finding the first match // Otherwise, collect all matching nodes if (hasUniqueResourceId) { return } } } } } // Recursively traverse all properties (unless we already found a match with unique resource ID) if (!hasUniqueResourceId || matchingJsxNodes.length === 0) { for (const key in astNode) { if (astNode.hasOwnProperty(key)) { const value = astNode[key] if (Array.isArray(value)) { value.forEach((item) => traverseAST(item)) } else if (typeof value === 'object') { traverseAST(value) } } } } } // Traverse the entire component AST content traverseAST(componentChunk.content) // Even if all JSX nodes already have initialData, we should still add the fetch to getStaticProps // This handles cases where multiple data-source-list nodes share the same renderPropIdentifier // but have different params (e.g., one with limit, one without) const nodesToUpdate: types.JSXElement[] = matchingJsxNodes // Check if filters have dynamic destinations (can't use initialData in this case) // tslint:disable-next-line:no-any const resourceParamsForFilters = (node.content as any).resource?.params || {} const nodeFilters = resourceParamsForFilters.filters?.content || [] const hasDynamicFilterDestinations = Array.isArray(nodeFilters) && nodeFilters.some((f: any) => ASTUtils.isUIDLDynamicReference(f?.destination)) // Update all target JSX nodes with initialData (only those that don't already have it) for (const jsxNode of nodesToUpdate) { // For SSR/SSG with initialData, rename 'children' to 'renderSuccess' const childrenAttrIndex = jsxNode.openingElement.attributes.findIndex( (attr) => (attr as types.JSXAttribute).name?.name === 'children' ) if (childrenAttrIndex !== -1) { const childrenAttr = jsxNode.openingElement.attributes[ childrenAttrIndex ] as types.JSXAttribute const renderSuccessAttr = types.jsxAttribute( types.jsxIdentifier('renderSuccess'), childrenAttr.value ) jsxNode.openingElement.attributes[childrenAttrIndex] = renderSuccessAttr } // Remove existing initialData and persistDataDuringLoading attributes to avoid duplicates jsxNode.openingElement.attributes = jsxNode.openingElement.attributes.filter( (attr) => (attr as types.JSXAttribute).name?.name !== 'initialData' && (attr as types.JSXAttribute).name?.name !== 'persistDataDuringLoading' ) // Only add initialData if filters don't have dynamic destinations // (dynamic destinations can't be resolved at build time, so initialData would be incorrect) if (!hasDynamicFilterDestinations) { const initialDataAttr = types.jsxAttribute( types.jsxIdentifier('initialData'), types.jsxExpressionContainer( types.memberExpression(types.identifier('props'), types.identifier(propKey)) ) ) jsxNode.openingElement.attributes.push(initialDataAttr) } // Add persistDataDuringLoading={true} const persistDataAttr = types.jsxAttribute( types.jsxIdentifier('persistDataDuringLoading'), types.jsxExpressionContainer(types.booleanLiteral(true)) ) jsxNode.openingElement.attributes.push(persistDataAttr) } // End of target node processing // Generate safe file name for the fetcher const fileName = generateSafeFileName(dataSourceType, tableName || 'data', dataSourceId) if (!fileName || fileName === 'unknown') { return { success: false } } // Generate fetcher code with core function let fetcherCode: string try { fetcherCode = generateDataSourceFetcherWithCore( dataSource, tableName || '', false, categories ) } catch (error) { // tslint:disable-next-line:no-console console.error(`Failed to generate fetcher for ${dataSourceType} (${dataSourceId}):`, error) return { success: false } } // Ensure extracted resources object exists if (!extractedResources || typeof extractedResources !== 'object') { return { success: false } } // Add the fetcher to utils folder (for server-side use) // Use a unique key to avoid conflicts with API routes extractedResources[`utils/${fileName}`] = { fileName, fileType: FileType.JS, path: ['utils', 'data-sources'], content: fetcherCode, } // Add dependency for the fetcher const fetcherImportName = StringUtils.dashCaseToCamelCase(fileName) // Calculate the correct relative path based on page folder depth. // folderPath contains subfolders within pages/ (e.g. ['admin'] for pages/admin/X.js). // We add 1 for the pages/ directory itself. // Pages at pages/X.js (folderPath=[]) need '../' (1 level up) // Pages at pages/admin/X.js (folderPath=['admin']) need '../../' (2 levels up) const depth = (folderPath ? folderPath.length : 0) + 1 const relativePrefix = '../'.repeat(depth) dependencies[fetcherImportName] = { type: 'local', path: `${relativePrefix}utils/data-sources/${fileName}`, } // Build params object from resource params // tslint:disable-next-line:no-any const resourceParams = (node.content as any).resource?.params || {} const paramsProperties: types.ObjectProperty[] = [] // tslint:disable-next-line:no-any Object.entries(resourceParams).forEach(([key, value]: [string, any]) => { if (value.type === 'static') { let astValue: any if (value.content === null || value.content === undefined) { // Skip null/undefined values entirely return } else if (Array.isArray(value.content)) { // Filter out null/undefined values from the array const validItems = value.content.filter( (item: any) => item !== null && item !== undefined ) // Skip the array entirely if it's empty or only had null values if (validItems.length === 0) { return } // For sorts, filters, and queryColumns, stringify the array for consistency with API handler if (key === 'sorts' || key === 'filters' || key === 'queryColumns') { let itemsToProcess = validItems if (key === 'filters') { // Keep filter items that are either static or route-resolvable (via dynamicRouteAttr) // Filter out items with unresolvable dynamic destinations itemsToProcess = validItems.filter((item: any) => { if (!ASTUtils.isUIDLDynamicReference(item?.destination)) { return true } if (dynamicRouteAttr) { return isFilterDestinationRouteResolvable(item.destination, dynamicRouteAttr) } return false }) if (itemsToProcess.length === 0) { return } } const hasRouteResolvableFilters = key === 'filters' && dynamicRouteAttr && itemsToProcess.some( (item: any) => item && typeof item === 'object' && ASTUtils.isUIDLDynamicReference(item?.destination) ) const arrayExpr = types.arrayExpression( itemsToProcess.map((item: any) => { if (typeof item === 'string') { return types.stringLiteral(item) } if (typeof item === 'number') { return types.numericLiteral(item) } if (typeof item === 'boolean') { return types.booleanLiteral(item) } if (typeof item === 'object' && item !== null) { if ( hasRouteResolvableFilters && ASTUtils.isUIDLDynamicReference(item?.destination) ) { return buildFilterObjectAST(item, dynamicRouteAttr) } return ASTUtils.objectToObjectExpression(item) } return types.nullLiteral() }) ) // Wrap in JSON.stringify() astValue = types.callExpression( types.memberExpression(types.identifier('JSON'), types.identifier('stringify')), [arrayExpr] ) } else { // Handle other array values normally astValue = types.arrayExpression( validItems.map((item: any) => { if (typeof item === 'string') { return types.stringLiteral(item) } if (typeof item === 'number') { return types.numericLiteral(item) } if (typeof item === 'boolean') { return types.booleanLiteral(item) } if (typeof item === 'object' && item !== null) { // Handle object items (like sort/filter objects) return ASTUtils.objectToObjectExpression(item) } return types.nullLiteral() }) ) } } else if (typeof value.content === 'string') { astValue = types.stringLiteral(value.content) } else if (typeof value.content === 'number') { astValue = types.numericLiteral(value.content) } else if (typeof value.content === 'boolean') { astValue = types.booleanLiteral(value.content) } else { // Skip other null-like values return } paramsProperties.push(types.objectProperty(types.stringLiteral(key), astValue)) } else if ( value.type === 'dynamic' && dynamicRouteAttr && isDynamicParamRouteResolvable(value, dynamicRouteAttr) ) { paramsProperties.push( types.objectProperty( types.stringLiteral(key), createContextParamsAccess(value.content.id) ) ) } }) const fetchCallExpression = types.callExpression( types.memberExpression(types.identifier(fetcherImportName), types.identifier('fetchData')), [types.objectExpression(paramsProperties)] ) const safeFetchExpression = createSafeFetchExpression(fetchCallExpression, propKey) // Get response value path (if specified) const responseMemberAST = node.content.valuePath ? ASTUtils.generateMemberExpressionASTFromPath([ propKey, ...ASTUtils.parseValuePath(node.content.valuePath), ]) : types.identifier(propKey) // Create or update getStaticProps chunk let tryBlock: types.TryStatement | null = null if (!getStaticPropsChunk) { tryBlock = types.tryStatement( types.blockStatement([ types.returnStatement( types.objectExpression([ types.objectProperty( types.identifier('props'), types.objectExpression([ types.objectProperty(types.identifier(propKey), responseMemberAST, false, false), ]) ), types.objectProperty(types.identifier('revalidate'), types.numericLiteral(1)), ]) ), ]), types.catchClause( types.identifier('error'), types.blockStatement([ types.expressionStatement( types.callExpression( types.memberExpression(types.identifier('console'), types.identifier('error')), [types.stringLiteral('Error in getStaticProps:'), types.identifier('error')] ) ), types.returnStatement( types.objectExpression([ types.objectProperty(types.identifier('props'), types.objectExpression([])), ]) ), ]) ) ) getStaticPropsChunk = { name: 'getStaticProps', type: ChunkType.AST, fileType: FileType.JS, content: types.exportNamedDeclaration( types.functionDeclaration( types.identifier('getStaticProps'), [types.identifier('context')], types.blockStatement([tryBlock]), false, true ) ), linkAfter: ['jsx-component'], } chunks.push(getStaticPropsChunk) } else { // Update existing getStaticProps const functionDeclaration = (getStaticPropsChunk.content as types.ExportNamedDeclaration) .declaration as types.FunctionDeclaration const functionBody = functionDeclaration.body.body tryBlock = functionBody.find( (subNode) => subNode.type === 'TryStatement' ) as types.TryStatement if (!tryBlock) { return { success: false } } } if (!tryBlock) { return { success: false } } // Ensure props include current data source const returnStatement: types.ReturnStatement = tryBlock.block.body.find( (subNode) => subNode.type === 'ReturnStatement' ) as types.ReturnStatement if (!returnStatement) { return { success: false } } const propsObject = (returnStatement.argument as types.ObjectExpression).properties.find( (property) => ((property as types.ObjectProperty).key as types.Identifier).name === 'props' ) as types.ObjectProperty const propsValue = propsObject.value as types.ObjectExpression // Check if propKey already exists in the parallel fetch metadata const parallelFetchMeta = getStaticPropsChunk.meta?.parallelFetchData as | ParallelFetchMeta | undefined const existingInFetchMeta = parallelFetchMeta?.names.includes(propKey) // Always ensure both the fetch and the prop are in sync if (!existingInFetchMeta) { // Add to parallel fetch metadata registerParallelFetch(getStaticPropsChunk, tryBlock, propKey, safeFetchExpression) // Check if prop exists in return object const existingProp = propsValue.properties.find( (prop) => prop.type === 'ObjectProperty' && prop.key.type === 'Identifier' && (prop.key as types.Identifier).name === propKey ) // Add prop if it doesn't exist if (!existingProp) { propsValue.properties.unshift( types.objectProperty(types.identifier(propKey), responseMemberAST, false, false) ) } } return { success: true, chunk: getStaticPropsChunk } } catch (error) { return { success: false } } } interface ParallelFetchMeta { names: string[] expressions: types.Expression[] declaration?: types.VariableDeclaration } const createSafeFetchExpression = ( fetchCallExpression: types.CallExpression, label: string ): types.Expression => { const errorIdentifier = types.identifier('error') const catchHandler = types.arrowFunctionExpression( [errorIdentifier], types.blockStatement([ types.expressionStatement( types.callExpression( types.memberExpression(types.identifier('console'), types.identifier('error')), [types.stringLiteral(`Error fetching ${label}:`), errorIdentifier] ) ), types.returnStatement(types.arrayExpression([])), ]) ) return types.callExpression( types.memberExpression(fetchCallExpression, types.identifier('catch')), [catchHandler] ) } const registerParallelFetch = ( getStaticPropsChunk: ChunkDefinition, tryBlock: types.TryStatement, propKey: string, expression: types.Expression ) => { if (!getStaticPropsChunk.meta) { getStaticPropsChunk.meta = {} } const meta = (getStaticPropsChunk.meta.parallelFetchData as ParallelFetchMeta) ?? ((getStaticPropsChunk.meta.parallelFetchData = { names: [], expressions: [], }) as ParallelFetchMeta) meta.names.push(propKey) meta.expressions.push(expression) updateParallelFetchStatement(tryBlock, meta) } const updateParallelFetchStatement = (tryBlock: types.TryStatement, meta: ParallelFetchMeta) => { if (meta.declaration) { const existingIndex = tryBlock.block.body.indexOf(meta.declaration) if (existingIndex !== -1) { tryBlock.block.body.splice(existingIndex, 1) } } const promiseAllCall = types.awaitExpression( types.callExpression( types.memberExpression(types.identifier('Promise'), types.identifier('all')), [types.arrayExpression(meta.expressions.map((expression) => expression))] ) ) const arrayPattern = types.arrayPattern(meta.names.map((name) => types.identifier(name))) meta.declaration = types.variableDeclaration('const', [ types.variableDeclarator(arrayPattern, promiseAllCall), ]) tryBlock.block.body.unshift(meta.declaration) }