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 中文
' + ); + expect(firstQuestion.distractors[0]).toBe( + 'Quiz 1, question 1, distractor 1 with 中文
' + ); + expect(firstQuestion.distractors[1]).toBe( + 'Quiz 1, question 1, distractor 2 with 中文
' + ); + expect(firstQuestion.distractors[2]).toBe( + 'Quiz 1, question 1, distractor 3 with 中文
' + ); + expect(firstQuestion.answer).toBe( + 'Quiz 1, question 1, answer with 中文
' + ); + }); + + 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 中文
Quiz 1, question 2, distractor 1 with 中文
Quiz 1, question 2, distractor 2 with 中文
Quiz 1, question 2, distractor 3 with 中文
Quiz 1, question 2, answer with 中文
Quiz 1, question 3 with zhōng wén
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
Quiz 1, question 3, answer with zhōng wén
Instructions containing 汉字.
\nWang Hua uses 请问 to politely start her question.
\nQuestion text containing 汉字.
' + ); + + const answer1 = question.answers[0]; + expect(answer1.answer).toContain( + '你好' + ); + + const answer2 = question.answers[1]; + expect(answer2.answer).toContain( + '请' + ); + expect(answer2.feedback).toBe( + '请 is not correct.
' + ); + + const answer3 = question.answers[2]; + expect(answer3.answer).toContain( + '请问' + ); + + const answer4 = question.answers[3]; + expect(answer4.answer).toContain( + '问' + ); + }); }); 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 请问.
' + ); + }); + + 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 = + '你好
'; + 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( + '你好 and 再见
' + ); + }); + + 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
请问 (qǐng wèn)