Skip to content
This repository was archived by the owner on Oct 27, 2026. It is now read-only.

Commit 0654c85

Browse files
committed
test: cover email HAST traversal
1 parent dce839e commit 0654c85

2 files changed

Lines changed: 78 additions & 22 deletions

File tree

‎app/_components/toolkit-docs/lib/neutralize-emails.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export function splitEmails(text: string): ReactNode {
4949
}
5050

5151
/** Splits `value` into text/`<wbr>` element pairs at each email `@` break. */
52-
function neutralizeTextValue(value: string): Array<Text | Element> {
52+
export function splitEmailText(value: string): Array<Text | Element> {
5353
const breaks = atBreakOffsets(value);
5454
const out: Array<Text | Element> = [];
5555
let cursor = 0;
@@ -73,7 +73,7 @@ export function rehypeNeutralizeEmails() {
7373
if (index === undefined || !parent) {
7474
return;
7575
}
76-
const replacement = neutralizeTextValue(node.value);
76+
const replacement = splitEmailText(node.value);
7777
if (replacement.length <= 1) {
7878
return;
7979
}

‎tests/neutralize-emails.test.tsx‎

Lines changed: 76 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import type { Element, Root, RootContent } from "hast";
12
import { renderToStaticMarkup } from "react-dom/server";
23
import { describe, expect, test } from "vitest";
34
import {
45
rehypeNeutralizeEmails,
56
splitEmails,
7+
splitEmailText,
68
} from "@/app/_components/toolkit-docs/lib/neutralize-emails";
79

810
/**
@@ -39,27 +41,51 @@ describe("splitEmails", () => {
3941
});
4042
});
4143

42-
type HastNode = {
43-
type: string;
44-
value?: string;
45-
tagName?: string;
46-
properties?: Record<string, unknown>;
47-
children?: HastNode[];
48-
};
44+
describe("splitEmailText", () => {
45+
test("returns one text node when there are no emails", () => {
46+
expect(splitEmailText("just some text")).toEqual([
47+
{ type: "text", value: "just some text" },
48+
]);
49+
});
50+
51+
test("splits every email while preserving the text", () => {
52+
const nodes = splitEmailText(
53+
"contact jane@example.com or sam@example.org today"
54+
);
55+
56+
expect(nodes).toEqual([
57+
{ type: "text", value: "contact jane" },
58+
{ type: "element", tagName: "wbr", properties: {}, children: [] },
59+
{ type: "text", value: "@example.com or sam" },
60+
{ type: "element", tagName: "wbr", properties: {}, children: [] },
61+
{ type: "text", value: "@example.org today" },
62+
]);
63+
expect(
64+
nodes
65+
.filter((node) => node.type === "text")
66+
.map((node) => node.value)
67+
.join("")
68+
).toBe("contact jane@example.com or sam@example.org today");
69+
});
70+
});
4971

50-
const collectText = (node: HastNode): string =>
51-
node.type === "text"
52-
? (node.value ?? "")
53-
: (node.children ?? []).map(collectText).join("");
72+
const collectText = (node: Root | RootContent): string => {
73+
if (node.type === "text") {
74+
return node.value;
75+
}
76+
return "children" in node ? node.children.map(collectText).join("") : "";
77+
};
5478

55-
const hasContiguousEmail = (node: HastNode): boolean =>
56-
node.type === "text"
57-
? EMAIL.test(node.value ?? "")
58-
: (node.children ?? []).some(hasContiguousEmail);
79+
const hasContiguousEmail = (node: Root | RootContent): boolean => {
80+
if (node.type === "text") {
81+
return EMAIL.test(node.value);
82+
}
83+
return "children" in node ? node.children.some(hasContiguousEmail) : false;
84+
};
5985

6086
describe("rehypeNeutralizeEmails", () => {
6187
test("splits email text nodes and inserts a <wbr>, losslessly", () => {
62-
const tree: HastNode = {
88+
const tree: Root = {
6389
type: "root",
6490
children: [
6591
{
@@ -73,13 +99,43 @@ describe("rehypeNeutralizeEmails", () => {
7399

74100
rehypeNeutralizeEmails()(tree);
75101

76-
const paragraph = tree.children?.[0];
77-
expect(paragraph?.children?.some((child) => child.tagName === "wbr")).toBe(
78-
true
79-
);
102+
const paragraph = tree.children[0] as Element;
103+
expect(
104+
paragraph.children.some(
105+
(child) => child.type === "element" && child.tagName === "wbr"
106+
)
107+
).toBe(true);
80108
// No single text node still holds a full email...
81109
expect(hasContiguousEmail(tree)).toBe(false);
82110
// ...and the concatenated text is unchanged.
83111
expect(collectText(tree)).toBe("reach user@example.com now");
84112
});
113+
114+
test("visits nested elements and every matching text node", () => {
115+
const tree: Root = {
116+
type: "root",
117+
children: [
118+
{
119+
type: "element",
120+
tagName: "blockquote",
121+
properties: {},
122+
children: [
123+
{
124+
type: "element",
125+
tagName: "em",
126+
properties: {},
127+
children: [{ type: "text", value: "jane@example.com" }],
128+
},
129+
{ type: "text", value: " and sam@example.org" },
130+
],
131+
},
132+
],
133+
};
134+
135+
rehypeNeutralizeEmails()(tree);
136+
137+
expect(hasContiguousEmail(tree)).toBe(false);
138+
expect(collectText(tree)).toBe("jane@example.com and sam@example.org");
139+
expect(JSON.stringify(tree).match(/"tagName":"wbr"/g)).toHaveLength(2);
140+
});
85141
});

0 commit comments

Comments
 (0)