From 7802e27578a6ff08bc2ee3db059466de944c200a Mon Sep 17 00:00:00 2001 From: Will Harney <62956339+wjhsf@users.noreply.github.com> Date: Mon, 21 Oct 2024 14:06:17 -0400 Subject: [PATCH] refactor(ssr): add back references to `esTemplate` and infer types (#4660) * refactor: list all IR nodes explicitly * feat(ssr): implement iterator:* directive * test(ssr): add test for multiple iterator blocks * fix(ssr-for-of): use empty array as default for missing iterator * fix(ssr): update error in for-of directive for invalid value * chore(eslint): don't enforce header in spec files * test(lwc-if): add tests for lwc:if, lwc:elseif, and lwc:else * feat(ssr): handle lwc:elseif * fix(ssr): add comments to lwc:if output * fix(ssr): don't add comments for if:true and if:false * feat(ssr): explicitly disallow lwc:dynamic * fix(ssr): only complain about lwc:dynamic if it gets used * refactor(ssr): update `esTemplate` for better type inferencing * refactor(ssr): update `esTemplate` usage * refactor(ssr): re-use replacement node validation * refactor(ssr): update esTemplates to use back refs * fix(ssr): ensure `esTemplate` type inferences work properly * fix(ssr): add predicate return type * Update packages/@lwc/ssr-compiler/src/estemplate.ts Co-authored-by: Nolan Lawson * chore(ssr): clean up `isBool` * chore(ssr): clean up types * chore(ssr): clean up `ToReplacementParameters` * chore(ssr): avoid unnecessary conditional --------- Co-authored-by: Nolan Lawson --- .../src/compile-js/generate-markup.ts | 39 ++-- .../src/compile-js/stylesheet-scope-token.ts | 24 +-- .../src/compile-js/stylesheets.ts | 8 +- .../src/compile-template/component.ts | 4 +- .../src/compile-template/element.ts | 8 +- .../src/compile-template/for-each.ts | 8 +- .../src/compile-template/for-of.ts | 9 +- .../src/compile-template/index.ts | 31 ++- .../src/compile-template/shared.ts | 4 +- .../ssr-compiler/src/compile-template/text.ts | 46 +--- packages/@lwc/ssr-compiler/src/estemplate.ts | 204 ++++++++++++------ .../@lwc/ssr-compiler/src/estree/builders.ts | 4 +- .../ssr-compiler/src/estree/validators.ts | 45 ++-- 13 files changed, 256 insertions(+), 178 deletions(-) diff --git a/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts b/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts index 609f5d8b0e..2149ccc375 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/generate-markup.ts @@ -17,10 +17,22 @@ import type { Program, ImportDeclaration, Property, + SimpleLiteral, + SimpleCallExpression, + Identifier, + MemberExpression, } from 'estree'; import type { ComponentMetaState } from './types'; -const bGenerateMarkup = esTemplate` +/** Node representing `.render()`. */ +type RenderCallExpression = SimpleCallExpression & { + callee: MemberExpression & { property: Identifier & { name: 'render' } }; +}; + +/** Node representing a string literal. */ +type StringLiteral = SimpleLiteral & { value: string }; + +const bGenerateMarkup = esTemplate` export async function* generateMarkup(tagName, props, attrs, slotted) { attrs = attrs ?? {}; ${isNullableOf(is.expressionStatement)}; @@ -35,18 +47,18 @@ const bGenerateMarkup = esTemplate` yield tmplFn.stylesheetScopeTokenHostClass ?? ''; yield *__renderAttrs(instance, attrs) yield '>'; - yield* tmplFn(props, attrs, slotted, ${is.identifier}, instance); + yield* tmplFn(props, attrs, slotted, ${1}, instance); yield \`\`; } -`; +`; -const bInsertFallbackTmplImport = esTemplate` +const bInsertFallbackTmplImport = esTemplate` import { fallbackTmpl as __fallbackTmpl, renderAttrs as __renderAttrs } from '@lwc/ssr-runtime'; -`; +`; -const bCreateReflectedPropArr = esTemplate` +const bCreateReflectedPropArr = esTemplate` const __REFLECTED_PROPS__ = ${is.arrayExpression}; -`; +`; function bReflectedAttrsObj(reflectedPropNames: (keyof typeof AriaPropNameToAttrNameMap)[]) { // This will build getter properties for each reflected property. It'll look @@ -135,12 +147,17 @@ export function addGenerateMarkupExport( const classIdentifier = b.identifier(state.lwcClassName!); const renderCall = hasRenderMethod - ? b.callExpression(b.memberExpression(b.identifier('instance'), b.identifier('render')), []) + ? (b.callExpression( + b.memberExpression(b.identifier('instance'), b.identifier('render')), + [] + ) as RenderCallExpression) : b.identifier('tmpl'); if (!tmplExplicitImports) { const defaultTmplPath = filename.replace(/\.js$/, '.html'); - program.body.unshift(bImportDeclaration(b.identifier('tmpl'), b.literal(defaultTmplPath))); + program.body.unshift( + bImportDeclaration(b.identifier('tmpl'), b.literal(defaultTmplPath) as StringLiteral) + ); } let attrsAugmentation: ExpressionStatement | null = null; @@ -153,7 +170,5 @@ export function addGenerateMarkupExport( program.body.unshift(bInsertFallbackTmplImport()); program.body.push(bCreateReflectedPropArr(reflectedPropArr)); - program.body.push( - bGenerateMarkup(attrsAugmentation, classIdentifier, renderCall, classIdentifier) - ); + program.body.push(bGenerateMarkup(attrsAugmentation, classIdentifier, renderCall)); } diff --git a/packages/@lwc/ssr-compiler/src/compile-js/stylesheet-scope-token.ts b/packages/@lwc/ssr-compiler/src/compile-js/stylesheet-scope-token.ts index fcd14e3ddc..7690b7f106 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/stylesheet-scope-token.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/stylesheet-scope-token.ts @@ -11,29 +11,29 @@ import { builders as b } from 'estree-toolkit/dist/builders'; import { esTemplate } from '../estemplate'; import type { BlockStatement, ExportNamedDeclaration, Program, VariableDeclaration } from 'estree'; -const bStylesheetTokenDeclaration = esTemplate` +const bStylesheetTokenDeclaration = esTemplate` const stylesheetScopeToken = '${is.literal}'; -`; +`; const bAdditionalDeclarations = [ - esTemplate` + esTemplate` const hasScopedStylesheets = defaultScopedStylesheets && defaultScopedStylesheets.length > 0; - `, - esTemplate` + `, + esTemplate` const stylesheetScopeTokenClass = hasScopedStylesheets ? \` class="\${stylesheetScopeToken}"\` : ''; - `, - esTemplate` + `, + esTemplate` const stylesheetScopeTokenHostClass = hasScopedStylesheets ? \` class="\${stylesheetScopeToken}-host"\` : ''; - `, - esTemplate` + `, + esTemplate` const stylesheetScopeTokenClassPrefix = hasScopedStylesheets ? (stylesheetScopeToken + ' ') : ''; - `, + `, ]; // Scope tokens are associated with a given template. This is assigned here so that it can be used in `generateMarkup`. -const tmplAssignmentBlock = esTemplate` +const tmplAssignmentBlock = esTemplate` ${is.identifier}.stylesheetScopeTokenHostClass = stylesheetScopeTokenHostClass; -`; +`; export function addScopeTokenDeclarations( program: Program, diff --git a/packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts b/packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts index 32a082326c..336be9f396 100644 --- a/packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts +++ b/packages/@lwc/ssr-compiler/src/compile-js/stylesheets.ts @@ -12,13 +12,13 @@ import type { NodePath } from 'estree-toolkit'; import type { ImportDeclaration } from 'estree'; import type { ComponentMetaState } from './types'; -const bDefaultStyleImport = esTemplate` +const bDefaultStyleImport = esTemplate` import defaultStylesheets from '${is.literal}'; -`; +`; -const bDefaultScopedStyleImport = esTemplate` +const bDefaultScopedStyleImport = esTemplate` import defaultScopedStylesheets from '${is.literal}'; -`; +`; export function catalogStyleImport(path: NodePath, state: ComponentMetaState) { const specifier = path.node!.specifiers[0]; diff --git a/packages/@lwc/ssr-compiler/src/compile-template/component.ts b/packages/@lwc/ssr-compiler/src/compile-template/component.ts index 7c95893729..3c946375f4 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/component.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/component.ts @@ -25,7 +25,7 @@ import type { } from '@lwc/template-compiler'; import type { Transformer } from './types'; -const bYieldFromChildGenerator = esTemplateWithYield` +const bYieldFromChildGenerator = esTemplateWithYield` { const childProps = ${is.objectExpression}; const childAttrs = ${is.objectExpression}; @@ -34,7 +34,7 @@ const bYieldFromChildGenerator = esTemplateWithYield` }; yield* ${is.identifier}(${is.literal}, childProps, childAttrs, childSlottedContentGenerator); } -`; +`; const bImportGenerateMarkup = (localName: string, importPath: string) => b.importDeclaration( diff --git a/packages/@lwc/ssr-compiler/src/compile-template/element.ts b/packages/@lwc/ssr-compiler/src/compile-template/element.ts index 601bed0f0f..e63bd73176 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/element.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/element.ts @@ -36,7 +36,7 @@ import type { import type { Transformer } from './types'; const bYield = (expr: EsExpression) => b.expressionStatement(b.yieldExpression(expr)); -const bConditionalLiveYield = esTemplateWithYield` +const bConditionalLiveYield = esTemplateWithYield` { const prefix = (${/* isClass */ is.literal} && stylesheetScopeTokenClassPrefix) || ''; const attrOrPropValue = ${is.expression}; @@ -48,14 +48,14 @@ const bConditionalLiveYield = esTemplateWithYield` } } } -`; +`; -const bStringLiteralYield = esTemplateWithYield` +const bStringLiteralYield = esTemplateWithYield` { const prefix = (${/* isClass */ is.literal} && stylesheetScopeTokenClassPrefix) || ''; yield ' ' + ${is.literal} + '="' + prefix + "${is.literal}" + '"' } -`; +`; function yieldAttrOrPropLiteralValue( name: string, diff --git a/packages/@lwc/ssr-compiler/src/compile-template/for-each.ts b/packages/@lwc/ssr-compiler/src/compile-template/for-each.ts index 2e2f785ca2..a6b7ba668f 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/for-each.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/for-each.ts @@ -15,7 +15,6 @@ import type { Expression as EsExpression, ForOfStatement as EsForOfStatement, Identifier as EsIdentifier, - Statement as EsStatement, MemberExpression as EsMemberExpression, } from 'estree'; import type { Transformer } from './types'; @@ -29,14 +28,11 @@ function getRootIdentifier(node: EsMemberExpression): EsIdentifier | null { return is.identifier(rootMemberExpression?.object) ? rootMemberExpression.object : null; } -const bForOfYieldFrom = esTemplate< - EsForOfStatement, - [EsIdentifier, EsIdentifier, EsExpression, EsStatement[]] ->` +const bForOfYieldFrom = esTemplate` for (let [${is.identifier}, ${is.identifier}] of Object.entries(${is.expression} ?? {})) { ${is.statement}; } -`; +`; export const ForEach: Transformer = function ForEach(node, cxt): EsForOfStatement[] { const forItemId = node.item.name; diff --git a/packages/@lwc/ssr-compiler/src/compile-template/for-of.ts b/packages/@lwc/ssr-compiler/src/compile-template/for-of.ts index efd7dda2cb..61158654ad 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/for-of.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/for-of.ts @@ -15,7 +15,6 @@ import type { Expression as EsExpression, ForOfStatement as EsForOfStatement, Identifier as EsIdentifier, - Statement as EsStatement, MemberExpression as EsMemberExpression, ImportDeclaration as EsImportDeclaration, } from 'estree'; @@ -30,15 +29,15 @@ function getRootIdentifier(node: EsMemberExpression): EsIdentifier | null { return is.identifier(rootMemberExpression?.object) ? rootMemberExpression.object : null; } -const bForOfYieldFrom = esTemplate` +const bForOfYieldFrom = esTemplate` for (let ${is.identifier} of toIteratorDirective(${is.expression} ?? [])) { ${is.statement}; } -`; +`; -const bToIteratorDirectiveImport = esTemplate` +const bToIteratorDirectiveImport = esTemplate` import { toIteratorDirective } from '@lwc/ssr-runtime'; -`; +`; export const ForOf: Transformer = function ForEach(node, cxt): EsForOfStatement[] { const id = node.iterator.name; diff --git a/packages/@lwc/ssr-compiler/src/compile-template/index.ts b/packages/@lwc/ssr-compiler/src/compile-template/index.ts index 9149baaddc..5c759388ff 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/index.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/index.ts @@ -16,22 +16,23 @@ import { optimizeAdjacentYieldStmts } from './shared'; import { templateIrToEsTree } from './ir-to-es'; import type { Node as EsNode, - Statement as EsStatement, - Literal as EsLiteral, ExportDefaultDeclaration as EsExportDefaultDeclaration, ImportDeclaration as EsImportDeclaration, + SimpleLiteral, } from 'estree'; -const isBool = (node: EsNode | null) => is.literal(node) && typeof node.value === 'boolean'; +type Nullable = T | null | undefined; +type BooleanLiteral = SimpleLiteral & { value: boolean }; -const bStyleValidationImport = esTemplate` +const isBool = (node: Nullable): node is BooleanLiteral => { + return is.literal(node) && typeof node.value === 'boolean'; +}; + +const bStyleValidationImport = esTemplate` import { validateStyleTextContents } from '@lwc/ssr-runtime'; -`; +`; -const bExportTemplate = esTemplate< - EsExportDefaultDeclaration, - [EsLiteral, EsStatement[], EsLiteral] ->` +const bExportTemplate = esTemplate` export default async function* tmpl(props, attrs, slottedContent, Cmp, instance) { if (!${isBool} && Cmp.renderMode !== 'light') { yield \`'; } @@ -63,7 +64,7 @@ const bExportTemplate = esTemplate< yield* slottedContent(); } } -`; +`; export default function compileTemplate( src: string, @@ -108,7 +109,7 @@ export default function compileTemplate( const tmplRenderMode = root.directives.find((directive) => directive.name === 'RenderMode')?.value?.value ?? 'shadow'; - const astShadowModeBool = tmplRenderMode === 'light' ? b.literal(true) : b.literal(false); + const astShadowModeBool = b.literal(tmplRenderMode === 'light') as BooleanLiteral; const preserveComments = !!root.directives.find( (directive) => directive.name === 'PreserveComments' @@ -119,11 +120,7 @@ export default function compileTemplate( const moduleBody = [ ...hoisted, bStyleValidationImport(), - bExportTemplate( - astShadowModeBool, - optimizeAdjacentYieldStmts(statements), - astShadowModeBool - ), + bExportTemplate(astShadowModeBool, optimizeAdjacentYieldStmts(statements)), ]; const program = b.program(moduleBody, 'module'); diff --git a/packages/@lwc/ssr-compiler/src/compile-template/shared.ts b/packages/@lwc/ssr-compiler/src/compile-template/shared.ts index e8629f229c..fc11d96503 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/shared.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/shared.ts @@ -11,9 +11,9 @@ import { esTemplate } from '../estemplate'; import type { ImportDeclaration as EsImportDeclaration, Statement as EsStatement } from 'estree'; -export const bImportHtmlEscape = esTemplate` +export const bImportHtmlEscape = esTemplate` import { htmlEscape } from '@lwc/shared'; -`; +`; export const importHtmlEscapeKey = 'import:htmlEscape'; // This is a mostly-correct regular expression will only match if the entire string diff --git a/packages/@lwc/ssr-compiler/src/compile-template/text.ts b/packages/@lwc/ssr-compiler/src/compile-template/text.ts index ffdf883aaa..29d24326ce 100644 --- a/packages/@lwc/ssr-compiler/src/compile-template/text.ts +++ b/packages/@lwc/ssr-compiler/src/compile-template/text.ts @@ -10,12 +10,7 @@ import { esTemplateWithYield } from '../estemplate'; import { bImportHtmlEscape, importHtmlEscapeKey } from './shared'; import { expressionIrToEs } from './expression'; -import type { - Expression as EsExpression, - Identifier as EsIdentifier, - Literal as EsLiteral, - Statement as EsStatement, -} from 'estree'; +import type { Expression as EsExpression, Statement as EsStatement } from 'estree'; import type { ComplexExpression as IrComplexExpression, Expression as IrExpression, @@ -26,29 +21,16 @@ import type { Transformer } from './types'; const bYield = (expr: EsExpression) => b.expressionStatement(b.yieldExpression(expr)); -const bYieldEscapedString = esTemplateWithYield< - EsStatement[], - [ - EsIdentifier, - EsExpression, - EsIdentifier, - EsLiteral, - EsIdentifier, - EsIdentifier, - EsIdentifier, - EsIdentifier, - EsIdentifier, - ] ->` +const bYieldEscapedString = esTemplateWithYield` const ${is.identifier} = ${is.expression}; - if (typeof ${is.identifier} === 'string') { - yield (${is.literal} && ${is.identifier} === '') ? '\\u200D' : htmlEscape(${is.identifier}); - } else if (typeof ${is.identifier} === 'number') { - yield ${is.identifier}.toString(); + if (typeof ${0} === 'string') { + yield (${is.literal} && ${0} === '') ? '\\u200D' : htmlEscape(${0}); + } else if (typeof ${0} === 'number') { + yield ${0}.toString(); } else { - yield htmlEscape((${is.identifier} ?? '').toString()); + yield ${0} ? htmlEscape(${0}.toString()) : '\\u200D'; } -`; +`; function isLiteral(node: IrLiteral | IrExpression | IrComplexExpression): node is IrLiteral { return node.type === 'Literal'; @@ -68,15 +50,5 @@ export const Text: Transformer = function Text(node, cxt): EsStatement[] cxt.hoist(bImportHtmlEscape(), importHtmlEscapeKey); const tempVariable = b.identifier(cxt.getUniqueVar()); - return bYieldEscapedString( - tempVariable, - valueToYield, - tempVariable, - isIsolatedTextNode, - tempVariable, - tempVariable, - tempVariable, - tempVariable, - tempVariable - ); + return bYieldEscapedString(tempVariable, valueToYield, isIsolatedTextNode); }; diff --git a/packages/@lwc/ssr-compiler/src/estemplate.ts b/packages/@lwc/ssr-compiler/src/estemplate.ts index 5ca735eba9..fac5a82f6a 100644 --- a/packages/@lwc/ssr-compiler/src/estemplate.ts +++ b/packages/@lwc/ssr-compiler/src/estemplate.ts @@ -12,36 +12,96 @@ import type { Node as EsNode, Program as EsProgram, FunctionDeclaration as EsFunctionDeclaration, + Statement as EsStatement, } from 'estree'; +import type { Checker } from 'estree-toolkit/dist/generated/is-type'; -export const placeholder = false; -type ReplacementNode = EsNode | EsNode[] | null; +/** Placeholder value to use to opt out of validation. */ +const NO_VALIDATION = false; -type ReturnsBool = (node: EsNode | null) => boolean; -export type Validator = ReturnsBool | typeof placeholder; +/** A function that accepts a node and checks that it is a particular type of node. */ +type Validator = ( + node: EsNode | null | undefined +) => node is T; + +/** + * A pointer to a previous value in the template literal, indicating that the value should be re-used. + * @see {@linkcode esTemplate} + */ +type ValidatorReference = number; + +/** A validator, validation opt-out, or reference to previously-used validator. */ +type ValidatorPlaceholder = + | Validator + | ValidatorReference + | typeof NO_VALIDATION; + +/** Extracts the type being validated from the validator function. */ +type ValidatedType = + T extends Validator + ? // estree's `Checker` satisfies our `Validator`, but has an extra overload that + // messes with the inferred type `V`, so we must check `Checker` explicitly + T extends Checker + ? // estree validator + C | C[] + : // custom validator + V | Array> // avoid invalid `Array` + : T extends typeof NO_VALIDATION + ? // no validation = broadest type possible + EsNode | EsNode[] | null + : // not a validator! + never; + +/** + * Converts the validators and refs used in the template to the list of parameters required by the + * created template function. Removes back references to previous slots from the list. + */ +type ToReplacementParameters = Arr extends [infer Head, ...infer Rest] + ? Head extends number + ? // `Head` is a back reference, drop it from the parameter list + ToReplacementParameters + : // `Head` is a validator, extract the type that it validates + [ValidatedType, ...ToReplacementParameters] + : []; // `Arr` is an empty array -- nothing to transform const PLACEHOLDER_PREFIX = `__ESTEMPLATE_${Math.random().toString().slice(2)}_PLACEHOLDER__`; interface TraversalState { - placeholderToValidator: Map; - replacementNodes: ReplacementNode[]; + placeholderToValidator: Map; + replacementNodes: Array; } +const getReplacementNode = ( + state: TraversalState, + placeholderId: string, + nodeType: string +): EsNode | EsNode[] | null => { + const key = Number(placeholderId.slice(PLACEHOLDER_PREFIX.length)); + const nodeCount = state.replacementNodes.length; + if (key >= nodeCount) { + throw new Error( + `Cannot use index ${key} when only ${nodeCount} values have been provided.` + ); + } + + const validateReplacement = state.placeholderToValidator.get(key); + const replacementNode = state.replacementNodes[key]; + if ( + validateReplacement && + !(Array.isArray(replacementNode) + ? replacementNode.every(validateReplacement) + : validateReplacement(replacementNode)) + ) { + throw new Error(`Validation failed for templated node of type ${nodeType}`); + } + + return replacementNode; +}; + const visitors: Visitors = { Identifier(path, state) { if (path.node?.name.startsWith(PLACEHOLDER_PREFIX)) { - const key = path.node.name.slice(PLACEHOLDER_PREFIX.length); - const validateReplacement = state.placeholderToValidator.get(key)!; - const replacementNode = state.replacementNodes[key as unknown as number]; - - if ( - validateReplacement && - !(Array.isArray(replacementNode) - ? replacementNode.every(validateReplacement) - : validateReplacement(replacementNode)) - ) { - throw new Error(`Validation failed for templated node of type ${path.node.type}`); - } + const replacementNode = getReplacementNode(state, path.node.name, path.node.type); if (replacementNode === null) { path.remove(); @@ -49,8 +109,8 @@ const visitors: Visitors = { if (replacementNode.length === 0) { path.remove(); } else { - if (path.parentPath!.node!.type === 'ExpressionStatement') { - path.parentPath!.replaceWithMultiple(replacementNode); + if (path.parentPath?.node?.type === 'ExpressionStatement') { + path.parentPath.replaceWithMultiple(replacementNode); } else { path.replaceWithMultiple(replacementNode); } @@ -65,36 +125,47 @@ const visitors: Visitors = { typeof path.node?.value === 'string' && path.node.value.startsWith(PLACEHOLDER_PREFIX) ) { - const key = path.node.value.slice(PLACEHOLDER_PREFIX.length); - const validateReplacement = state.placeholderToValidator.get(key)!; - const replacementNode = state.replacementNodes[key as unknown as number] as EsNode; - - if (validateReplacement && !validateReplacement(replacementNode)) { - throw new Error(`Validation failed for templated node of type ${path.node.type}`); - } + // A literal can only be replaced with a single node + const replacementNode = getReplacementNode( + state, + path.node.value, + path.node.type + ) as EsNode; path.replaceWith(replacementNode); } }, }; -function esTemplateImpl( +function esTemplateImpl[]>( javascriptSegments: TemplateStringsArray, - validatorFns: Validator[], + validators: Validators, wrap?: (code: string) => string, - unwrap?: (node: RetType) => any -): (...replacementNodes: ArgTypes) => RetType { + unwrap?: (node: any) => EsStatement | EsStatement[] +): (...replacementNodes: ToReplacementParameters) => RetType { let placeholderCount = 0; let parsableCode = javascriptSegments[0]; - validatorFns.reverse(); - const placeholderToValidator = new Map(); - - for (const segment of javascriptSegments.slice(1)) { - const validatorFn = validatorFns.pop(); - if (validatorFn) { - placeholderToValidator.set(placeholderCount.toString(), validatorFn); + const placeholderToValidator = new Map(); + + for (let i = 1; i < javascriptSegments.length; i += 1) { + const segment = javascriptSegments[i]; + const validator = validators[i - 1]; // always one less value than strings in template literals + if (typeof validator === 'function' || validator === NO_VALIDATION) { + // Template slot will be filled by a *new* argument passed to the generated function + if (validator !== NO_VALIDATION) { + placeholderToValidator.set(placeholderCount, validator); + } + parsableCode += `${PLACEHOLDER_PREFIX}${placeholderCount}`; + placeholderCount += 1; + } else { + // Template slot uses a *previously defined* argument passed to the generated function + if (validator >= placeholderCount) { + throw new Error( + `Reference to argument ${validator} at index ${i} cannot be used. Only ${placeholderCount - 1} arguments have been defined.` + ); + } + parsableCode += `${PLACEHOLDER_PREFIX}${validator}`; } - parsableCode += `${PLACEHOLDER_PREFIX}${placeholderCount++}`; parsableCode += segment; } @@ -126,42 +197,49 @@ function esTemplateImpl( + ...replacementNodes: ToReplacementParameters + ): RetType { const result = produce(originalAst, (astDraft) => traverse(astDraft, visitors, { placeholderToValidator, replacementNodes, }) - ) as RetType; - return unwrap ? unwrap(result) : result; + ); + return (unwrap ? unwrap(result) : result) as RetType; }; } -export function esTemplate< - RetType = EsNode, - ArgTypes extends ReplacementNode[] = ReplacementNode[], ->( +/** + * Template literal tag that generates a builder function. Like estree's `builders`, but for more + * complex structures. The template values should be estree `is` validators or a back reference to + * a previous slot (to re-use the referenced value). + * + * To have the generated function return a particular node type, the generic comes _after_ the + * template literal. Kinda weird, but it's necessary to infer the types of the template values. + * (If it were at the start, we'd need to explicitly provide _all_ type params. Tedious!) + * @example + * const bSum = esTemplate`(${is.identifier}, ${is.identifier}) => ${0} + ${1}` + * const sumFuncNode = bSum(b.identifier('a'), b.identifier('b')) + * // `sumFuncNode` is an AST node representing `(a, b) => a + b` + */ +export function esTemplate[]>( javascriptSegments: TemplateStringsArray, - ...validatorFns: Validator[] -): (...replacementNodes: ArgTypes) => RetType { - return esTemplateImpl(javascriptSegments, validatorFns); + ...Validators: Validators +): (...replacementNodes: ToReplacementParameters) => RetType { + return esTemplateImpl(javascriptSegments, Validators); } -export function esTemplateWithYield< - RetType = EsNode, - ArgTypes extends ReplacementNode[] = ReplacementNode[], ->( +/** Similar to {@linkcode esTemplate}, but supports `yield` expressions. */ +export function esTemplateWithYield[]>( javascriptSegments: TemplateStringsArray, - ...validatorFns: Validator[] -): (...replacementNodes: ArgTypes) => RetType { + ...validators: Validators +): (...replacementNodes: ToReplacementParameters) => RetType { const wrap = (code: string) => `function* placeholder() {${code}}`; const unwrap = (node: EsFunctionDeclaration) => - node.body.body.length === 1 ? (node.body.body[0] as RetType) : (node.body.body as RetType); - - return esTemplateImpl( - javascriptSegments, - validatorFns, - wrap, - unwrap - ) as unknown as (...replacementNodes: ArgTypes) => RetType; + node.body.body.length === 1 ? node.body.body[0] : node.body.body; + + return esTemplateImpl(javascriptSegments, validators, wrap, unwrap) as ( + ...replacementNodes: ToReplacementParameters + ) => RetType; } diff --git a/packages/@lwc/ssr-compiler/src/estree/builders.ts b/packages/@lwc/ssr-compiler/src/estree/builders.ts index dc71c548d2..39af62fc6f 100644 --- a/packages/@lwc/ssr-compiler/src/estree/builders.ts +++ b/packages/@lwc/ssr-compiler/src/estree/builders.ts @@ -11,6 +11,6 @@ import { isStringLiteral } from './validators'; import type { ImportDeclaration } from 'estree'; -export const bImportDeclaration = esTemplate` +export const bImportDeclaration = esTemplate` import ${is.identifier} from "${isStringLiteral}"; -`; +`; diff --git a/packages/@lwc/ssr-compiler/src/estree/validators.ts b/packages/@lwc/ssr-compiler/src/estree/validators.ts index 4e306ef684..f1da7d7bf8 100644 --- a/packages/@lwc/ssr-compiler/src/estree/validators.ts +++ b/packages/@lwc/ssr-compiler/src/estree/validators.ts @@ -6,20 +6,41 @@ */ import { is } from 'estree-toolkit'; +import type { CallExpression, Identifier, MemberExpression, SimpleLiteral } from 'estree'; +import type { Checker } from 'estree-toolkit/dist/generated/is-type'; +import type { Node } from 'estree-toolkit/dist/helpers'; // estree's `Node` is not compatible? -import type { Node } from 'estree'; -import type { Validator } from '../estemplate'; +/** Node representing a string literal. */ +type StringLiteral = SimpleLiteral & { value: string }; -export const isStringLiteral = (node: Node | null) => - is.literal(node) && typeof node.value === 'string'; +export const isStringLiteral = (node: Node | null | undefined): node is StringLiteral => { + return is.literal(node) && typeof node.value === 'string'; +}; -export const isIdentOrRenderCall = (node: Node | null) => - is.identifier(node) || - (is.callExpression(node) && - is.memberExpression(node.callee) && - is.identifier(node.callee.property) && - node.callee.property.name === 'render'); +/** Node representing an identifier named "render". */ +type RenderIdentifier = Identifier & { name: 'render' }; +/** Node representing a member expression `.render`. */ +type RenderMemberExpression = MemberExpression & { property: RenderIdentifier }; +/** Node representing a method call `.render()`. */ +type RenderCall = CallExpression & { callee: RenderMemberExpression }; -export function isNullableOf(validator: Validator) { - return (node: Node | null) => node === null || (validator && validator(node)); +/** Returns `true` if the node is an identifier or `.render()`. */ +export const isIdentOrRenderCall = ( + node: Node | null | undefined +): node is Identifier | RenderCall => { + return ( + is.identifier(node) || + (is.callExpression(node) && + is.memberExpression(node.callee) && + is.identifier(node.callee.property) && + node.callee.property.name === 'render') + ); +}; + +/** A validator that returns `true` if the node is `null`. */ +type NullableChecker = (node: Node | null | undefined) => node is T | null; + +/** Extends a validator to return `true` if the node is `null`. */ +export function isNullableOf(validator: Checker): NullableChecker { + return (node: Node | null | undefined): node is T | null => node === null || validator(node); }