Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions bestax-mcp/data/skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,12 @@
{
"id": "bloomer-prop-map",
"file": "references/bloomer/prop-map.md",
"bytes": 24625
"bytes": 26302
},
{
"id": "bloomer-unmappables",
"file": "references/bloomer/unmappables.md",
"bytes": 16955
"bytes": 17686
},
{
"id": "bulma-classes-component-map",
Expand Down Expand Up @@ -128,12 +128,12 @@
{
"id": "rbx-prop-map",
"file": "references/rbx/prop-map.md",
"bytes": 12390
"bytes": 13930
},
{
"id": "rbx-unmappables",
"file": "references/rbx/unmappables.md",
"bytes": 14336
"bytes": 15067
},
{
"id": "react-bulma-components-component-map",
Expand All @@ -143,12 +143,12 @@
{
"id": "react-bulma-components-prop-map",
"file": "references/react-bulma-components/prop-map.md",
"bytes": 7334
"bytes": 8850
},
{
"id": "react-bulma-components-unmappables",
"file": "references/react-bulma-components/unmappables.md",
"bytes": 12252
"bytes": 12997
}
],
"examples": []
Expand Down
6 changes: 6 additions & 0 deletions bestax-migrate/e2e/bloomer-kitchen-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,12 @@ describe('bloomer kitchen-sink e2e', () => {
present: true,
});
}
// A Subtitle size it cannot read, which bestax would turn into a level.
expect(
todos.some(
t => t.rule === 'prop:isSize' && t.message.includes('textSize')
)
).toBe(true);
const migrated = fs.readFileSync(
path.join(tmpDir, 'src', 'leftovers.tsx'),
'utf8'
Expand Down
4 changes: 4 additions & 0 deletions bestax-migrate/e2e/kitchen-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,10 @@ describe('kitchen-sink e2e', () => {
expect(rules).toContain('prop:remove');
expect(rules).toContain('prop:subtitle');
expect(rules).toContain('prop:heading');
// A Heading size it cannot read, which bestax would turn into a level.
expect(
todos.some(t => t.rule === 'prop:size' && t.message.includes('textSize'))
).toBe(true);
expect(rules).toContain('prop:as');
expect(rules).toContain('prop:href');
expect(todos.length).toBeGreaterThanOrEqual(10);
Expand Down
4 changes: 4 additions & 0 deletions bestax-migrate/e2e/rbx-kitchen-sink.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ describe('rbx kitchen-sink e2e', () => {
expect(rules).toContain('prop:target');
// A group size bestax does not take, or cannot be read, stays a TODO.
expect(rules).toContain('prop:size');
// So does a Title size it cannot read, which bestax would turn into a level.
expect(
todos.some(t => t.rule === 'prop:size' && t.message.includes('textSize'))
).toBe(true);
expect(todos.length).toBeGreaterThanOrEqual(10);
const migrated = fs.readFileSync(
path.join(tmpDir, 'src', 'leftovers.tsx'),
Expand Down
116 changes: 116 additions & 0 deletions bestax-migrate/e2e/title-levels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* A migrated title keeps its heading level (#1069).
*
* Every source library rendered a title at the element its tag prop named,
* or at its own default, whatever the size. bestax's `Title` and `SubTitle`
* render the heading their `size` names unless `as="p"`, so a plain rename
* of the size turned bloomer's `<Subtitle isSize={4}>` from an <h2> into an
* <h4>, which a fixture that compares text cannot see. This renders the
* migrated output against the built library and compares the opening tag
* with what the source library rendered, class list included. `is-size-N`
* (from `textSize`) reads as `is-N` there, because Bulma gives both the same
* font size.
*
* Only the OUTPUT runs. The inputs import libraries this repo never
* installs, so what each one rendered is written beside it, read off the
* library's own component at the SHA its corpus script pins.
*/

import { SOURCES } from '../src/sources/registry.js';
import { runTransform } from '../src/runner.js';
import type { TodoEntry } from '../src/types.js';
import {
loadModules,
normalizeHtml,
renderExports,
} from './support/render-module.js';

/** [source JSX, the opening tag the source library rendered for it] */
type Case = [string, string];

const LIBRARIES: Array<[source: string, imports: string, cases: Case[]]> = [
[
// Title defaulted to an <h1> and Subtitle to an <h2>.
'bloomer',
"import { Subtitle, Title } from 'bloomer';",
[
['<Title>x</Title>', '<h1 class="title">'],
['<Title isSize={1}>x</Title>', '<h1 class="title is-1">'],
['<Title isSize={4}>x</Title>', '<h1 class="title is-4">'],
['<Title isSize={4} tag="h2">x</Title>', '<h2 class="title is-4">'],
['<Title isSize={4} tag="h4">x</Title>', '<h4 class="title is-4">'],
['<Title isSize={6} tag="p">x</Title>', '<p class="title is-6">'],
['<Title isSize={7}>x</Title>', '<h1 class="title is-7">'],
['<Title isSize={7} tag="p">x</Title>', '<p class="title is-7">'],
['<Subtitle>x</Subtitle>', '<h2 class="subtitle">'],
['<Subtitle isSize={2}>x</Subtitle>', '<h2 class="subtitle is-2">'],
['<Subtitle isSize={4}>x</Subtitle>', '<h2 class="subtitle is-4">'],
[
'<Subtitle isSize="5" tag="h3">x</Subtitle>',
'<h3 class="subtitle is-5">',
],
],
],
[
// Title defaulted to an <h1>, subtitle or not.
'rbx',
"import { Title } from 'rbx';",
[
['<Title size={1}>x</Title>', '<h1 class="title is-1">'],
['<Title size={4}>x</Title>', '<h1 class="title is-4">'],
['<Title subtitle size={6}>x</Title>', '<h1 class="subtitle is-6">'],
['<Title as="h3" size={3}>x</Title>', '<h3 class="title is-3">'],
['<Title as="h2" size={5}>x</Title>', '<h2 class="title is-5">'],
[
'<Title as="p" subtitle size={5}>x</Title>',
'<p class="subtitle is-5">',
],
['<Title as="p" size={7}>x</Title>', '<p class="title is-7">'],
],
],
[
// Heading's `renderAs` defaulted to an <h1>, subtitle or not.
'react-bulma-components',
"import { Heading } from 'react-bulma-components';",
[
['<Heading size={1}>x</Heading>', '<h1 class="title is-1">'],
['<Heading size={4}>x</Heading>', '<h1 class="title is-4">'],
['<Heading subtitle size={6}>x</Heading>', '<h1 class="subtitle is-6">'],
[
'<Heading renderAs="h3" size={3}>x</Heading>',
'<h3 class="title is-3">',
],
[
'<Heading renderAs="h2" size="5">x</Heading>',
'<h2 class="title is-5">',
],
[
'<Heading renderAs="p" subtitle size={5}>x</Heading>',
'<p class="subtitle is-5">',
],
['<Heading size={7}>x</Heading>', '<h1 class="title is-7">'],
],
],
];

/** The rendered element's opening tag, with `is-size-N` read as `is-N`. */
function openingTag(html: string): string {
return normalizeHtml(
html.slice(0, html.indexOf('>') + 1).replace(/\bis-size-(\d)\b/g, 'is-$1')
);
}

describe.each(LIBRARIES)('a %s title', (source, imports, cases) => {
test.each(cases)('%s keeps its element', (input, rendered) => {
const todos: TodoEntry[] = [];
const { output } = runTransform(
SOURCES[source].transform,
'title.tsx',
`${imports}\nexport const A = () => ${input};\n`,
{ add: entry => todos.push(entry) }
);
expect(todos).toEqual([]);
const { A } = renderExports(loadModules({ title: output ?? '' }).title);
expect(openingTag(A)).toBe(normalizeHtml(rendered));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export const Leftovers = ({
Wide
</Title>

{/* bestax's Subtitle renders the heading its size names, and this one is unreadable */}
<Subtitle isSize={dim === "x" ? 4 : 5}>a dynamic size</Subtitle>

{/* the render prop, and Font Awesome 4 classes */}
<Button render={go} isLink isColor="info">
Rendered
Expand Down
2 changes: 2 additions & 0 deletions bestax-migrate/fixtures/kitchen-sink/src/leftovers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ export function Leftovers() {
/>
<Heading subtitle={choice === 'a'}>Dynamic subtitle</Heading>
<Heading heading={choice === 'a'}>Dynamic heading</Heading>
{/* bestax's Title renders the heading its size names, and this one is unreadable */}
<Heading size={choice === 'a' ? 3 : 4}>Dynamic size</Heading>
</div>
);
}
4 changes: 4 additions & 0 deletions bestax-migrate/fixtures/rbx-kitchen-sink/src/leftovers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Numeric,
Tag,
Tile,
Title,
} from "rbx";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHome } from "@fortawesome/free-solid-svg-icons";
Expand Down Expand Up @@ -100,6 +101,9 @@ export const Leftovers = ({ where }: { where: "left" | "right" }) => (
<Button>sized</Button>
</Button.Group>

{/* bestax's Title renders the heading its size names, and this one is unreadable */}
<Title size={where === "left" ? 3 : 4}>a dynamic size</Title>

{/* dynamic values are never best-guessed */}
<Generic as="div" textAlign={where === "left" ? "left" : "right"} />
</Generic>
Expand Down
104 changes: 104 additions & 0 deletions bestax-migrate/src/sources/_shared/specials-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,110 @@ export function restrictAsToTargets(
ctx.dirty = true;
}

/** The sizes bestax's Title and SubTitle turn into a heading level. */
const TITLE_SIZES = ['1', '2', '3', '4', '5', '6'];
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

/** Bulma's seven sizes, all of which `textSize` takes. */
const TEXT_SIZES = [...TITLE_SIZES, '7'];

/** What a source's title handler tells `keepTitleLevel` about it. */
export interface TitleSource {
/** The source component, for the TODO (`Heading`, `Subtitle`). */
where: string;
/** The bestax component it becomes: `Title` or `SubTitle`. */
target: string;
/** The source's size prop: `isSize` in bloomer, `size` elsewhere. */
size: string;
/** The source's element prop: `tag`, `renderAs` or `as`. */
tag: string;
/** The element the source rendered when no tag was given. */
level: string;
}

/**
* Carry a title's size across without changing its heading level.
*
* Every source library rendered a title at the element its tag prop named,
* or at its own default, whatever the size. bestax's `Title` and `SubTitle`
* render the heading their `size` names unless `as="p"`, so `<Title size={4}>`
* is an <h4>. A size therefore stays `size` only where that renders the
* element the source did: a <p>, or the <hN> the size names, at a size bestax
* takes. Anywhere else it moves to the `textSize` helper, which sets the same
* font size and leaves the level to `as`. Any other size gets a TODO.
*
* bestax defaults to an <h1>, so a source whose default is another level gets
* it written out as `as`, unless the size already renders it or a spread may
* name the element.
*
* Returns the props it consumed, for the handler's `handledProps`.
*/
export function keepTitleLevel(
ctx: TransformContext,
path: ASTPath<any>,
element: any,
source: TitleSource
): string[] {
const { j } = ctx;
const tagAttr = findAttr(element, source.tag) ?? findAttr(element, 'as');
const spread = (element.openingElement.attributes ?? []).some(
(a: any) => a.type === 'JSXSpreadAttribute'
);
// The element the source rendered, where the codemod can see it. A spread
// names the element only where the source spells its tag prop `as`, the
// name bestax reads: a `tag` or `renderAs` inside one never reaches `as`.
let rendered: string | undefined;
if (tagAttr) {
const literal = literalValueOf(tagAttr);
if (literal.kind === 'string') rendered = literal.value;
} else if (!spread || source.tag !== 'as') {
rendered = source.level;
}

const handled: string[] = [];
let sizeRendersLevel = false;
const sizeAttr = findAttr(element, source.size);
if (sizeAttr) {
const literal = literalValueOf(sizeAttr);
const size =
literal.kind === 'string' || literal.kind === 'number'
? String(literal.value)
: undefined;
const movable = size !== undefined && TEXT_SIZES.includes(size);
const textSize = findAttr(element, 'textSize');
// A size the codemod cannot read is still safe beside a <p>; a literal
// has to be one bestax's `size` takes, or its class goes.
const takes = size === undefined || TITLE_SIZES.includes(size);
if (takes && (rendered === 'p' || rendered === `h${size}`)) {
// `size` renders this element too, so the prop map carries it as is.
sizeRendersLevel = true;
} else if (movable && !textSize) {
sizeAttr.name = j.jsxIdentifier('textSize');
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
sizeAttr.value = j.stringLiteral(size);
handled.push(source.size);
ctx.dirty = true;
} else if (literal.kind !== 'boolean') {
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
const level = `bestax's \`${source.target}\` renders the heading its \`size\` names unless \`as="p"\``;
addTodo(
ctx,
path,
`prop:${source.size}`,
textSize
? `\`${source.where}\` sets a size and \`textSize\` both, and ${level}; to keep the level, drop \`size\` and let \`textSize\` set the font size`
: `\`${source.where}\` kept its element at every size, but ${level}; to keep the level, carry the size with \`textSize\`, which takes '1' to '7' as strings`
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
);
// Written as bestax's prop either way, so the prop map adds nothing.
sizeAttr.name = j.jsxIdentifier('size');
handled.push(source.size);
}
}

if (!tagAttr && rendered && source.level !== 'h1' && !sizeRendersLevel) {
addAttr(element, makeAttr(j, 'as', source.level));
ctx.dirty = true;
}
return handled;
}

/**
* Strip the link attributes a plain element cannot carry.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import "@allxsmith/bestax-bulma/bestax.css";

export const Basic = () => (
<Box>
<Title size={2} isSpaced>
<Title textSize="2" isSpaced>
Heading
</Title>
<SubTitle size={4} as="h3">
<SubTitle textSize="4" as="h3">
Sub
</SubTitle>
<Button color="primary" size="medium" isOutlined>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from "react";
import { Subtitle, Title } from "bloomer";

// bloomer rendered Title as an <h1> and Subtitle as an <h2> at every isSize,
// or the element `tag` named. bestax renders the heading `size` names, so
// the size moves to textSize wherever `size` would change the element.
export const Levels = () => (
<section>
<Title>h1</Title>
<Title isSize={1}>h1, which size 1 renders too</Title>
<Title isSize={4}>h1</Title>
<Title isSize={4} tag="h2">h2</Title>
<Title isSize={4} tag="h4">h4, which size 4 renders too</Title>
<Title isSize={6} tag="p">p, which size never changes</Title>
<Title isSize={7}>h1, at a size bestax's size does not take</Title>
<Title isSize={7} tag="p">p, where size 7 would lose its class</Title>
<Subtitle>h2</Subtitle>
<Subtitle isSize={2}>h2, which size 2 renders too</Subtitle>
<Subtitle isSize={4}>h2</Subtitle>
<Subtitle isSize="5" tag="h3">h3</Subtitle>
</section>
);
Loading
Loading