Skip to content

Commit 923f6c9

Browse files
committed
[WB-2247-bodytext-lint] Consolidate utilities
1 parent 9cee9f8 commit 923f6c9

8 files changed

Lines changed: 278 additions & 327 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@
108108
"@types/react-window": "^1.8.8",
109109
"@typescript-eslint/eslint-plugin": "8.17.0",
110110
"@typescript-eslint/parser": "8.17.0",
111-
"@typescript-eslint/rule-tester": "^8.46.3",
111+
"@typescript-eslint/rule-tester": "^8.58.0",
112112
"@vitejs/plugin-react-swc": "^3.9.0",
113113
"@vitest/browser": "^4.0.15",
114114
"@vitest/browser-playwright": "^4.0.15",

packages/eslint-plugin-wonder-blocks/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,14 @@
2222
"prepublishOnly": "../../utils/publish/package-pre-publish-check.sh"
2323
},
2424
"dependencies": {
25-
"@typescript-eslint/utils": "^8.46.4"
25+
"@typescript-eslint/utils": "^8.58.0"
2626
},
2727
"peerDependencies": {
2828
"eslint": "^8.57.0"
2929
},
3030
"devDependencies": {
3131
"@khanacademy/wb-dev-build-settings": "workspace:*",
32-
"@typescript-eslint/rule-tester": "^8.46.3",
32+
"@typescript-eslint/typescript-estree": "^8.58.2",
3333
"eslint": "^8.57.0"
3434
}
3535
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import {parse} from "@typescript-eslint/typescript-estree";
2+
import {TSESTree} from "@typescript-eslint/utils";
3+
4+
import {getAttributeStringValue} from "../jsx-utils";
5+
6+
/**
7+
* Parses a self-closing JSX element and returns its opening element node.
8+
*/
9+
function parseOpeningElement(code: string): TSESTree.JSXOpeningElement {
10+
const ast = parse(code, {jsx: true});
11+
const stmt = ast.body[0] as TSESTree.ExpressionStatement;
12+
const element = stmt.expression as TSESTree.JSXElement;
13+
return element.openingElement;
14+
}
15+
16+
describe("getAttributeStringValue", () => {
17+
it("returns the string value of a string-literal attribute", () => {
18+
// Arrange
19+
const node = parseOpeningElement(`<C tag="span" />`);
20+
21+
// Act
22+
const result = getAttributeStringValue(node, "tag");
23+
24+
// Assert
25+
expect(result).toBe("span");
26+
});
27+
28+
it("returns the string value of an expression-container string literal", () => {
29+
// Arrange
30+
const node = parseOpeningElement(`<C tag={"div"} />`);
31+
32+
// Act
33+
const result = getAttributeStringValue(node, "tag");
34+
35+
// Assert
36+
expect(result).toBe("div");
37+
});
38+
39+
it("returns null when the attribute has a dynamic expression", () => {
40+
// Arrange
41+
const node = parseOpeningElement(`<C tag={isLabel ? "span" : "p"} />`);
42+
43+
// Act
44+
const result = getAttributeStringValue(node, "tag");
45+
46+
// Assert
47+
expect(result).toBeNull();
48+
});
49+
50+
it("returns null when the attribute is absent", () => {
51+
// Arrange
52+
const node = parseOpeningElement(`<C />`);
53+
54+
// Act
55+
const result = getAttributeStringValue(node, "tag");
56+
57+
// Assert
58+
expect(result).toBeNull();
59+
});
60+
61+
it("returns null for a boolean (value-less) attribute", () => {
62+
// Arrange
63+
const node = parseOpeningElement(`<C tag />`);
64+
65+
// Act
66+
const result = getAttributeStringValue(node, "tag");
67+
68+
// Assert
69+
expect(result).toBeNull();
70+
});
71+
72+
it("returns null when a different attribute name is queried", () => {
73+
// Arrange
74+
const node = parseOpeningElement(`<C size="small" />`);
75+
76+
// Act
77+
const result = getAttributeStringValue(node, "tag");
78+
79+
// Assert
80+
expect(result).toBeNull();
81+
});
82+
});

packages/eslint-plugin-wonder-blocks/src/rules/__tests__/no-invalid-bodytext-parent.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* and that the auto-fix correctly adds tag="span".
77
*/
88
import {RuleTester} from "@typescript-eslint/rule-tester";
9-
import noInvalidBodyTextParent from "../no-invalid-bodytext-parent";
9+
10+
import {rules} from "..";
1011

