From 7948b23e0d772b05794acb0b87c4f2564daccc7b Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 5 Sep 2026 08:44:37 -0400 Subject: [PATCH 01/18] refactor(bestax-migrate): hoist the alias registry and literal helpers into _shared Both transforms carried the same owner-keyed alias resolver, and rbx's responsive pass carried literal/object helpers RBC re-implemented with a different signature. A third source would have copied both again. Behaviour is unchanged: the unit suites and fixture outputs are identical. --- bestax-migrate/src/sources/_shared/imports.ts | 98 +++++++++++++++++++ .../src/sources/_shared/jsx-utils.ts | 45 +++++++++ bestax-migrate/src/sources/rbx/responsive.ts | 34 +------ bestax-migrate/src/sources/rbx/transform.ts | 78 +-------------- .../react-bulma-components/responsive.ts | 12 +-- .../react-bulma-components/transform.ts | 78 +-------------- 6 files changed, 155 insertions(+), 190 deletions(-) diff --git a/bestax-migrate/src/sources/_shared/imports.ts b/bestax-migrate/src/sources/_shared/imports.ts index b5a65e4e1..11552a7fa 100644 --- a/bestax-migrate/src/sources/_shared/imports.ts +++ b/bestax-migrate/src/sources/_shared/imports.ts @@ -164,3 +164,101 @@ export function resolvesToBinding( // the shadowing case it guards against. return !declaring || declaring === expected; } + +/** + * Registry of `const { Item } = Card`-style aliases, keyed by the AST node + * that owns the declaring scope rather than by bare name. Two functions can + * each destructure `Header` from a different component; a name-keyed map let + * the second overwrite the first, and both declarations were then pruned + * while both `` references were left resolving to nothing. + * + * Resolution walks the reference's ANCESTORS rather than consulting + * `scope.lookup`, because by the time references are rewritten the alias + * pass has already pruned the declaration -- so the binding is gone from the + * scope table, and `path.scope` objects are not identity-stable across + * traversals either. + * + * Source-agnostic: every transform resolves aliases the same way, and #613 + * shipped this block duplicated in two of them. + */ +export interface AliasRegistry { + /** Record that `local` in the scope owned by `owner` names `target`. */ + register(owner: unknown, local: string, target: string[]): void; + /** + * The canonical path `name` aliases at `at` (a reference's path), or + * undefined when no alias applies to that reference. + */ + aliasAt(name: string, at?: unknown): string[] | undefined; + /** Whether ANY scope aliases `name` — regardless of the reference. */ + anyAlias(name: string): boolean; +} + +export function makeAliasRegistry(): AliasRegistry { + const aliasesByOwner = new Map>(); + const ownersFor = (name: string): unknown[] => { + const owners: unknown[] = []; + for (const [owner, perScope] of aliasesByOwner) { + if (perScope.has(name)) owners.push(owner); + } + return owners; + }; + const register = (owner: unknown, local: string, target: string[]) => { + let perScope = aliasesByOwner.get(owner); + if (!perScope) { + perScope = new Map(); + aliasesByOwner.set(owner, perScope); + } + perScope.set(local, target); + }; + const aliasAt = (name: string, at?: unknown): string[] | undefined => { + const owners = ownersFor(name); + if (owners.length === 0) return undefined; + // Without a reference location there is nothing to walk, so the only + // owner is the best answer available. WITH one, always walk: an unrelated + // `Header` parameter in another function must not resolve to the alias + // merely because the alias is the only one by that name. A single-owner + // shortcut here rewrote such a parameter to `{ Header: Card.Header }`, + // which is not even valid syntax. + if (at === undefined) { + return owners.length === 1 + ? aliasesByOwner.get(owners[0])!.get(name) + : undefined; + } + type Anc = { parent?: Anc; node?: unknown }; + let cursor = at as Anc | undefined; + while (cursor) { + if (cursor.node !== undefined) { + const hit = aliasesByOwner.get(cursor.node)?.get(name); + if (hit) { + // The walk finds the nearest OWNER, but a binding can intervene + // between the reference and that owner: `function F(Header)` under + // a module-level `const { Header } = Card` shadows the alias, and + // resolving through it rewrote the parameter itself to + // `F(Card.Header)`. If the scope table still binds the name + // somewhere other than the owner, that nearer binding wins. (After + // the alias declaration is pruned the lookup returns nothing for + // the alias itself, which is the case this must keep allowing.) + const declaring = ( + at as { + scope?: { + lookup(n: string): { path?: { node?: unknown } } | null; + }; + } + ).scope?.lookup(name)?.path?.node; + if ( + declaring !== undefined && + declaring !== null && + declaring !== cursor.node + ) { + return undefined; + } + return hit; + } + } + cursor = cursor.parent; + } + return undefined; + }; + const anyAlias = (name: string): boolean => ownersFor(name).length > 0; + return { register, aliasAt, anyAlias }; +} diff --git a/bestax-migrate/src/sources/_shared/jsx-utils.ts b/bestax-migrate/src/sources/_shared/jsx-utils.ts index 0af15fd7d..a9dde600d 100644 --- a/bestax-migrate/src/sources/_shared/jsx-utils.ts +++ b/bestax-migrate/src/sources/_shared/jsx-utils.ts @@ -123,6 +123,51 @@ export function literalValueOf(attr: any): LiteralValue { return { kind: 'expression' }; } +/** + * The literal value of an expression node (as opposed to `literalValueOf`, + * which reads a JSX attribute): a string, number or boolean literal's value, + * otherwise undefined. + */ +export function literalOf(node: any): string | number | boolean | undefined { + if (!node) return undefined; + if (node.type === 'StringLiteral') return node.value; + if (node.type === 'NumericLiteral') return node.value; + if (node.type === 'BooleanLiteral') return node.value; + return undefined; +} + +/** + * Reuse a value node as a JSX attribute value: a string literal can sit bare + * (`size="one-third"`), anything else needs an expression container. + */ +export function attrValue(j: JSCodeshift, node: any): any { + return node?.type === 'StringLiteral' + ? j.stringLiteral(node.value) + : j.jsxExpressionContainer(node); +} + +/** The static key of an object property (`{ tablet: … }`, `{ 'inline-block': … }`). */ +export function propKey(prop: any): string | undefined { + const key = prop.key?.name ?? prop.key?.value; + return typeof key === 'string' ? key : undefined; +} + +/** The ObjectExpression inside a JSX attribute value, or null. */ +export function objectExpressionOf(value: any): any | null { + return value?.type === 'JSXExpressionContainer' && + value.expression?.type === 'ObjectExpression' + ? value.expression + : null; +} + +/** The ArrayExpression inside a JSX attribute value, or null. */ +export function arrayExpressionOf(value: any): any | null { + return value?.type === 'JSXExpressionContainer' && + value.expression?.type === 'ArrayExpression' + ? value.expression + : null; +} + export type Booleanish = 'truthy' | 'falsy' | 'expression'; /** diff --git a/bestax-migrate/src/sources/rbx/responsive.ts b/bestax-migrate/src/sources/rbx/responsive.ts index 1a686f7dd..f0b651096 100644 --- a/bestax-migrate/src/sources/rbx/responsive.ts +++ b/bestax-migrate/src/sources/rbx/responsive.ts @@ -27,7 +27,11 @@ import { addAttr, addTodo, attributesOf, + attrValue, + literalOf, makeAttr, + objectExpressionOf, + propKey, removeAttr, type TransformContext, } from '../_shared/jsx-utils.js'; @@ -42,36 +46,6 @@ export const RESPONSIVE_KINDS: Record = { 'column-group': 'column-group', }; -function literalOf(node: any): string | number | boolean | undefined { - if (!node) return undefined; - if (node.type === 'StringLiteral') return node.value; - if (node.type === 'NumericLiteral') return node.value; - if (node.type === 'BooleanLiteral') return node.value; - return undefined; -} - -/** - * Reuse a value node as a JSX attribute value: a string literal can sit bare - * (`size="one-third"`), anything else needs an expression container. - */ -function attrValue(j: any, node: any): any { - return node?.type === 'StringLiteral' - ? j.stringLiteral(node.value) - : j.jsxExpressionContainer(node); -} - -function propKey(prop: any): string | undefined { - const key = prop.key?.name ?? prop.key?.value; - return typeof key === 'string' ? key : undefined; -} - -function objectExpressionOf(value: any): any | null { - return value?.type === 'JSXExpressionContainer' && - value.expression?.type === 'ObjectExpression' - ? value.expression - : null; -} - /** * One `{ value, only }` cell of the universal `responsive` prop. Returns the * inner `value` node, or undefined when the cell isn't the expected shape. diff --git a/bestax-migrate/src/sources/rbx/transform.ts b/bestax-migrate/src/sources/rbx/transform.ts index e7e817a2c..8186c94f7 100644 --- a/bestax-migrate/src/sources/rbx/transform.ts +++ b/bestax-migrate/src/sources/rbx/transform.ts @@ -42,6 +42,7 @@ import { import { applyPropAction, applyUniversalProps } from '../_shared/props.js'; import { collectBoundNames, + makeAliasRegistry, makeReserve, nameOf, prefersTabs, @@ -260,75 +261,7 @@ export default function transform( // The scope each binding was collected in, so a reference can be checked // against the binding it actually resolves to rather than matched by name. const programScope: unknown = root.find(j.Program).paths()[0]?.scope; - // Aliases are keyed by the AST node that owns the declaring scope, not by - // bare name. Two functions can each destructure `Header` from a different - // component; a name-keyed map let the second overwrite the first, and both - // declarations were then pruned while both `` references were left - // resolving to nothing. - // - // Resolution walks the reference's ANCESTORS rather than consulting - // `scope.lookup`, because by the time references are rewritten the alias - // pass has already pruned the declaration -- so the binding is gone from - // the scope table, and `path.scope` objects are not identity-stable across - // traversals either. - const aliasesByOwner = new Map>(); - const ownersFor = (name: string): unknown[] => { - const owners: unknown[] = []; - for (const [owner, perScope] of aliasesByOwner) { - if (perScope.has(name)) owners.push(owner); - } - return owners; - }; - const aliasAt = (name: string, at?: unknown): string[] | undefined => { - const owners = ownersFor(name); - if (owners.length === 0) return undefined; - // Without a reference location there is nothing to walk, so the only - // owner is the best answer available. WITH one, always walk: an unrelated - // `Header` parameter in another function must not resolve to the alias - // merely because the alias is the only one by that name. A single-owner - // shortcut here rewrote such a parameter to `{ Header: Card.Header }`, - // which is not even valid syntax. - if (at === undefined) { - return owners.length === 1 - ? aliasesByOwner.get(owners[0])!.get(name) - : undefined; - } - type Anc = { parent?: Anc; node?: unknown }; - let cursor = at as Anc | undefined; - while (cursor) { - if (cursor.node !== undefined) { - const hit = aliasesByOwner.get(cursor.node)?.get(name); - if (hit) { - // The walk finds the nearest OWNER, but a binding can intervene - // between the reference and that owner: `function F(Header)` under - // a module-level `const { Header } = Card` shadows the alias, and - // resolving through it rewrote the parameter itself to - // `F(Card.Header)`. If the scope table still binds the name - // somewhere other than the owner, that nearer binding wins. (After - // the alias declaration is pruned the lookup returns nothing for - // the alias itself, which is the case this must keep allowing.) - const declaring = ( - at as { - scope?: { - lookup(n: string): { path?: { node?: unknown } } | null; - }; - } - ).scope?.lookup(name)?.path?.node; - if ( - declaring !== undefined && - declaring !== null && - declaring !== cursor.node - ) { - return undefined; - } - return hit; - } - } - cursor = cursor.parent; - } - return undefined; - }; - const anyAlias = (name: string): boolean => ownersFor(name).length > 0; + const { register: registerAlias, aliasAt, anyAlias } = makeAliasRegistry(); // ---- 1b. Resolve `const { Item } = Card` destructuring ----------------- root.find(j.VariableDeclarator).forEach(path => { @@ -353,13 +286,8 @@ export default function transform( ) { const owner = (path.scope as unknown as { path?: { node?: unknown } }) ?.path?.node; - let perScope = aliasesByOwner.get(owner); - if (!perScope) { - perScope = new Map(); - aliasesByOwner.set(owner, perScope); - } const target = [...basePath, prop.key.name]; - perScope.set(prop.value.name, target); + registerAlias(owner, prop.value.name, target); aliases.set(prop.value.name, target); // Pruning the declaration is only safe if every use of the alias will // actually be rewritten. For a name the table cannot map, the JSX is diff --git a/bestax-migrate/src/sources/react-bulma-components/responsive.ts b/bestax-migrate/src/sources/react-bulma-components/responsive.ts index 1d8388997..f120a4c92 100644 --- a/bestax-migrate/src/sources/react-bulma-components/responsive.ts +++ b/bestax-migrate/src/sources/react-bulma-components/responsive.ts @@ -17,6 +17,7 @@ import { addAttr, addTodo, attributesOf, + literalOf, makeAttr, removeAttr, type TransformContext, @@ -33,15 +34,6 @@ const TEXT_ALIGN_MAP: Record = { right: 'right', }; -function literalOf(prop: any): string | number | boolean | undefined { - const v = prop.value; - if (!v) return undefined; - if (v.type === 'StringLiteral') return v.value; - if (v.type === 'NumericLiteral') return v.value; - if (v.type === 'BooleanLiteral') return v.value; - return undefined; -} - export function flattenResponsiveProps( ctx: TransformContext, path: ASTPath, @@ -87,7 +79,7 @@ export function flattenResponsiveProps( continue; } const key = prop.key?.name ?? prop.key?.value; - const literal = literalOf(prop); + const literal = literalOf(prop.value); if (key === 'display' && typeof literal === 'string') { if (literal === 'hidden') { diff --git a/bestax-migrate/src/sources/react-bulma-components/transform.ts b/bestax-migrate/src/sources/react-bulma-components/transform.ts index e7f18079a..4d2cfc0a6 100644 --- a/bestax-migrate/src/sources/react-bulma-components/transform.ts +++ b/bestax-migrate/src/sources/react-bulma-components/transform.ts @@ -27,6 +27,7 @@ import { import { applyPropAction, applyUniversalProps } from '../_shared/props.js'; import { collectBoundNames, + makeAliasRegistry, makeReserve, nameOf, prefersTabs, @@ -189,75 +190,7 @@ export default function transform( // The scope each binding was collected in, so a reference can be checked // against the binding it actually resolves to rather than matched by name. const programScope: unknown = root.find(j.Program).paths()[0]?.scope; - // Aliases are keyed by the AST node that owns the declaring scope, not by - // bare name. Two functions can each destructure `Header` from a different - // component; a name-keyed map let the second overwrite the first, and both - // declarations were then pruned while both `` references were left - // resolving to nothing. - // - // Resolution walks the reference's ANCESTORS rather than consulting - // `scope.lookup`, because by the time references are rewritten the alias - // pass has already pruned the declaration -- so the binding is gone from - // the scope table, and `path.scope` objects are not identity-stable across - // traversals either. - const aliasesByOwner = new Map>(); - const ownersFor = (name: string): unknown[] => { - const owners: unknown[] = []; - for (const [owner, perScope] of aliasesByOwner) { - if (perScope.has(name)) owners.push(owner); - } - return owners; - }; - const aliasAt = (name: string, at?: unknown): string[] | undefined => { - const owners = ownersFor(name); - if (owners.length === 0) return undefined; - // Without a reference location there is nothing to walk, so the only - // owner is the best answer available. WITH one, always walk: an unrelated - // `Header` parameter in another function must not resolve to the alias - // merely because the alias is the only one by that name. A single-owner - // shortcut here rewrote such a parameter to `{ Header: Card.Header }`, - // which is not even valid syntax. - if (at === undefined) { - return owners.length === 1 - ? aliasesByOwner.get(owners[0])!.get(name) - : undefined; - } - type Anc = { parent?: Anc; node?: unknown }; - let cursor = at as Anc | undefined; - while (cursor) { - if (cursor.node !== undefined) { - const hit = aliasesByOwner.get(cursor.node)?.get(name); - if (hit) { - // The walk finds the nearest OWNER, but a binding can intervene - // between the reference and that owner: `function F(Header)` under - // a module-level `const { Header } = Card` shadows the alias, and - // resolving through it rewrote the parameter itself to - // `F(Card.Header)`. If the scope table still binds the name - // somewhere other than the owner, that nearer binding wins. (After - // the alias declaration is pruned the lookup returns nothing for - // the alias itself, which is the case this must keep allowing.) - const declaring = ( - at as { - scope?: { - lookup(n: string): { path?: { node?: unknown } } | null; - }; - } - ).scope?.lookup(name)?.path?.node; - if ( - declaring !== undefined && - declaring !== null && - declaring !== cursor.node - ) { - return undefined; - } - return hit; - } - } - cursor = cursor.parent; - } - return undefined; - }; - const anyAlias = (name: string): boolean => ownersFor(name).length > 0; + const { register: registerAlias, aliasAt, anyAlias } = makeAliasRegistry(); // ---- 1b. Resolve `const { Input, Field: F } = Form` destructuring ----- root.find(j.VariableDeclarator).forEach(path => { @@ -282,13 +215,8 @@ export default function transform( ) { const owner = (path.scope as unknown as { path?: { node?: unknown } }) ?.path?.node; - let perScope = aliasesByOwner.get(owner); - if (!perScope) { - perScope = new Map(); - aliasesByOwner.set(owner, perScope); - } const target = [...basePath, prop.key.name]; - perScope.set(prop.value.name, target); + registerAlias(owner, prop.value.name, target); aliases.set(prop.value.name, target); // Pruning the declaration is only safe if every use of the alias will // actually be rewritten. For a name the table cannot map, the JSX is From 9e7c84b65a88082953ec06f20e94613a19226d90 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Sat, 5 Sep 2026 09:06:04 -0400 Subject: [PATCH 02/18] feat(bestax-migrate): add bloomer as a migration source bloomer's exports are flat while bestax's families are dotted, so most of the table renames a flat name onto a compound target; every component carries bloomer's helper props through its HOC, so isDisplay/isHidden get their own array/object flattening; tag, render, isSize and isAlign are resolved per component because bestax declares their counterparts on a subset, with differing types. Icons, pagination, tabs, breadcrumb items and the Bulma 0.4 nav family are handled structurally. Registers the source with the telemetry worker's allowlist in the same commit, since the worker drops events for unknown sources at ingest. --- bestax-migrate/src/sources/_shared/props.ts | 28 +- .../src/sources/_shared/specials-utils.ts | 147 ++++ .../src/sources/_shared/viewports.ts | 34 + .../__testfixtures__/compounds.input.tsx | 140 ++++ .../__testfixtures__/compounds.output.tsx | 93 +++ .../bloomer/__testfixtures__/form.input.tsx | 53 ++ .../bloomer/__testfixtures__/form.output.tsx | 43 + .../helpers-responsive.input.tsx | 37 + .../helpers-responsive.output.tsx | 56 ++ .../__testfixtures__/icons-images.input.tsx | 27 + .../__testfixtures__/icons-images.output.tsx | 31 + .../__testfixtures__/imports-basic.input.tsx | 28 + .../__testfixtures__/imports-basic.output.tsx | 28 + .../tabs-pagination-panel.input.tsx | 92 +++ .../tabs-pagination-panel.output.tsx | 62 ++ .../todos-unmappable.input.tsx | 47 ++ .../todos-unmappable.output.tsx | 49 ++ .../sources/bloomer/__tests__/deps.test.ts | 127 +++ .../__tests__/mapping-coverage.test.ts | 159 ++++ .../bloomer/__tests__/responsive.test.ts | 159 ++++ .../bloomer/__tests__/specials.test.ts | 287 +++++++ .../sources/bloomer/__tests__/styles.test.ts | 53 ++ .../bloomer/__tests__/transform.test.ts | 284 +++++++ .../bloomer/__tests__/value-unions.test.ts | 122 +++ bestax-migrate/src/sources/bloomer/deps.ts | 144 ++++ bestax-migrate/src/sources/bloomer/index.ts | 12 + bestax-migrate/src/sources/bloomer/mapping.ts | 757 ++++++++++++++++++ .../src/sources/bloomer/responsive.ts | 359 +++++++++ .../src/sources/bloomer/specials.ts | 736 +++++++++++++++++ bestax-migrate/src/sources/bloomer/styles.ts | 19 + .../src/sources/bloomer/transform.ts | 717 +++++++++++++++++ bestax-migrate/src/sources/rbx/specials.ts | 140 +--- bestax-migrate/src/sources/registry.ts | 2 + telemetry-worker/src/schema.ts | 6 +- 34 files changed, 4949 insertions(+), 129 deletions(-) create mode 100644 bestax-migrate/src/sources/_shared/viewports.ts create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/compounds.input.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/compounds.output.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/form.input.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/form.output.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/helpers-responsive.input.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/helpers-responsive.output.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/icons-images.input.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/icons-images.output.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/imports-basic.input.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/imports-basic.output.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/tabs-pagination-panel.input.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/tabs-pagination-panel.output.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/todos-unmappable.input.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__testfixtures__/todos-unmappable.output.tsx create mode 100644 bestax-migrate/src/sources/bloomer/__tests__/deps.test.ts create mode 100644 bestax-migrate/src/sources/bloomer/__tests__/mapping-coverage.test.ts create mode 100644 bestax-migrate/src/sources/bloomer/__tests__/responsive.test.ts create mode 100644 bestax-migrate/src/sources/bloomer/__tests__/specials.test.ts create mode 100644 bestax-migrate/src/sources/bloomer/__tests__/styles.test.ts create mode 100644 bestax-migrate/src/sources/bloomer/__tests__/transform.test.ts create mode 100644 bestax-migrate/src/sources/bloomer/__tests__/value-unions.test.ts create mode 100644 bestax-migrate/src/sources/bloomer/deps.ts create mode 100644 bestax-migrate/src/sources/bloomer/index.ts create mode 100644 bestax-migrate/src/sources/bloomer/mapping.ts create mode 100644 bestax-migrate/src/sources/bloomer/responsive.ts create mode 100644 bestax-migrate/src/sources/bloomer/specials.ts create mode 100644 bestax-migrate/src/sources/bloomer/styles.ts create mode 100644 bestax-migrate/src/sources/bloomer/transform.ts diff --git a/bestax-migrate/src/sources/_shared/props.ts b/bestax-migrate/src/sources/_shared/props.ts index d35ec8c42..f436342de 100644 --- a/bestax-migrate/src/sources/_shared/props.ts +++ b/bestax-migrate/src/sources/_shared/props.ts @@ -28,7 +28,7 @@ import { * source props can map onto one bestax prop (e.g. RBC's `textTransform` + * `italic`), and a silent duplicate would be invalid JSX. */ -function addConverted( +export function addConverted( ctx: TransformContext, path: ASTPath, element: any, @@ -36,16 +36,38 @@ function addConverted( name: string, value: string | undefined ): void { + addAttrOnce(ctx, path, element, originalName, makeAttr(ctx.j, name, value)); +} + +/** + * The same refusal for an attribute the caller has already built — a numeric + * `size={6}` or a reused expression node, which `makeAttr` cannot express. + */ +export function addAttrOnce( + ctx: TransformContext, + path: ASTPath, + element: any, + originalName: string, + attr: any +): void { + const name: string = attr.name.name; if (findAttr(element, name)) { + const value = attr.value; + const rendered = + value == null + ? '' + : value.type === 'StringLiteral' + ? `="${value.value}"` + : '={…}'; addTodo( ctx, path, `prop:${originalName}`, - `\`${originalName}\` maps to \`${name}${value === undefined ? '' : `="${value}"`}\`, but \`${name}\` is already set on this element; reconcile by hand` + `\`${originalName}\` maps to \`${name}${rendered}\`, but \`${name}\` is already set on this element; reconcile by hand` ); return; } - addAttr(element, makeAttr(ctx.j, name, value)); + addAttr(element, attr); } /** Apply one PropAction to `attr` on `element`. */ diff --git a/bestax-migrate/src/sources/_shared/specials-utils.ts b/bestax-migrate/src/sources/_shared/specials-utils.ts index 29a6a37ce..0f8a37c9f 100644 --- a/bestax-migrate/src/sources/_shared/specials-utils.ts +++ b/bestax-migrate/src/sources/_shared/specials-utils.ts @@ -11,9 +11,12 @@ import type { ASTPath } from 'jscodeshift'; import type { PropAction } from '../../types.js'; import { + addAttr, addTodo, + attributesOf, findAttr, literalValueOf, + plainElement, removeAttr, type TransformContext, } from './jsx-utils.js'; @@ -187,3 +190,147 @@ export function parseIconClasses( } return null; } + +/** + * Turn a literal modifier prop into an `is-*` class fragment for an element + * on its way to becoming plain HTML. Returns the class (or undefined) and + * always removes the attribute. + */ +export function modifierClass( + ctx: TransformContext, + path: ASTPath, + element: any, + prop: string, + base: string, + where: string +): string { + const attr = findAttr(element, prop); + if (!attr) return base; + const literal = literalValueOf(attr); + removeAttr(element, attr); + if (literal.kind === 'string' || literal.kind === 'number') { + return `${base} is-${literal.value}`; + } + addTodo( + ctx, + path, + `prop:${prop}`, + `dynamic ${where} \`${prop}\`; add the matching is-* class by hand` + ); + return base; +} + +/** + * A source that puts `as` (or its own name for it) on every component can + * land, through a handler that picks its target from a prop value, on a + * bestax component that has no `as` — Media.Item resolves to + * Left/Content/Right where only Left declares one. Opting into `as` in the + * mapping table cannot express that, so the target has to be checked after + * it is picked. + */ +export function restrictAsToTargets( + ctx: TransformContext, + path: ASTPath, + element: any, + target: string | undefined, + allowed: string[], + prop = 'as' +): void { + const asAttr = findAttr(element, prop); + if (!asAttr || (target && allowed.includes(target))) return; + removeAttr(element, asAttr); + addTodo( + ctx, + path, + `prop:${prop}`, + `bestax's \`${target}\` has no \`as\` prop (of the targets this can resolve to, only ${allowed + .map(a => `\`${a}\``) + .join(' / ')} does); render the tag directly or restructure` + ); + ctx.dirty = true; +} + +/** Attribute filter a source applies before an element becomes plain HTML. */ +export type AttrStrip = ( + ctx: TransformContext, + path: ASTPath, + attrs: any[], + where: string +) => any[]; + +/** + * The two structural rewrites every source needs, bound to that source's own + * attribute strip (its modifier vocabulary is the one thing that differs): + * replacing an element with plain HTML, and folding a wrapper onto the single + * child it exists to wrap. + */ +export function makeStructuralHelpers(strip: AttrStrip) { + /** Replace the element with a plain HTML tag carrying `className`. */ + function replaceWithPlain( + ctx: TransformContext, + path: ASTPath, + element: any, + tag: string, + className: string | undefined, + where: string + ): SpecialResult { + const merged = mergeClassName(ctx, path, element, className, where); + const rest = strip(ctx, path, attributesOf(element), where); + path.replace( + plainElement(ctx.j, tag, merged, rest, element.children ?? []) + ); + ctx.dirty = true; + return { replaced: true }; + } + + /** + * A wrapper that owns the modifiers, where bestax's component renders that + * wrapper itself: move the wrapper's (already-converted) attributes onto + * its single child and replace the wrapper with it — otherwise both would + * rename to the same bestax component and nest, which is invalid. + * + * Returns null when the element isn't the single-JSX-child shape, or the + * child is not one of the components this wrapper exists to wrap (a + * ratio-box `