import { ComponentPlugin, ComponentPluginFactory, UIDLDependency, UIDLExternalDependency, } from '@teleporthq/teleport-types' import * as types from '@babel/types' import { StringUtils } from '@teleporthq/teleport-shared' export const USE_ECOMMERCE_HOOK: UIDLDependency = { type: 'local', path: '@/ecommerce-context', meta: { namedImport: true, }, } export const USE_CART_HOOK: UIDLDependency = { type: 'local', path: '@/ecommerce-context', meta: { namedImport: true, }, } /** The blog's baked category taxonomy — see `blog/blog-context-generator.ts`. */ export const USE_BLOG_CATEGORIES_HOOK: UIDLDependency = { type: 'local', path: '@/blog-context', meta: { namedImport: true, }, } export const USE_TRANSLATIONS_HOOK: UIDLExternalDependency = { type: 'package', path: 'next-intl', // next-intl version above to 2.10.0 has issues with next@12 and react@17 which we use. // The latest version is 3.20 something, which relies on next/navigation. Which is only available in next@13. // Which we don't use. So we are sticking with 2.10.0 for now.' version: '2.10.0', meta: { namedImport: true, }, } export const USE_ROUTER_HOOK: UIDLExternalDependency = { type: 'library', path: 'next/router', version: '^12.1.10', meta: { namedImport: true, }, } export const NEXT_LINK: UIDLExternalDependency = { type: 'library', path: 'next/link', version: '^12.1.0', } export const USE_GLOBAL_CONTEXT_HOOK: UIDLDependency = { type: 'local', path: '@/global-context', meta: { namedImport: true, }, } /** * Checks if an expression is a member access to `.short` (e.g., `all_languages?.short`) */ const isShortMemberAccess = (expr: types.Expression | types.JSXEmptyExpression): boolean => { if (types.isOptionalMemberExpression(expr) || types.isMemberExpression(expr)) { return types.isIdentifier(expr.property) && expr.property.name === 'short' } return false } /** * Checks if `const router = useRouter()` is already declared in the component body */ const useRouterAlreadyInBody = (body: types.Statement[]): boolean => { return body.some((statement) => { return ( statement.type === 'VariableDeclaration' && statement.declarations.some((declaration) => { return declaration.id.type === 'Identifier' && declaration.id.name === 'router' }) ) }) } /** `const router = useRouter()` */ const buildRouterHook = (): types.VariableDeclaration => types.variableDeclaration('const', [ types.variableDeclarator( types.identifier('router'), types.callExpression(types.identifier('useRouter'), []) ), ]) /** * Declares `const router = useRouter()` at the top of a component that does * not have it yet, for a plugin running after this one that navigates in the * visitor's language (a form's redirect to a page). */ export const ensureRouterHook = ( componentBody: types.BlockStatement, dependencies: Record ) => { dependencies.useRouter = { ...USE_ROUTER_HOOK } if (!useRouterAlreadyInBody(componentBody.body)) { componentBody.body.unshift(buildRouterHook()) } } /** * Recursively traverses a JSX AST node to find elements * and transforms them to children * for proper Next.js locale switching. * * Returns true if any transformation was made. */ const transformLanguageSwitcherLinks = (node: types.Node): boolean => { let transformed = false if (types.isJSXElement(node)) { const opening = node.openingElement if (types.isJSXIdentifier(opening.name) && opening.name.name === 'a') { const hrefAttrIndex = opening.attributes.findIndex( (attr): attr is types.JSXAttribute => types.isJSXAttribute(attr) && types.isJSXIdentifier(attr.name) && attr.name.name === 'href' ) if (hrefAttrIndex !== -1) { const hrefAttr = opening.attributes[hrefAttrIndex] as types.JSXAttribute if ( types.isJSXExpressionContainer(hrefAttr.value) && !types.isJSXEmptyExpression(hrefAttr.value.expression) && isShortMemberAccess(hrefAttr.value.expression) ) { const localeExpr = hrefAttr.value.expression const originalChildren = [...node.children] // Drop href/target/rel on the inner anchor — a locale switch should // always stay in the same tab and is driven by Next's router, so the // new-tab affordances from the UIDL's `newTab: true` would be wrong. const innerAnchorStrippedAttrs = new Set(['href', 'target', 'rel']) const nonHrefAttrs = opening.attributes.filter( (attr) => !( types.isJSXAttribute(attr) && types.isJSXIdentifier(attr.name) && innerAnchorStrippedAttrs.has(attr.name.name) ) ) // Mutate node: change to opening.name = types.jsxIdentifier('Link') if (node.closingElement) { node.closingElement.name = types.jsxIdentifier('Link') } // Set Link attributes: href={router.asPath} locale={localeExpr} opening.attributes = [ types.jsxAttribute( types.jsxIdentifier('href'), types.jsxExpressionContainer( types.memberExpression(types.identifier('router'), types.identifier('asPath')) ) ), types.jsxAttribute( types.jsxIdentifier('locale'), types.jsxExpressionContainer(localeExpr) ), ] // Create inner with original children and non-href attributes (Next.js 12 pattern) const innerA = types.jsxElement( types.jsxOpeningElement(types.jsxIdentifier('a'), nonHrefAttrs, false), types.jsxClosingElement(types.jsxIdentifier('a')), originalChildren, false ) node.children = [innerA] return true } } } // Traverse JSX element children for (const child of node.children) { if (transformLanguageSwitcherLinks(child)) { transformed = true } } // A switcher built as an array mapper over the languages renders its // links from a render-prop ATTRIBUTE (` // …}`), not from the children. for (const attr of opening.attributes) { if ( types.isJSXAttribute(attr) && types.isJSXExpressionContainer(attr.value) && transformLanguageSwitcherLinks(attr.value) ) { transformed = true } } return transformed } if (types.isJSXFragment(node)) { for (const child of node.children) { if (transformLanguageSwitcherLinks(child)) { transformed = true } } return transformed } if (types.isJSXExpressionContainer(node) && !types.isJSXEmptyExpression(node.expression)) { return transformLanguageSwitcherLinks(node.expression) } if (types.isCallExpression(node)) { for (const arg of node.arguments) { if (transformLanguageSwitcherLinks(arg)) { transformed = true } } return transformed } if (types.isArrowFunctionExpression(node)) { return transformLanguageSwitcherLinks(node.body) } if (types.isParenthesizedExpression(node)) { return transformLanguageSwitcherLinks(node.expression) } if (types.isConditionalExpression(node)) { if (transformLanguageSwitcherLinks(node.consequent)) { transformed = true } if (transformLanguageSwitcherLinks(node.alternate)) { transformed = true } return transformed } if (types.isLogicalExpression(node)) { if (transformLanguageSwitcherLinks(node.left)) { transformed = true } if (transformLanguageSwitcherLinks(node.right)) { transformed = true } return transformed } if (types.isBlockStatement(node)) { for (const stmt of node.body) { if (transformLanguageSwitcherLinks(stmt)) { transformed = true } } return transformed } if (types.isReturnStatement(node) && node.argument) { return transformLanguageSwitcherLinks(node.argument) } return false } /** * The translation key `node-to-jsx` stashed in the placeholder span it emits for * a translated child (`{/* locale- *\/}`). */ const readLocaleReferenceKey = (localeRef: types.JSXElement): string | null => { const expressionContainer = localeRef.children.find( (child): child is types.JSXExpressionContainer => child.type === 'JSXExpressionContainer' ) if (!expressionContainer) { return null } const comment = expressionContainer.expression.innerComments?.[0]?.value if (!comment?.startsWith('locale-')) { return null } return comment.replace('locale-', '') || null } /** `translate.raw('')`, with the key sanitized exactly like the messages file. */ export const buildTranslationLookup = (key: string): types.CallExpression => types.callExpression( types.memberExpression(types.identifier('translate'), types.identifier('raw')), [types.stringLiteral(StringUtils.sanitizeTranslationKey(key))] ) /** `const translate = useTranslations()` */ const buildTranslateHook = (): types.VariableDeclaration => types.variableDeclaration('const', [ types.variableDeclarator( types.identifier('translate'), types.callExpression(types.identifier('useTranslations'), []) ), ]) const useTranslationsAlreadyInBody = (componentBody: types.Statement[]) => { return componentBody.some((statement) => { return ( statement.type === 'VariableDeclaration' && statement.declarations.some((declaration) => { return declaration.id.type === 'Identifier' && declaration.id.name === 'translate' }) ) }) } /** * Declares `const translate = useTranslations()` at the top of a component * that does not have it yet, for a plugin running after this one that reads * translations at run time (a form's alert texts). */ export const ensureTranslateHook = ( componentBody: types.BlockStatement, dependencies: Record ) => { dependencies.useTranslations = USE_TRANSLATIONS_HOOK if (!useTranslationsAlreadyInBody(componentBody.body)) { componentBody.body.unshift(buildTranslateHook()) } } /** * Elements whose content model is TEXT ONLY. A translated child of one of these * cannot go through the usual `dangerouslySetInnerHTML` span — ` → ... if (structure.dependencies.useGlobalContext) { const returnStatement = componentBody.body.find( (stmt): stmt is types.ReturnStatement => stmt.type === 'ReturnStatement' ) if (returnStatement && returnStatement.argument) { const wasTransformed = transformLanguageSwitcherLinks(returnStatement.argument) if (wasTransformed) { structure.dependencies.Link = { ...NEXT_LINK } structure.dependencies.useRouter = { ...USE_ROUTER_HOOK } if (!useRouterAlreadyInBody(componentBody.body)) { reactHooks.push(buildRouterHook()) } } } } componentBody.body.unshift(...reactHooks) return structure } return nextInternationalization }