1112
const ruleTester = new RuleTester({
1213
languageOptions: {
@@ -18,7 +19,10 @@ const ruleTester = new RuleTester({
1819
},
1920
});
2021

21-
ruleTester.run("no-invalid-bodytext-parent", noInvalidBodyTextParent, {
22+
const ruleName = "no-invalid-bodytext-parent";
23+
const rule = rules[ruleName];
24+
25+
ruleTester.run(ruleName, rule, {
2226
// ------------------------------------------------------------------ //
2327
// VALID — BodyText with an inline tag is allowed everywhere; //
2428
// BodyText without a tag is fine in non-restricted contexts. //

packages/eslint-plugin-wonder-blocks/src/rules/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import {TSESLint} from "@typescript-eslint/utils";
2+
13
import noCustomTabRole from "./no-custom-tab-role";
24
import noInvalidBodyTextParent from "./no-invalid-bodytext-parent";
35

4-
const rules = {
6+
const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
57
"no-custom-tab-role": noCustomTabRole,
68
"no-invalid-bodytext-parent": noInvalidBodyTextParent,
79
};
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {TSESTree} from "@typescript-eslint/utils";
2+
3+
/**
4+
* Inline/phrasing-content tags. When BodyText uses one of these it renders as
5+
* an inline element rather than a block-level <p>.
6+
*/
7+
export const INLINE_BODY_TEXT_TAGS = new Set([
8+
"span",
9+
"sup",
10+
"sub",
11+
"em",
12+
"strong",
13+
"a",
14+
"abbr",
15+
"code",
16+
"kbd",
17+
"mark",
18+
"cite",
19+
"q",
20+
"s",
21+
"u",
22+
"b",
23+
"i",
24+
"small",
25+
"del",
26+
"ins",
27+
"time",
28+
"var",
29+
"samp",
30+
"dfn",
31+
]);
32+
33+
/**
34+
* Block-level container tags. When BodyText uses one of these it renders as a
35+
* block container rather than <p>, so it can legitimately hold block children.
36+
*/
37+
export const BLOCK_CONTAINER_TAGS = new Set([
38+
"div",
39+
"section",
40+
"article",
41+
"aside",
42+
"main",
43+
"header",
44+
"footer",
45+
"nav",
46+
"blockquote",
47+
"figure",
48+
"figcaption",
49+
"details",
50+
"summary",
51+
]);
52+
53+
/**
54+
* Wonder Blocks form components that internally render a <label> or similar
55+
* inline-context element around their content.
56+
*/
57+
export const WB_FORM_COMPONENTS = new Set(["Choice", "Checkbox", "Radio"]);
58+
59+
/**
60+
* Wonder Blocks Button component names. Buttons are inline contexts and
61+
* cannot contain block-level elements like <p>.
62+
*/
63+
export const WB_BUTTON_COMPONENTS = new Set(["Button", "ActivityButton"]);
64+
65+
/**
66+
* HTML heading element names.
67+
*/
68+
export const HTML_HEADING_ELEMENTS = new Set([
69+
"h1",
70+
"h2",
71+
"h3",
72+
"h4",
73+
"h5",
74+
"h6",
75+
]);
76+
77+
/**
78+
* Wonder Blocks Heading component names. These render as block-level heading
79+
* elements (h1–h5) and cannot be children of a <p>.
80+
*/
81+
export const WB_HEADING_COMPONENTS = new Set([
82+
"Heading",
83+
"HeadingLarge",
84+
"HeadingMedium",
85+
"HeadingSmall",
86+
"HeadingXSmall",
87+
]);
88+
89+
/**
90+
* Returns the string value of a JSX attribute if it is a simple string
91+
* literal, otherwise null. Returns null for dynamic expressions.
92+
*/
93+
export function getAttributeStringValue(
94+
openingElement: TSESTree.JSXOpeningElement,
95+
attributeName: string,
96+
): string | null {
97+
const attr = openingElement.attributes.find(
98+
(a): a is TSESTree.JSXAttribute =>
99+
a.type === "JSXAttribute" &&
100+
a.name.type === "JSXIdentifier" &&
101+
(a.name as TSESTree.JSXIdentifier).name === attributeName,
102+
);
103+
104+
if (!attr?.value) {
105+
return null;
106+
}
107+
108+
if (attr.value.type === "Literal") {
109+
return String(attr.value.value);
110+
}
111+
112+
if (
113+
attr.value.type === "JSXExpressionContainer" &&
114+
attr.value.expression.type === "Literal"
115+
) {
116+
return String((attr.value.expression as TSESTree.Literal).value);
117+
}
118+
119+
return null;
120+
}

packages/eslint-plugin-wonder-blocks/src/rules/no-invalid-bodytext-parent.ts

Lines changed: 9 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
11
import {ESLintUtils, TSESLint, TSESTree} from "@typescript-eslint/utils";
22

33
import type {WonderBlocksPluginDocs} from "../types";
4+
import {
5+
getAttributeStringValue,
6+
INLINE_BODY_TEXT_TAGS,
7+
WB_BUTTON_COMPONENTS,
8+
WB_FORM_COMPONENTS,
9+
BLOCK_CONTAINER_TAGS,
10+
WB_HEADING_COMPONENTS,
11+
HTML_HEADING_ELEMENTS,
12+
} from "./jsx-utils";
413

514
const createRule = ESLintUtils.RuleCreator<WonderBlocksPluginDocs>(
615
(name) =>
@@ -16,119 +25,6 @@ type MessageIds =
1625
| "nestedInBodyText"
1726
| "nestedInHeading";
1827

19-
/**
20-
* Inline tags that make BodyText safe to nest inside inline/interactive
21-
* contexts (e.g., inside a button, label, or paragraph element).
22-
*/
23-
const INLINE_BODY_TEXT_TAGS = new Set([
24-
"span",
25-
"sup",
26-
"sub",
27-
"em",
28-
"strong",
29-
"a",
30-
"abbr",
31-
"code",
32-
"kbd",
33-
"mark",
34-
"cite",
35-
"q",
36-
"s",
37-
"u",
38-
"b",
39-
"i",
40-
"small",
41-
"del",
42-
"ins",
43-
"time",
44-
"var",
45-
"samp",
46-
"dfn",
47-
]);
48-
49-
/**
50-
* Block-level container tags. When an outer BodyText uses one of these, it
51-
* can contain an inner BodyText that renders as <p> without creating invalid
52-
* HTML nesting.
53-
*/
54-
const BLOCK_CONTAINER_TAGS = new Set([
55-
"div",
56-
"section",
57-
"article",
58-
"aside",
59-
"main",
60-
"header",
61-
"footer",
62-
"nav",
63-
"blockquote",
64-
"figure",
65-
"figcaption",
66-
"details",
67-
"summary",
68-
]);
69-
70-
/**
71-
* Wonder Blocks form components that internally render a <label> or similar
72-
* inline-context element around their content.
73-
*/
74-
const WB_FORM_COMPONENTS = new Set(["Choice", "Checkbox", "Radio"]);
75-
76-
/**
77-
* Wonder Blocks Button component names. Buttons are inline contexts and
78-
* cannot contain block-level elements like <p>.
79-
*/
80-
const WB_BUTTON_COMPONENTS = new Set(["Button", "ActivityButton"]);
81-
82-
/**
83-
* Wonder Blocks Heading component names. Headings are block-level but
84-
* cannot contain other block-level elements like <p>.
85-
*/
86-
const WB_HEADING_COMPONENTS = new Set([
87-
"Heading",
88-
"HeadingLarge",
89-
"HeadingMedium",
90-
"HeadingSmall",
91-
"HeadingXSmall",
92-
]);
93-
94-
/**
95-
* HTML heading element names.
96-
*/
97-
const HTML_HEADING_ELEMENTS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
98-
99-
/**
100-
* Returns the string value of a JSX attribute if it's a simple string
101-
* literal, otherwise null.
102-
*/
103-
function getAttributeStringValue(
104-
openingElement: TSESTree.JSXOpeningElement,
105-
attributeName: string,
106-
): string | null {
107-
const attr = openingElement.attributes.find(
108-
(a): a is TSESTree.JSXAttribute =>
109-
a.type === "JSXAttribute" &&
110-
a.name.type === "JSXIdentifier" &&
111-
(a.name as TSESTree.JSXIdentifier).name === attributeName,
112-
);
113-
114-
if (!attr?.value) {
115-
return null;
116-
}
117-
118-
if (attr.value.type === "Literal") {
119-
return String(attr.value.value);
120-
}
121-
122-
if (
123-
attr.value.type === "JSXExpressionContainer" &&
124-
attr.value.expression.type === "Literal"
125-
) {
126-
return String((attr.value.expression as TSESTree.Literal).value);
127-
}
128-
129-
return null;
130-
}
131-
13228
/**
13329
* Returns true if this BodyText uses an inline `tag` prop, making it safe
13430
* to place inside any of the restricted parent elements.

0 commit comments

Comments
 (0)