From 724dff56dc2416d7305590cb579323b3cc31c532 Mon Sep 17 00:00:00 2001 From: Alex Smith Date: Tue, 29 Sep 2026 07:50:15 -0400 Subject: [PATCH 1/4] feat(bestax-migrate): convert .file inside a Field, building its tree from props File renders the whole .file tree from props, and a .field of its own around it outside a Field, so the family stayed markup. It now converts when its tree is exactly the one File renders and it sits inside a Field: a bestax one already in the file, or a .field converting in the same run, which a pass after planning checks. The 's attributes become File's own, since File puts the ones it doesn't read there, and its other classes become inputClassName. The button text becomes buttonLabel (left out when it's File's own default), each .file-icon's content iconLeft or iconRight, and the .file-name's text fileName beside hasName. The .file itself carries no attribute but a key, since that would move to the too. A color stays a class, since color renders has-text- as well, and so does is-centered beside isRight. A has-name under a condition stays in the call: File renders {hasName && ...}, and the truth test caught a 0 condition rendering as text. The render truth test wraps the markup in a .field and the component in a Field for each tree shape; the props test typechecks the attributes File takes. Closes #809 --- bestax-migrate/CLAUDE.md | 3 +- .../e2e/bulma-classes-props.test.ts | 79 +++++++ .../e2e/bulma-classes-truth.test.ts | 148 +++++++++--- bestax-migrate/e2e/support/file-tree.ts | 129 ++++++++++ .../src/elements.tsx | 14 ++ .../__testfixtures__/file.input.tsx | 73 ++++++ .../__testfixtures__/file.output.tsx | 60 +++++ .../bulma-classes/__tests__/plan.test.ts | 222 ++++++++++++++++++ .../bulma-classes/__tests__/transform.test.ts | 81 +++++++ .../src/sources/bulma-classes/class-map.ts | 67 +++++- .../src/sources/bulma-classes/plan.ts | 160 +++++++++++++ .../src/sources/bulma-classes/transform.ts | 176 +++++++++++++- .../migration/bulma-classes.md | 6 +- .../references/bulma-classes/component-map.md | 39 ++- .../references/bulma-classes/prop-map.md | 50 ++-- .../references/bulma-classes/unmappables.md | 16 +- 16 files changed, 1243 insertions(+), 80 deletions(-) create mode 100644 bestax-migrate/e2e/support/file-tree.ts create mode 100644 bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.input.tsx create mode 100644 bestax-migrate/src/sources/bulma-classes/__testfixtures__/file.output.tsx diff --git a/bestax-migrate/CLAUDE.md b/bestax-migrate/CLAUDE.md index a1435d1c9..bc2933427 100644 --- a/bestax-migrate/CLAUDE.md +++ b/bestax-migrate/CLAUDE.md @@ -100,7 +100,8 @@ is the one with no library behind it, converting raw Bulma classes on plain JSX. `.table-container`) or a TODO out; an entry that `absorbs` its only child (`.select`'s `'s + // attributes and the parts, as the transform writes them. + const file = conversion.file; + if (file) { + const [label] = children ?? fileChildren(); + const [input, cta] = label.children!; + const parts = cta.children!; + const jsx = (child: ChildFacts): string => + `<${child.tag}${child.tokens ? ` className="${child.tokens.join(' ')}"` : ''} />`; + const content = (part: ChildFacts) => + part.children ? `{${jsx(part.children[0])}}` : JSON.stringify(part.text); + for (const [attribute, value] of input.attributes) { + if (attribute === 'type') continue; + attrs.push( + conversion.numbers.includes(attribute) + ? `${attribute}={${Number(value)}}` + : jsxAttr(attribute, value as string | true) + ); + } + if (file.inputClassName !== null) { + attrs.push(jsxAttr('inputClassName', file.inputClassName)); + } + for (const key of ['buttonLabel', 'iconLeft', 'iconRight'] as const) { + const index = file[key]; + if (index !== undefined) attrs.push(`${key}=${content(parts[index])}`); + } + if (file.fileName !== undefined) { + attrs.push(jsxAttr('fileName', file.fileName)); + } + return `<${name} ${attrs.join(' ')} />`; + } // A target that renders the children itself closes itself, as the // transform writes it, and so does one in place of a void child. return VOID.has(tag) || @@ -363,6 +401,47 @@ describe('every bulma-classes conversion typechecks', () => { ]) ); } + // The tree it renders from props: the 's attributes it takes as + // its own, its extra classes, the icons, the default text and a name. + if (entry.buildsFile) { + const trees: FileTree[] = [ + { label: 'Choose a file\u2026' }, + { iconLeft: ['fas', 'fa-upload'], iconRight: ['fas', 'fa-check'] }, + { inputTokens: ['my-input'] }, + ...[ + ...COMMON, + ['name', 'resume'], + ['accept', 'image/*'], + ['multiple', true], + ['required', true], + ['disabled', true], + ].map(([attribute, value]): FileTree => ({ + input: { [attribute as string]: value as string | true }, + })), + ]; + for (const tree of trees) { + add( + converted( + entry.tag!, + [root], + [], + undefined, + undefined, + fileChildren(tree) + ) + ); + } + add( + converted( + entry.tag!, + [root, 'has-name'], + [], + undefined, + undefined, + fileChildren({ name: 'cv.pdf' }) + ) + ); + } expect({ root, conversions: lines.length > 0 }).toEqual({ root, conversions: true, diff --git a/bestax-migrate/e2e/bulma-classes-truth.test.ts b/bestax-migrate/e2e/bulma-classes-truth.test.ts index 49762d474..64dc61140 100644 --- a/bestax-migrate/e2e/bulma-classes-truth.test.ts +++ b/bestax-migrate/e2e/bulma-classes-truth.test.ts @@ -35,6 +35,12 @@ import { type ChildFacts, type ElementFacts, } from '../src/sources/bulma-classes/plan.js'; +import { + fileChildren, + fileProps, + rawFrom, + type FileTree, +} from './support/file-tree.js'; import { GLYPHS, iconChild, @@ -199,9 +205,14 @@ function renderBoth( // A target that builds its icons from props: the children as written, and // the component given them as props, with one icon's text as its own. const icons = result.conversion.icons; + // A target that renders the whole tree from props: the tree as written, + // inside a `.field`, against the component inside a `Field`. + const file = result.conversion.file; const rawChildren = icons ? facts.childElements!.map(rawChild) - : (counted ?? soleElement ?? (child ? child.raw : children)); + : file + ? facts.childElements!.map(rawFrom) + : (counted ?? soleElement ?? (child ? child.raw : children)); // A target that renders the element around this one too is compared with // the raw element inside that one. const raw = result.conversion.replacesParent @@ -210,7 +221,13 @@ function renderBoth( null, createElement(facts.tag, rawProps, rawChildren) ) - : renderElement(facts.tag, rawProps, rawChildren); + : file + ? renderElement( + 'div', + { className: 'field' }, + createElement(facts.tag, rawProps, rawChildren) + ) + : renderElement(facts.tag, rawProps, rawChildren); const { target, props, className, drop, numbers, absorbs } = result.conversion; const given = { ...extra }; @@ -227,37 +244,42 @@ function renderBoth( numbers.includes(name) ? Number(value) : value, ]) ); - const converted = renderElement( - target, - { - ...kept, - ...Object.fromEntries( - [...props, ...(absorbs?.props ?? [])].map(([name, value]) => [ - name, - numbers.includes(name) ? Number(value) : value, - ]) - ), - ...Object.fromEntries(flags.map(([prop, token]) => [prop, when[token]])), - ...(className || stays.length > 0 - ? { className: [className ?? '', ...stays.flat()].join(' ').trim() } - : {}), - ...(icons && builtProps(icons, facts.childElements!)), - }, - // A target that couldn't absorb the child renders it as given. - icons - ? icons.length === 1 - ? icons[0].text - : undefined - : counted - ? undefined - : sole - ? absorbs - ? soleChildren - : soleElement - : child - ? child.converted - : children - ); + const convertedProps = { + ...kept, + ...Object.fromEntries( + [...props, ...(absorbs?.props ?? [])].map(([name, value]) => [ + name, + numbers.includes(name) ? Number(value) : value, + ]) + ), + ...Object.fromEntries(flags.map(([prop, token]) => [prop, when[token]])), + ...(className || stays.length > 0 + ? { className: [className ?? '', ...stays.flat()].join(' ').trim() } + : {}), + ...(icons && builtProps(icons, facts.childElements!)), + ...(file && fileProps(file, facts.childElements!, numbers)), + }; + // A target that couldn't absorb the child renders it as given. + const convertedChildren = icons + ? icons.length === 1 + ? icons[0].text + : undefined + : counted || file + ? undefined + : sole + ? absorbs + ? soleChildren + : soleElement + : child + ? child.converted + : children; + const converted = file + ? renderElement( + 'Field', + {}, + createElement(target, convertedProps, convertedChildren) + ) + : renderElement(target, convertedProps, convertedChildren); return { raw: normalizeHtml(raw), converted: normalizeHtml(converted) }; } @@ -323,6 +345,12 @@ function factsFor( ...(entries.some(entry => entry?.buildsIcons) && { childElements: [iconChild(['fas', 'fa-home']), textChild()], }), + // One that renders the whole `.file` tree holds that tree, inside a + // `.field`. + ...(entries.some(entry => entry?.buildsFile) && { + childElements: fileChildren({ iconLeft: ['fas', 'fa-upload'] }), + classesAround: ['field'], + }), // One that needs element children holds one (`Icon` around an ). ...(!absorbs && entries.some(entry => entry?.needsElementChildren) && { @@ -1077,6 +1105,60 @@ describe.each(mapped.filter(([, entry]) => entry.buildsIcons))( } ); +/** + * An element whose target renders its whole tree from props, around each + * shape of tree it reads, inside a `.field`: the markup and the component + * given what it builds, inside a `Field`, render the same. + */ +describe.each(mapped.filter(([, entry]) => entry.buildsFile))( + 'the tree `.%s` renders', + (root, entry) => { + const same = (label: string, tree: FileTree, tokens: string[] = []) => { + const facts: ElementFacts = { + ...factsFor(entry.tag!, [root, ...tokens]), + childElements: fileChildren(tree), + classesAround: ['field'], + }; + const found = renderBoth(facts); + expect({ label, converts: found !== null }).toEqual({ + label, + converts: true, + }); + expect({ label, html: found!.converted }).toEqual({ + label, + html: found!.raw, + }); + }; + + it('renders each shape of tree the same', () => { + same('bare', {}); + same('with the text it renders by default', { + label: 'Choose a file\u2026', + }); + same('with an icon on each side', { + iconLeft: ['fas', 'fa-upload'], + iconRight: ['fas', 'fa-check'], + }); + same('with the input its attributes and classes', { + input: { name: 'cv', accept: 'image/*', multiple: true, id: 'x' }, + inputTokens: ['my-input'], + }); + same('with a number attribute on the input', { + input: { tabIndex: '0' }, + }); + same('with a name', { name: 'cv.pdf' }, ['has-name']); + same('with a name slot and no name', {}, ['has-name']); + same('with its modifiers together', { iconLeft: ['fas', 'fa-upload'] }, [ + 'is-boxed', + 'is-fullwidth', + 'is-right', + 'is-small', + 'mt-2', + ]); + }); + } +); + describe('wrappers', () => { it.each(Object.entries(WRAPPERS))( '<%s> with helper classes renders the same as bestax `%s`', diff --git a/bestax-migrate/e2e/support/file-tree.ts b/bestax-migrate/e2e/support/file-tree.ts new file mode 100644 index 000000000..c61c543ed --- /dev/null +++ b/bestax-migrate/e2e/support/file-tree.ts @@ -0,0 +1,129 @@ +/** + * The `.file` tree a target that `buildsFile` renders from props, as the + * planner reads it, and the props the transform gives that target from it, + * for the tests that render or typecheck what it builds. + */ + +import type { + BuiltFile, + ChildFacts, +} from '../../src/sources/bulma-classes/plan.js'; +import { createElement } from './render-module.js'; + +export interface FileTree { + /** The 's attributes beside `type="file"`. */ + input?: Record; + /** Its classes beside `file-input`. */ + inputTokens?: string[]; + /** The button text. */ + label?: string; + /** The classes of the in each icon, before and after the text. */ + iconLeft?: string[]; + iconRight?: string[]; + /** The `.file-name`'s text. */ + name?: string; +} + +const facts = ( + tag: string, + tokens: string[], + extra: Partial = {} +): ChildFacts => ({ + tag, + tokens, + attributes: new Map(), + hasSpread: false, + isEmpty: false, + ...extra, +}); + +/** The `.file`'s children, its one `.file-label`