From 0c844ab007ea91843b449b0219ea9703d1ba7b10 Mon Sep 17 00:00:00 2001 From: Huyen Nguyen <25715018+huyenltnguyen@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:25:57 -0800 Subject: [PATCH] feat(client,challenge-parser): render Chinese as ruby markup (#63424) Co-authored-by: Oliver Eyton-Williams --- client/src/components/layouts/global.css | 4 + .../parser/__fixtures__/with-chinese-mcq.md | 52 ++++++ .../__fixtures__/with-chinese-quizzes.md | 69 +++++++ .../parser/plugins/add-assignment.js | 18 +- .../parser/plugins/add-quizzes.js | 51 ++--- .../parser/plugins/add-quizzes.test.js | 74 ++++++++ .../parser/plugins/add-text.js | 6 +- .../parser/plugins/add-text.test.js | 21 ++- .../parser/plugins/add-video-question.js | 175 +++++++++--------- .../parser/plugins/add-video-question.test.js | 41 +++- .../parser/plugins/utils/i18n-stringify.js | 78 ++++++++ .../plugins/utils/i18n-stringify.test.js | 129 +++++++++++++ .../parser/plugins/utils/mdast-to-html.js | 11 +- 13 files changed, 601 insertions(+), 128 deletions(-) create mode 100644 tools/challenge-parser/parser/__fixtures__/with-chinese-mcq.md create mode 100644 tools/challenge-parser/parser/__fixtures__/with-chinese-quizzes.md create mode 100644 tools/challenge-parser/parser/plugins/utils/i18n-stringify.js create mode 100644 tools/challenge-parser/parser/plugins/utils/i18n-stringify.test.js diff --git a/client/src/components/layouts/global.css b/client/src/components/layouts/global.css index c8f6121adaa..0fcd84f8166 100644 --- a/client/src/components/layouts/global.css +++ b/client/src/components/layouts/global.css @@ -89,6 +89,10 @@ hr { border-top: 1px solid var(--quaternary-background); } +rt { + font-size: 0.8rem; +} + #___gatsby { height: 100%; } diff --git a/tools/challenge-parser/parser/__fixtures__/with-chinese-mcq.md b/tools/challenge-parser/parser/__fixtures__/with-chinese-mcq.md new file mode 100644 index 00000000000..beb0c4df2f1 --- /dev/null +++ b/tools/challenge-parser/parser/__fixtures__/with-chinese-mcq.md @@ -0,0 +1,52 @@ +--- +id: test-with-chinese-mcq +title: Ruby Test +challengeType: 19 +lang: zh-CN +--- + +# --instructions-- + +Instructions containing `汉字 (hàn zì)`. + +# --questions-- + +## --text-- + +Question text containing `汉字 (hàn zì)`. + +## --answers-- + +`你好 (nǐ hǎo)` + +### --feedback-- + +Feedback text. + +--- + +`请 (qǐng)` + +### --feedback-- + +`请 (qǐng)` is not correct. + +--- + +`请问 (qǐng wèn)` + +--- + +`问 (wèn)` + +### --feedback-- + +Feedback text. + +## --video-solution-- + +3 + +# --explanation-- + +Wang Hua uses `请问 (qǐng wèn)` to politely start her question. \ No newline at end of file diff --git a/tools/challenge-parser/parser/__fixtures__/with-chinese-quizzes.md b/tools/challenge-parser/parser/__fixtures__/with-chinese-quizzes.md new file mode 100644 index 00000000000..3254c05ec3d --- /dev/null +++ b/tools/challenge-parser/parser/__fixtures__/with-chinese-quizzes.md @@ -0,0 +1,69 @@ +# --quizzes-- + +## --quiz-- + +### --question-- + +#### --text-- + +Quiz 1, question 1 with `中文 (zhōng wén)` + +#### --distractors-- + +Quiz 1, question 1, distractor 1 with `中文 (zhōng wén)` + +--- + +Quiz 1, question 1, distractor 2 with `中文 (zhōng wén)` + +--- + +Quiz 1, question 1, distractor 3 with `中文 (zhōng wén)` + +#### --answer-- + +Quiz 1, question 1, answer with `中文 (zhōng wén)` + +### --question-- + +#### --text-- + +Quiz 1, question 2 with `中文` + +#### --distractors-- + +Quiz 1, question 2, distractor 1 with `中文` + +--- + +Quiz 1, question 2, distractor 2 with `中文` + +--- + +Quiz 1, question 2, distractor 3 with `中文` + +#### --answer-- + +Quiz 1, question 2, answer with `中文` + +### --question-- + +#### --text-- + +Quiz 1, question 3 with `zhōng wén` + +#### --distractors-- + +Quiz 1, question 3, distractor 1 with `zhōng wén` + +--- + +Quiz 1, question 3, distractor 2 with `zhōng wén` + +--- + +Quiz 1, question 3, distractor 3 with `zhōng wén` + +#### --answer-- + +Quiz 1, question 3, answer with `zhōng wén` diff --git a/tools/challenge-parser/parser/plugins/add-assignment.js b/tools/challenge-parser/parser/plugins/add-assignment.js index 1c826fc7c09..90cce317426 100644 --- a/tools/challenge-parser/parser/plugins/add-assignment.js +++ b/tools/challenge-parser/parser/plugins/add-assignment.js @@ -1,23 +1,23 @@ const { getSection } = require('./utils/get-section'); -const mdastToHtml = require('./utils/mdast-to-html'); +const { createMdastToHtml } = require('./utils/i18n-stringify'); const { splitOnThematicBreak } = require('./utils/split-on-thematic-break'); function plugin() { return transformer; function transformer(tree, file) { + const toHtml = createMdastToHtml(file.data.lang); const assignmentNodes = getSection(tree, '--assignment--'); - const assignment = getAssignments(assignmentNodes).filter(a => a != ''); + function getAssignments(assignmentNodes) { + const assignmentGroups = splitOnThematicBreak(assignmentNodes); + return assignmentGroups.map(assignment => toHtml(assignment)); + } - file.data.assignments = assignment; - } - - function getAssignments(assignmentNodes) { - const assignmentGroups = splitOnThematicBreak(assignmentNodes); - - return assignmentGroups.map(assignment => mdastToHtml(assignment)); + file.data.assignments = getAssignments(assignmentNodes).filter( + a => a != '' + ); } } diff --git a/tools/challenge-parser/parser/plugins/add-quizzes.js b/tools/challenge-parser/parser/plugins/add-quizzes.js index 54369866b94..defca36581e 100644 --- a/tools/challenge-parser/parser/plugins/add-quizzes.js +++ b/tools/challenge-parser/parser/plugins/add-quizzes.js @@ -1,14 +1,39 @@ const { root } = require('mdast-builder'); const { getSection, getAllSections } = require('./utils/get-section'); -const mdastToHtml = require('./utils/mdast-to-html'); +const { createMdastToHtml } = require('./utils/i18n-stringify'); const { splitOnThematicBreak } = require('./utils/split-on-thematic-break'); function plugin() { return transformer; function transformer(tree, file) { + const toHtml = createMdastToHtml(file.data.lang); const quizzesNodes = getSection(tree, `--quizzes--`); + function getDistractors(distractorsNodes) { + const distractorsGroups = splitOnThematicBreak(distractorsNodes); + + if (distractorsGroups.length !== 3) + throw Error('Three distractors are required per quiz-question'); + + return distractorsGroups.map(distractorsGroup => { + return toHtml(distractorsGroup); + }); + } + + function getQuestion(textNodes, distractorNodes, answerNodes) { + const text = toHtml(textNodes); + const distractors = getDistractors(distractorNodes); + const answer = toHtml(answerNodes); + + if (!text) throw Error('--text-- is missing from the quiz question'); + if (!distractors) + throw Error('--distractors-- are missing from quiz question'); + if (!answer) throw Error('--answer-- is missing from quiz question'); + + return { text, distractors, answer }; + } + if (quizzesNodes.length > 0) { const compiledQuizzes = []; const quizSections = getAllSections(root(quizzesNodes), `--quiz--`); @@ -51,28 +76,4 @@ function plugin() { } } -function getQuestion(textNodes, distractorNodes, answerNodes) { - const text = mdastToHtml(textNodes); - const distractors = getDistractors(distractorNodes); - const answer = mdastToHtml(answerNodes); - - if (!text) throw Error('--text-- is missing from the quiz question'); - if (!distractors) - throw Error('--distractors-- are missing from quiz question'); - if (!answer) throw Error('--answer-- is missing from quiz question'); - - return { text, distractors, answer }; -} - -function getDistractors(distractorsNodes) { - const distractorsGroups = splitOnThematicBreak(distractorsNodes); - - if (distractorsGroups.length !== 3) - throw Error('Three distractors are required per quiz-question'); - - return distractorsGroups.map(distractorsGroup => { - return mdastToHtml(distractorsGroup); - }); -} - module.exports = plugin; diff --git a/tools/challenge-parser/parser/plugins/add-quizzes.test.js b/tools/challenge-parser/parser/plugins/add-quizzes.test.js index bb065e737d3..a3564099a46 100644 --- a/tools/challenge-parser/parser/plugins/add-quizzes.test.js +++ b/tools/challenge-parser/parser/plugins/add-quizzes.test.js @@ -4,11 +4,13 @@ import addQuizzes from './add-quizzes'; describe('add-quizzes plugin', () => { let mockQuizzesAST; + let chineseQuizzesAST; const plugin = addQuizzes(); let file = { data: {} }; beforeAll(async () => { mockQuizzesAST = await parseFixture('with-quizzes.md'); + chineseQuizzesAST = await parseFixture('with-chinese-quizzes.md'); }); beforeEach(() => { @@ -61,4 +63,76 @@ describe('add-quizzes plugin', () => { plugin(mockQuizzesAST, file); expect(file.data).toMatchSnapshot(); }); + + it('should render Chinese hanzi (pinyin) in quizzes when lang is zh-CN and the text contains hanzi (pinyin) format', () => { + const zhFile = { data: { lang: 'zh-CN' } }; + plugin(chineseQuizzesAST, zhFile); + const quizzes = zhFile.data.quizzes; + expect(Array.isArray(quizzes)).toBe(true); + expect(quizzes.length).toBe(1); + + const firstQuiz = quizzes[0]; + const firstQuestion = firstQuiz.questions[0]; + + // Quiz 1, Question 1 + expect(firstQuestion.text).toBe( + '

Quiz 1, question 1 with 中文(zhōng wén)

' + ); + expect(firstQuestion.distractors[0]).toBe( + '

Quiz 1, question 1, distractor 1 with 中文(zhōng wén)

' + ); + expect(firstQuestion.distractors[1]).toBe( + '

Quiz 1, question 1, distractor 2 with 中文(zhōng wén)

' + ); + expect(firstQuestion.distractors[2]).toBe( + '

Quiz 1, question 1, distractor 3 with 中文(zhōng wén)

' + ); + expect(firstQuestion.answer).toBe( + '

Quiz 1, question 1, answer with 中文(zhōng wén)

' + ); + }); + + it('should render Chinese in quizzes when lang is zh-CN and the text does not contain hanzi (pinyin) format', () => { + const zhFile = { data: { lang: 'zh-CN' } }; + plugin(chineseQuizzesAST, zhFile); + const quizzes = zhFile.data.quizzes; + + const firstQuiz = quizzes[0]; + const secondQuestion = firstQuiz.questions[1]; + const thirdQuestion = firstQuiz.questions[2]; + + // Quiz 1, Question 2 + expect(secondQuestion.text).toBe( + '

Quiz 1, question 2 with 中文

' + ); + expect(secondQuestion.distractors[0]).toBe( + '

Quiz 1, question 2, distractor 1 with 中文

' + ); + expect(secondQuestion.distractors[1]).toBe( + '

Quiz 1, question 2, distractor 2 with 中文

' + ); + expect(secondQuestion.distractors[2]).toBe( + '

Quiz 1, question 2, distractor 3 with 中文

' + ); + expect(secondQuestion.answer).toBe( + '

Quiz 1, question 2, answer with 中文

' + ); + + // Quiz 1, Question 3 + expect(thirdQuestion.text).toBe( + '

Quiz 1, question 3 with zhōng wén

' + ); + expect(thirdQuestion.distractors[0]).toBe( + '

Quiz 1, question 3, distractor 1 with zhōng wén

' + ); + expect(thirdQuestion.distractors[1]).toBe( + '

Quiz 1, question 3, distractor 2 with zhōng wén

' + ); + expect(thirdQuestion.distractors[2]).toBe( + '

Quiz 1, question 3, distractor 3 with zhōng wén

' + ); + expect(thirdQuestion.answer).toBe( + '

Quiz 1, question 3, answer with zhōng wén

' + ); + }); }); diff --git a/tools/challenge-parser/parser/plugins/add-text.js b/tools/challenge-parser/parser/plugins/add-text.js index f593b928ba4..be4415169e8 100644 --- a/tools/challenge-parser/parser/plugins/add-text.js +++ b/tools/challenge-parser/parser/plugins/add-text.js @@ -2,7 +2,7 @@ const { isEmpty } = require('lodash'); const find = require('unist-util-find'); const { root } = require('mdast-builder'); const { getSection, isMarker } = require('./utils/get-section'); -const mdastToHTML = require('./utils/mdast-to-html'); +const { createMdastToHtml } = require('./utils/i18n-stringify'); function addText(sectionIds) { if (!sectionIds || !Array.isArray(sectionIds) || sectionIds.length <= 0) { @@ -17,7 +17,9 @@ function addText(sectionIds) { `The --${sectionId}-- section should not have any subsections. Found subsection ${subSection.children[0].value}` ); } - const sectionText = mdastToHTML(textNodes); + + const toHtml = createMdastToHtml(file.data.lang); + const sectionText = toHtml(textNodes); if (!isEmpty(sectionText)) { file.data = { ...file.data, diff --git a/tools/challenge-parser/parser/plugins/add-text.test.js b/tools/challenge-parser/parser/plugins/add-text.test.js index 66c5ae84017..2a76a53c9cd 100644 --- a/tools/challenge-parser/parser/plugins/add-text.test.js +++ b/tools/challenge-parser/parser/plugins/add-text.test.js @@ -3,7 +3,11 @@ import parseFixture from '../__fixtures__/parse-fixture'; import addText from './add-text'; describe('add-text', () => { - let realisticAST, mockAST, withSubSectionAST, withNestedInstructionsAST; + let realisticAST, + mockAST, + withSubSectionAST, + withNestedInstructionsAST, + withChineseAST; const descriptionId = 'description'; const instructionsId = 'instructions'; const missingId = 'missing'; @@ -16,6 +20,7 @@ describe('add-text', () => { withNestedInstructionsAST = await parseFixture( 'with-nested-instructions.md' ); + withChineseAST = await parseFixture('with-chinese-mcq.md'); }); beforeEach(() => { @@ -156,4 +161,18 @@ describe('add-text', () => { plugin(mockAST, file); expect(file.data).toMatchSnapshot(); }); + + it('should render Chinese inline code as ruby when lang is zh-CN', () => { + const plugin = addText(['instructions', 'explanation']); + + const zhFile = { data: { lang: 'zh-CN' } }; + plugin(withChineseAST, zhFile); + + expect(zhFile.data.instructions).toBe( + '
\n

Instructions containing 汉字(hàn zì).

\n
' + ); + expect(zhFile.data.explanation).toBe( + '
\n

Wang Hua uses 请问(qǐng wèn) to politely start her question.

\n
' + ); + }); }); diff --git a/tools/challenge-parser/parser/plugins/add-video-question.js b/tools/challenge-parser/parser/plugins/add-video-question.js index a8360019a62..6e7706543be 100644 --- a/tools/challenge-parser/parser/plugins/add-video-question.js +++ b/tools/challenge-parser/parser/plugins/add-video-question.js @@ -2,14 +2,103 @@ const { root } = require('mdast-builder'); const find = require('unist-util-find'); const { getSection } = require('./utils/get-section'); const getAllBefore = require('./utils/before-heading'); -const mdastToHtml = require('./utils/mdast-to-html'); const { getParagraphContent } = require('./utils/get-paragraph-content'); const { splitOnThematicBreak } = require('./utils/split-on-thematic-break'); +const { createMdastToHtml } = require('./utils/i18n-stringify'); function plugin() { return transformer; function transformer(tree, file) { + const toHtml = createMdastToHtml(file.data.lang); + + function getQuestion(textNodes, answersNodes, solutionNodes) { + const text = toHtml(textNodes); + const answers = getAnswers(answersNodes); + const solution = getSolution(solutionNodes); + + if (!text) throw Error('text is missing from question'); + if (!answers) throw Error('answers are missing from question'); + if (!solution) throw Error('solution is missing from question'); + + return { text, answers, solution }; + } + + function getAnswers(answersNodes) { + const answerGroups = splitOnThematicBreak(answersNodes); + + return answerGroups.map(answerGroup => { + const answerTree = root(answerGroup); + const feedbackNodes = getSection(answerTree, '--feedback--'); + const audioIdNodes = getSection(answerTree, '--audio-id--'); + const hasFeedback = feedbackNodes.length > 0; + const hasAudioId = audioIdNodes.length > 0; + + if (hasFeedback || hasAudioId) { + let answerNodes; + + if (hasFeedback && hasAudioId) { + const feedbackHeading = find(answerTree, { + type: 'heading', + children: [{ type: 'text', value: '--feedback--' }] + }); + const audioIdHeading = find(answerTree, { + type: 'heading', + children: [{ type: 'text', value: '--audio-id--' }] + }); + + const feedbackIndex = answerTree.children.indexOf(feedbackHeading); + const audioIdIndex = answerTree.children.indexOf(audioIdHeading); + const firstMarker = + feedbackIndex < audioIdIndex ? '--feedback--' : '--audio-id--'; + answerNodes = getAllBefore(answerTree, firstMarker); + } else if (hasFeedback) { + answerNodes = getAllBefore(answerTree, '--feedback--'); + } else { + answerNodes = getAllBefore(answerTree, '--audio-id--'); + } + + if (answerNodes.length < 1) { + throw Error('Answer missing'); + } + + let extractedAudioId = null; + if (hasAudioId) { + const audioIdContent = getParagraphContent(audioIdNodes[0]); + if (audioIdContent && audioIdContent.trim()) { + extractedAudioId = audioIdContent.trim(); + } + } + + return { + answer: toHtml(answerNodes), + feedback: hasFeedback ? toHtml(feedbackNodes) : null, + audioId: extractedAudioId + }; + } + + return { + answer: toHtml(answerGroup), + feedback: null, + audioId: null + }; + }); + } + + function getSolution(solutionNodes) { + let solution; + try { + solution = Number(getParagraphContent(solutionNodes[0])); + if (Number.isNaN(solution)) throw Error('Not a number'); + if (solution < 1) throw Error('Not positive number'); + } catch (e) { + console.log(e); + throw Error('A video solution should be a positive integer'); + } + + return solution; + } + const allQuestionNodes = getSection(tree, '--questions--'); if (allQuestionNodes.length > 0) { @@ -41,88 +130,4 @@ function plugin() { } } -function getQuestion(textNodes, answersNodes, solutionNodes) { - const text = mdastToHtml(textNodes); - const answers = getAnswers(answersNodes); - const solution = getSolution(solutionNodes); - - if (!text) throw Error('text is missing from question'); - if (!answers) throw Error('answers are missing from question'); - if (!solution) throw Error('solution is missing from question'); - - return { text, answers, solution }; -} - -function getAnswers(answersNodes) { - const answerGroups = splitOnThematicBreak(answersNodes); - - return answerGroups.map(answerGroup => { - const answerTree = root(answerGroup); - - const feedbackNodes = getSection(answerTree, '--feedback--'); - const audioIdNodes = getSection(answerTree, '--audio-id--'); - const hasFeedback = feedbackNodes.length > 0; - const hasAudioId = audioIdNodes.length > 0; - - if (hasFeedback || hasAudioId) { - let answerNodes; - - if (hasFeedback && hasAudioId) { - const feedbackHeading = find(answerTree, { - type: 'heading', - children: [{ type: 'text', value: '--feedback--' }] - }); - const audioIdHeading = find(answerTree, { - type: 'heading', - children: [{ type: 'text', value: '--audio-id--' }] - }); - - const feedbackIndex = answerTree.children.indexOf(feedbackHeading); - const audioIdIndex = answerTree.children.indexOf(audioIdHeading); - const firstMarker = - feedbackIndex < audioIdIndex ? '--feedback--' : '--audio-id--'; - answerNodes = getAllBefore(answerTree, firstMarker); - } else if (hasFeedback) { - answerNodes = getAllBefore(answerTree, '--feedback--'); - } else { - answerNodes = getAllBefore(answerTree, '--audio-id--'); - } - - if (answerNodes.length < 1) { - throw Error('Answer missing'); - } - - let extractedAudioId = null; - if (hasAudioId) { - const audioIdContent = getParagraphContent(audioIdNodes[0]); - if (audioIdContent && audioIdContent.trim()) { - extractedAudioId = audioIdContent.trim(); - } - } - - return { - answer: mdastToHtml(answerNodes), - feedback: hasFeedback ? mdastToHtml(feedbackNodes) : null, - audioId: extractedAudioId - }; - } - - return { answer: mdastToHtml(answerGroup), feedback: null, audioId: null }; - }); -} - -function getSolution(solutionNodes) { - let solution; - try { - solution = Number(getParagraphContent(solutionNodes[0])); - if (Number.isNaN(solution)) throw Error('Not a number'); - if (solution < 1) throw Error('Not positive number'); - } catch (e) { - console.log(e); - throw Error('A video solution should be a positive integer'); - } - - return solution; -} - module.exports = plugin; diff --git a/tools/challenge-parser/parser/plugins/add-video-question.test.js b/tools/challenge-parser/parser/plugins/add-video-question.test.js index 44e062c84ce..a9d1f5651d7 100644 --- a/tools/challenge-parser/parser/plugins/add-video-question.test.js +++ b/tools/challenge-parser/parser/plugins/add-video-question.test.js @@ -7,7 +7,8 @@ describe('add-video-question plugin', () => { videoAST, multipleQuestionAST, videoOutOfOrderAST, - videoWithAudioAST; + videoWithAudioAST, + chineseVideoAST; const plugin = addVideoQuestion(); let file = { data: {} }; @@ -21,6 +22,7 @@ describe('add-video-question plugin', () => { 'with-video-question-out-of-order.md' ); videoWithAudioAST = await parseFixture('with-video-question-audio.md'); + chineseVideoAST = await parseFixture('with-chinese-mcq.md'); }); beforeEach(() => { @@ -107,7 +109,7 @@ describe('add-video-question plugin', () => { it('should NOT throw if there is no question', () => { expect.assertions(1); - expect(() => plugin(simpleAST)).not.toThrow(); + expect(() => plugin(simpleAST, file)).not.toThrow(); }); it('should extract audioId from answers when present', () => { @@ -139,4 +141,39 @@ describe('add-video-question plugin', () => { plugin(videoAST, file); expect(file.data).toMatchSnapshot(); }); + + it('should render Chinese inline code as ruby in question text, answers, and feedback', async () => { + const zhFile = { data: { lang: 'zh-CN' } }; + + plugin(chineseVideoAST, zhFile); + + const question = zhFile.data.questions[0]; + + expect(question.text).toBe( + '

Question text containing 汉字(hàn zì).

' + ); + + const answer1 = question.answers[0]; + expect(answer1.answer).toContain( + '你好(nǐ hǎo)' + ); + + const answer2 = question.answers[1]; + expect(answer2.answer).toContain( + '(qǐng)' + ); + expect(answer2.feedback).toBe( + '

(qǐng) is not correct.

' + ); + + const answer3 = question.answers[2]; + expect(answer3.answer).toContain( + '请问(qǐng wèn)' + ); + + const answer4 = question.answers[3]; + expect(answer4.answer).toContain( + '(wèn)' + ); + }); }); diff --git a/tools/challenge-parser/parser/plugins/utils/i18n-stringify.js b/tools/challenge-parser/parser/plugins/utils/i18n-stringify.js new file mode 100644 index 00000000000..0e10a036ff4 --- /dev/null +++ b/tools/challenge-parser/parser/plugins/utils/i18n-stringify.js @@ -0,0 +1,78 @@ +const mdastToHTML = require('./mdast-to-html'); + +/** + * Parses Chinese text in format: hanzi (pinyin) + * @param {string} text - Text in format: hanzi (pinyin) + * @returns {{ hanzi: string, pinyin: string } | null} Parsed hanzi and pinyin, or null if not matching + */ +function parseChinesePattern(text) { + const match = text.match(/^(.+?)\s*\((.+?)\)$/); + + if (!match) { + return null; + } + + return { + hanzi: match[1].trim(), + pinyin: match[2].trim() + }; +} + +/** + * Custom handler for Chinese inline code to render as ruby elements + * @param {object} state - The state object from mdast-util-to-hast + * @param {object} node - The inlineCode node + * @returns {object} Hast element node + */ +function chineseInlineCodeHandler(state, node) { + const parsed = parseChinesePattern(node.value); + + if (parsed) { + return { + type: 'element', + tagName: 'ruby', + properties: {}, + children: [ + { type: 'text', value: parsed.hanzi }, + { + type: 'element', + tagName: 'rp', + properties: {}, + children: [{ type: 'text', value: '(' }] + }, + { + type: 'element', + tagName: 'rt', + properties: {}, + children: [{ type: 'text', value: parsed.pinyin }] + }, + { + type: 'element', + tagName: 'rp', + properties: {}, + children: [{ type: 'text', value: ')' }] + } + ] + }; + } + + return { + type: 'element', + // TODO: change this to span + // https://github.com/freeCodeCamp/language-curricula/issues/22 + tagName: 'code', + properties: {}, + children: [{ type: 'text', value: node.value }] + }; +} + +const rubyOptions = { + handlers: { + inlineCode: chineseInlineCodeHandler + } +}; + +const createMdastToHtml = lang => + lang == 'zh-CN' ? x => mdastToHTML(x, rubyOptions) : mdastToHTML; + +module.exports = { parseChinesePattern, createMdastToHtml }; diff --git a/tools/challenge-parser/parser/plugins/utils/i18n-stringify.test.js b/tools/challenge-parser/parser/plugins/utils/i18n-stringify.test.js new file mode 100644 index 00000000000..64631528c58 --- /dev/null +++ b/tools/challenge-parser/parser/plugins/utils/i18n-stringify.test.js @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { createMdastToHtml, parseChinesePattern } from './i18n-stringify'; + +describe('parseChinesePattern', () => { + it('should parse Chinese text with hanzi and pinyin', () => { + const result = parseChinesePattern('你好 (nǐ hǎo)'); + expect(result).toEqual({ + hanzi: '你好', + pinyin: 'nǐ hǎo' + }); + }); + + it('should handle text without spaces before parentheses', () => { + const result = parseChinesePattern('你好(nǐ hǎo)'); + expect(result).toEqual({ + hanzi: '你好', + pinyin: 'nǐ hǎo' + }); + }); + + it('should handle text with multiple spaces', () => { + const result = parseChinesePattern('你好 (nǐ hǎo)'); + expect(result).toEqual({ + hanzi: '你好', + pinyin: 'nǐ hǎo' + }); + }); + + it('should return null for text without parentheses', () => { + const result = parseChinesePattern('你好'); + expect(result).toBeNull(); + }); + + it('should return null for text with only opening parenthesis', () => { + const result = parseChinesePattern('你好 (nǐ hǎo'); + expect(result).toBeNull(); + }); + + it('should return null for empty string', () => { + const result = parseChinesePattern(''); + expect(result).toBeNull(); + }); +}); + +describe('createMdastToHtml', () => { + it('should render Chinese inline code as ruby when lang is zh-CN', () => { + const toHtml = createMdastToHtml('zh-CN'); + const nodes = [ + { + type: 'paragraph', + children: [ + { type: 'text', value: 'This is ' }, + { type: 'inlineCode', value: '请问 (qǐng wèn)' }, + { type: 'text', value: '.' } + ] + } + ]; + const actual = toHtml(nodes); + expect(actual).toBe( + '

This is 请问(qǐng wèn).

' + ); + }); + + it('should render Chinese inline code as ruby with or without space before parenthesis', () => { + const toHtml = createMdastToHtml('zh-CN'); + const nodesWithSpace = [ + { + type: 'paragraph', + children: [{ type: 'inlineCode', value: '你好 (nǐ hǎo)' }] + } + ]; + const nodesWithoutSpace = [ + { + type: 'paragraph', + children: [{ type: 'inlineCode', value: '你好(nǐ hǎo)' }] + } + ]; + const expected = + '

你好(nǐ hǎo)

'; + expect(toHtml(nodesWithSpace)).toBe(expected); + expect(toHtml(nodesWithoutSpace)).toBe(expected); + }); + + it('should handle multiple Chinese inline codes in one paragraph', () => { + const toHtml = createMdastToHtml('zh-CN'); + const nodes = [ + { + type: 'paragraph', + children: [ + { type: 'inlineCode', value: '你好 (nǐ hǎo)' }, + { type: 'text', value: ' and ' }, + { type: 'inlineCode', value: '再见 (zài jiàn)' } + ] + } + ]; + const actual = toHtml(nodes); + expect(actual).toBe( + '

你好(nǐ hǎo) and 再见(zài jiàn)

' + ); + }); + + it('should fallback to code element if pattern does not match', () => { + const toHtml = createMdastToHtml('zh-CN'); + const nodes = [ + { + type: 'paragraph', + children: [ + { type: 'inlineCode', value: '你好' }, + { type: 'text', value: ' and ' }, + { type: 'inlineCode', value: 'nǐ hǎo' } + ] + } + ]; + const actual = toHtml(nodes, { lang: 'zh-CN' }); + expect(actual).toBe('

你好 and nǐ hǎo

'); + }); + + it('should render as regular code when lang is not zh-CN', () => { + const toHtml = createMdastToHtml('zh'); + const nodes = [ + { + type: 'paragraph', + children: [{ type: 'inlineCode', value: '请问 (qǐng wèn)' }] + } + ]; + const actual = toHtml(nodes); + expect(actual).toBe('

请问 (qǐng wèn)

'); + }); +}); diff --git a/tools/challenge-parser/parser/plugins/utils/mdast-to-html.js b/tools/challenge-parser/parser/plugins/utils/mdast-to-html.js index a4034d43f4f..96c3ae18b78 100644 --- a/tools/challenge-parser/parser/plugins/utils/mdast-to-html.js +++ b/tools/challenge-parser/parser/plugins/utils/mdast-to-html.js @@ -2,14 +2,17 @@ const hastToHTML = require('hast-util-to-html'); const { root } = require('mdast-builder'); const mdastToHast = require('mdast-util-to-hast'); -function mdastToHTML(nodes) { +function mdastToHTML(nodes, hastOptions = {}) { if (!Array.isArray(nodes)) throw Error('mdastToHTML expects an array argument'); // - the 'nodes' are children, so first need embedding in a parent - return hastToHTML(mdastToHast(root(nodes), { allowDangerousHtml: true }), { - allowDangerousHtml: true - }); + return hastToHTML( + mdastToHast(root(nodes), { allowDangerousHtml: true, ...hastOptions }), + { + allowDangerousHtml: true + } + ); } module.exports = mdastToHTML;