Skip to content

Commit cabddb7

Browse files
feat(challenge-parser,client): display Chinese dialogue with ruby annotations (freeCodeCamp#64235)
1 parent 39eb3e0 commit cabddb7

File tree

6 files changed

+205
-1
lines changed

6 files changed

+205
-1
lines changed

client/src/templates/Challenges/components/scene/scene-helpers.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,5 +191,22 @@ describe('scene-helpers', () => {
191191
'\n<strong>Naomi</strong>: Use <div> and <span> tags\n'
192192
);
193193
});
194+
195+
it('should preserve Chinese dialogue with ruby annotations', () => {
196+
const commands: SceneCommand[] = [
197+
{
198+
character: 'Naomi',
199+
startTime: 1,
200+
dialogue: {
201+
text: '<ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby>,<ruby>世界<rp>(</rp><rt>shì jiè</rt><rp>)</rp></ruby>。',
202+
align: 'left'
203+
}
204+
}
205+
];
206+
const result = buildTranscript(commands);
207+
expect(result).toBe(
208+
'\n<strong>Naomi</strong>: <ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby>,<ruby>世界<rp>(</rp><rt>shì jiè</rt><rp>)</rp></ruby>。\n'
209+
);
210+
});
194211
});
195212
});

client/src/templates/Challenges/components/scene/scene.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,7 +393,10 @@ export function Scene({
393393
}`}
394394
>
395395
<div className='scene-dialogue-label'>{dialogue.label}</div>
396-
<div className='scene-dialogue-text'>{dialogue.text}</div>
396+
<div
397+
className='scene-dialogue-text'
398+
dangerouslySetInnerHTML={{ __html: dialogue.text }}
399+
/>
397400
</div>
398401
)}
399402
</>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# --description--
2+
3+
This challenge has a Chinese scene with plain hanzi (no pinyin).
4+
5+
# --scene--
6+
7+
```json
8+
{
9+
"setup": {
10+
"background": "company1-reception.png",
11+
"characters": [
12+
{
13+
"character": "Wang Hua",
14+
"position": { "x": 50, "y": 15, "z": 1.4 },
15+
"opacity": 0
16+
}
17+
],
18+
"audio": {
19+
"filename": "test.mp3",
20+
"startTime": 1
21+
}
22+
},
23+
"commands": [
24+
{
25+
"character": "Wang Hua",
26+
"startTime": 1,
27+
"finishTime": 2,
28+
"dialogue": {
29+
"text": "你好,世界。",
30+
"align": "center"
31+
}
32+
}
33+
]
34+
}
35+
```
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# --description--
2+
3+
This challenge has a Chinese scene with hanzi-pinyin pairs.
4+
5+
# --scene--
6+
7+
```json
8+
{
9+
"setup": {
10+
"background": "company1-reception.png",
11+
"characters": [
12+
{
13+
"character": "Wang Hua",
14+
"position": { "x": 50, "y": 15, "z": 1.4 },
15+
"opacity": 0
16+
}
17+
],
18+
"audio": {
19+
"filename": "ZH_A1_welcome_hello_world.mp3",
20+
"startTime": 1,
21+
"startTimestamp": 5.18,
22+
"finishTimestamp": 6.71
23+
}
24+
},
25+
"commands": [
26+
{
27+
"character": "Wang Hua",
28+
"startTime": 1,
29+
"finishTime": 2.53,
30+
"dialogue": {
31+
"text": "你好 (nǐ hǎo),世界 (shì jiè)。",
32+
"align": "center"
33+
}
34+
}
35+
]
36+
}
37+
```

tools/challenge-parser/parser/plugins/add-scene.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
const { getSection } = require('./utils/get-section');
2+
const {
3+
createMdastToHtml,
4+
parseHanziPinyinPairs
5+
} = require('./utils/i18n-stringify');
26

37
function plugin() {
48
return transformer;
@@ -17,6 +21,47 @@ function plugin() {
1721

1822
// throws if we can't parse it.
1923
const sceneJson = JSON.parse(sceneNodes[0].value);
24+
25+
// Convert hanzi-pinyin pairs to HTML in dialogue text
26+
if (sceneJson.commands) {
27+
const toHtml = createMdastToHtml(file.data.lang);
28+
29+
sceneJson.commands = sceneJson.commands.map(command => {
30+
if (
31+
command.dialogue &&
32+
command.dialogue.text &&
33+
parseHanziPinyinPairs(command.dialogue.text).length > 0
34+
) {
35+
// Wrap text in inlineCode node so the Chinese handler can process it.
36+
// The paragraph wrapper is required by mdastToHTML's structure.
37+
const nodes = [
38+
{
39+
type: 'paragraph',
40+
children: [
41+
{
42+
type: 'inlineCode',
43+
value: command.dialogue.text
44+
}
45+
]
46+
}
47+
];
48+
49+
const html = toHtml(nodes);
50+
// Remove the <p> wrapper tags, keeping only the inner ruby elements
51+
const innerHtml = html.replace(/^<p>|<\/p>$/g, '');
52+
53+
return {
54+
...command,
55+
dialogue: {
56+
...command.dialogue,
57+
text: innerHtml
58+
}
59+
};
60+
}
61+
return command;
62+
});
63+
}
64+
2065
file.data.scene = sceneJson;
2166
}
2267
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
2+
import parseFixture from '../__fixtures__/parse-fixture';
3+
import addScene from './add-scene';
4+
5+
describe('add-scene', () => {
6+
let sceneAST, chineseSceneAST, chineseSceneNoPinyinAST;
7+
let file;
8+
9+
beforeAll(async () => {
10+
sceneAST = await parseFixture('scene.md');
11+
chineseSceneAST = await parseFixture('with-chinese-scene.md');
12+
chineseSceneNoPinyinAST = await parseFixture(
13+
'with-chinese-scene-no-pinyin.md'
14+
);
15+
});
16+
17+
beforeEach(() => {
18+
file = { data: { lang: 'en' } };
19+
});
20+
21+
it('should add scene data to file when scene section exists', () => {
22+
const plugin = addScene();
23+
plugin(sceneAST, file);
24+
25+
expect(file.data.scene).toBeDefined();
26+
expect(file.data.scene.setup.background).toBe('company2-center.png');
27+
expect(file.data.scene.commands).toHaveLength(3);
28+
});
29+
30+
it('should preserve dialogue text for non-Chinese scenes', () => {
31+
const plugin = addScene();
32+
plugin(sceneAST, file);
33+
34+
expect(file.data.scene.commands[1].dialogue.text).toBe(
35+
"I'm Maria, the team lead."
36+
);
37+
expect(file.data.scene.commands[1].dialogue.text).not.toContain('<ruby>');
38+
});
39+
40+
it('should convert Chinese hanzi-pinyin pairs to ruby HTML', () => {
41+
file.data.lang = 'zh-CN';
42+
const plugin = addScene();
43+
plugin(chineseSceneAST, file);
44+
45+
const dialogueText = file.data.scene.commands[0].dialogue.text;
46+
expect(dialogueText).toBe(
47+
'<ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby>,<ruby>世界<rp>(</rp><rt>shì jiè</rt><rp>)</rp></ruby>。'
48+
);
49+
});
50+
51+
it('should not convert Hanzi-only to ruby HTML', () => {
52+
file.data.lang = 'zh-CN';
53+
const plugin = addScene();
54+
plugin(chineseSceneNoPinyinAST, file);
55+
56+
expect(file.data.scene.commands[0].dialogue.text).toBe('你好,世界。');
57+
expect(file.data.scene.commands[0].dialogue.text).not.toContain('<ruby>');
58+
});
59+
60+
it('should handle commands without dialogue', () => {
61+
const plugin = addScene();
62+
plugin(sceneAST, file);
63+
64+
expect(file.data.scene.commands[0].dialogue).toBeUndefined();
65+
expect(file.data.scene.commands[2].dialogue).toBeUndefined();
66+
});
67+
});

0 commit comments

Comments
 (0)