mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-04-30 16:01:14 -04:00
feat(client,challenge-parser): render Chinese as ruby markup (#63424)
Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
@@ -89,6 +89,10 @@ hr {
|
||||
border-top: 1px solid var(--quaternary-background);
|
||||
}
|
||||
|
||||
rt {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
#___gatsby {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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 != ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
'<p>Quiz 1, question 1 with <ruby>中文<rp>(</rp><rt>zhōng wén</rt><rp>)</rp></ruby></p>'
|
||||
);
|
||||
expect(firstQuestion.distractors[0]).toBe(
|
||||
'<p>Quiz 1, question 1, distractor 1 with <ruby>中文<rp>(</rp><rt>zhōng wén</rt><rp>)</rp></ruby></p>'
|
||||
);
|
||||
expect(firstQuestion.distractors[1]).toBe(
|
||||
'<p>Quiz 1, question 1, distractor 2 with <ruby>中文<rp>(</rp><rt>zhōng wén</rt><rp>)</rp></ruby></p>'
|
||||
);
|
||||
expect(firstQuestion.distractors[2]).toBe(
|
||||
'<p>Quiz 1, question 1, distractor 3 with <ruby>中文<rp>(</rp><rt>zhōng wén</rt><rp>)</rp></ruby></p>'
|
||||
);
|
||||
expect(firstQuestion.answer).toBe(
|
||||
'<p>Quiz 1, question 1, answer with <ruby>中文<rp>(</rp><rt>zhōng wén</rt><rp>)</rp></ruby></p>'
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<p>Quiz 1, question 2 with <code>中文</code></p>'
|
||||
);
|
||||
expect(secondQuestion.distractors[0]).toBe(
|
||||
'<p>Quiz 1, question 2, distractor 1 with <code>中文</code></p>'
|
||||
);
|
||||
expect(secondQuestion.distractors[1]).toBe(
|
||||
'<p>Quiz 1, question 2, distractor 2 with <code>中文</code></p>'
|
||||
);
|
||||
expect(secondQuestion.distractors[2]).toBe(
|
||||
'<p>Quiz 1, question 2, distractor 3 with <code>中文</code></p>'
|
||||
);
|
||||
expect(secondQuestion.answer).toBe(
|
||||
'<p>Quiz 1, question 2, answer with <code>中文</code></p>'
|
||||
);
|
||||
|
||||
// Quiz 1, Question 3
|
||||
expect(thirdQuestion.text).toBe(
|
||||
'<p>Quiz 1, question 3 with <code>zhōng wén</code></p>'
|
||||
);
|
||||
expect(thirdQuestion.distractors[0]).toBe(
|
||||
'<p>Quiz 1, question 3, distractor 1 with <code>zhōng wén</code></p>'
|
||||
);
|
||||
expect(thirdQuestion.distractors[1]).toBe(
|
||||
'<p>Quiz 1, question 3, distractor 2 with <code>zhōng wén</code></p>'
|
||||
);
|
||||
expect(thirdQuestion.distractors[2]).toBe(
|
||||
'<p>Quiz 1, question 3, distractor 3 with <code>zhōng wén</code></p>'
|
||||
);
|
||||
expect(thirdQuestion.answer).toBe(
|
||||
'<p>Quiz 1, question 3, answer with <code>zhōng wén</code></p>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
'<section id="instructions">\n<p>Instructions containing <ruby>汉字<rp>(</rp><rt>hàn zì</rt><rp>)</rp></ruby>.</p>\n</section>'
|
||||
);
|
||||
expect(zhFile.data.explanation).toBe(
|
||||
'<section id="explanation">\n<p>Wang Hua uses <ruby>请问<rp>(</rp><rt>qǐng wèn</rt><rp>)</rp></ruby> to politely start her question.</p>\n</section>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
'<p>Question text containing <ruby>汉字<rp>(</rp><rt>hàn zì</rt><rp>)</rp></ruby>.</p>'
|
||||
);
|
||||
|
||||
const answer1 = question.answers[0];
|
||||
expect(answer1.answer).toContain(
|
||||
'<ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby>'
|
||||
);
|
||||
|
||||
const answer2 = question.answers[1];
|
||||
expect(answer2.answer).toContain(
|
||||
'<ruby>请<rp>(</rp><rt>qǐng</rt><rp>)</rp></ruby>'
|
||||
);
|
||||
expect(answer2.feedback).toBe(
|
||||
'<p><ruby>请<rp>(</rp><rt>qǐng</rt><rp>)</rp></ruby> is not correct.</p>'
|
||||
);
|
||||
|
||||
const answer3 = question.answers[2];
|
||||
expect(answer3.answer).toContain(
|
||||
'<ruby>请问<rp>(</rp><rt>qǐng wèn</rt><rp>)</rp></ruby>'
|
||||
);
|
||||
|
||||
const answer4 = question.answers[3];
|
||||
expect(answer4.answer).toContain(
|
||||
'<ruby>问<rp>(</rp><rt>wèn</rt><rp>)</rp></ruby>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
@@ -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(
|
||||
'<p>This is <ruby>请问<rp>(</rp><rt>qǐng wèn</rt><rp>)</rp></ruby>.</p>'
|
||||
);
|
||||
});
|
||||
|
||||
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 =
|
||||
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby></p>';
|
||||
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(
|
||||
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby> and <ruby>再见<rp>(</rp><rt>zài jiàn</rt><rp>)</rp></ruby></p>'
|
||||
);
|
||||
});
|
||||
|
||||
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('<p><code>你好</code> and <code>nǐ hǎo</code></p>');
|
||||
});
|
||||
|
||||
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('<p><code>请问 (qǐng wèn)</code></p>');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user