diff --git a/packages/deno-web-test/utils.ts b/packages/deno-web-test/utils.ts index 66025fbae..ac01cd288 100644 --- a/packages/deno-web-test/utils.ts +++ b/packages/deno-web-test/utils.ts @@ -81,6 +81,7 @@ async function copy(src: string, dest: string): Promise { } else if (stat.isDirectory) { await copyDir(src, dest); } else { + await Deno.mkdir(path.dirname(dest), { recursive: true }); await Deno.copyFile(src, dest); } } diff --git a/packages/patterns/default-app.tsx b/packages/patterns/default-app.tsx index d85e5bf5d..c8a7c3226 100644 --- a/packages/patterns/default-app.tsx +++ b/packages/patterns/default-app.tsx @@ -3,7 +3,6 @@ import { Cell, derive, handler, - lift, NAME, navigateTo, recipe, @@ -99,10 +98,6 @@ const spawnNote = handler((_, __) => { })); }); -const getCharmName = lift(({ charm }: { charm: MinimalCharm }) => { - return charm?.[NAME] || "Untitled Charm"; -}); - export default recipe( "DefaultCharmList", (_) => { @@ -176,7 +171,7 @@ export default recipe( {allCharms.map((charm) => ( - {getCharmName({ charm })} + {charm?.[NAME] || "Untitled Charm"} , ): ts.Symbol | undefined { - if (seen.has(type)) return undefined; - seen.add(type); - - const direct = getBrandSymbolFromType(type, checker); - if (direct) return direct; - - const apparent = checker.getApparentType(type); - if (apparent !== type) { - const fromApparent = findCellBrandSymbol(apparent, checker, seen); - if (fromApparent) return fromApparent; - } - - if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) { - const compound = type as ts.UnionOrIntersectionType; - for (const child of compound.types) { - const childSymbol = findCellBrandSymbol(child, checker, seen); - if (childSymbol) return childSymbol; - } - } - - if (!(type.flags & ts.TypeFlags.Object)) { - return undefined; - } - - const objectType = type as ts.ObjectType; - - if (objectType.objectFlags & ts.ObjectFlags.Reference) { - const typeRef = objectType as ts.TypeReference; - if (typeRef.target) { - const fromTarget = findCellBrandSymbol(typeRef.target, checker, seen); - if (fromTarget) return fromTarget; - } - } - - if (objectType.objectFlags & ts.ObjectFlags.ClassOrInterface) { - const baseTypes = checker.getBaseTypes(objectType as ts.InterfaceType) ?? - []; - for (const base of baseTypes) { - const fromBase = findCellBrandSymbol(base, checker, seen); - if (fromBase) return fromBase; - } - } - - return undefined; + return traverseTypeHierarchy(type, { + checker, + checkType: (t) => getBrandSymbolFromType(t, checker), + visitApparentType: true, + visitTypeReferenceTarget: true, + visitBaseTypes: true, + }, seen); } export function getCellBrand( @@ -176,38 +140,23 @@ function extractWrapperTypeReference( checker: ts.TypeChecker, seen: Set, ): ts.TypeReference | undefined { - if (seen.has(type)) return undefined; - seen.add(type); - - if (type.flags & ts.TypeFlags.Object) { - const objectType = type as ts.ObjectType; - if (objectType.objectFlags & ts.ObjectFlags.Reference) { - const typeRef = objectType as ts.TypeReference; - const typeArgs = typeRef.typeArguments ?? - checker.getTypeArguments(typeRef); - if (typeArgs && typeArgs.length > 0) { - return typeRef; + return traverseTypeHierarchy(type, { + checker, + checkType: (t) => { + if (t.flags & ts.TypeFlags.Object) { + const objectType = t as ts.ObjectType; + if (objectType.objectFlags & ts.ObjectFlags.Reference) { + const typeRef = objectType as ts.TypeReference; + const typeArgs = typeRef.typeArguments ?? + checker.getTypeArguments(typeRef); + if (typeArgs && typeArgs.length > 0) { + return typeRef; + } + } } - } - } - - if (type.flags & ts.TypeFlags.Intersection) { - const intersectionType = type as ts.IntersectionType; - for (const constituent of intersectionType.types) { - const ref = extractWrapperTypeReference(constituent, checker, seen); - if (ref) return ref; - } - } - - if (type.flags & ts.TypeFlags.Union) { - const unionType = type as ts.UnionType; - for (const member of unionType.types) { - const ref = extractWrapperTypeReference(member, checker, seen); - if (ref) return ref; - } - } - - return undefined; + return undefined; + }, + }, seen); } export function getCellWrapperInfo( diff --git a/packages/schema-generator/src/typescript/type-traversal.ts b/packages/schema-generator/src/typescript/type-traversal.ts new file mode 100644 index 000000000..c58db2e82 --- /dev/null +++ b/packages/schema-generator/src/typescript/type-traversal.ts @@ -0,0 +1,121 @@ +import ts from "typescript"; + +export interface TypeTraversalOptions { + /** TypeScript type checker */ + readonly checker: ts.TypeChecker; + + /** Function to check each type and potentially return a result */ + readonly checkType: (type: ts.Type) => T | undefined; + + /** How to combine results from union types (default: return first match) */ + readonly handleUnion?: "first" | "some" | "every"; + + /** How to combine results from intersection types (default: return first match) */ + readonly handleIntersection?: "first" | "some" | "every"; + + /** Whether to check apparent type (default: false) */ + readonly visitApparentType?: boolean; + + /** Whether to traverse base types for interfaces (default: false) */ + readonly visitBaseTypes?: boolean; + + /** Whether to traverse type reference targets (default: false) */ + readonly visitTypeReferenceTarget?: boolean; +} + +/** + * Traverses a TypeScript type hierarchy with configurable behavior. + * Handles unions, intersections, type references, and base types. + * Uses a seen set to avoid infinite recursion on circular types. + * + * @param type - The type to traverse + * @param options - Configuration for traversal behavior + * @param seen - Set of already-visited types (for cycle detection) + * @returns Result from checkType function, or undefined if no match found + */ +export function traverseTypeHierarchy( + type: ts.Type, + options: TypeTraversalOptions, + seen = new Set(), +): T | undefined { + // Prevent infinite recursion on circular types + if (seen.has(type)) return undefined; + seen.add(type); + + // Check the type directly first + const direct = options.checkType(type); + if (direct !== undefined) return direct; + + // Check apparent type if requested + if (options.visitApparentType) { + const apparent = options.checker.getApparentType(type); + if (apparent !== type) { + const fromApparent = traverseTypeHierarchy(apparent, options, seen); + if (fromApparent !== undefined) return fromApparent; + } + } + + // Handle union and intersection types + if (type.flags & (ts.TypeFlags.Union | ts.TypeFlags.Intersection)) { + const compound = type as ts.UnionOrIntersectionType; + const isUnion = !!(type.flags & ts.TypeFlags.Union); + const strategy = isUnion + ? (options.handleUnion ?? "first") + : (options.handleIntersection ?? "first"); + + if (strategy === "first") { + // Return first match + for (const child of compound.types) { + const result = traverseTypeHierarchy(child, options, seen); + if (result !== undefined) return result; + } + // No match found in union/intersection + return undefined; + } else if (strategy === "some") { + // Check if any child matches (for boolean results) + const hasMatch = compound.types.some((child) => + traverseTypeHierarchy(child, options, seen) + ); + return (hasMatch as unknown as T); + } else if (strategy === "every") { + // Check if all children match (for boolean results) + const allMatch = compound.types.every((child) => + traverseTypeHierarchy(child, options, seen) + ); + return (allMatch as unknown as T); + } + } + + // Handle object types + if (type.flags & ts.TypeFlags.Object) { + const objectType = type as ts.ObjectType; + + // Check type reference targets if requested + if ( + options.visitTypeReferenceTarget && + objectType.objectFlags & ts.ObjectFlags.Reference + ) { + const typeRef = objectType as ts.TypeReference; + if (typeRef.target) { + const fromTarget = traverseTypeHierarchy(typeRef.target, options, seen); + if (fromTarget !== undefined) return fromTarget; + } + } + + // Check base types if requested + if ( + options.visitBaseTypes && + objectType.objectFlags & ts.ObjectFlags.ClassOrInterface + ) { + const baseTypes = options.checker.getBaseTypes( + objectType as ts.InterfaceType, + ) ?? []; + for (const base of baseTypes) { + const fromBase = traverseTypeHierarchy(base, options, seen); + if (fromBase !== undefined) return fromBase; + } + } + } + + return undefined; +} diff --git a/packages/ts-transformers/src/closures/computed-aliases.ts b/packages/ts-transformers/src/closures/computed-aliases.ts new file mode 100644 index 000000000..bb04b822e --- /dev/null +++ b/packages/ts-transformers/src/closures/computed-aliases.ts @@ -0,0 +1,406 @@ +import ts from "typescript"; +import type { TransformationContext } from "../core/mod.ts"; +import { createCaptureAccessExpression } from "../utils/capture-tree.ts"; +import type { CaptureTreeNode } from "../utils/capture-tree.ts"; +import { + getUniqueIdentifier, + maybeReuseIdentifier, +} from "../utils/identifiers.ts"; +import { createDeriveCall } from "../transformers/builtins/derive.ts"; + +function isBindingPattern(name: ts.BindingName): name is ts.BindingPattern { + return ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name); +} + +export interface ComputedAliasInfo { + readonly symbol: ts.Symbol; + readonly aliasName: string; + readonly keyExpression: ts.Expression; + readonly keyIdentifier: ts.Identifier; + readonly path: readonly string[]; + readonly baseTemplate?: ts.Expression; +} + +export interface ElementBindingAnalysis { + readonly bindingName: ts.BindingName; + readonly elementIdentifier: ts.Identifier; + readonly computedAliases: readonly ComputedAliasInfo[]; + readonly destructureStatement?: ts.Statement; +} + +interface ElementBindingPlan { + readonly aliases: ComputedAliasInfo[]; + readonly residualPattern?: ts.BindingName; +} + +export function normalizeBindingName( + name: ts.BindingName, + factory: ts.NodeFactory, + used: Set, +): ts.BindingName { + if (ts.isIdentifier(name)) { + return maybeReuseIdentifier(name, used); + } + + if (ts.isObjectBindingPattern(name)) { + const elements = name.elements.map((element) => + factory.createBindingElement( + element.dotDotDotToken, + element.propertyName, + normalizeBindingName(element.name, factory, used), + element.initializer as ts.Expression | undefined, + ) + ); + return factory.createObjectBindingPattern(elements); + } + + if (ts.isArrayBindingPattern(name)) { + const elements = name.elements.map((element) => { + if (ts.isOmittedExpression(element)) { + return element; + } + if (ts.isBindingElement(element)) { + return factory.createBindingElement( + element.dotDotDotToken, + element.propertyName, + normalizeBindingName(element.name, factory, used), + element.initializer as ts.Expression | undefined, + ); + } + return element; + }); + return factory.createArrayBindingPattern(elements); + } + + return name; +} + +function buildElementBindingPlan( + elemParam: ts.ParameterDeclaration, + context: TransformationContext, +): ElementBindingPlan { + const { factory, checker } = context; + + const aliasBucket: ComputedAliasInfo[] = []; + const keyNames = new Set(); + + const walk = ( + node: ts.BindingName, + path: readonly string[], + template: ts.Expression | undefined, + ): ts.BindingName | undefined => { + if (ts.isIdentifier(node)) { + return factory.createIdentifier(node.text); + } + + if (ts.isObjectBindingPattern(node)) { + const elements: ts.BindingElement[] = []; + + for (const element of node.elements) { + const propertyName = element.propertyName; + let nextPath = path; + let nextTemplate = template; + + if (propertyName && ts.isComputedPropertyName(propertyName)) { + if (ts.isIdentifier(element.name)) { + const symbol = checker.getSymbolAtLocation(element.name); + if (symbol) { + const aliasName = element.name.text; + const keyBase = `__ct_${aliasName}_key`; + const unique = getUniqueIdentifier(keyBase, keyNames, { + fallback: keyBase, + }); + keyNames.add(unique); + aliasBucket.push({ + symbol, + aliasName, + keyExpression: propertyName.expression, + keyIdentifier: factory.createIdentifier(unique), + path, + baseTemplate: template, + }); + } + } + continue; + } + + if (propertyName && ts.isIdentifier(propertyName)) { + nextPath = [...path, propertyName.text]; + const base = nextTemplate ?? + factory.createIdentifier("__ct_placeholder"); + nextTemplate = factory.createPropertyAccessExpression( + base, + factory.createIdentifier(propertyName.text), + ); + } else if (propertyName && ts.isStringLiteral(propertyName)) { + nextPath = [...path, propertyName.text]; + const base = nextTemplate ?? + factory.createIdentifier("__ct_placeholder"); + nextTemplate = factory.createElementAccessExpression( + base, + factory.createStringLiteral(propertyName.text), + ); + } else if (!propertyName && ts.isIdentifier(element.name)) { + nextPath = [...path, element.name.text]; + const base = nextTemplate ?? + factory.createIdentifier("__ct_placeholder"); + nextTemplate = factory.createPropertyAccessExpression( + base, + factory.createIdentifier(element.name.text), + ); + } + + let clonedName: ts.BindingName | undefined; + if (ts.isIdentifier(element.name)) { + clonedName = factory.createIdentifier(element.name.text); + } else if (isBindingPattern(element.name)) { + clonedName = walk(element.name, nextPath, nextTemplate); + } + + if (!clonedName && !element.dotDotDotToken) { + continue; + } + + elements.push( + factory.createBindingElement( + element.dotDotDotToken, + element.propertyName, + clonedName ?? element.name, + element.initializer as ts.Expression | undefined, + ), + ); + } + + if (elements.length === 0) return undefined; + return factory.createObjectBindingPattern(elements); + } + + if (ts.isArrayBindingPattern(node)) { + const newElements = node.elements.map((element) => { + if (ts.isOmittedExpression(element)) return element; + if (ts.isBindingElement(element)) { + let clonedName: ts.BindingName | undefined; + if (ts.isIdentifier(element.name)) { + clonedName = factory.createIdentifier(element.name.text); + } else if (isBindingPattern(element.name)) { + clonedName = walk(element.name, path, template); + } + if (!clonedName && !element.dotDotDotToken) { + return element; + } + return factory.createBindingElement( + element.dotDotDotToken, + element.propertyName, + clonedName ?? element.name, + element.initializer as ts.Expression | undefined, + ); + } + return element; + }); + return factory.createArrayBindingPattern(newElements); + } + + return undefined; + }; + + const residualPattern = walk(elemParam.name, [], undefined); + + return { + aliases: aliasBucket, + residualPattern, + }; +} + +export function analyzeElementBinding( + elemParam: ts.ParameterDeclaration | undefined, + captureTree: Map, + context: TransformationContext, + used: Set, + createBindingIdentifier: (candidate: string) => ts.Identifier, +): ElementBindingAnalysis { + const { factory } = context; + + if (!elemParam) { + const identifier = createBindingIdentifier( + captureTree.has("element") ? "__ct_element" : "element", + ); + return { + bindingName: identifier, + elementIdentifier: identifier, + computedAliases: [], + }; + } + + if (ts.isIdentifier(elemParam.name)) { + const identifier = maybeReuseIdentifier(elemParam.name, used); + return { + bindingName: identifier, + elementIdentifier: identifier, + computedAliases: [], + }; + } + + const plan = buildElementBindingPlan(elemParam, context); + + if (plan.aliases.length === 0) { + const normalized = normalizeBindingName( + elemParam.name, + factory, + used, + ); + return { + bindingName: normalized, + elementIdentifier: factory.createIdentifier( + ts.isIdentifier(normalized) ? normalized.text : "element", + ), + computedAliases: [], + }; + } + + const elementIdentifier = createBindingIdentifier( + captureTree.has("element") ? "__ct_element" : "element", + ); + + let destructureStatement: ts.Statement | undefined; + if (plan.residualPattern) { + const normalized = normalizeBindingName( + plan.residualPattern, + factory, + used, + ); + destructureStatement = factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + normalized, + undefined, + undefined, + factory.createIdentifier(elementIdentifier.text), + ), + ], + ts.NodeFlags.Const, + ), + ); + } + + return { + bindingName: elementIdentifier, + elementIdentifier, + computedAliases: plan.aliases, + destructureStatement, + }; +} + +function createDerivedAliasExpression( + info: ComputedAliasInfo, + elementIdentifier: ts.Identifier, + context: TransformationContext, +): ts.Expression { + const { factory, ctHelpers, tsContext } = context; + const keyIdent = factory.createIdentifier(info.keyIdentifier.text); + + const accessBase = createCaptureAccessExpression( + elementIdentifier.text, + info.path, + factory, + info.baseTemplate, + ); + + const elementAccess = factory.createElementAccessExpression( + accessBase, + keyIdent, + ); + + const elementRef = factory.createIdentifier(elementIdentifier.text); + + const deriveExpression = createDeriveCall( + elementAccess, + [elementRef, keyIdent], + { + factory, + tsContext, + ctHelpers, + }, + ); + + return deriveExpression ?? elementAccess; +} + +export function rewriteCallbackBody( + body: ts.ConciseBody, + analysis: ElementBindingAnalysis, + context: TransformationContext, +): ts.ConciseBody { + if (analysis.computedAliases.length === 0) { + return body; + } + + const { factory } = context; + + let block: ts.Block; + if (ts.isBlock(body)) { + block = body; + } else { + block = factory.createBlock([ + factory.createReturnStatement(body as ts.Expression), + ], true); + } + + const prologue: ts.Statement[] = []; + + if (analysis.destructureStatement) { + prologue.push(analysis.destructureStatement); + } + + for (const info of analysis.computedAliases) { + prologue.push( + factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier(info.aliasName), + undefined, + undefined, + createDerivedAliasExpression( + info, + analysis.elementIdentifier, + context, + ), + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + } + + const statements: ts.Statement[] = [...prologue, ...block.statements]; + + const keyStatements: ts.Statement[] = []; + for (const info of analysis.computedAliases) { + keyStatements.push( + factory.createVariableStatement( + undefined, + factory.createVariableDeclarationList( + [ + factory.createVariableDeclaration( + factory.createIdentifier(info.keyIdentifier.text), + undefined, + undefined, + info.keyExpression, + ), + ], + ts.NodeFlags.Const, + ), + ), + ); + } + + if (keyStatements.length > 0) { + statements.unshift(...keyStatements); + } + + return factory.createBlock(statements, true); +} diff --git a/packages/ts-transformers/src/closures/transformer.ts b/packages/ts-transformers/src/closures/transformer.ts index 5056ca574..68d1fc27d 100644 --- a/packages/ts-transformers/src/closures/transformer.ts +++ b/packages/ts-transformers/src/closures/transformer.ts @@ -8,10 +8,17 @@ import { } from "../utils/capture-tree.ts"; import type { CaptureTreeNode } from "../utils/capture-tree.ts"; import { - getUniqueIdentifier, - isSafeIdentifierText, - maybeReuseIdentifier, + createBindingElementsFromNames, + createParameterFromBindings, + createPropertyName, + reserveIdentifier, } from "../utils/identifiers.ts"; +import { + analyzeElementBinding, + normalizeBindingName, + rewriteCallbackBody, +} from "./computed-aliases.ts"; +import type { ComputedAliasInfo } from "./computed-aliases.ts"; export class ClosureTransformer extends Transformer { override filter(context: TransformationContext): boolean { @@ -404,63 +411,6 @@ function isOpaqueRefArrayMapCall( hasArrayTypeArgument(originType, checker); } -/** - * Extract the root identifier name from an expression. - * For property access like state.discount, returns "state". - */ -type CaptureTreeMap = Map; - -function createSafePropertyName( - name: string, - factory: ts.NodeFactory, -): ts.PropertyName { - return isSafeIdentifierText(name) - ? factory.createIdentifier(name) - : factory.createStringLiteral(name); -} - -function normalizeBindingName( - name: ts.BindingName, - factory: ts.NodeFactory, - used: Set, -): ts.BindingName { - if (ts.isIdentifier(name)) { - return maybeReuseIdentifier(name, used); - } - - if (ts.isObjectBindingPattern(name)) { - const elements = name.elements.map((element) => - factory.createBindingElement( - element.dotDotDotToken, - element.propertyName, - normalizeBindingName(element.name, factory, used), - element.initializer as ts.Expression | undefined, - ) - ); - return factory.createObjectBindingPattern(elements); - } - - if (ts.isArrayBindingPattern(name)) { - const elements = name.elements.map((element) => { - if (ts.isOmittedExpression(element)) { - return element; - } - if (ts.isBindingElement(element)) { - return factory.createBindingElement( - element.dotDotDotToken, - element.propertyName, - normalizeBindingName(element.name, factory, used), - element.initializer as ts.Expression | undefined, - ); - } - return element; - }); - return factory.createArrayBindingPattern(elements); - } - - return name; -} - function typeNodeForExpression( expr: ts.Expression, context: TransformationContext, @@ -503,7 +453,7 @@ function buildCaptureTypeProperties( properties.push( factory.createPropertySignature( undefined, - createSafePropertyName(propName, factory), + createPropertyName(propName, factory), undefined, typeNode, ), @@ -534,7 +484,7 @@ function buildParamsTypeElements( properties.push( factory.createPropertySignature( undefined, - createSafePropertyName(rootName, factory), + createPropertyName(rootName, factory), undefined, typeNode, ), @@ -891,6 +841,7 @@ function createRecipeCallWithParams( arrayParam: ts.ParameterDeclaration | undefined, captureTree: Map, context: TransformationContext, + visitor: ts.Visitor, ): ts.CallExpression { const { factory } = context; @@ -898,26 +849,25 @@ function createRecipeCallWithParams( const usedBindingNames = new Set(); const createBindingIdentifier = (name: string): ts.Identifier => { - if (isSafeIdentifierText(name) && !usedBindingNames.has(name)) { - usedBindingNames.add(name); - return factory.createIdentifier(name); - } - const fallback = name.length > 0 ? name : "ref"; - const unique = getUniqueIdentifier(fallback, usedBindingNames, { - fallback: "ref", - }); - return factory.createIdentifier(unique); + return reserveIdentifier(name, usedBindingNames, factory); }; - const elementBindingName = elemParam - ? normalizeBindingName(elemParam.name, factory, usedBindingNames) - : createBindingIdentifier( - captureTree.has("element") ? "__ct_element" : "element", - ); + const elementAnalysis = analyzeElementBinding( + elemParam, + captureTree, + context, + usedBindingNames, + createBindingIdentifier, + ); + const elementBindingName = elementAnalysis.bindingName; + const elementPropertyName = ts.isIdentifier(elementBindingName) && + elementBindingName.text === "element" + ? undefined + : factory.createIdentifier("element"); bindingElements.push( factory.createBindingElement( undefined, - factory.createIdentifier("element"), + elementPropertyName, elementBindingName, undefined, ), @@ -945,20 +895,11 @@ function createRecipeCallWithParams( ); } - const paramsBindings: ts.BindingElement[] = []; - for (const [rootName] of captureTree) { - const propertyName = isSafeIdentifierText(rootName) - ? undefined - : createSafePropertyName(rootName, factory); - paramsBindings.push( - factory.createBindingElement( - undefined, - propertyName, - createBindingIdentifier(rootName), - undefined, - ), - ); - } + const paramsBindings = createBindingElementsFromNames( + captureTree.keys(), + factory, + createBindingIdentifier, + ); const paramsPattern = factory.createObjectBindingPattern(paramsBindings); @@ -971,13 +912,30 @@ function createRecipeCallWithParams( ), ); - const destructuredParam = factory.createParameterDeclaration( - undefined, - undefined, - factory.createObjectBindingPattern(bindingElements), - undefined, - undefined, - undefined, + const destructuredParam = createParameterFromBindings( + bindingElements, + factory, + ); + + const visitedAliases: ComputedAliasInfo[] = elementAnalysis + .computedAliases.map((info) => { + const keyExpression = ts.visitNode( + info.keyExpression, + visitor, + ts.isExpression, + ) ?? info.keyExpression; + return { ...info, keyExpression }; + }); + + const rewrittenBody = rewriteCallbackBody( + transformedBody, + { + bindingName: elementAnalysis.bindingName, + elementIdentifier: elementAnalysis.elementIdentifier, + destructureStatement: elementAnalysis.destructureStatement, + computedAliases: visitedAliases, + }, + context, ); const newCallback = factory.createArrowFunction( @@ -988,7 +946,7 @@ function createRecipeCallWithParams( ts.isArrowFunction(callback) ? callback.equalsGreaterThanToken : factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - transformedBody, + rewrittenBody, ); context.markAsMapCallback(newCallback); @@ -1013,7 +971,7 @@ function createRecipeCallWithParams( for (const [rootName, rootNode] of captureTree) { paramProperties.push( factory.createPropertyAssignment( - createSafePropertyName(rootName, factory), + createPropertyName(rootName, factory), buildHierarchicalParamsValue(rootNode, rootName, factory), ), ); @@ -1082,6 +1040,7 @@ function transformMapCallback( arrayParam, captureTree, context, + visitor, ); } diff --git a/packages/ts-transformers/src/transformers/builtins/derive.ts b/packages/ts-transformers/src/transformers/builtins/derive.ts index 4934d1a8f..13560fdee 100644 --- a/packages/ts-transformers/src/transformers/builtins/derive.ts +++ b/packages/ts-transformers/src/transformers/builtins/derive.ts @@ -7,8 +7,11 @@ import { parseCaptureExpression, } from "../../utils/capture-tree.ts"; import { - getUniqueIdentifier, - isSafeIdentifierText, + createBindingElementsFromNames, + createParameterFromBindings, + createPropertyName, + createPropertyParamNames, + reserveIdentifier, } from "../../utils/identifiers.ts"; function replaceOpaqueRefsWithParams( @@ -66,17 +69,13 @@ function planDeriveEntries( const usedParamNames = new Set(); fallback.forEach((ref, index) => { - const baseName = getExpressionText(ref).replace(/\./g, "_"); - const propertyName = getUniqueIdentifier(baseName, usedPropertyNames, { - fallback: `ref${index + 1}`, - trimLeadingUnderscores: true, - }); - - const paramName = ts.isIdentifier(ref) - ? getUniqueIdentifier(ref.text, usedParamNames) - : getUniqueIdentifier(`_v${index + 1}`, usedParamNames, { - fallback: `_v${index + 1}`, - }); + const { propertyName, paramName } = createPropertyParamNames( + getExpressionText(ref), + ts.isIdentifier(ref), + index, + usedPropertyNames, + usedParamNames, + ); fallbackEntries.push({ ref, propertyName, paramName }); refToParamName.set(ref, paramName); @@ -85,15 +84,6 @@ function planDeriveEntries( return { captureTree, fallbackEntries, refToParamName }; } -function createPropertyName( - factory: ts.NodeFactory, - name: string, -): ts.PropertyName { - return isSafeIdentifierText(name) - ? factory.createIdentifier(name) - : factory.createStringLiteral(name); -} - function createParameterForPlan( factory: ts.NodeFactory, captureTree: ReturnType, @@ -104,30 +94,12 @@ function createParameterForPlan( const usedNames = new Set(); const register = (candidate: string): ts.Identifier => { - if (isSafeIdentifierText(candidate) && !usedNames.has(candidate)) { - usedNames.add(candidate); - return factory.createIdentifier(candidate); - } - const unique = getUniqueIdentifier(candidate, usedNames, { - fallback: candidate.length > 0 ? candidate : "ref", - }); - return factory.createIdentifier(unique); + return reserveIdentifier(candidate, usedNames, factory); }; - for (const [rootName] of captureTree) { - const bindingIdentifier = register(rootName); - const propertyName = isSafeIdentifierText(rootName) - ? undefined - : createPropertyName(factory, rootName); - bindings.push( - factory.createBindingElement( - undefined, - propertyName, - bindingIdentifier, - undefined, - ), - ); - } + bindings.push( + ...createBindingElementsFromNames(captureTree.keys(), factory, register), + ); for (const entry of fallbackEntries) { const bindingIdentifier = register(entry.paramName); @@ -145,32 +117,7 @@ function createParameterForPlan( ); } - const shouldInlineSoleBinding = bindings.length === 1 && - captureTree.size === 0 && - fallbackEntries.length === 1 && - !bindings[0]!.propertyName && - !bindings[0]!.dotDotDotToken && - !bindings[0]!.initializer; - - if (shouldInlineSoleBinding) { - return factory.createParameterDeclaration( - undefined, - undefined, - bindings[0]!.name, - undefined, - undefined, - undefined, - ); - } - - return factory.createParameterDeclaration( - undefined, - undefined, - factory.createObjectBindingPattern(bindings), - undefined, - undefined, - undefined, - ); + return createParameterFromBindings(bindings, factory); } function createDeriveArgs( @@ -183,7 +130,7 @@ function createDeriveArgs( for (const [rootName, node] of captureTree) { properties.push( factory.createPropertyAssignment( - createPropertyName(factory, rootName), + createPropertyName(rootName, factory), buildHierarchicalParamsValue(node, rootName, factory), ), ); @@ -204,16 +151,6 @@ function createDeriveArgs( } } - if (properties.length === 1 && fallbackEntries.length === 0) { - const first = captureTree.values().next(); - if (!first.done) { - const node = first.value; - if (node.expression && node.properties.size === 0) { - return [node.expression]; - } - } - } - return [ factory.createObjectLiteralExpression(properties, properties.length > 1), ]; diff --git a/packages/ts-transformers/src/transformers/opaque-ref/bindings.ts b/packages/ts-transformers/src/transformers/opaque-ref/bindings.ts index 81d4adfe0..3ee554389 100644 --- a/packages/ts-transformers/src/transformers/opaque-ref/bindings.ts +++ b/packages/ts-transformers/src/transformers/opaque-ref/bindings.ts @@ -1,7 +1,7 @@ import ts from "typescript"; import { getExpressionText, type NormalizedDataFlow } from "../../ast/mod.ts"; -import { getUniqueIdentifier } from "../../utils/identifiers.ts"; +import { createPropertyParamNames } from "../../utils/identifiers.ts"; export interface BindingPlanEntry { readonly dataFlow: NormalizedDataFlow; @@ -14,20 +14,6 @@ export interface BindingPlan { readonly usesObjectBinding: boolean; } -function deriveBaseName( - expression: ts.Expression, - index: number, -): string { - if (ts.isIdentifier(expression)) { - return expression.text; - } - if (ts.isPropertyAccessExpression(expression)) { - // Use getExpressionText to handle both regular and synthetic nodes - return getExpressionText(expression).replace(/\./g, "_"); - } - return `ref${index + 1}`; -} - export function createBindingPlan( dataFlows: readonly NormalizedDataFlow[], ): BindingPlan { @@ -36,19 +22,13 @@ export function createBindingPlan( const entries: BindingPlanEntry[] = []; dataFlows.forEach((dataFlow, index) => { - const base = deriveBaseName(dataFlow.expression, index); - const fallback = `ref${index + 1}`; - const propertyName = getUniqueIdentifier(base, usedPropertyNames, { - fallback, - trimLeadingUnderscores: true, - }); - - const paramCandidate = ts.isIdentifier(dataFlow.expression) - ? dataFlow.expression.text - : `_v${index + 1}`; - const paramName = getUniqueIdentifier(paramCandidate, usedParamNames, { - fallback: `_v${index + 1}`, - }); + const { propertyName, paramName } = createPropertyParamNames( + getExpressionText(dataFlow.expression), + ts.isIdentifier(dataFlow.expression), + index, + usedPropertyNames, + usedParamNames, + ); entries.push({ dataFlow, propertyName, paramName }); }); diff --git a/packages/ts-transformers/src/utils/identifiers.ts b/packages/ts-transformers/src/utils/identifiers.ts index 91df9ee3b..ec80ee874 100644 --- a/packages/ts-transformers/src/utils/identifiers.ts +++ b/packages/ts-transformers/src/utils/identifiers.ts @@ -13,13 +13,20 @@ export interface UniqueIdentifierOptions extends SanitizeIdentifierOptions { export function isSafeIdentifierText(name: string): boolean { if (name.length === 0) return false; - const first = name.codePointAt(0)!; - if (!ts.isIdentifierStart(first, ts.ScriptTarget.ESNext)) { + const codePoints = Array.from(name); + const first = codePoints[0]?.codePointAt(0); + if ( + first === undefined || + !ts.isIdentifierStart(first, ts.ScriptTarget.ESNext) + ) { return false; } - for (let i = 1; i < name.length; i++) { - const code = name.codePointAt(i)!; - if (!ts.isIdentifierPart(code, ts.ScriptTarget.ESNext)) { + for (const codePoint of codePoints.slice(1)) { + const code = codePoint.codePointAt(0); + if ( + code === undefined || + !ts.isIdentifierPart(code, ts.ScriptTarget.ESNext) + ) { return false; } } @@ -53,7 +60,11 @@ export function sanitizeIdentifierCandidate( return DEFAULT_FALLBACK; } - if (!ts.isIdentifierStart(text.charCodeAt(0), ts.ScriptTarget.ESNext)) { + const first = text.codePointAt(0); + if ( + first === undefined || + !ts.isIdentifierStart(first, ts.ScriptTarget.ESNext) + ) { text = `${DEFAULT_FALLBACK}${text}`; } @@ -79,7 +90,11 @@ export function sanitizeIdentifierCandidate( const ensureIdentifierStart = (text: string): string => { if (text.length === 0) return fallback; - if (ts.isIdentifierStart(text.charCodeAt(0), ts.ScriptTarget.ESNext)) { + const first = text.codePointAt(0); + if ( + first !== undefined && + ts.isIdentifierStart(first, ts.ScriptTarget.ESNext) + ) { return text; } return `${fallback}${text}`; @@ -150,3 +165,129 @@ export function createSafeIdentifier( const text = getUniqueIdentifier(name, used, options); return ts.factory.createIdentifier(text); } + +export function createPropertyName( + name: string, + factory: ts.NodeFactory, +): ts.PropertyName { + return isSafeIdentifierText(name) + ? factory.createIdentifier(name) + : factory.createStringLiteral(name); +} + +export interface ReserveIdentifierOptions extends UniqueIdentifierOptions { + readonly emptyFallback?: string; +} + +export function reserveIdentifier( + candidate: string, + used: Set, + factory: ts.NodeFactory, + options: ReserveIdentifierOptions = {}, +): ts.Identifier { + if (isSafeIdentifierText(candidate) && !used.has(candidate)) { + used.add(candidate); + return factory.createIdentifier(candidate); + } + + const emptyFallback = options.emptyFallback ?? "ref"; + const baseCandidate = candidate.length > 0 ? candidate : emptyFallback; + + const unique = getUniqueIdentifier(baseCandidate, used, { + ...options, + fallback: emptyFallback, + }); + return factory.createIdentifier(unique); +} + +/** + * Creates binding elements for object destructuring from property names. + * Handles safe identifier vs string literal property names automatically. + * + * @param names - Property names to create bindings for + * @param factory - TypeScript node factory + * @param createBindingName - Callback to generate the binding identifier/pattern for each property + * @returns Array of binding elements suitable for createObjectBindingPattern + */ +export function createBindingElementsFromNames( + names: Iterable, + factory: ts.NodeFactory, + createBindingName: (propertyName: string) => ts.BindingName, +): ts.BindingElement[] { + const elements: ts.BindingElement[] = []; + for (const name of names) { + const propertyName = isSafeIdentifierText(name) + ? undefined + : createPropertyName(name, factory); + elements.push( + factory.createBindingElement( + undefined, + propertyName, + createBindingName(name), + undefined, + ), + ); + } + return elements; +} + +export interface ParameterFromBindingsOptions { + readonly type?: ts.TypeNode; +} + +/** + * Creates a parameter declaration with object binding pattern from binding elements. + * + * @param bindings - Binding elements for the parameter + * @param factory - TypeScript node factory + * @param options - Optional configuration + * @returns Parameter declaration with object binding pattern + */ +export function createParameterFromBindings( + bindings: readonly ts.BindingElement[], + factory: ts.NodeFactory, + options: ParameterFromBindingsOptions = {}, +): ts.ParameterDeclaration { + return factory.createParameterDeclaration( + undefined, + undefined, + factory.createObjectBindingPattern([...bindings]), + undefined, + options.type, + undefined, + ); +} + +/** + * Generate both property name and param name for an expression, + * following the standard pattern used across derive/opaque-ref bindings. + * + * @param expressionText - Base text from the expression (typically from getExpressionText) + * @param isIdentifier - Whether the expression is a simple identifier + * @param index - Index for fallback naming (e.g., ref1, ref2, _v1, _v2) + * @param usedPropertyNames - Set of used property names (mutated) + * @param usedParamNames - Set of used param names (mutated) + * @returns Object with propertyName and paramName + */ +export function createPropertyParamNames( + expressionText: string, + isIdentifier: boolean, + index: number, + usedPropertyNames: Set, + usedParamNames: Set, +): { propertyName: string; paramName: string } { + // Property name: use expression text with dots replaced by underscores + const baseName = expressionText.replace(/\./g, "_"); + const propertyName = getUniqueIdentifier(baseName, usedPropertyNames, { + fallback: `ref${index + 1}`, + trimLeadingUnderscores: true, + }); + + // Param name: use identifier text directly, or fallback to _v1, _v2, etc. + const paramCandidate = isIdentifier ? expressionText : `_v${index + 1}`; + const paramName = getUniqueIdentifier(paramCandidate, usedParamNames, { + fallback: `_v${index + 1}`, + }); + + return { propertyName, paramName }; +} diff --git a/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx index ffd3bc9db..7c83d243b 100644 --- a/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/builder-conditional.expected.tsx @@ -21,7 +21,7 @@ export default recipe({ return { [NAME]: state.label, [UI]: (
- {__ctHelpers.ifElse(__ctHelpers.derive(state, ({ state }) => state && state.count > 0),

Positive

,

Non-positive

)} + {__ctHelpers.ifElse(__ctHelpers.derive({ state: state }, ({ state }) => state && state.count > 0),

Positive

,

Non-positive

)}
), }; }); diff --git a/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx b/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx index 97312715b..deb45d7b7 100644 --- a/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx +++ b/packages/ts-transformers/test/fixtures/ast-transform/event-handler-no-derive.expected.tsx @@ -32,7 +32,7 @@ export default recipe({ return { [UI]: (
{/* Regular JSX expression - should be wrapped in derive */} - Count: {__ctHelpers.derive(count, ({ count }) => count + 1)} + Count: {__ctHelpers.derive({ count: count }, ({ count }) => count + 1)} {/* Event handler with OpaqueRef - should NOT be wrapped in derive */} diff --git a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.expected.tsx index 06263bd78..e8f0dd15b 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-side-effect.expected.tsx @@ -42,7 +42,14 @@ export default recipe({ } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element: { [nextKey()]: amount }, params: {} }) => ({amount})), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => { + const __ct_amount_key = nextKey(); + const amount = __ctHelpers.derive({ + element: element, + __ct_amount_key: __ct_amount_key + }, ({ element, __ct_amount_key }) => element[__ct_amount_key]); + return ({amount}); + }), {})}
), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-strict.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-strict.expected.tsx index f0c33260f..86e4c634a 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-strict.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-strict.expected.tsx @@ -64,9 +64,14 @@ export default recipe({ required: ["value", "other"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element: { [dynamicKey]: val }, params: {} }) => { + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => { + const __ct_val_key = dynamicKey; + const val = __ctHelpers.derive({ + element: element, + __ct_val_key: __ct_val_key + }, ({ element, __ct_val_key }) => element[__ct_val_key]); "use strict"; - return {__ctHelpers.derive(val, ({ val }) => val * 2)}; + return {__ctHelpers.derive({ val: val }, ({ val }) => val * 2)}; }), {})} ), }; diff --git a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-with-plain-binding.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-with-plain-binding.expected.tsx new file mode 100644 index 000000000..a459ec440 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-with-plain-binding.expected.tsx @@ -0,0 +1,87 @@ +import * as __ctHelpers from "commontools"; +import { recipe, UI } from "commontools"; +function dynamicKey(): "value" { + return "value"; +} +interface Item { + foo: number; + value: number; +} +interface State { + items: Item[]; +} +export default recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + items: { + type: "array", + items: { + $ref: "#/$defs/Item" + } + } + }, + required: ["items"], + $defs: { + Item: { + type: "object", + properties: { + foo: { + type: "number" + }, + value: { + type: "number" + } + }, + required: ["foo", "value"] + } + } +} as const satisfies __ctHelpers.JSONSchema, (state) => { + return { + [UI]: (
+ {state.items.mapWithPattern(__ctHelpers.recipe({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + element: { + $ref: "#/$defs/Item" + }, + params: { + type: "object", + properties: {} + } + }, + required: ["element", "params"], + $defs: { + Item: { + type: "object", + properties: { + foo: { + type: "number" + }, + value: { + type: "number" + } + }, + required: ["foo", "value"] + } + } + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => { + const __ct_val_key = dynamicKey(); + const { foo } = element; + const val = __ctHelpers.derive({ + element: element, + __ct_val_key: __ct_val_key + }, ({ element, __ct_val_key }) => element[__ct_val_key]); + return ({__ctHelpers.derive({ + foo: foo, + val: val + }, ({ foo, val }) => foo + val)}); + }), {})} +
), + }; +}); +// @ts-ignore: Internals +function h(...args: any[]) { return __ctHelpers.h.apply(null, args); } +// @ts-ignore: Internals +h.fragment = __ctHelpers.h.fragment; diff --git a/packages/ts-transformers/test/fixtures/closures/map-computed-alias-with-plain-binding.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-with-plain-binding.input.tsx new file mode 100644 index 000000000..bdca6d703 --- /dev/null +++ b/packages/ts-transformers/test/fixtures/closures/map-computed-alias-with-plain-binding.input.tsx @@ -0,0 +1,27 @@ +/// +import { recipe, UI } from "commontools"; + +function dynamicKey(): "value" { + return "value"; +} + +interface Item { + foo: number; + value: number; +} + +interface State { + items: Item[]; +} + +export default recipe("MapComputedAliasWithPlainBinding", (state) => { + return { + [UI]: ( +
+ {state.items.map(({ foo, [dynamicKey()]: val }) => ( + {foo + val} + ))} +
+ ), + }; +}); diff --git a/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.expected.tsx index 01c5e8ed3..93643ab2f 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-destructured-computed-alias.expected.tsx @@ -64,7 +64,14 @@ export default recipe({ required: ["value", "other"] } } - } as const satisfies __ctHelpers.JSONSchema, ({ element: { [dynamicKey]: val }, params: {} }) => ({val})), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element, params: {} }) => { + const __ct_val_key = dynamicKey; + const val = __ctHelpers.derive({ + element: element, + __ct_val_key: __ct_val_key + }, ({ element, __ct_val_key }) => element[__ct_val_key]); + return ({val}); + }), {})} ), }; }); diff --git a/packages/ts-transformers/test/fixtures/closures/map-outer-element.expected.tsx b/packages/ts-transformers/test/fixtures/closures/map-outer-element.expected.tsx index 250be498a..489a1e343 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-outer-element.expected.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-outer-element.expected.tsx @@ -29,6 +29,9 @@ export default recipe({ element: { type: "number" }, + index: { + type: "number" + }, params: { type: "object", properties: { @@ -41,7 +44,7 @@ export default recipe({ } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element: __ct_element, params: { element } }) => ({element})), { + } as const satisfies __ctHelpers.JSONSchema, ({ element: _, index: index, params: { element } }) => ({element})), { element: element })} ), diff --git a/packages/ts-transformers/test/fixtures/closures/map-outer-element.input.tsx b/packages/ts-transformers/test/fixtures/closures/map-outer-element.input.tsx index 41fdbbc8f..4cca2e57f 100644 --- a/packages/ts-transformers/test/fixtures/closures/map-outer-element.input.tsx +++ b/packages/ts-transformers/test/fixtures/closures/map-outer-element.input.tsx @@ -11,8 +11,8 @@ export default recipe("MapOuterElement", (state) => { return { [UI]: (
- {state.items.map(() => ( - {element} + {state.items.map((_, index) => ( + {element} ))}
), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx index e87b9d271..bc912c2fb 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-array-length-conditional.expected.tsx @@ -4,7 +4,7 @@ export default recipe("MapArrayLengthConditional", (_state) => { const list = cell(["apple", "banana", "cherry"]); return { [UI]: (
- {__ctHelpers.derive(list, ({ list }) => list.length > 0 && (
+ {__ctHelpers.derive({ list: list }, ({ list }) => list.length > 0 && (
{list.map((name) => ({name}))}
))}
), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx index 7fb8bb300..0e06f95b9 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture-no-name.expected.tsx @@ -7,7 +7,7 @@ export default recipe(true as const satisfies __ctHelpers.JSONSchema, (_state: a ]); return { [UI]: (
- {__ctHelpers.derive(people, ({ people }) => people.length > 0 && (
    + {__ctHelpers.derive({ people: people }, ({ people }) => people.length > 0 && (
      {people.map((person) => (
    • {person.name}
    • ))}
    ))}
), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx index 986700838..62a280cca 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/map-single-capture.expected.tsx @@ -7,7 +7,7 @@ export default recipe("MapSingleCapture", (_state) => { ]); return { [UI]: (
- {__ctHelpers.derive(people, ({ people }) => people.length > 0 && (
    + {__ctHelpers.derive({ people: people }, ({ people }) => people.length > 0 && (
      {people.map((person) => (
    • {person.name}
    • ))}
    ))}
), diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx index ecc93e204..0e0e5fa05 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/method-chains.expected.tsx @@ -234,7 +234,7 @@ export default recipe({ } }, required: ["element", "params"] - } as const satisfies __ctHelpers.JSONSchema, ({ element: name, params: {} }) => (
  • {__ctHelpers.derive(name, ({ name }) => name.trim().toLowerCase().replace(" ", "-"))}
  • )), {})} + } as const satisfies __ctHelpers.JSONSchema, ({ element: name, params: {} }) => (
  • {__ctHelpers.derive({ name: name }, ({ name }) => name.trim().toLowerCase().replace(" ", "-"))}
  • )), {})} {/* Reduce with reactive accumulator */} diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx index a71d5cf49..f8fd280e7 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-cell-map.expected.tsx @@ -100,12 +100,12 @@ export default recipe("Charms Launcher", () => { [NAME]: "Charms Launcher", [UI]: (

    Stored Charms:

    - {ifElse(__ctHelpers.derive(typedCellRef, ({ typedCellRef }) => !typedCellRef?.length),
    No charms created yet
    ,
      + {ifElse(__ctHelpers.derive({ typedCellRef: typedCellRef }, ({ typedCellRef }) => !typedCellRef?.length),
      No charms created yet
      ,
        {typedCellRef.map((charm: any, index: number) => (
      • - Go to Charm {__ctHelpers.derive(index, ({ index }) => index + 1)} + Go to Charm {__ctHelpers.derive({ index: index }, ({ index }) => index + 1)} - Charm {__ctHelpers.derive(index, ({ index }) => index + 1)}: {__ctHelpers.derive(charm, ({ charm }) => charm[NAME] || "Unnamed")} + Charm {__ctHelpers.derive({ index: index }, ({ index }) => index + 1)}: {__ctHelpers.derive({ charm: charm }, ({ charm }) => charm[NAME] || "Unnamed")}
      • ))}
      )} diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx index 86433b132..6f035f9d0 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/opaque-ref-operations.expected.tsx @@ -6,9 +6,9 @@ export default recipe("OpaqueRefOperations", (_state) => { return { [UI]: (

      Count: {count}

      -

      Next: {__ctHelpers.derive(count, ({ count }) => count + 1)}

      -

      Double: {__ctHelpers.derive(count, ({ count }) => count * 2)}

      -

      Total: {__ctHelpers.derive(price, ({ price }) => price * 1.1)}

      +

      Next: {__ctHelpers.derive({ count: count }, ({ count }) => count + 1)}

      +

      Double: {__ctHelpers.derive({ count: count }, ({ count }) => count * 2)}

      +

      Total: {__ctHelpers.derive({ price: price }, ({ price }) => price * 1.1)}

      ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx index 87c666ad2..76d3d4fa5 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-chain-predicate.expected.tsx @@ -5,7 +5,7 @@ export default recipe("Optional Chain Predicate", () => { return { [NAME]: "Optional chain predicate", [UI]: (
      - {__ctHelpers.derive(items, ({ items }) => !items?.length && No items)} + {__ctHelpers.derive({ items: items }, ({ items }) => !items?.length && No items)}
      ), }; }); diff --git a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx index 5af9047cf..a3bfc2a78 100644 --- a/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx +++ b/packages/ts-transformers/test/fixtures/jsx-expressions/optional-element-access.expected.tsx @@ -5,7 +5,7 @@ export default recipe("Optional Element Access", () => { return { [NAME]: "Optional element access", [UI]: (
      - {__ctHelpers.derive(list, ({ list }) => !list?.[0] && No first entry)} + {__ctHelpers.derive({ list: list }, ({ list }) => !list?.[0] && No first entry)}
      ), }; }); diff --git a/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts b/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts index 3fabe3d33..7dda4bc0e 100644 --- a/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts +++ b/packages/ts-transformers/test/opaque-ref/map-callbacks.test.ts @@ -61,7 +61,7 @@ describe("OpaqueRef map callbacks", () => { // Index parameter still gets derive wrapping for the arithmetic operation assertStringIncludes( output, - "__ctHelpers.derive(index, ({ index }) => index + 1)", + "__ctHelpers.derive({ index: index }, ({ index }) => index + 1)", ); // element[NAME] uses NAME from module scope (import), defaultName from params assertStringIncludes( diff --git a/packages/ts-transformers/test/utils/identifiers.test.ts b/packages/ts-transformers/test/utils/identifiers.test.ts index 94a57fb23..56c94f32b 100644 --- a/packages/ts-transformers/test/utils/identifiers.test.ts +++ b/packages/ts-transformers/test/utils/identifiers.test.ts @@ -1,8 +1,15 @@ import { assertEquals } from "@std/assert"; -import { sanitizeIdentifierCandidate } from "../../src/utils/identifiers.ts"; +import { + isSafeIdentifierText, + sanitizeIdentifierCandidate, +} from "../../src/utils/identifiers.ts"; Deno.test("sanitizeIdentifierCandidate normalises invalid fallback prefixes", () => { const result = sanitizeIdentifierCandidate("", { fallback: "-ref" }); assertEquals(result, "_ref"); }); + +Deno.test("isSafeIdentifierText recognises astral plane characters", () => { + assertEquals(isSafeIdentifierText("𠮷"), true); +});