feat(challenge-parser,client): display Chinese dialogue with ruby annotations (#64235)

This commit is contained in:
Huyen Nguyen
2025-12-08 01:00:05 -08:00
committed by GitHub
parent 39eb3e0f34
commit cabddb74cb
6 changed files with 205 additions and 1 deletions

View File

@@ -191,5 +191,22 @@ describe('scene-helpers', () => {
'\n<strong>Naomi</strong>: Use <div> and <span> tags\n'
);
});
it('should preserve Chinese dialogue with ruby annotations', () => {
const commands: SceneCommand[] = [
{
character: 'Naomi',
startTime: 1,
dialogue: {
text: '<ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby><ruby>世界<rp>(</rp><rt>shì jiè</rt><rp>)</rp></ruby>。',
align: 'left'
}
}
];
const result = buildTranscript(commands);
expect(result).toBe(
'\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'
);
});
});
});

View File

@@ -393,7 +393,10 @@ export function Scene({
}`}
>
<div className='scene-dialogue-label'>{dialogue.label}</div>
<div className='scene-dialogue-text'>{dialogue.text}</div>
<div
className='scene-dialogue-text'
dangerouslySetInnerHTML={{ __html: dialogue.text }}
/>
</div>
)}
</>

View File

@@ -0,0 +1,35 @@
# --description--
This challenge has a Chinese scene with plain hanzi (no pinyin).
# --scene--
```json
{
"setup": {
"background": "company1-reception.png",
"characters": [
{
"character": "Wang Hua",
"position": { "x": 50, "y": 15, "z": 1.4 },
"opacity": 0
}
],
"audio": {
"filename": "test.mp3",
"startTime": 1
}
},
"commands": [
{
"character": "Wang Hua",
"startTime": 1,
"finishTime": 2,
"dialogue": {
"text": "你好,世界。",
"align": "center"
}
}
]
}
```

View File

@@ -0,0 +1,37 @@
# --description--
This challenge has a Chinese scene with hanzi-pinyin pairs.
# --scene--
```json
{
"setup": {
"background": "company1-reception.png",
"characters": [
{
"character": "Wang Hua",
"position": { "x": 50, "y": 15, "z": 1.4 },
"opacity": 0
}
],
"audio": {
"filename": "ZH_A1_welcome_hello_world.mp3",
"startTime": 1,
"startTimestamp": 5.18,
"finishTimestamp": 6.71
}
},
"commands": [
{
"character": "Wang Hua",
"startTime": 1,
"finishTime": 2.53,
"dialogue": {
"text": "你好 (nǐ hǎo),世界 (shì jiè)。",
"align": "center"
}
}
]
}
```

View File

@@ -1,4 +1,8 @@
const { getSection } = require('./utils/get-section');
const {
createMdastToHtml,
parseHanziPinyinPairs
} = require('./utils/i18n-stringify');
function plugin() {
return transformer;
@@ -17,6 +21,47 @@ function plugin() {
// throws if we can't parse it.
const sceneJson = JSON.parse(sceneNodes[0].value);
// Convert hanzi-pinyin pairs to HTML in dialogue text
if (sceneJson.commands) {
const toHtml = createMdastToHtml(file.data.lang);
sceneJson.commands = sceneJson.commands.map(command => {
if (
command.dialogue &&
command.dialogue.text &&
parseHanziPinyinPairs(command.dialogue.text).length > 0
) {
// Wrap text in inlineCode node so the Chinese handler can process it.
// The paragraph wrapper is required by mdastToHTML's structure.
const nodes = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value: command.dialogue.text
}
]
}
];
const html = toHtml(nodes);
// Remove the <p> wrapper tags, keeping only the inner ruby elements
const innerHtml = html.replace(/^<p>|<\/p>$/g, '');
return {
...command,
dialogue: {
...command.dialogue,
text: innerHtml
}
};
}
return command;
});
}
file.data.scene = sceneJson;
}
}

View File

@@ -0,0 +1,67 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import parseFixture from '../__fixtures__/parse-fixture';
import addScene from './add-scene';
describe('add-scene', () => {
let sceneAST, chineseSceneAST, chineseSceneNoPinyinAST;
let file;
beforeAll(async () => {
sceneAST = await parseFixture('scene.md');
chineseSceneAST = await parseFixture('with-chinese-scene.md');
chineseSceneNoPinyinAST = await parseFixture(
'with-chinese-scene-no-pinyin.md'
);
});
beforeEach(() => {
file = { data: { lang: 'en' } };
});
it('should add scene data to file when scene section exists', () => {
const plugin = addScene();
plugin(sceneAST, file);
expect(file.data.scene).toBeDefined();
expect(file.data.scene.setup.background).toBe('company2-center.png');
expect(file.data.scene.commands).toHaveLength(3);
});
it('should preserve dialogue text for non-Chinese scenes', () => {
const plugin = addScene();
plugin(sceneAST, file);
expect(file.data.scene.commands[1].dialogue.text).toBe(
"I'm Maria, the team lead."
);
expect(file.data.scene.commands[1].dialogue.text).not.toContain('<ruby>');
});
it('should convert Chinese hanzi-pinyin pairs to ruby HTML', () => {
file.data.lang = 'zh-CN';
const plugin = addScene();
plugin(chineseSceneAST, file);
const dialogueText = file.data.scene.commands[0].dialogue.text;
expect(dialogueText).toBe(
'<ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby><ruby>世界<rp>(</rp><rt>shì jiè</rt><rp>)</rp></ruby>。'
);
});
it('should not convert Hanzi-only to ruby HTML', () => {
file.data.lang = 'zh-CN';
const plugin = addScene();
plugin(chineseSceneNoPinyinAST, file);
expect(file.data.scene.commands[0].dialogue.text).toBe('你好,世界。');
expect(file.data.scene.commands[0].dialogue.text).not.toContain('<ruby>');
});
it('should handle commands without dialogue', () => {
const plugin = addScene();
plugin(sceneAST, file);
expect(file.data.scene.commands[0].dialogue).toBeUndefined();
expect(file.data.scene.commands[2].dialogue).toBeUndefined();
});
});