chore: import upstream snapshot with attribution
i18n - Build Validation / Validate i18n Builds (24) (push) Has been cancelled
CI - Node.js / Lint (24) (push) Has been cancelled
CI - Node.js / Build (24) (push) Has been cancelled
CI - Node.js / Test (24) (push) Has been cancelled
CI - Node.js / Test - Upcoming Changes (24) (push) Has been cancelled
CI - Node.js / Test - i18n (italian, 24) (push) Has been cancelled
CI - Node.js / Test - i18n (portuguese, 24) (push) Has been cancelled
CD - Docker - GHCR Images / Build and Push Images (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:53 +08:00
commit dde272c4b8
19405 changed files with 2730632 additions and 0 deletions
@@ -0,0 +1,12 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`process-frontmatter plugin > should have an output to match the snapshot 1`] = `
{
"challengeType": 0,
"forumTopicId": 18276,
"id": "bd7123c8c441eddfaeb5bdef",
"isHidden": false,
"title": "Say Hello to HTML Elements",
"videoUrl": "https://scrimba.com/p/pVMPUv/cE8Gpt2",
}
`;
@@ -0,0 +1,13 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`add-before-hook plugin > should have an output to match the snapshot 1`] = `
{
"hooks": {
"beforeAll": "// before all code
function foo() {
return 'bar';
}
foo();",
},
}
`;
@@ -0,0 +1,52 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`add-quizzes plugin > should match the quizzes snapshot 1`] = `
{
"quizzes": [
{
"questions": [
{
"answer": "<p>Quiz 1, question 1, answer</p>",
"distractors": [
"<p>Quiz 1, question 1, distractor 1</p>",
"<p>Quiz 1, question 1, distractor 2</p>",
"<p>Quiz 1, question 1, distractor 3</p>",
],
"text": "<p>Quiz 1, question 1</p>",
},
{
"answer": "<p>Quiz 1, question 2, answer</p>",
"distractors": [
"<p>Quiz 1, question 2, distractor 1</p>",
"<p>Quiz 1, question 2, distractor 2</p>",
"<p>Quiz 1, question 2, distractor 3</p>",
],
"text": "<p>Quiz 1, question 2</p>",
},
],
},
{
"questions": [
{
"answer": "<p>Quiz 2, question 1, answer</p>",
"distractors": [
"<p>Quiz 2, question 1, distractor 1</p>",
"<p>Quiz 2, question 1, distractor 2</p>",
"<p>Quiz 2, question 1, distractor 3</p>",
],
"text": "<p>Quiz 2, question 1</p>",
},
{
"answer": "<p>Quiz 2, question 2, answer</p>",
"distractors": [
"<p>Quiz 2, question 2, distractor 1</p>",
"<p>Quiz 2, question 2, distractor 2</p>",
"<p>Quiz 2, question 2, distractor 3</p>",
],
"text": "<p>Quiz 2, question 2</p>",
},
],
},
],
}
`;
@@ -0,0 +1,45 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`add-seed plugin > should have an output to match the snapshot 1`] = `
{
"challengeFiles": [
{
"contents": "<html>
<body>
</body>
</html>",
"editableRegionBoundaries": [],
"ext": "html",
"id": "",
"name": "index",
},
{
"contents": "body {
background: green;
}",
"editableRegionBoundaries": [],
"ext": "css",
"id": "",
"name": "styles",
},
{
"contents": "var x = 'y';",
"editableRegionBoundaries": [],
"ext": "js",
"id": "",
"name": "script",
},
{
"contents": "{
"compilerOptions": {
"target": "ES2020"
}
}",
"editableRegionBoundaries": [],
"ext": "json",
"id": "",
"name": "tsconfig",
},
],
}
`;
@@ -0,0 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`add solution plugin > should have an output to match the snapshot 1`] = `
{
"solutions": [
[
{
"contents": "<html>
<body>
</body>
</html>",
"ext": "html",
"id": "html-key",
"name": "index",
},
{
"contents": "body {
background: white;
}",
"ext": "css",
"id": "",
"name": "styles",
},
{
"contents": "var x = 'y';",
"ext": "js",
"id": "",
"name": "script",
},
],
],
}
`;
@@ -0,0 +1,23 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`add-tests plugin > should have an output to match the snapshot 1`] = `
{
"tests": [
{
"testString": "// test code",
"text": "<p>First hint</p>",
},
{
"testString": "// more test code",
"text": "<p>Second hint with <code>code</code></p>",
},
{
"testString": "// more test code
if(let x of xs) {
console.log(x);
}",
"text": "<p>Third <em>hint</em> with <code>code</code> and <code>inline code</code></p>",
},
],
}
`;
@@ -0,0 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`add-text > should have an output to match the snapshot 1`] = `
{
"description": "<section id="description">
<p>Paragraph 1</p>
<pre><code class="language-html">code example
</code></pre>
</section>",
"instructions": "<section id="instructions">
<p>Paragraph 0</p>
<pre><code class="language-html">code example 0
</code></pre>
</section>",
}
`;
@@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`add-video-question plugin > should match the video snapshot 1`] = `
{
"questions": [
{
"answers": [
{
"answer": "<p>Some inline <code>code</code></p>",
"audioId": null,
"feedback": "<p>That is not correct.</p>",
},
{
"answer": "<p>Some <em>italics</em></p>
<p>A second answer paragraph.</p>",
"audioId": null,
"feedback": null,
},
{
"answer": "<p><code> code in </code> code tags</p>",
"audioId": null,
"feedback": null,
},
],
"solution": 3,
"text": "<p>Question line 1</p>
<pre><code class="language-js"> var x = 'y';
</code></pre>",
},
],
}
`;
@@ -0,0 +1,543 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`replace-imports > should have an output to match the snapshot 1`] = `
{
"children": [
{
"children": [
{
"position": {
"end": {
"column": 18,
"line": 3,
"offset": 67,
},
"start": {
"column": 3,
"line": 3,
"offset": 52,
},
},
"type": "text",
"value": "--description--",
},
],
"depth": 1,
"position": {
"end": {
"column": 18,
"line": 3,
"offset": 67,
},
"start": {
"column": 1,
"line": 3,
"offset": 50,
},
},
"type": "heading",
},
{
"children": [
{
"position": {
"end": {
"column": 12,
"line": 5,
"offset": 80,
},
"start": {
"column": 1,
"line": 5,
"offset": 69,
},
},
"type": "text",
"value": "Paragraph 1",
},
],
"position": {
"end": {
"column": 12,
"line": 5,
"offset": 80,
},
"start": {
"column": 1,
"line": 5,
"offset": 69,
},
},
"type": "paragraph",
},
{
"lang": "html",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 9,
"offset": 106,
},
"start": {
"column": 1,
"line": 7,
"offset": 82,
},
},
"type": "code",
"value": "code example",
},
{
"children": [
{
"position": {
"end": {
"column": 19,
"line": 11,
"offset": 126,
},
"start": {
"column": 3,
"line": 11,
"offset": 110,
},
},
"type": "text",
"value": "--instructions--",
},
],
"depth": 1,
"position": {
"end": {
"column": 19,
"line": 11,
"offset": 126,
},
"start": {
"column": 1,
"line": 11,
"offset": 108,
},
},
"type": "heading",
},
{
"children": [
{
"position": {
"end": {
"column": 12,
"line": 13,
"offset": 139,
},
"start": {
"column": 1,
"line": 13,
"offset": 128,
},
},
"type": "text",
"value": "Paragraph 0",
},
],
"position": {
"end": {
"column": 12,
"line": 13,
"offset": 139,
},
"start": {
"column": 1,
"line": 13,
"offset": 128,
},
},
"type": "paragraph",
},
{
"lang": "html",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 17,
"offset": 167,
},
"start": {
"column": 1,
"line": 15,
"offset": 141,
},
},
"type": "code",
"value": "code example 0",
},
{
"children": [
{
"position": {
"end": {
"column": 12,
"line": 19,
"offset": 180,
},
"start": {
"column": 3,
"line": 19,
"offset": 171,
},
},
"type": "text",
"value": "--hints--",
},
],
"depth": 1,
"position": {
"end": {
"column": 12,
"line": 19,
"offset": 180,
},
"start": {
"column": 1,
"line": 19,
"offset": 169,
},
},
"type": "heading",
},
{
"children": [
{
"position": {
"end": {
"column": 11,
"line": 21,
"offset": 192,
},
"start": {
"column": 1,
"line": 21,
"offset": 182,
},
},
"type": "text",
"value": "First hint",
},
],
"position": {
"end": {
"column": 11,
"line": 21,
"offset": 192,
},
"start": {
"column": 1,
"line": 21,
"offset": 182,
},
},
"type": "paragraph",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 25,
"offset": 216,
},
"start": {
"column": 1,
"line": 23,
"offset": 194,
},
},
"type": "code",
"value": "// test code",
},
{
"children": [
{
"position": {
"end": {
"column": 18,
"line": 27,
"offset": 235,
},
"start": {
"column": 1,
"line": 27,
"offset": 218,
},
},
"type": "text",
"value": "Second hint with ",
},
{
"position": {
"end": {
"column": 24,
"line": 27,
"offset": 241,
},
"start": {
"column": 18,
"line": 27,
"offset": 235,
},
},
"type": "html",
"value": "<code>",
},
{
"position": {
"end": {
"column": 28,
"line": 27,
"offset": 245,
},
"start": {
"column": 24,
"line": 27,
"offset": 241,
},
},
"type": "text",
"value": "code",
},
{
"position": {
"end": {
"column": 35,
"line": 27,
"offset": 252,
},
"start": {
"column": 28,
"line": 27,
"offset": 245,
},
},
"type": "html",
"value": "</code>",
},
],
"position": {
"end": {
"column": 35,
"line": 27,
"offset": 252,
},
"start": {
"column": 1,
"line": 27,
"offset": 218,
},
},
"type": "paragraph",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 31,
"offset": 281,
},
"start": {
"column": 1,
"line": 29,
"offset": 254,
},
},
"type": "code",
"value": "// more test code",
},
{
"children": [
{
"position": {
"end": {
"column": 11,
"line": 34,
"offset": 294,
},
"start": {
"column": 3,
"line": 34,
"offset": 286,
},
},
"type": "text",
"value": "--seed--",
},
],
"depth": 1,
"position": {
"end": {
"column": 11,
"line": 34,
"offset": 294,
},
"start": {
"column": 1,
"line": 34,
"offset": 284,
},
},
"type": "heading",
},
{
"children": [
{
"position": {
"end": {
"column": 21,
"line": 36,
"offset": 316,
},
"start": {
"column": 4,
"line": 36,
"offset": 299,
},
},
"type": "text",
"value": "--seed-contents--",
},
],
"depth": 2,
"position": {
"end": {
"column": 21,
"line": 36,
"offset": 316,
},
"start": {
"column": 1,
"line": 36,
"offset": 296,
},
},
"type": "heading",
},
{
"lang": "html",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 43,
"offset": 363,
},
"start": {
"column": 1,
"line": 38,
"offset": 318,
},
},
"type": "code",
"value": "<html>
<body>
</body>
</html>",
},
{
"lang": "css",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 49,
"offset": 405,
},
"start": {
"column": 1,
"line": 45,
"offset": 365,
},
},
"type": "code",
"value": "body {
background: green;
}",
},
{
"attributes": {
"id": "custom-name",
},
"children": [],
"name": "id",
"position": {
"end": {
"column": 19,
"line": 51,
"offset": 425,
},
"start": {
"column": 1,
"line": 51,
"offset": 407,
},
},
"type": "leafDirective",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 55,
"offset": 449,
},
"start": {
"column": 1,
"line": 53,
"offset": 427,
},
},
"type": "code",
"value": "var x = 'y';",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 6,
"offset": 125,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "code",
"value": "for (let index = 0; index < array.length; index++) {
const element = array[index];
// imported from script.md
}",
},
],
"position": {
"end": {
"column": 1,
"line": 58,
"offset": 477,
},
"start": {
"column": 1,
"line": 1,
"offset": 0,
},
},
"type": "root",
}
`;
@@ -0,0 +1,24 @@
const { getSection } = require('./utils/get-section');
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--');
function getAssignments(assignmentNodes) {
const assignmentGroups = splitOnThematicBreak(assignmentNodes);
return assignmentGroups.map(assignment => toHtml(assignment));
}
file.data.assignments = getAssignments(assignmentNodes).filter(
a => a != ''
);
}
}
module.exports = plugin;
@@ -0,0 +1,152 @@
const { root } = require('mdast-builder');
const find = require('unist-util-find');
const visit = require('unist-util-visit');
const { getSection } = require('./utils/get-section');
const getAllBefore = require('./utils/before-heading');
const {
createMdastToHtml,
parseHanziPinyinPairs
} = require('./utils/i18n-stringify');
const { splitOnThematicBreak } = require('./utils/split-on-thematic-break');
const NOT_IN_PARAGRAPHS = `Each inline code block in the fillInTheBlank sentence section must in its own paragraph
If you have more than one code block, check that they're separated by a blank line
Example of bad formatting:
\`too close\`
\`to each other\`
Example of good formatting:
\`separated\`
\`by a blank line\`
`;
const NOT_IN_CODE_BLOCK = `Each paragraph in the fillInTheBlank sentence section must be inside an inline code block
Example of bad formatting:
## --sentence--
This is a sentence
Example of good formatting:
## --sentence--
\`This is a sentence\`
`;
function plugin() {
return transformer;
function transformer(tree, file) {
const fillInTheBlankNodes = getSection(tree, '--fillInTheBlank--');
if (fillInTheBlankNodes.length > 0) {
const fillInTheBlankTree = root(fillInTheBlankNodes);
validateBlanksSectionCount(fillInTheBlankTree);
const sentenceNodes = getSection(fillInTheBlankTree, '--sentence--');
const blanksNodes = getSection(fillInTheBlankTree, '--blanks--');
const lang = file.data.lang;
const inputType = file.data.inputType;
const toHtml = createMdastToHtml(lang);
file.data.fillInTheBlank = getFillInTheBlank(sentenceNodes, blanksNodes);
function getFillInTheBlank(sentenceNodes, blanksNodes) {
const sentenceWithoutCodeBlocks = sentenceNodes.map(node => {
node.children.forEach(child => {
if (child.type === 'text' && child.value.trim() === '')
throw Error(NOT_IN_PARAGRAPHS);
if (child.type !== 'inlineCode') throw Error(NOT_IN_CODE_BLOCK);
});
// For Chinese hanzi-pinyin, keep as inlineCode so handler generates ruby elements
if (lang === 'zh-CN') {
const hasChinesePairs = node.children.some(
child =>
child.type === 'inlineCode' &&
parseHanziPinyinPairs(child.value).length > 0
);
if (hasChinesePairs) {
return node;
}
}
// Convert inlineCode to text for non-Chinese content
const children = node.children.map(child => ({
...child,
type: 'text'
}));
return { ...node, children };
});
const sentence = toHtml(sentenceWithoutCodeBlocks);
const blanks = getBlanks(blanksNodes);
if (!sentence)
throw Error('sentence is missing from fill in the blank');
if (!blanks) throw Error('blanks are missing from fill in the blank');
if (sentence.match(/BLANK/g).length !== blanks.length)
throw Error(`Number of BLANKs doesn't match the number of answers.`);
// For 'pinyin-to-hanzi' inputType, all answers must be of type 'hanzi-pinyin'.
// This validation ensures compatibility with the pinyin input in the UI,
// where users type pinyin and the system automatically converts it to hanzi
// if the input value matches the expected pinyin from the answer.
if (inputType === 'pinyin-to-hanzi') {
const allAnswersAreHanziPinyin = blanks.every(
blank => parseHanziPinyinPairs(blank.answer).length === 1
);
if (!allAnswersAreHanziPinyin) {
throw Error(
`When inputType is 'pinyin-to-hanzi', all answers must be in 'hanzi (pinyin)' format.`
);
}
}
return { sentence, blanks };
}
function getBlanks(blanksNodes) {
const blanksGroups = splitOnThematicBreak(blanksNodes);
return blanksGroups.map(blanksGroup => {
const blanksTree = root(blanksGroup);
const feedback = find(blanksTree, { value: '--feedback--' });
if (feedback) {
const blanksNodes = getAllBefore(blanksTree, '--feedback--');
const feedbackNodes = getSection(blanksTree, '--feedback--');
return {
answer: blanksNodes[0].children[0].value,
feedback: toHtml(feedbackNodes)
};
}
return {
answer: blanksGroup[0].children[0].value,
feedback: null
};
});
}
}
}
}
function validateBlanksSectionCount(fillInTheBlankTree) {
let blanksCount = 0;
visit(fillInTheBlankTree, { value: '--blanks--' }, () => {
blanksCount++;
});
if (blanksCount !== 1)
throw Error(
`There should only be one --blanks-- section in the fillInTheBlank challenge`
);
}
module.exports = plugin;
@@ -0,0 +1,276 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import parseFixture from '../__fixtures__/parse-fixture';
import addFillInTheBlankQuestion from './add-fill-in-the-blank';
describe('fill-in-the-blanks plugin', () => {
let mockFillInTheBlankAST,
mockFillInTheBlankYouAreAST,
mockFillInTheBlankTwoSentencesAST,
mockFillInTheBlankBadSentence,
mockFillInTheBlankBadParagraph,
mockFillInTheBlankMultipleBlanks,
mockChineseFillInTheBlankAST,
mockChineseFillInTheBlankNoPinyinAST,
mockChineseFillInTheBlankNoHanziAST,
mockChineseFillInTheBlankWrongAnswerFormatAST,
mockChineseFillInTheBlankBlankAnswerMismatchAST,
mockChineseFillInTheBlankLatinAST;
const plugin = addFillInTheBlankQuestion();
let file = { data: {} };
beforeAll(async () => {
mockFillInTheBlankAST = await parseFixture('with-fill-in-the-blank.md');
mockFillInTheBlankYouAreAST = await parseFixture(
'with-fill-in-the-blank-one-blank.md'
);
mockFillInTheBlankTwoSentencesAST = await parseFixture(
'with-fill-in-the-blank-two-sentences.md'
);
mockFillInTheBlankBadSentence = await parseFixture(
'with-fill-in-the-blank-bad-sentence.md'
);
mockFillInTheBlankBadParagraph = await parseFixture(
'with-fill-in-the-blank-bad-paragraph.md'
);
mockFillInTheBlankMultipleBlanks = await parseFixture(
'with-fill-in-the-blank-many-blanks.md'
);
mockChineseFillInTheBlankAST = await parseFixture(
'with-chinese-fill-in-the-blank.md'
);
mockChineseFillInTheBlankNoPinyinAST = await parseFixture(
'with-chinese-fill-in-the-blank-no-pinyin.md'
);
mockChineseFillInTheBlankNoHanziAST = await parseFixture(
'with-chinese-fill-in-the-blank-no-hanzi.md'
);
mockChineseFillInTheBlankWrongAnswerFormatAST = await parseFixture(
'with-chinese-fill-in-the-blank-wrong-answer-format.md'
);
mockChineseFillInTheBlankBlankAnswerMismatchAST = await parseFixture(
'with-chinese-fill-in-the-blank-blank-answer-mismatch.md'
);
mockChineseFillInTheBlankLatinAST = await parseFixture(
'with-chinese-fill-in-the-blank-latin.md'
);
});
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `fillInTheBlank` property to `file.data`', () => {
plugin(mockFillInTheBlankAST, file);
expect('fillInTheBlank' in file.data).toBe(true);
});
it('should generate a fillInTheBlank object from a fill-in-the-blank challenge AST', () => {
plugin(mockFillInTheBlankAST, file);
const testObject = file.data.fillInTheBlank;
expect(Object.keys(testObject).length).toBe(2);
expect(testObject).toHaveProperty('sentence');
expect(typeof testObject.sentence).toBe('string');
expect(testObject).toHaveProperty('blanks');
expect(Array.isArray(testObject.blanks)).toBe(true);
expect(testObject.blanks.length).toBe(3);
expect(testObject.blanks[0]).toHaveProperty('answer');
expect(testObject.blanks[0].answer).toEqual('are');
expect(testObject.blanks[0]).toHaveProperty('feedback');
expect(typeof testObject.blanks[0].feedback).toBe('string');
expect(testObject.blanks[1]).toHaveProperty('answer');
expect(testObject.blanks[1].answer).toEqual('right');
expect(testObject.blanks[1]).toHaveProperty('feedback');
expect(typeof testObject.blanks[1].feedback).toBe('string');
expect(testObject.blanks[2]).toHaveProperty('answer');
expect(testObject.blanks[2].answer).toEqual('Nice');
expect(testObject.blanks[2]).toHaveProperty('feedback');
expect(testObject.blanks[2].feedback).toBeNull();
});
it('should convert feedback markdown into html', () => {
plugin(mockFillInTheBlankAST, file);
const testObject = file.data.fillInTheBlank;
expect(testObject.blanks[0]).toStrictEqual({
answer: 'are',
feedback:
'<p>The verb <code>to be</code> is an irregular verb. ' +
'When conjugated with the pronoun <code>you</code>, <code>be</code> ' +
'becomes <code>are</code>. For example: <code>You are an English learner.</code></p>'
});
expect(testObject.blanks[1]).toStrictEqual({
answer: 'right',
feedback: '<p>Feedback 2</p>'
});
expect(testObject.blanks[2]).toStrictEqual({
answer: 'Nice',
feedback: null
});
});
it('should extract the sentence from the surrounding inline code block', () => {
plugin(mockFillInTheBlankAST, file);
const testObject = file.data.fillInTheBlank;
expect(testObject.sentence).toBe(
'<p>Hello, You BLANK the new graphic designer, BLANK? BLANK to meet you!</p>'
);
});
it('should extract sentences from multiple inline code blocks', () => {
plugin(mockFillInTheBlankTwoSentencesAST, file);
const testObject = file.data.fillInTheBlank;
expect(testObject.sentence).toBe(
`<p>A sentence BLANK paragraph 1</p>
<p>Sentence in BLANK 2</p>`
);
});
it('should throw if a sentence is not inside an inline code block', () => {
expect(() => {
plugin(mockFillInTheBlankBadSentence, file);
}).toThrow(
`Each paragraph in the fillInTheBlank sentence section must be inside an inline code block
Example of bad formatting:
## --sentence--
This is a sentence
Example of good formatting:
## --sentence--
\`This is a sentence\`
`
);
});
it('should throw if there are multiple inline code blocks in the same paragraph', () => {
expect(() => {
plugin(mockFillInTheBlankBadParagraph, file);
}).toThrow(
`Each inline code block in the fillInTheBlank sentence section must in its own paragraph
If you have more than one code block, check that they're separated by a blank line
Example of bad formatting:
\`too close\`
\`to each other\`
Example of good formatting:
\`separated\`
\`by a blank line\`
`
);
});
it('should throw if there are multiple --blanks-- sections', () => {
// TODO: Check if this is too wordy
expect(() => {
plugin(mockFillInTheBlankMultipleBlanks, file);
}).toThrow(
`There should only be one --blanks-- section in the fillInTheBlank challenge`
);
});
it('should handle one blank', () => {
plugin(mockFillInTheBlankYouAreAST, file);
const testObject = file.data.fillInTheBlank;
expect(testObject.blanks[0]).toStrictEqual({
answer: 'are',
feedback:
'<p>The verb <code>to be</code> is an irregular verb. When conjugated with the pronoun <code>you</code>, <code>be</code> becomes <code>are</code>. For example: <code>You are an English learner.</code></p>'
});
});
it('should parse Chinese fill-in-the-blank sentence and answer correctly if they are in `hanzi (pinyin)` format', () => {
file.data.lang = 'zh-CN';
file.data.inputType = 'pinyin-to-hanzi';
plugin(mockChineseFillInTheBlankAST, file);
const testObject = file.data.fillInTheBlank;
expect(file.data.inputType).toBe('pinyin-to-hanzi');
expect(testObject.sentence).toBe(
'<p>BLANK BLANKBLANK <ruby>是王华<rp>(</rp><rt>shì Wang Hua</rt><rp>)</rp></ruby><ruby>请问你<rp>(</rp><rt>qǐng wèn nǐ</rt><rp>)</rp></ruby> BLANK <ruby>什么名字<rp>(</rp><rt>shén me míng zi</rt><rp>)</rp></ruby></p>'
);
expect(testObject.blanks.length).toBe(4);
expect(testObject.blanks[0].answer).toEqual('你 (nǐ)');
expect(testObject.blanks[0].feedback).toBe(
'<p>Feedback text containing <ruby>汉字<rp>(</rp><rt>hàn zì</rt><rp>)</rp></ruby>.</p>'
);
expect(testObject.blanks[1].answer).toEqual('好 (hǎo)');
expect(testObject.blanks[1].feedback).toBe(
'<p>This means "good" or "well".</p>'
);
expect(testObject.blanks[2].answer).toEqual('我 (wǒ)');
expect(testObject.blanks[2].feedback).toBe('<p>This means "I".</p>');
expect(testObject.blanks[3].answer).toEqual('叫 (jiào)');
expect(testObject.blanks[3].feedback).toBe(
'<p>This means "to be called".</p>'
);
});
it('should return sentence as plain text when sentence does not contain pinyin', () => {
file.data.lang = 'zh-CN';
plugin(mockChineseFillInTheBlankNoPinyinAST, file);
const testObject = file.data.fillInTheBlank;
expect(testObject.sentence).toBe('<p>BLANK好</p>');
expect(testObject.blanks[0].answer).toEqual('你 (nǐ)');
});
it('should return sentence as plain text when sentence does not contain hanzi', () => {
file.data.lang = 'zh-CN';
plugin(mockChineseFillInTheBlankNoHanziAST, file);
const testObject = file.data.fillInTheBlank;
expect(testObject.sentence).toBe('<p>BLANK hǎo</p>');
expect(testObject.blanks[0].answer).toEqual('nǐ');
});
it("should throw if the number of blanks in the sentence doesn't match the number of answers", () => {
file.data.lang = 'zh-CN';
expect(() => {
plugin(mockChineseFillInTheBlankBlankAnswerMismatchAST, file);
}).toThrow(`Number of BLANKs doesn't match the number of answers.`);
});
it('should throw error when inputType is pinyin-to-hanzi but answer is not in hanzi-pinyin format', () => {
file.data.lang = 'zh-CN';
file.data.inputType = 'pinyin-to-hanzi';
expect(() => {
plugin(mockChineseFillInTheBlankWrongAnswerFormatAST, file);
}).toThrow(
"When inputType is 'pinyin-to-hanzi', all answers must be in 'hanzi (pinyin)' format."
);
});
it('should separate BLANK and adjacent Latin text in Chinese sentences', () => {
file.data.lang = 'zh-CN';
plugin(mockChineseFillInTheBlankLatinAST, file);
const testObject = file.data.fillInTheBlank;
expect(testObject.sentence).toBe(
'<p><ruby>我<rp>(</rp><rt>wǒ</rt><rp>)</rp></ruby> BLANK UI <ruby>设计师<rp>(</rp><rt>shè jì shī</rt><rp>)</rp></ruby> 。</p>'
);
expect(testObject.blanks.length).toBe(1);
expect(testObject.blanks[0].answer).toEqual('是 (shì)');
expect(testObject.blanks[0].feedback).toBe('<p>Feedback text.</p>');
});
});
@@ -0,0 +1,18 @@
const YAML = require('js-yaml');
const visit = require('unist-util-visit');
function plugin() {
return transformer;
function transformer(tree, file) {
visit(tree, 'yaml', visitor);
function visitor(node) {
const frontmatter = YAML.load(node.value);
file.data = { ...file.data, ...frontmatter };
}
}
}
module.exports = plugin;
@@ -0,0 +1,76 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import { isObject } from 'lodash';
import parse from '../__fixtures__/parse-fixture';
import processFrontmatter from './add-frontmatter';
describe('process-frontmatter plugin', () => {
let mockAST;
beforeAll(async () => {
mockAST = await parse('with-frontmatter.md');
});
const plugin = processFrontmatter();
let file = { data: {} };
beforeEach(() => {
file = { data: {} };
});
it('should return a plugin which is a function', () => {
expect(typeof plugin).toEqual('function');
});
it('should maintain an object for the `file.data` property', () => {
plugin(mockAST, file);
expect(isObject(file.data)).toBe(true);
});
// And no others. The AST includes some yaml code, and this also
// checks that none of those keys get parsed
it('should add all keys from frontmatter to the `file.data` property', () => {
const expectedKeys = [
'id',
'title',
'challengeType',
'isHidden',
'videoUrl',
'forumTopicId'
];
plugin(mockAST, file);
const actualKeys = Object.keys(file.data);
expect(actualKeys).toEqual(expectedKeys);
});
it('should not mutate any type held in the frontmatter', () => {
plugin(mockAST, file);
const { id, title, challengeType, videoUrl, forumTopicId } = file.data;
expect(typeof id).toEqual('string');
expect(typeof title).toEqual('string');
expect(typeof challengeType).toEqual('number');
expect(typeof videoUrl).toEqual('string');
expect(typeof forumTopicId).toEqual('number');
});
it('should trim extra whitespace from keys and values', () => {
plugin(mockAST, file);
const whitespaceRE = /(^\s\S+|\S\s$)/;
const keys = Object.keys(file.data);
keys.forEach(key => expect(whitespaceRE.test(key)).toBe(false));
const values = keys.map(key => file.data[key]);
values
.filter(value => typeof value === 'string')
.forEach(value => expect(whitespaceRE.test(value)).toBe(false));
});
it('should not mutate url strings', () => {
const expectedUrl = 'https://scrimba.com/p/pVMPUv/cE8Gpt2';
plugin(mockAST, file);
expect(file.data.videoUrl).toEqual(expectedUrl);
});
it('should have an output to match the snapshot', () => {
plugin(mockAST, file);
expect(file.data).toMatchSnapshot();
});
});
@@ -0,0 +1,42 @@
const { getSection } = require('./utils/get-section');
function plugin() {
return transformer;
function transformer(tree, file) {
const beforeAll = getHook(tree, '--before-all--');
const beforeEach = getHook(tree, '--before-each--');
const afterEach = getHook(tree, '--after-each--');
const afterAll = getHook(tree, '--after-all--');
if (!beforeAll && !beforeEach && !afterEach && !afterAll) return;
file.data.hooks = file.data.hooks = {
...(beforeAll && { beforeAll }),
...(beforeEach && { beforeEach }),
...(afterEach && { afterEach }),
...(afterAll && { afterAll })
};
}
}
function getHook(tree, sectionName) {
const section = getSection(tree, sectionName);
if (section.length === 0) return;
if (section.length > 1)
throw Error(
`# ${sectionName} section must only contain a single code block`
);
const codeNode = section[0];
if (codeNode.type !== 'code')
throw Error(`# ${sectionName} section must contain a code block`);
if (codeNode.lang !== 'javascript' && codeNode.lang !== 'js')
throw Error(`# ${sectionName} hook must be written in JavaScript`);
return codeNode.value;
}
module.exports = plugin;
@@ -0,0 +1,191 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import parseFixture from '../__fixtures__/parse-fixture';
import addBeforeHook from './add-hooks';
describe('add-before-hook plugin', () => {
let withBeforeHookAST,
withInvalidHookAST,
withAnotherInvalidHookAST,
withNonJSHookAST,
withBeforeEachHookAST,
withInvalidBeforeEachHookAST,
withAnotherInvalidBeforeEachHookAST,
withNonJSBeforeEachHookAST,
withAfterEachHookAST,
withInvalidAfterEachHookAST,
withAnotherInvalidAfterEachHookAST,
withNonJSAfterEachHookAST,
withAfterAllHookAST,
withInvalidAfterAllHookAST,
withAnotherInvalidAfterAllHookAST,
withNonJSAfterAllHookAST;
const plugin = addBeforeHook();
let file = { data: {} };
beforeAll(async () => {
withBeforeHookAST = await parseFixture('with-before-hook.md');
withInvalidHookAST = await parseFixture('with-invalid-before-hook.md');
withAnotherInvalidHookAST = await parseFixture(
'with-another-invalid-before-hook.md'
);
withNonJSHookAST = await parseFixture('with-non-js-before-hook.md');
withBeforeEachHookAST = await parseFixture('with-before-each-hook.md');
withInvalidBeforeEachHookAST = await parseFixture(
'with-invalid-before-each-hook.md'
);
withAnotherInvalidBeforeEachHookAST = await parseFixture(
'with-another-invalid-before-each-hook.md'
);
withNonJSBeforeEachHookAST = await parseFixture(
'with-non-js-before-each-hook.md'
);
withAfterEachHookAST = await parseFixture('with-after-each-hook.md');
withInvalidAfterEachHookAST = await parseFixture(
'with-invalid-after-each-hook.md'
);
withAnotherInvalidAfterEachHookAST = await parseFixture(
'with-another-invalid-after-each-hook.md'
);
withNonJSAfterEachHookAST = await parseFixture(
'with-non-js-after-each-hook.md'
);
withAfterAllHookAST = await parseFixture('with-after-all-hook.md');
withInvalidAfterAllHookAST = await parseFixture(
'with-invalid-after-all-hook.md'
);
withAnotherInvalidAfterAllHookAST = await parseFixture(
'with-another-invalid-after-all-hook.md'
);
withNonJSAfterAllHookAST = await parseFixture(
'with-non-js-after-all-hook.md'
);
});
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `hooks` property to `file.data`', () => {
plugin(withBeforeHookAST, file);
expect('hooks' in file.data).toBe(true);
});
it('populates `hooks.beforeAll` with the contents of the code block', () => {
plugin(withBeforeHookAST, file);
expect(file.data.hooks.beforeAll).toBe(`// before all code
function foo() {
return 'bar';
}
foo();`);
});
it('should throw an error if the beforeAll section has more than one child', () => {
expect(() => plugin(withInvalidHookAST, file)).toThrow(
`# --before-all-- section must only contain a single code block`
);
});
it('should throw an error if the beforeAll section does not contain a code block', () => {
expect(() => plugin(withAnotherInvalidHookAST, file)).toThrow(
`# --before-all-- section must contain a code block`
);
});
it('should throw an error if the code language is not javascript', () => {
expect(() => plugin(withNonJSHookAST, file)).toThrow(
`# --before-all-- hook must be written in JavaScript`
);
});
it('should have an output to match the snapshot', () => {
plugin(withBeforeHookAST, file);
expect(file.data).toMatchSnapshot();
});
it('populates `hooks.beforeEach` with the contents of the code block', () => {
plugin(withBeforeEachHookAST, file);
expect(file.data.hooks.beforeEach).toBe(`// before each code
function setup() {
return 'initialized';
}
setup();`);
});
it('should throw an error if the beforeEach section has more than one child', () => {
expect(() => plugin(withInvalidBeforeEachHookAST, file)).toThrow(
`# --before-each-- section must only contain a single code block`
);
});
it('should throw an error if the beforeEach section does not contain a code block', () => {
expect(() => plugin(withAnotherInvalidBeforeEachHookAST, file)).toThrow(
`# --before-each-- section must contain a code block`
);
});
it('should throw an error if the beforeEach code language is not javascript', () => {
expect(() => plugin(withNonJSBeforeEachHookAST, file)).toThrow(
`# --before-each-- hook must be written in JavaScript`
);
});
it('populates `hooks.afterEach` with the contents of the code block', () => {
plugin(withAfterEachHookAST, file);
expect(file.data.hooks.afterEach).toBe(`// after each code
function cleanup() {
return 'cleaned up';
}
cleanup();`);
});
it('should throw an error if the afterEach section has more than one child', () => {
expect(() => plugin(withInvalidAfterEachHookAST, file)).toThrow(
`# --after-each-- section must only contain a single code block`
);
});
it('should throw an error if the afterEach section does not contain a code block', () => {
expect(() => plugin(withAnotherInvalidAfterEachHookAST, file)).toThrow(
`# --after-each-- section must contain a code block`
);
});
it('should throw an error if the afterEach code language is not javascript', () => {
expect(() => plugin(withNonJSAfterEachHookAST, file)).toThrow(
`# --after-each-- hook must be written in JavaScript`
);
});
it('populates `hooks.afterAll` with the contents of the code block', () => {
plugin(withAfterAllHookAST, file);
expect(file.data.hooks.afterAll).toBe(`// after all code
function teardown() {
return 'cleaned up';
}
teardown();`);
});
it('should throw an error if the afterAll section has more than one child', () => {
expect(() => plugin(withInvalidAfterAllHookAST, file)).toThrow(
`# --after-all-- section must only contain a single code block`
);
});
it('should throw an error if the afterAll section does not contain a code block', () => {
expect(() => plugin(withAnotherInvalidAfterAllHookAST, file)).toThrow(
`# --after-all-- section must contain a code block`
);
});
it('should throw an error if the afterAll code language is not javascript', () => {
expect(() => plugin(withNonJSAfterAllHookAST, file)).toThrow(
`# --after-all-- hook must be written in JavaScript`
);
});
});
@@ -0,0 +1,73 @@
const { root } = require('mdast-builder');
const find = require('unist-util-find');
const { isEmpty } = require('lodash');
const { getFilenames } = require('./utils/get-file-visitor');
const { getSection, isMarker } = require('./utils/get-section');
const mdastToHTML = require('./utils/mdast-to-html');
function plugin() {
return transformer;
function transformer(tree, file) {
const interactiveNodes = getSection(tree, `--interactive--`, 1);
const subSection = find(root(interactiveNodes), isMarker);
if (subSection) {
throw Error(
`The --interactive-- section should not have any subsections. Found subsection ${subSection.children[0].value}`
);
}
if (!isEmpty(interactiveNodes)) {
const nodules =
interactiveNodes.map(node => {
if (
node.type === 'containerDirective' &&
node.name === 'interactive_editor'
) {
return {
type: 'interactiveEditor',
files: getFiles(node.children)
};
} else {
const paragraph = mdastToHTML([node]);
return {
type: 'paragraph',
contents: paragraph
};
}
}) ?? [];
file.data.nodules = nodules;
}
}
}
function getFiles(filesNodes) {
const invalidNode = filesNodes.find(node => node.type !== 'code');
if (invalidNode) {
throw Error('The :::interactive_editor should only contain code blocks.');
}
// TODO: refactor into two steps, 1) count languages, 2) map to files
const counts = {};
return filesNodes.map(node => {
counts[node.lang] = counts[node.lang] ? counts[node.lang] + 1 : 1;
const contentsHtml = mdastToHTML([node]);
const out = {
contents: node.value,
ext: node.lang,
name:
getFilenames(node.lang) +
(counts[node.lang] ? `-${counts[node.lang]}` : ''),
contentsHtml
};
return out;
});
}
module.exports = plugin;
@@ -0,0 +1,147 @@
import { describe, beforeEach, it, expect } from 'vitest';
const parseFixture = require('./../__fixtures__/parse-fixture');
const addInteractiveElements = require('./add-interactive-elements');
describe('add-interactive-editor plugin', () => {
const plugin = addInteractiveElements();
let file = { data: {} };
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `nodules` property to `file.data`', async () => {
const mockAST = await parseFixture('with-interactive.md');
plugin(mockAST, file);
expect(file.data).toHaveProperty('nodules');
expect(Array.isArray(file.data.nodules)).toBe(true);
});
it('converts paragraphs to paragraph nodes', async () => {
const mockAST = await parseFixture('with-interactive.md');
plugin(mockAST, file);
expect(file.data.nodules.slice(0, 2)).toEqual([
{
type: 'paragraph',
contents: '<p>Normal markdown</p>'
},
{
type: 'paragraph',
contents:
'<pre><code class="language-html">&#x3C;div>This is NOT an interactive element&#x3C;/div>\n</code></pre>'
}
]);
});
it('populates `nodules` with editor objects', async () => {
const mockAST = await parseFixture('with-interactive.md');
plugin(mockAST, file);
const editorElements = file.data.nodules.filter(
element => element.type === 'interactiveEditor'
);
expect(editorElements).toEqual([
{
type: 'interactiveEditor',
files: [
{
contents: "console.log('Interactive JS');",
ext: 'js',
name: 'script-1',
contentsHtml:
'<pre><code class="language-js">console.log(\'Interactive JS\');\n</code></pre>'
}
]
},
{
type: 'interactiveEditor',
files: [
{
contents: '<div>This is an interactive element</div>',
ext: 'html',
name: 'index-1',
contentsHtml:
'<pre><code class="language-html">&#x3C;div>This is an interactive element&#x3C;/div>\n</code></pre>'
}
]
},
{
type: 'interactiveEditor',
files: [
{
contents: '<div>This is an interactive element</div>',
ext: 'html',
name: 'index-1',
contentsHtml:
'<pre><code class="language-html">&#x3C;div>This is an interactive element&#x3C;/div>\n</code></pre>'
},
{
contents: "console.log('Interactive JS');",
ext: 'js',
name: 'script-1',
contentsHtml:
'<pre><code class="language-js">console.log(\'Interactive JS\');\n</code></pre>'
}
]
}
]);
});
it('provides unique names for each file with the same extension', async () => {
const mockAST = await parseFixture('with-multiple-js-files.md');
plugin(mockAST, file);
const editorElements = file.data.nodules.filter(
element => element.type === 'interactiveEditor'
);
expect(editorElements).toHaveLength(1);
const { files } = editorElements[0];
expect(files).toHaveLength(2);
// Both files should be JavaScript but have unique names
expect(files[0].ext).toBe('js');
expect(files[1].ext).toBe('js');
// TODO: only number if there are multiple files.
expect(files[0].name).toBe('script-1');
expect(files[1].name).toBe('script-2');
// Contents should match
expect(files[0].contents).toBe("console.log('First JavaScript file');");
expect(files[1].contents).toBe("console.log('Second JavaScript file');");
expect(files[0].contentsHtml).toContain('<pre><code class="language-js">');
expect(files[1].contentsHtml).toContain('<pre><code class="language-js">');
});
it('respects the order of elements in the original markdown', async () => {
const expectedTypes = [
'paragraph',
'paragraph',
'interactiveEditor',
'interactiveEditor',
'paragraph',
'interactiveEditor',
'paragraph'
];
const mockAST = await parseFixture('with-interactive.md');
plugin(mockAST, file);
const elements = file.data.nodules;
const types = elements.map(element => element.type);
expect(types).toEqual(expectedTypes);
});
it('throws if the interactive_editor directive contains non-code nodes', async () => {
const mockAST = await parseFixture('with-interactive-non-code.md');
expect(() => plugin(mockAST, file)).toThrow(
'The :::interactive_editor should only contain code blocks.'
);
});
});
@@ -0,0 +1,144 @@
const { root } = require('mdast-builder');
const { getSection, getAllSections } = require('./utils/get-section');
const {
createMdastToHtml,
parseHanziPinyinPairs
} = 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, audioNodes) {
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');
const questionData = { text, distractors, answer };
// Extract audio data if present
if (audioNodes.length > 0) {
// Audio should be in a JSON code block
if (audioNodes[0].type !== 'code' || audioNodes[0].lang !== 'json') {
throw Error('--audio-- section must contain a ```json code block');
}
try {
const audioData = JSON.parse(audioNodes[0].value);
// Validate structure
if (!audioData.audio || !audioData.audio.filename) {
throw Error('--audio-- section must contain audio.filename');
}
if (!audioData.transcript || !Array.isArray(audioData.transcript)) {
throw Error(
'--audio-- section must contain transcript as an array'
);
}
if (audioData.transcript.length === 0) {
throw Error('--audio-- section transcript array cannot be empty');
}
// Validate each transcript line
audioData.transcript.forEach((line, index) => {
if (!line.character || !line.text) {
throw Error(
`--audio-- transcript line ${index} must have character and text properties`
);
}
});
// Convert hanzi-pinyin pairs in transcript text to HTML ruby elements
audioData.transcript = audioData.transcript.map(line => {
if (parseHanziPinyinPairs(line.text).length > 0) {
const nodes = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: line.text }]
}
];
const html = toHtml(nodes);
const innerHtml = html.replace(/^<p>|<\/p>$/g, '');
return { ...line, text: innerHtml };
}
return line;
});
questionData.audioData = audioData;
} catch (error) {
if (error instanceof SyntaxError) {
throw Error('--audio-- section must contain valid JSON');
}
throw error;
}
}
return questionData;
}
if (quizzesNodes.length > 0) {
const compiledQuizzes = [];
const quizSections = getAllSections(root(quizzesNodes), `--quiz--`);
if (quizSections.length === 0) {
throw Error(
'The --quizzes-- section should contain at least one quiz.'
);
}
quizSections.forEach(quizNodes => {
const quizQuestions = [];
const questionTrees = getAllSections(root(quizNodes), `--question--`);
if (questionTrees.length === 0) {
throw Error(
'The --quiz-- section should contain at least one question.'
);
}
questionTrees.forEach(singleQuestionNodes => {
const questionTree = root(singleQuestionNodes);
const textNodes = getSection(questionTree, '--text--');
const distractorNodes = getSection(questionTree, '--distractors--');
const answerNodes = getSection(questionTree, '--answer--');
const audioNodes = getSection(questionTree, '--audio--');
quizQuestions.push(
getQuestion(textNodes, distractorNodes, answerNodes, audioNodes)
);
});
compiledQuizzes.push({ questions: quizQuestions });
});
if (compiledQuizzes.length > 0) {
file.data.quizzes = compiledQuizzes;
}
}
}
}
module.exports = plugin;
@@ -0,0 +1,285 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import parseFixture from './../__fixtures__/parse-fixture';
import addQuizzes from './add-quizzes';
describe('add-quizzes plugin', () => {
let mockQuizzesAST;
let chineseQuizzesAST;
let quizzesWithAudioAST;
let quizzesWithAudioInvalidJsonAST;
let quizzesWithAudioMissingFilenameAST;
let quizzesWithAudioTranscriptNotArrayAST;
let quizzesWithAudioEmptyTranscriptAST;
let quizzesWithAudioMissingTranscriptAST;
let quizzesWithAudioTranscriptMissingCharacterAST;
let quizzesWithAudioTranscriptMissingTextAST;
let quizzesWithAudioNotJsonCodeBlockAST;
const plugin = addQuizzes();
let file = { data: {} };
beforeAll(async () => {
mockQuizzesAST = await parseFixture('with-quizzes.md');
chineseQuizzesAST = await parseFixture('with-chinese-quizzes.md');
quizzesWithAudioAST = await parseFixture('with-quizzes-audio.md');
quizzesWithAudioInvalidJsonAST = await parseFixture(
'with-quizzes-audio-invalid-json.md'
);
quizzesWithAudioMissingFilenameAST = await parseFixture(
'with-quizzes-audio-missing-filename.md'
);
quizzesWithAudioTranscriptNotArrayAST = await parseFixture(
'with-quizzes-audio-transcript-not-array.md'
);
quizzesWithAudioEmptyTranscriptAST = await parseFixture(
'with-quizzes-audio-empty-transcript.md'
);
quizzesWithAudioMissingTranscriptAST = await parseFixture(
'with-quizzes-audio-missing-transcript.md'
);
quizzesWithAudioTranscriptMissingCharacterAST = await parseFixture(
'with-quizzes-audio-transcript-missing-character.md'
);
quizzesWithAudioTranscriptMissingTextAST = await parseFixture(
'with-quizzes-audio-transcript-missing-text.md'
);
quizzesWithAudioNotJsonCodeBlockAST = await parseFixture(
'with-quizzes-audio-not-json-code-block.md'
);
});
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `quizzes` property to `file.data`', () => {
plugin(mockQuizzesAST, file);
expect('quizzes' in file.data).toBe(true);
});
it('should generate a quizzes array from a quizzes challenge AST', () => {
expect.assertions(44);
plugin(mockQuizzesAST, file);
const quizzes = file.data.quizzes;
expect(Array.isArray(quizzes)).toBe(true);
expect(quizzes.length).toBe(2);
quizzes.forEach(quiz => {
expect(quiz).toHaveProperty('questions');
expect(Array.isArray(quiz.questions)).toBe(true);
const questions = quiz.questions;
expect(questions.length).toBe(2);
questions.forEach(question => {
expect(question).toHaveProperty('text');
expect(typeof question.text).toBe('string');
expect(question).toHaveProperty('answer');
expect(typeof question.answer).toBe('string');
expect(question).toHaveProperty('distractors');
expect(Array.isArray(question.distractors)).toBe(true);
const distractors = question.distractors;
distractors.forEach(distractor => {
expect(typeof distractor).toBe('string');
});
});
});
});
it('should match the quizzes snapshot', () => {
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>'
);
expect(firstQuestion.audioData.transcript).toEqual([
{
character: 'Wang Hua',
text: '<ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby>。'
}
]);
});
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 <span class="highlighted-text">中文</span></p>'
);
expect(secondQuestion.distractors[0]).toBe(
'<p>Quiz 1, question 2, distractor 1 with <span class="highlighted-text">中文</span></p>'
);
expect(secondQuestion.distractors[1]).toBe(
'<p>Quiz 1, question 2, distractor 2 with <span class="highlighted-text">中文</span></p>'
);
expect(secondQuestion.distractors[2]).toBe(
'<p>Quiz 1, question 2, distractor 3 with <span class="highlighted-text">中文</span></p>'
);
expect(secondQuestion.answer).toBe(
'<p>Quiz 1, question 2, answer with <span class="highlighted-text">中文</span></p>'
);
// Quiz 1, Question 3
expect(thirdQuestion.text).toBe(
'<p>Quiz 1, question 3 with <span class="highlighted-text">zhōng wén</span></p>'
);
expect(thirdQuestion.distractors[0]).toBe(
'<p>Quiz 1, question 3, distractor 1 with <span class="highlighted-text">zhōng wén</span></p>'
);
expect(thirdQuestion.distractors[1]).toBe(
'<p>Quiz 1, question 3, distractor 2 with <span class="highlighted-text">zhōng wén</span></p>'
);
expect(thirdQuestion.distractors[2]).toBe(
'<p>Quiz 1, question 3, distractor 3 with <span class="highlighted-text">zhōng wén</span></p>'
);
expect(thirdQuestion.answer).toBe(
'<p>Quiz 1, question 3, answer with <span class="highlighted-text">zhōng wén</span></p>'
);
});
it('should parse audio sections in quiz questions', () => {
plugin(quizzesWithAudioAST, file);
const quizzes = file.data.quizzes;
expect(Array.isArray(quizzes)).toBe(true);
expect(quizzes.length).toBe(1);
const firstQuiz = quizzes[0];
const firstQuestion = firstQuiz.questions[0];
const secondQuestion = firstQuiz.questions[1];
const thirdQuestion = firstQuiz.questions[2];
// First question has audio with timestamps
expect(firstQuestion).toHaveProperty('audioData');
expect(firstQuestion.audioData.audio.filename).toBe(
'audio-with-timestamps.mp3'
);
expect(firstQuestion.audioData.audio.startTimestamp).toBe(1.5);
expect(firstQuestion.audioData.audio.finishTimestamp).toBe(3.8);
expect(firstQuestion.audioData.transcript).toEqual([
{
character: 'Maria',
text: 'Hello, how are you?'
},
{
character: 'Tom',
text: "I'm doing well, thank you."
}
]);
expect(firstQuestion.text).toBe(
'<p>Quiz 1, question 1 with audio timestamps</p>'
);
expect(firstQuestion.distractors.length).toBe(3);
expect(firstQuestion.answer).toBe('<p>Quiz 1, question 1, answer</p>');
// Second question has audio without timestamps
expect(secondQuestion).toHaveProperty('audioData');
expect(secondQuestion.audioData.audio.filename).toBe('audio-full-file.mp3');
expect(secondQuestion.audioData.audio.startTimestamp).toBeUndefined();
expect(secondQuestion.audioData.audio.finishTimestamp).toBeUndefined();
expect(secondQuestion.audioData.transcript).toEqual([
{
character: 'Speaker',
text: 'This is the full audio transcript.'
}
]);
expect(secondQuestion.text).toBe(
'<p>Quiz 1, question 2 with audio but no timestamps</p>'
);
// Third question has no audio
expect(thirdQuestion.audioData).toBeUndefined();
expect(thirdQuestion.text).toBe('<p>Quiz 1, question 3 without audio</p>');
});
it('should throw error for audio section not in JSON code block', () => {
expect(() => plugin(quizzesWithAudioNotJsonCodeBlockAST, file)).toThrow(
'--audio-- section must contain a ```json code block'
);
});
it('should throw error for invalid JSON in audio section', () => {
expect(() => plugin(quizzesWithAudioInvalidJsonAST, file)).toThrow(
'--audio-- section must contain valid JSON'
);
});
it('should throw error for missing audio filename', () => {
expect(() => plugin(quizzesWithAudioMissingFilenameAST, file)).toThrow(
'--audio-- section must contain audio.filename'
);
});
it('should throw error for transcript not being an array', () => {
expect(() => plugin(quizzesWithAudioTranscriptNotArrayAST, file)).toThrow(
'--audio-- section must contain transcript as an array'
);
});
it('should throw error for empty transcript array', () => {
expect(() => plugin(quizzesWithAudioEmptyTranscriptAST, file)).toThrow(
'--audio-- section transcript array cannot be empty'
);
});
it('should throw error for missing transcript', () => {
expect(() => plugin(quizzesWithAudioMissingTranscriptAST, file)).toThrow(
'--audio-- section must contain transcript as an array'
);
});
it('should throw error for transcript line missing character', () => {
expect(() =>
plugin(quizzesWithAudioTranscriptMissingCharacterAST, file)
).toThrow(
'--audio-- transcript line 0 must have character and text properties'
);
});
it('should throw error for transcript line missing text', () => {
expect(() =>
plugin(quizzesWithAudioTranscriptMissingTextAST, file)
).toThrow(
'--audio-- transcript line 0 must have character and text properties'
);
});
});
@@ -0,0 +1,70 @@
const { getSection } = require('./utils/get-section');
const {
createMdastToHtml,
parseHanziPinyinPairs
} = require('./utils/i18n-stringify');
function plugin() {
return transformer;
function transformer(tree, file) {
const sceneNodes = getSection(tree, '--scene--');
if (sceneNodes.length > 0) {
if (sceneNodes.length !== 1) {
throw Error('You can only have one item in a scene, a JSON array.');
}
if (sceneNodes[0].type !== 'code' || sceneNodes[0].lang !== 'json') {
throw Error('A scene must have a ```json code block');
}
// 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;
}
}
}
module.exports = plugin;
@@ -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();
});
});
@@ -0,0 +1,129 @@
const { isEmpty } = require('lodash');
const { root } = require('mdast-builder');
const visitChildren = require('unist-util-visit-children');
const { getSection } = require('./utils/get-section');
const { getFileVisitor } = require('./utils/get-file-visitor');
const path = require('path');
const editableRegionMarker = '--fcc-editable-region--';
function isWorkshop(file) {
return file.path && file.path.includes(path.sep + 'workshop-');
}
function findRegionMarkers(challengeFile) {
const lines = challengeFile.contents.split('\n');
const editableLines = lines
.map((line, id) => (line.trim() === editableRegionMarker ? id : -1))
.filter(id => id >= 0);
if (editableLines.length > 2) {
throw Error('Editable region has too many markers: ' + editableLines);
}
if (editableLines.length === 0) {
return null;
} else if (editableLines.length === 1) {
throw Error(`Editable region not closed`);
} else {
return editableLines;
}
}
function removeLines(contents, toRemove) {
const lines = contents.split('\n');
return lines.filter((_, id) => !toRemove.includes(id)).join('\n');
}
// TODO: DRY this. Start with an array of markers and go from there.
function addSeeds() {
function transformer(tree, file) {
const seedTree = root(getSection(tree, `--seed--`));
// Not all challenges have seeds (video challenges, for example), so we stop
// processing in these cases.
if (isEmpty(seedTree.children)) return;
const contentsTree = root(getSection(seedTree, `--seed-contents--`));
const seeds = {};
// Seed contents are required.
if (isEmpty(contentsTree.children))
throw Error('## --seed-contents-- must appear in # --seed-- sections');
const visitForContents = visitChildren(
getFileVisitor(seeds, 'contents', validateEditableMarkers)
);
visitForContents(contentsTree);
const seedVals = Object.values(seeds);
file.data = {
...file.data,
challengeFiles: seedVals
};
// process region markers - remove them from content and add them to data
let totalEditableRegionMarkers = 0;
const challengeFiles = Object.values(seeds).map(data => {
const seed = { ...data };
// Per-file check: ensures no single seed file has more than 2 markers.
// This is distinct from the workshop-level check below, which enforces
// exactly 2 total markers across all seed files combined.
const editRegionMarkers = findRegionMarkers(seed);
if (editRegionMarkers) {
seed.contents = removeLines(seed.contents, editRegionMarkers);
if (editRegionMarkers[1] <= editRegionMarkers[0]) {
throw Error('Editable region must be non zero');
}
seed.editableRegionBoundaries = editRegionMarkers;
totalEditableRegionMarkers += editRegionMarkers.length;
} else {
seed.editableRegionBoundaries = [];
}
return seed;
});
if (isWorkshop(file) && totalEditableRegionMarkers !== 2) {
throw Error(
`Workshop challenge ${file.path} must have exactly 2 editable region markers`
);
}
file.data = {
...file.data,
challengeFiles
};
}
return transformer;
}
function validateEditableMarkers({ value, position }) {
const twoMarkersRE = RegExp(
editableRegionMarker + '.*' + editableRegionMarker
);
const formattedMarkerRE = /--fcc - editable - region--/;
const lines = value.split('\n');
const baseLineNumber = position.start.line + 1;
lines.forEach((line, index) => {
if (line.match(twoMarkersRE)) {
throw Error(
`Line ${
baseLineNumber + index
} has two markers. Each line should only have one.`
);
}
if (line.match(formattedMarkerRE)) {
throw Error(
`Line ${
baseLineNumber + index
} has a malformed marker. It should be --fcc-editable-region--`
);
}
});
}
module.exports = addSeeds;
module.exports.editableRegionMarker = editableRegionMarker;
module.exports.isWorkshop = isWorkshop;
@@ -0,0 +1,199 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import isArray from 'lodash/isArray';
import parseFixture from '../__fixtures__/parse-fixture';
import addSeed from './add-seed';
describe('add-seed plugin', () => {
let adjacentKeysAST,
withSeedKeysAST,
cCodeAST,
withErmsOnOneLineAST,
withEmptyContentsAST,
simpleAST,
withEditableMarkersAST,
withSeedKeysOrphanAST,
withSeedKeysExtraLinesAST,
withSeedKeysJSXAST;
const plugin = addSeed();
let file = { data: {} };
beforeAll(async () => {
adjacentKeysAST = await parseFixture('with-seed-keys-adjacent.md');
withSeedKeysAST = await parseFixture('with-seed-keys.md');
cCodeAST = await parseFixture('with-c-code.md');
withErmsOnOneLineAST = await parseFixture(
'with-editable-markers-on-one-line.md'
);
withEmptyContentsAST = await parseFixture('with-empty-contents.md');
simpleAST = await parseFixture('simple.md');
withEditableMarkersAST = await parseFixture('with-editable-markers.md');
withSeedKeysOrphanAST = await parseFixture('with-seed-keys-orphan.md');
withSeedKeysExtraLinesAST = await parseFixture(
'with-seed-keys-extra-lines.md'
);
withSeedKeysJSXAST = await parseFixture('with-seed-keys-jsx.md');
});
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `challengeFiles` property to `file.data`', () => {
plugin(simpleAST, file);
expect('challengeFiles' in file.data).toBe(true);
});
it('ensures that the `challengeFiles` property is an array', () => {
plugin(simpleAST, file);
expect(isArray(file.data.challengeFiles)).toBe(true);
});
it('adds test objects to the challengeFiles array following a schema', () => {
expect.assertions(11);
plugin(simpleAST, file);
const {
data: { challengeFiles }
} = file;
const testObject = challengeFiles.find(x => x.ext === 'js');
expect(Object.keys(testObject).length).toEqual(5);
expect(testObject).toHaveProperty('ext');
expect(typeof testObject['ext']).toBe('string');
expect(testObject).toHaveProperty('name');
expect(typeof testObject['name']).toBe('string');
expect(testObject).toHaveProperty('contents');
expect(typeof testObject['contents']).toBe('string');
expect(testObject).toHaveProperty('id');
expect(typeof testObject['id']).toBe('string');
expect(testObject).toHaveProperty('editableRegionBoundaries');
expect(isArray(testObject['editableRegionBoundaries'])).toBe(true);
});
it('parses seeds without ids', () => {
expect.assertions(3);
plugin(simpleAST, file);
const {
data: { challengeFiles }
} = file;
const scriptjs = challengeFiles.find(x => x.ext === 'js');
const indexhtml = challengeFiles.find(x => x.ext === 'html');
const stylescss = challengeFiles.find(x => x.ext === 'css');
expect(scriptjs.contents).toBe(`var x = 'y';`);
expect(indexhtml.contents).toBe(`<html>
<body>
</body>
</html>`);
expect(stylescss.contents).toBe(`body {
background: green;
}`);
});
it('removes region markers from contents', () => {
expect.assertions(2);
plugin(withEditableMarkersAST, file);
const {
data: { challengeFiles }
} = file;
const stylescss = challengeFiles.find(x => x.ext === 'css');
expect(stylescss.contents).not.toMatch('--fcc-editable-region--');
expect(stylescss.editableRegionBoundaries).toEqual([1, 4]);
});
// TODO: can we reuse 'name'? It's always 'index', I think, which suggests
// it could be reused as an id. Revisit this once the parser is live.
it('parses seeds with adjacent ids, adding the id to data', () => {
expect.assertions(3);
plugin(withSeedKeysAST, file);
const {
data: { challengeFiles }
} = file;
const scriptjs = challengeFiles.find(x => x.ext === 'js');
const indexhtml = challengeFiles.find(x => x.ext === 'html');
const stylescss = challengeFiles.find(x => x.ext === 'css');
expect(indexhtml.id).toBe('');
expect(stylescss.id).toBe('key-for-css');
expect(scriptjs.id).toBe('key-for-js');
});
it('throws if an id is anywhere except directly before a code node', () => {
expect.assertions(2);
expect(() => plugin(adjacentKeysAST, file)).toThrow(
'::id{#id}s must come before code blocks'
);
expect(() => plugin(withSeedKeysOrphanAST, file)).toThrow(
'::id{#id}s must come before code blocks'
);
});
it('ignores empty lines between ::id{#id}s and code blocks', () => {
expect.assertions(1);
plugin(withSeedKeysAST, file);
const fileTwo = { data: {} };
plugin(withSeedKeysExtraLinesAST, fileTwo);
expect(file).toEqual(fileTwo);
});
it('throws an error if there is any code of an unsupported language', () => {
expect.assertions(1);
expect(() => plugin(cCodeAST, file)).toThrow(
"On line 18 'c' is not a supported language.\n" +
' Please use one of js, css, html, jsx, ts, tsx or py'
);
});
it('throws an error (with line number) if 2 markers appear on 1 line', () => {
expect.assertions(1);
expect(() => plugin(withErmsOnOneLineAST, file)).toThrow(
`Line 53 has two markers. Each line should only have one.`
);
});
it('handles jsx', () => {
expect.assertions(1);
plugin(withSeedKeysJSXAST, file);
const {
data: { challengeFiles }
} = file;
const indexjsx = challengeFiles.find(x => x.ext === 'jsx');
expect(indexjsx.contents).toBe(`var x = 'y';
/* comment */
const Button = () => {
return <button> {/* another comment! */} text </button>;
};`);
});
it('handles json', () => {
expect.assertions(1);
plugin(simpleAST, file);
const {
data: { challengeFiles }
} = file;
const tsconfigjsonc = challengeFiles.find(x => x.ext === 'json');
expect(tsconfigjsonc.contents).toBe(
`{\n "compilerOptions": {\n "target": "ES2020"\n }\n}`
);
});
it('should throw an error if a seed has no contents', () => {
expect.assertions(1);
expect(() => plugin(withEmptyContentsAST, file)).toThrow(
`## --seed-contents-- must appear in # --seed-- sections`
);
});
it('should have an output to match the snapshot', () => {
plugin(simpleAST, file);
expect(file.data).toMatchSnapshot();
});
});
@@ -0,0 +1,63 @@
const { isEmpty } = require('lodash');
const { root } = require('mdast-builder');
const visitChildren = require('unist-util-visit-children');
const { editableRegionMarker, isWorkshop } = require('./add-seed');
const { getSection } = require('./utils/get-section');
const { getFileVisitor } = require('./utils/get-file-visitor');
const { splitOnThematicBreak } = require('./utils/split-on-thematic-break');
function validateMarkers({ value }) {
const lines = value.split('\n');
if (lines.some(line => line.match(RegExp(editableRegionMarker))))
throw Error(
'--fcc-editable-region-- should only appear in the --seed-contents--\n' +
'section, not in --solutions--'
);
}
function createPlugin() {
return function transformer(tree, file) {
const solutionArrays = splitOnThematicBreak(
getSection(tree, `--solutions--`)
);
const solutions = [];
solutionArrays.forEach(nodes => {
const solution = {};
const solutionTree = root(nodes);
const visitForContents = visitChildren(
getFileVisitor(solution, 'contents', validateMarkers)
);
visitForContents(solutionTree);
if (!isEmpty(solution)) solutions.push(Object.values(solution));
});
if (isWorkshop(file)) {
const seedSection = getSection(tree, `--seed--`);
const seedContents = getSection(root(seedSection), `--seed-contents--`);
let totalMarkers = 0;
const seedTree = root(seedContents);
seedTree.children.forEach(node => {
if (node.value) {
const lines = node.value.split('\n');
lines.forEach(line => {
if (line.trim() === '--fcc-editable-region--') totalMarkers++;
});
}
});
if (totalMarkers > 0 && totalMarkers !== 2) {
throw Error(
`Workshop challenge ${file.path} must have exactly 2 editable region markers`
);
}
}
file.data = {
...file.data,
solutions: solutions
};
};
}
module.exports = createPlugin;
@@ -0,0 +1,113 @@
import path from 'path';
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import { isObject } from 'lodash';
import parseFixture from '../__fixtures__/parse-fixture';
import addSolution from './add-solution';
describe('add solution plugin', () => {
let mockAST, multiSolnsAST, editableSolutionAST;
beforeAll(async () => {
editableSolutionAST = await parseFixture('with-erm-in-solution.md');
mockAST = await parseFixture('simple.md');
multiSolnsAST = await parseFixture('with-multiple-solns.md');
});
const plugin = addSolution();
let file = { data: {} };
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `solutions` property to `file.data`', () => {
plugin(mockAST, file);
expect('solutions' in file.data).toBe(true);
});
it('ensures that the `solutions` property is an array', () => {
plugin(mockAST, file);
expect(Array.isArray(file.data.solutions)).toBe(true);
});
it('each entry in the `solutions` array is an object', () => {
plugin(mockAST, file);
expect(file.data.solutions.every(el => isObject(el))).toBe(true);
});
it('adds solution objects to the challengeFiles array following a schema', () => {
expect.assertions(9);
plugin(mockAST, file);
const {
data: { solutions }
} = file;
const testObject = solutions[0].find(solution => solution.ext === 'js');
expect(Object.keys(testObject).length).toEqual(4);
expect(testObject).toHaveProperty('ext');
expect(typeof testObject['ext']).toBe('string');
expect(testObject).toHaveProperty('name');
expect(typeof testObject['name']).toBe('string');
expect(testObject).toHaveProperty('contents');
expect(typeof testObject['contents']).toBe('string');
expect(testObject).toHaveProperty('id');
expect(typeof testObject['id']).toBe('string');
});
it('adds multiple solutions if they exist', () => {
expect.assertions(5);
plugin(multiSolnsAST, file);
const {
data: { solutions }
} = file;
expect(solutions.length).toBe(3);
expect(solutions[0].find(solution => solution.ext === 'js').contents).toBe(
"var x = 'y';"
);
expect(solutions[1].find(solution => solution.ext === 'html').contents)
.toBe(`<html>
<body>
solution number two
</body>
</html>`);
expect(solutions[1].find(solution => solution.ext === 'css').contents)
.toBe(`body {
background: white;
}`);
expect(solutions[2].find(solution => solution.ext === 'js').contents).toBe(
"var x = 'y3';"
);
});
it('should reject solutions with editable region markers', () => {
expect.assertions(1);
expect(() => plugin(editableSolutionAST, file)).toThrow(
'--fcc-editable-region-- should only appear in the --seed-contents--\n' +
'section, not in --solutions--'
);
});
it('should have an output to match the snapshot', () => {
plugin(mockAST, file);
expect(file.data).toMatchSnapshot();
});
it('should allow solutions in non-last steps for upcoming workshop blocks', async () => {
expect.assertions(1);
const workshopNonLastAST = await parseFixture('with-multiple-solns.md');
const upcomingWorkshopFile = {
data: {},
path: path.join(
__dirname,
'../__fixtures__/workshop-upcoming-test-steps/step-1.md'
)
};
expect(() =>
plugin(workshopNonLastAST, upcomingWorkshopFile)
).not.toThrow();
});
});
@@ -0,0 +1,34 @@
const chunk = require('lodash/chunk');
const { getSection } = require('./utils/get-section');
const mdastToHtml = require('./utils/mdast-to-html');
function plugin() {
return transformer;
function transformer(tree, file) {
const hintNodes = getSection(tree, '--hints--');
if (hintNodes.length % 2 !== 0)
throw Error(
'Hints must be in pairs: each hint text followed by a test code block'
);
const tests = chunk(hintNodes, 2).map(getTest);
file.data.tests = tests;
}
}
function getTest(hintNodes) {
const [textNode, testStringNode] = hintNodes;
const text = mdastToHtml([textNode]);
const testString = testStringNode.value;
if (!text) throw Error('text is missing from hint');
// stub tests (i.e. text, but no testString) are allowed, but the md must
// have a code block, even if it is empty.
if (!testString && testString !== '')
throw Error('testString (code block) is missing from hint');
return { text, testString };
}
module.exports = plugin;
@@ -0,0 +1,83 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import parseFixture from '../__fixtures__/parse-fixture';
import addTests from './add-tests';
describe('add-tests plugin', () => {
let brokenHintsAST, simpleAST, missingTestStringAST;
const plugin = addTests();
let file = { data: {} };
beforeAll(async () => {
simpleAST = await parseFixture('simple.md');
brokenHintsAST = await parseFixture('with-broken-hints.md');
missingTestStringAST = await parseFixture('with-missing-teststring.md');
});
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `tests` property to `file.data`', () => {
plugin(simpleAST, file);
expect('tests' in file.data).toBe(true);
});
it('adds test objects to the tests array following a schema', () => {
expect.assertions(5);
plugin(simpleAST, file);
const testObject = file.data.tests[0];
expect(Object.keys(testObject).length).toBe(2);
expect(testObject).toHaveProperty('testString');
expect(typeof testObject.testString).toBe('string');
expect(testObject).toHaveProperty('text');
expect(typeof testObject.text).toBe('string');
});
// TODO: make this a bit more robust and informative
it('should throw if a test pair is out of order', () => {
// TODO: update the markdown so it makes this error
expect(() => plugin(brokenHintsAST, file)).toThrow(
'Hints must be in pairs: each hint text followed by a test code block'
);
});
it('should throw if a test string is not found', () => {
expect(() => plugin(missingTestStringAST, file)).toThrow(
'testString (code block) is missing from hint'
);
});
it('preserves code whitespace in testStrings', () => {
plugin(simpleAST, file);
const testObject = file.data.tests[2];
expect(testObject.testString).toBe(`// more test code
if(let x of xs) {
console.log(x);
}`);
});
it('does not encode html', () => {
plugin(simpleAST, file);
const testObject = file.data.tests[1];
expect(testObject.text).toBe('<p>Second hint with <code>code</code></p>');
});
it('converts test text from md to html', () => {
plugin(simpleAST, file);
const testObject = file.data.tests[2];
expect(testObject.text).toBe(
'<p>Third <em>hint</em> with <code>code</code>' +
' and <code>inline code</code></p>'
);
});
it('should have an output to match the snapshot', () => {
plugin(simpleAST, file);
expect(file.data).toMatchSnapshot();
});
});
@@ -0,0 +1,36 @@
const { isEmpty } = require('lodash');
const find = require('unist-util-find');
const { root } = require('mdast-builder');
const { getSection, isMarker } = require('./utils/get-section');
const { createMdastToHtml } = require('./utils/i18n-stringify');
function addText(sectionIds) {
if (!sectionIds || !Array.isArray(sectionIds) || sectionIds.length <= 0) {
throw new Error('addText must have an array of section ids supplied');
}
function transformer(tree, file) {
for (const sectionId of sectionIds) {
const textNodes = getSection(tree, `--${sectionId}--`, 1);
const subSection = find(root(textNodes), isMarker);
if (subSection) {
throw Error(
`The --${sectionId}-- section should not have any subsections. Found subsection ${subSection.children[0].value}`
);
}
const toHtml = createMdastToHtml(file.data.lang);
const sectionText = toHtml(textNodes);
if (!isEmpty(sectionText)) {
file.data = {
...file.data,
[sectionId]: `<section id="${sectionId}">
${sectionText}
</section>`
};
}
}
}
return transformer;
}
module.exports = addText;
@@ -0,0 +1,224 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import parseFixture from '../__fixtures__/parse-fixture';
import addText from './add-text';
describe('add-text', () => {
let realisticAST,
mockAST,
withSubSectionAST,
withNestedInstructionsAST,
withChineseAST,
withEnUsAST,
withEsAST;
const descriptionId = 'description';
const instructionsId = 'instructions';
const missingId = 'missing';
let file = { data: {} };
beforeAll(async () => {
realisticAST = await parseFixture('realistic.md');
mockAST = await parseFixture('simple.md');
withSubSectionAST = await parseFixture('with-subsection.md');
withNestedInstructionsAST = await parseFixture(
'with-nested-instructions.md'
);
withChineseAST = await parseFixture('with-chinese-mcq.md');
withEnUsAST = await parseFixture('with-en-us-mcq.md');
withEsAST = await parseFixture('with-es-mcq.md');
});
beforeEach(() => {
file = { data: {} };
});
it('should return a function', () => {
const plugin = addText(['section']);
expect(typeof plugin).toEqual('function');
});
it('throws when no argument or the incorrect argument is supplied', () => {
expect.assertions(5);
const expectedError = 'addText must have an array of section ids supplied';
expect(() => {
addText();
}).toThrow(expectedError);
expect(() => {
addText('');
}).toThrow(expectedError);
expect(() => {
addText({});
}).toThrow(expectedError);
expect(() => {
addText(1);
}).toThrow(expectedError);
expect(() => {
addText([]);
}).toThrow(expectedError);
});
it('throws when there is a sub-section in one of the sections', () => {
const plugin = addText([instructionsId, descriptionId]);
expect(() => {
plugin(withSubSectionAST, file);
}).toThrow(
'The --description-- section should not have any subsections. Found subsection --not-allowed--'
);
});
it('should add a string relating to the section id to `file.data`', () => {
const plugin = addText([descriptionId]);
plugin(mockAST, file);
const expectedText = 'Paragraph 1';
expect(file.data[descriptionId]).toEqual(
expect.stringContaining(expectedText)
);
});
it('should not add a string relating a different id to `file.data`', () => {
const plugin = addText([descriptionId]);
plugin(mockAST, file);
// the following text is in the AST, but is associated with a different
// marker (instructions)
const expectedText = 'Paragraph 0';
expect(file.data[descriptionId]).not.toEqual(
expect.stringContaining(expectedText)
);
});
// TODO: do we need to add the ids to the section tags? Why not just have
// <section>?
it('should embed the text in sections with appropriate ids', () => {
const plugin = addText([descriptionId, instructionsId]);
plugin(mockAST, file);
// the following text is in the AST, but is associated with a different
// marker (instructions)
const descriptionSectionText = `<section id="description">
<p>Paragraph 1</p>
<pre><code class="language-html">code example
</code></pre>
</section>`;
expect(file.data[descriptionId]).toEqual(descriptionSectionText);
const instructionsSectionText = `<section id="instructions">
<p>Paragraph 0</p>
<pre><code class="language-html">code example 0
</code></pre>
</section>`;
expect(file.data[instructionsId]).toBe(instructionsSectionText);
});
it('should add nothing if a section id does not appear in the md', () => {
const plugin = addText([missingId]);
plugin(mockAST, file);
expect(file.data[descriptionId]).toBeUndefined();
});
it('should not escape html', () => {
const plugin = addText([descriptionId]);
plugin(realisticAST, file);
const expected = `last <code>h2</code> element`;
expect(file.data[descriptionId]).toEqual(expect.stringContaining(expected));
});
it('should preserve nested html', () => {
const plugin = addText([descriptionId]);
plugin(realisticAST, file);
const expected = `<blockquote>
<p>Some text in a blockquote</p>
<p>
Some text in a blockquote, with <code>code</code>
</p>
</blockquote>`;
expect(file.data[descriptionId]).toEqual(expect.stringContaining(expected));
});
it('should not add paragraphs when html elements are separated by whitespace', () => {
const plugin = addText([instructionsId]);
plugin(realisticAST, file);
const expectedText1 = `<code>code</code> <tag>with more after a space</tag>`;
const expectedText2 = `another pair of <strong>elements</strong> <em>with a space</em>`;
expect(file.data[instructionsId]).toEqual(
expect.stringContaining(expectedText1)
);
expect(file.data[instructionsId]).toEqual(
expect.stringContaining(expectedText2)
);
});
it('should ignore --instructions-- markers that are not at depth 1', () => {
const plugin = addText([instructionsId]);
plugin(withNestedInstructionsAST, file);
// Should only include the depth 1 instructions, not the nested ones
const expectedText = `<section id="instructions">
<p>These are the main instructions at depth 1.</p>
<pre><code class="language-html">&#x3C;div>Main instructions code&#x3C;/div>
</code></pre>
</section>`;
expect(file.data[instructionsId]).toEqual(expectedText);
});
it('should have an output to match the snapshot', () => {
const plugin = addText([descriptionId, instructionsId]);
plugin(mockAST, file);
expect(file.data).toMatchSnapshot();
});
it('should render inline code as code elements when lang is undefined', () => {
const plugin = addText(['instructions', 'explanation']);
const defaultFile = { data: {} };
plugin(withChineseAST, defaultFile);
expect(defaultFile.data.instructions).toBe(
'<section id="instructions">\n<p>Instructions containing <code>汉字 (hàn zì)</code>.</p>\n</section>'
);
expect(defaultFile.data.explanation).toBe(
'<section id="explanation">\n<p><code>我是 (wǒ shì) Web 开发者 (kāi fā zhě)。</code> I am a web developer.</p>\n<p><code>你好 (nǐ hǎo),我是王华 (wǒ shì Wang Hua),请问你叫什么名字 (qǐng wèn nǐ jiào shén me míng zi)</code> Hello, I am Wang Hua, may I ask what your name is?</p>\n</section>'
);
});
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><ruby>我是<rp>(</rp><rt>wǒ shì</rt><rp>)</rp></ruby> Web <ruby>开发者<rp>(</rp><rt>kāi fā zhě</rt><rp>)</rp></ruby>。 I am a web developer.</p>\n<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby><ruby>我是王华<rp>(</rp><rt>wǒ shì Wang Hua</rt><rp>)</rp></ruby><ruby>请问你叫什么名字<rp>(</rp><rt>qǐng wèn nǐ jiào shén me míng zi</rt><rp>)</rp></ruby> Hello, I am Wang Hua, may I ask what your name is?</p>\n</section>'
);
});
it('should render inline code as span elements when lang is en-US', () => {
const plugin = addText(['instructions', 'explanation']);
const enUsFile = { data: { lang: 'en-US' } };
plugin(withEnUsAST, enUsFile);
expect(enUsFile.data.instructions).toBe(
'<section id="instructions">\n<p>Instructions containing <span class="highlighted-text">some code</span>.</p>\n</section>'
);
expect(enUsFile.data.explanation).toBe(
'<section id="explanation">\n<p>Explanation text containing <span class="highlighted-text">highlighted text</span>.</p>\n</section>'
);
});
it('should render inline code as span elements when lang is es', () => {
const plugin = addText(['instructions', 'explanation']);
const esFile = { data: { lang: 'es' } };
plugin(withEsAST, esFile);
expect(esFile.data.instructions).toBe(
'<section id="instructions">\n<p>Instructions containing <span class="highlighted-text">texto resaltado</span>.</p>\n</section>'
);
expect(esFile.data.explanation).toBe(
'<section id="explanation">\n<p>Explanation text containing <span class="highlighted-text">texto resaltado</span>.</p>\n</section>'
);
});
});
@@ -0,0 +1,146 @@
const { root } = require('mdast-builder');
const find = require('unist-util-find');
const { getSection, getAllSections } = require('./utils/get-section');
const getAllBefore = require('./utils/before-heading');
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');
if (solution > answers.length)
throw Error(
`solution must be within range of number of answers: 1-${answers.length}`
);
if (answers[solution - 1].feedback)
throw Error('answer selected as solution cannot have feedback section');
return { text, answers, solution };
}
function getAnswers(answersNodes) {
const answerGroups = splitOnThematicBreak(answersNodes);
return answerGroups.map((answerGroup, index) => {
const answerTree = root(answerGroup);
const feedbackGroups = getAllSections(answerTree, '--feedback--');
if (feedbackGroups.length > 1)
throw new Error(`answer ${index + 1} has multiple feedback sections`);
const [feedbackNodes] = feedbackGroups;
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) {
const questions = [];
const questionTrees = [];
allQuestionNodes.forEach(questionNode => {
const isStartOfQuestion =
questionNode.children?.[0]?.value === '--text--';
if (isStartOfQuestion) {
questionTrees.push([questionNode]);
} else if (questionTrees.length === 0) {
throw Error('question text is missing in questions section');
} else {
questionTrees[questionTrees.length - 1].push(questionNode);
}
});
questionTrees.forEach(questionNodes => {
const questionTree = root(questionNodes);
const textNodes = getSection(questionTree, '--text--');
const answersNodes = getSection(questionTree, '--answers--');
const solutionNodes = getSection(questionTree, '--video-solution--');
questions.push(getQuestion(textNodes, answersNodes, solutionNodes));
});
file.data.questions = questions;
}
}
}
module.exports = plugin;
@@ -0,0 +1,268 @@
import { describe, beforeAll, beforeEach, it, expect } from 'vitest';
import parseFixture from '../__fixtures__/parse-fixture';
import addVideoQuestion from './add-video-question';
describe('add-video-question plugin', () => {
let simpleAST,
videoAST,
multipleQuestionAST,
videoOutOfOrderAST,
videoWithAudioAST,
chineseVideoAST,
enUsVideoAST,
esVideoAST,
videoWithSolutionAboveNumberOfAnswersAST,
videoWithFeedbackTwiceInARow,
videoWithCorrectAnswerWithFeedback;
const plugin = addVideoQuestion();
let file = { data: {} };
beforeAll(async () => {
simpleAST = await parseFixture('simple.md');
videoAST = await parseFixture('with-video-question.md');
multipleQuestionAST = await parseFixture(
'with-multiple-video-questions.md'
);
videoOutOfOrderAST = await parseFixture(
'with-video-question-out-of-order.md'
);
videoWithAudioAST = await parseFixture('with-video-question-audio.md');
videoWithSolutionAboveNumberOfAnswersAST = await parseFixture(
'with-video-question-solution-above-number-of-answers.md'
);
videoWithFeedbackTwiceInARow = await parseFixture(
'with-video-question-feedback-twice-in-a-row.md'
);
videoWithCorrectAnswerWithFeedback = await parseFixture(
'with-video-question-correct-answer-with-feedback.md'
);
chineseVideoAST = await parseFixture('with-chinese-mcq.md');
enUsVideoAST = await parseFixture('with-en-us-mcq.md');
esVideoAST = await parseFixture('with-es-mcq.md');
});
beforeEach(() => {
file = { data: {} };
});
it('returns a function', () => {
expect(typeof plugin).toEqual('function');
});
it('adds a `questions` property to `file.data`', () => {
plugin(videoAST, file);
expect('questions' in file.data).toBe(true);
});
const checkQuestion = question => {
expect(question).toHaveProperty('text');
expect(typeof question.text).toBe('string');
expect(question).toHaveProperty('solution');
expect(typeof question.solution).toBe('number');
expect(question).toHaveProperty('answers');
expect(Array.isArray(question.answers)).toBe(true);
expect(typeof question.answers[0]).toBe('object');
expect(question.answers[0]).toHaveProperty('answer');
expect(question.answers[0].answer).toBeTruthy();
expect(question.answers[0]).toHaveProperty('feedback');
expect(question.answers[0]).toHaveProperty('audioId');
};
it('should generate a questions array from a video challenge AST', () => {
plugin(videoAST, file);
const testArr = file.data.questions;
expect(Array.isArray(testArr)).toBe(true);
expect(testArr.length).toBe(1);
checkQuestion(testArr[0]);
});
it('should include multiple questions if present', () => {
plugin(multipleQuestionAST, file);
const testArr = file.data.questions;
expect(Array.isArray(testArr)).toBe(true);
expect(testArr.length).toBe(2);
for (const testObject of testArr) {
checkQuestion(testObject);
}
});
it('should convert question and answer markdown into html', () => {
plugin(videoAST, file);
const testObject = file.data.questions[0];
expect(Object.keys(testObject).length).toBe(3);
expect(testObject.text).toBe(
'<p>Question line 1</p>\n' +
`<pre><code class="language-js"> var x = 'y';\n` +
'</code></pre>'
);
expect(testObject.solution).toBe(3);
expect(testObject.answers[0]).toStrictEqual({
answer: '<p>Some inline <code>code</code></p>',
feedback: '<p>That is not correct.</p>',
audioId: null
});
expect(testObject.answers[1]).toStrictEqual({
answer: `<p>Some <em>italics</em></p>
<p>A second answer paragraph.</p>`,
feedback: null,
audioId: null
});
expect(testObject.answers[2]).toStrictEqual({
answer: '<p><code> code in </code> code tags</p>',
feedback: null,
audioId: null
});
});
// TODO: consider testing for more specific messages. Ideally we them to say
// 'The md is missing "x"', so it's obvious how to fix things.
it('should throw if the subheadings are outside the question heading', () => {
expect.assertions(1);
expect(() => plugin(videoOutOfOrderAST, file)).toThrow(
'question text is missing in questions section'
);
});
it('should throw if solution is higher than the number of answers', () => {
expect.assertions(1);
expect(() =>
plugin(videoWithSolutionAboveNumberOfAnswersAST, file)
).toThrow('solution must be within range of number of answers: 1-3');
});
it('should throw if answer has more than one feedback section', () => {
expect.assertions(1);
expect(() => plugin(videoWithFeedbackTwiceInARow, file)).toThrow(
'answer 2 has multiple feedback sections'
);
});
it('should throw if correct answer has feedback section', () => {
expect.assertions(1);
expect(() => plugin(videoWithCorrectAnswerWithFeedback, file)).toThrow(
'answer selected as solution cannot have feedback section'
);
});
it('should NOT throw if there is no question', () => {
expect.assertions(1);
expect(() => plugin(simpleAST, file)).not.toThrow();
});
it('should extract audioId from answers when present', () => {
plugin(videoWithAudioAST, file);
const testObject = file.data.questions[0];
expect(testObject.answers[0]).toStrictEqual({
answer: '<p>Some inline <code>code</code></p>',
feedback: '<p>That is not correct.</p>',
audioId: 'answer1-audio'
});
expect(testObject.answers[1]).toStrictEqual({
answer: `<p>Some <em>italics</em></p>
<p>A second answer paragraph.</p>`,
feedback: null,
audioId: 'answer2-audio'
});
expect(testObject.answers[2]).toStrictEqual({
answer: '<p><code> code in </code> code tags</p>',
feedback: null,
audioId: null
});
});
it('should match the video snapshot', () => {
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>'
);
});
it('should render inline code as spans in question text, answers, and feedback for en-US', async () => {
const enUsFile = { data: { lang: 'en-US' } };
plugin(enUsVideoAST, enUsFile);
const question = enUsFile.data.questions[0];
expect(question.text).toBe(
'<p>Question text containing <span class="highlighted-text">highlighted text</span>.</p>'
);
const answer1 = question.answers[0];
expect(answer1.answer).toBe(
'<p><span class="highlighted-text">correct answer</span></p>'
);
const answer2 = question.answers[1];
expect(answer2.answer).toBe(
'<p><span class="highlighted-text">wrong answer</span></p>'
);
expect(answer2.feedback).toBe(
'<p>Feedback text containing <span class="highlighted-text">highlighted text</span>.</p>'
);
});
it('should render inline code as spans in question text, answers, and feedback for es', async () => {
const esFile = { data: { lang: 'es' } };
plugin(esVideoAST, esFile);
const question = esFile.data.questions[0];
expect(question.text).toBe(
'<p>Question text containing <span class="highlighted-text">texto resaltado</span>.</p>'
);
const answer1 = question.answers[0];
expect(answer1.answer).toBe(
'<p><span class="highlighted-text">correct answer</span></p>'
);
const answer2 = question.answers[1];
expect(answer2.answer).toBe(
'<p><span class="highlighted-text">wrong answer</span></p>'
);
expect(answer2.feedback).toBe(
'<p>Feedback text containing <span class="highlighted-text">texto resaltado</span>.</p>'
);
});
});
@@ -0,0 +1,107 @@
const path = require('path');
const { isEmpty } = require('lodash');
const remark = require('remark');
const { read } = require('to-vfile');
const modifyChildren = require('unist-util-modify-children');
const remove = require('unist-util-remove');
const { selectAll } = require('unist-util-select');
const visit = require('unist-util-visit');
const { editableRegionMarker } = require('./add-seed');
const tableAndStrikeThrough = require('./table-and-strikethrough');
async function parse(file) {
return await remark().use(tableAndStrikeThrough).parse(file);
}
function plugin() {
return transformer;
function transformer(tree, file, next) {
const importedFiles = selectAll('leafDirective[name=import]', tree);
if (!file) {
next('replace-imports must be passed a file');
return;
}
if (isEmpty(importedFiles)) {
next();
return;
}
const importPromises = importedFiles.map(async ({ attributes }) => {
const { from, component } = attributes;
// if these are missing, bail, since it's not an import.
if (!from || !component) {
return null;
}
const location = path.resolve(file.dirname, from);
return await read(location)
.then(parse)
.then(importedFile => {
function modifier(node, index, parent) {
const { type, name, attributes } = node;
const target = attributes ? attributes.component : null;
if (
type === 'leafDirective' &&
name === 'use' &&
target === component
) {
if (!validateImports(importedFile))
throw Error(
'Importing files containing ' +
editableRegionMarker +
's is not supported.'
);
parent.children.splice(index, 1, ...importedFile.children);
}
}
const modify = modifyChildren(modifier);
modify(tree);
});
});
// We're not interested in the results of importing, we just want to
// modify the tree and pass that new tree to follow plugins - as a result,
// we can't just use .then(next), as it would pass the array into next.
// Also, we remove the import statements here.
Promise.all(importPromises)
.then(() => {
remove(tree, isImportNode);
next();
})
.catch(err => {
console.error('error processing ::import');
console.error(err);
next(err);
});
}
}
function isImportNode({ type, name, attributes }) {
if (!attributes) return false;
return (
type === 'leafDirective' &&
name === 'import' &&
attributes.component &&
attributes.from
);
}
function validateImports(fileTree) {
let valid = true;
function visitor({ value }) {
if (value && value.includes(editableRegionMarker)) {
valid = false;
return visit.EXIT;
} else {
return visit.CONTINUE;
}
}
visit(fileTree, visitor);
return valid;
}
module.exports = plugin;
@@ -0,0 +1,255 @@
import { resolve } from 'path';
import { describe, beforeAll, beforeEach, it, expect, vi } from 'vitest';
import cloneDeep from 'lodash/cloneDeep';
import toVfile from 'to-vfile';
import { selectAll } from 'unist-util-select';
import parseFixture from '../__fixtures__/parse-fixture';
import addImports from './replace-imports';
describe('replace-imports', () => {
let importsAST,
importsTwoAST,
importsExtraAST,
simpleAST,
markerAST,
correctFile,
incorrectFile;
let originalImportsAST,
originalImportsTwoAST,
originalImportsExtraAST,
originalMarkerAST,
originalSimpleAST;
beforeAll(async () => {
originalImportsAST = await parseFixture('with-imports.md');
originalImportsTwoAST = await parseFixture('with-imports-two.md');
originalImportsExtraAST = await parseFixture('with-imports-extra.md');
originalSimpleAST = await parseFixture('simple.md');
originalMarkerAST = await parseFixture('with-marker-imports.md');
});
beforeEach(() => {
importsAST = cloneDeep(originalImportsAST);
importsTwoAST = cloneDeep(originalImportsTwoAST);
importsExtraAST = cloneDeep(originalImportsExtraAST);
simpleAST = cloneDeep(originalSimpleAST);
markerAST = cloneDeep(originalMarkerAST);
correctFile = toVfile(
resolve(__dirname, '../__fixtures__/with-imports.md')
);
incorrectFile = toVfile(
resolve(__dirname, '../__fixtures__/incorrect-path/with-imports.md')
);
});
it('should return a function', () => {
expect.assertions(1);
const plugin = addImports();
expect(typeof plugin).toEqual('function');
});
it('should fail when the imported file is null', () => {
const plugin = addImports();
const nextSpy = vi.fn();
plugin(importsAST, null, nextSpy);
expect(nextSpy).toHaveBeenCalledWith(
'replace-imports must be passed a file'
);
});
it('should proceed when the imported file exists', async () => {
const plugin = addImports();
await expect(
new Promise(resolve => {
plugin(importsAST, correctFile, resolve);
})
).resolves.toBeUndefined();
});
it('should fail when the imported file cannot be found', async () => {
expect.assertions(2);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const plugin = addImports();
await expect(
new Promise((resolve, reject) => {
plugin(importsAST, incorrectFile, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
})
).rejects.toBeTruthy();
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
consoleErrorSpy.mockRestore();
});
it('should modify the tree when there are imports', async () => {
expect.assertions(1);
const plugin = addImports();
await new Promise(resolve => {
plugin(importsAST, correctFile, resolve);
});
expect(importsAST).not.toEqual(originalImportsAST);
});
it('should NOT modify the tree when there are NO imports', async () => {
expect.assertions(1);
const plugin = addImports();
await new Promise(resolve => {
plugin(simpleAST, correctFile, resolve);
});
expect(simpleAST).toEqual(originalSimpleAST);
});
it('should remove all import statements', async () => {
expect.assertions(2);
const selector = 'leafDirective[name=import]';
const plugin = addImports();
const importNodes = selectAll(selector, importsAST);
expect(importNodes.length).toBe(1);
await new Promise(resolve => {
plugin(importsAST, correctFile, resolve);
});
const importNodesAfter = selectAll(selector, importsAST);
expect(importNodesAfter.length).toBe(0);
});
it('should not remove an ::import without the required attributes', async () => {
expect.assertions(2);
const selector = 'leafDirective[name=import]';
const plugin = addImports();
const importNodes = selectAll(selector, importsExtraAST);
expect(importNodes.length).toBe(3);
await new Promise(resolve => {
plugin(importsExtraAST, correctFile, resolve);
});
const importNodesAfter = selectAll(selector, importsExtraAST);
expect(importNodesAfter.length).toBe(1);
});
it('should remove all matching ::use statements', async () => {
expect.assertions(2);
const selector = 'leafDirective[name=use]';
const plugin = addImports();
const components = selectAll(selector, importsAST);
// one matching component and two other jsx nodes
expect(components.length).toBe(1);
await new Promise(resolve => {
plugin(importsAST, correctFile, resolve);
});
const componentsAfter = selectAll(selector, importsAST);
expect(componentsAfter.length).toBe(0);
});
it('should replace the ::use statement with the imported content', async () => {
// checks the contents of script.md are there after the import step
expect.assertions(2);
const plugin = addImports();
await new Promise(resolve => {
plugin(importsAST, correctFile, resolve);
});
const jsNodes = selectAll('code[lang=js]', importsAST);
expect(jsNodes.length).toBe(4);
const codeValues = jsNodes.map(({ value }) => value);
expect(codeValues).toEqual(
expect.arrayContaining([
`for (let index = 0; index < array.length; index++) {
const element = array[index];
// imported from script.md
}`
])
);
});
it('should handle multiple import statements', async () => {
// checks the contents of script.md are there after the import step
expect.assertions(4);
const plugin = addImports();
await new Promise(resolve => {
plugin(importsTwoAST, correctFile, resolve);
});
const jsNodes = selectAll('code[lang=js]', importsTwoAST);
expect(jsNodes.length).toBe(4);
const codeValues = jsNodes.map(({ value }) => value);
expect(codeValues).toEqual(
expect.arrayContaining([
`for (let index = 0; index < array.length; index++) {
const element = array[index];
// imported from script.md
}`
])
);
const cssNodes = selectAll('code[lang=css]', importsTwoAST);
expect(cssNodes.length).toBe(2);
const cssValues = cssNodes.map(({ value }) => value);
expect(cssValues).toEqual(
expect.arrayContaining([
`div {
background: red
}`
])
);
});
it('should reject imported files with editable region markers', async () => {
expect.assertions(2);
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const plugin = addImports();
await expect(
new Promise((resolve, reject) => {
plugin(markerAST, correctFile, err => {
if (err) {
reject(err);
} else {
resolve();
}
});
})
).rejects.toBeTruthy();
expect(consoleErrorSpy).toHaveBeenCalledTimes(2);
consoleErrorSpy.mockRestore();
});
it('should have an output to match the snapshot', async () => {
const plugin = addImports();
await new Promise(resolve => {
plugin(importsAST, correctFile, resolve);
});
expect(importsAST).toMatchSnapshot();
});
});
@@ -0,0 +1,32 @@
const directive = require('mdast-util-directive');
var toMarkdown = require('mdast-util-to-markdown');
const { matches } = require('unist-util-select');
const visit = require('unist-util-visit');
function plugin() {
return transformer;
function transformer(tree) {
visit(tree, visitor);
function visitor(node, id, parent) {
// currently `remark-directive` seems to be ignoring containerDirectives
// but, assuming that will get fixed, we test for it anyway.
const isDirective =
matches('leafDirective', node) ||
matches('textDirective', node) ||
matches('containerDirective', node);
if (isDirective) {
parent.children[id] = {
type: 'text',
value: toMarkdown(node, {
extensions: [directive.toMarkdown]
}).trim()
};
}
}
}
}
module.exports = plugin;
@@ -0,0 +1,69 @@
import { describe, beforeEach, beforeAll, it, expect } from 'vitest';
import cloneDeep from 'lodash/cloneDeep';
import find from 'unist-util-find';
import { selectAll } from 'unist-util-select';
import parseFixture from '../__fixtures__/parse-fixture';
import restoreDirectives from './restore-directives';
describe('restore-directives', () => {
let directivesAST, directivesOriginalAST;
beforeAll(async () => {
directivesOriginalAST = await parseFixture('with-directives.md');
});
beforeEach(() => {
directivesAST = cloneDeep(directivesOriginalAST);
});
it('should return a function', () => {
expect.assertions(1);
const plugin = restoreDirectives();
expect(typeof plugin).toEqual('function');
});
// TODO: if remark-directive starts processing containers, add them to the
// tests
it('should remove any directives in the AST', () => {
expect.assertions(4);
const plugin = restoreDirectives();
let leaves = selectAll('leafDirective', directivesAST);
let text = selectAll('textDirective', directivesAST);
expect(leaves.length).toBe(2);
expect(text.length).toBe(2);
plugin(directivesAST);
leaves = selectAll('leafDirective', directivesAST);
text = selectAll('textDirective', directivesAST);
expect(leaves.length).toBe(0);
expect(text.length).toBe(0);
});
it('should put the original text into the AST', () => {
expect.assertions(4);
const plugin = restoreDirectives();
let nodeWithImport = find(
directivesAST,
node => node.value && node.value.includes('::import')
);
let nodeWithRoot = find(
directivesAST,
node => node.value && node.value.includes(':root')
);
expect(nodeWithImport).not.toBeTruthy();
expect(nodeWithRoot).not.toBeTruthy();
plugin(directivesAST);
nodeWithImport = find(
directivesAST,
node => node.value && node.value.includes('::import')
);
nodeWithRoot = find(
directivesAST,
node => node.value && node.value.includes(':root')
);
expect(nodeWithImport).toBeTruthy();
expect(nodeWithRoot).toBeTruthy();
});
});
@@ -0,0 +1,25 @@
'use strict';
var strikethroughFromMD = require('mdast-util-gfm-strikethrough/from-markdown');
var tableFromMD = require('mdast-util-gfm-table/from-markdown');
var strikethrough = require('micromark-extension-gfm-strikethrough');
var table = require('micromark-extension-gfm-table');
module.exports = tableAndStrikethrough;
function tableAndStrikethrough() {
var data = this.data();
add('micromarkExtensions', strikethrough());
add('micromarkExtensions', table);
add('fromMarkdownExtensions', strikethroughFromMD);
add('fromMarkdownExtensions', tableFromMD);
function add(field, value) {
if (data[field]) data[field].push(value);
else data[field] = [value];
}
}
// Based on remark-gfm, extended as described in
// https://github.com/remarkjs/remark/tree/main/packages/remark-parse#extending-the-parser
@@ -0,0 +1,395 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`getSection > should match the hints snapshot 1`] = `
[
{
"children": [
{
"position": {
"end": {
"column": 11,
"line": 19,
"offset": 142,
},
"start": {
"column": 1,
"line": 19,
"offset": 132,
},
},
"type": "text",
"value": "First hint",
},
],
"position": {
"end": {
"column": 11,
"line": 19,
"offset": 142,
},
"start": {
"column": 1,
"line": 19,
"offset": 132,
},
},
"type": "paragraph",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 23,
"offset": 166,
},
"start": {
"column": 1,
"line": 21,
"offset": 144,
},
},
"type": "code",
"value": "// test code",
},
{
"children": [
{
"position": {
"end": {
"column": 18,
"line": 25,
"offset": 185,
},
"start": {
"column": 1,
"line": 25,
"offset": 168,
},
},
"type": "text",
"value": "Second hint with ",
},
{
"position": {
"end": {
"column": 24,
"line": 25,
"offset": 191,
},
"start": {
"column": 18,
"line": 25,
"offset": 185,
},
},
"type": "html",
"value": "<code>",
},
{
"position": {
"end": {
"column": 28,
"line": 25,
"offset": 195,
},
"start": {
"column": 24,
"line": 25,
"offset": 191,
},
},
"type": "text",
"value": "code",
},
{
"position": {
"end": {
"column": 35,
"line": 25,
"offset": 202,
},
"start": {
"column": 28,
"line": 25,
"offset": 195,
},
},
"type": "html",
"value": "</code>",
},
],
"position": {
"end": {
"column": 35,
"line": 25,
"offset": 202,
},
"start": {
"column": 1,
"line": 25,
"offset": 168,
},
},
"type": "paragraph",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 29,
"offset": 231,
},
"start": {
"column": 1,
"line": 27,
"offset": 204,
},
},
"type": "code",
"value": "// more test code",
},
{
"children": [
{
"position": {
"end": {
"column": 7,
"line": 31,
"offset": 239,
},
"start": {
"column": 1,
"line": 31,
"offset": 233,
},
},
"type": "text",
"value": "Third ",
},
{
"children": [
{
"position": {
"end": {
"column": 12,
"line": 31,
"offset": 244,
},
"start": {
"column": 8,
"line": 31,
"offset": 240,
},
},
"type": "text",
"value": "hint",
},
],
"position": {
"end": {
"column": 13,
"line": 31,
"offset": 245,
},
"start": {
"column": 7,
"line": 31,
"offset": 239,
},
},
"type": "emphasis",
},
{
"position": {
"end": {
"column": 19,
"line": 31,
"offset": 251,
},
"start": {
"column": 13,
"line": 31,
"offset": 245,
},
},
"type": "text",
"value": " with ",
},
{
"position": {
"end": {
"column": 25,
"line": 31,
"offset": 257,
},
"start": {
"column": 19,
"line": 31,
"offset": 251,
},
},
"type": "html",
"value": "<code>",
},
{
"position": {
"end": {
"column": 29,
"line": 31,
"offset": 261,
},
"start": {
"column": 25,
"line": 31,
"offset": 257,
},
},
"type": "text",
"value": "code",
},
{
"position": {
"end": {
"column": 36,
"line": 31,
"offset": 268,
},
"start": {
"column": 29,
"line": 31,
"offset": 261,
},
},
"type": "html",
"value": "</code>",
},
{
"position": {
"end": {
"column": 41,
"line": 31,
"offset": 273,
},
"start": {
"column": 36,
"line": 31,
"offset": 268,
},
},
"type": "text",
"value": " and ",
},
{
"position": {
"end": {
"column": 54,
"line": 31,
"offset": 286,
},
"start": {
"column": 41,
"line": 31,
"offset": 273,
},
},
"type": "inlineCode",
"value": "inline code",
},
],
"position": {
"end": {
"column": 54,
"line": 31,
"offset": 286,
},
"start": {
"column": 1,
"line": 31,
"offset": 233,
},
},
"type": "paragraph",
},
{
"lang": "js",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 38,
"offset": 353,
},
"start": {
"column": 1,
"line": 33,
"offset": 288,
},
},
"type": "code",
"value": "// more test code
if(let x of xs) {
console.log(x);
}",
},
]
`;
exports[`getSection > should match the instructions snapshot 1`] = `
[
{
"children": [
{
"position": {
"end": {
"column": 12,
"line": 11,
"offset": 89,
},
"start": {
"column": 1,
"line": 11,
"offset": 78,
},
},
"type": "text",
"value": "Paragraph 0",
},
],
"position": {
"end": {
"column": 12,
"line": 11,
"offset": 89,
},
"start": {
"column": 1,
"line": 11,
"offset": 78,
},
},
"type": "paragraph",
},
{
"lang": "html",
"meta": null,
"position": {
"end": {
"column": 4,
"line": 15,
"offset": 117,
},
"start": {
"column": 1,
"line": 13,
"offset": 91,
},
},
"type": "code",
"value": "code example 0",
},
]
`;
@@ -0,0 +1,21 @@
const find = require('unist-util-find');
const findAllBefore = require('unist-util-find-all-before');
function getAllBefore(tree, marker) {
const start = find(tree, {
type: 'heading',
children: [
{
type: 'text',
value: marker
}
]
});
if (!start) return [];
// reverse because it goes up the tree backwards and adds nodes in that order
return findAllBefore(tree, start).reverse();
}
module.exports = getAllBefore;
@@ -0,0 +1,32 @@
import isArray from 'lodash/isArray';
import { describe, beforeAll, it, expect } from 'vitest';
import parseFixture from '../../__fixtures__/parse-fixture';
import getAllBefore from './before-heading';
describe('before-headings', () => {
let simpleAst;
beforeAll(async () => {
simpleAst = await parseFixture('simple.md');
});
it('should return an array', () => {
expect.assertions(1);
const actual = getAllBefore(simpleAst, '--hints--');
expect(isArray(actual)).toBe(true);
});
it('should return an empty array if the marker is not present', () => {
expect.assertions(2);
const actual = getAllBefore(simpleAst, '--not-a-marker--');
expect(isArray(actual)).toBe(true);
expect(actual.length).toBe(0);
});
it('should include the whole AST before the marker', () => {
expect.assertions(1);
const actual = getAllBefore(simpleAst, '--hints--');
expect(actual).toHaveLength(6);
});
});
@@ -0,0 +1,27 @@
const visit = require('unist-util-visit');
const _ = require('lodash');
/**
* Finds all nodes in a tree that match a given condition. This is a trivial
* extension of `unist-util-find` that returns all matching nodes.
*
* @param {Object} tree - The unist tree to search through.
* @param {Function|Object} condition - The condition to match nodes
* against. This can be a function that accepts a single node argument or an object to match.
* @returns {Array} An array of nodes that match the condition.
*/
function findAll(tree, condition) {
const predicate = _.iteratee(condition);
const results = [];
visit(tree, node => {
if (predicate(node)) {
results.push(node);
}
return visit.CONTINUE;
});
return results;
}
module.exports.findAll = findAll;
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { findAll } from './find-all';
const testTree = {
type: 'root',
children: [
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: 'test', testId: 1 }]
},
{
type: 'paragraph',
children: [{ type: 'text', value: 'different', testId: 2 }]
},
{
type: 'heading',
depth: 2,
children: [{ type: 'text', value: 'test', testId: 3 }]
},
{
type: 'heading',
depth: 1,
children: [{ type: 'text', value: 'test', testId: 4 }]
}
]
};
describe('findAll', () => {
it('should return an array', () => {
expect(findAll(testTree, () => false)).toEqual([]);
});
it('should return an array of nodes that match the test', () => {
expect(findAll(testTree, { type: 'text', value: 'test' })).toEqual([
{ type: 'text', value: 'test', testId: 1 },
{ type: 'text', value: 'test', testId: 3 },
{ type: 'text', value: 'test', testId: 4 }
]);
});
});
@@ -0,0 +1,99 @@
const { isEmpty } = require('lodash');
const is = require('unist-util-is');
const position = require('unist-util-position');
const getId = require('./get-id');
const supportedLanguages = [
'js',
'css',
'html',
'jsx',
'py',
'ts',
'tsx',
'json'
];
const longToShortLanguages = {
javascript: 'js',
typescript: 'ts',
python: 'py'
};
function defaultFile(lang, id) {
return {
ext: lang,
name: getFilenames(lang),
contents: '',
id
};
}
function getFilenames(lang) {
const langToFilename = {
js: 'script',
css: 'styles',
py: 'main',
json: 'tsconfig'
};
return langToFilename[lang] ?? 'index';
}
function getFileVisitor(seeds, seedKey, validate) {
return (node, index, parent) => {
if (is(node, 'root')) return;
if (is(node, 'code')) {
codeToData(node, seeds, seedKey, validate);
return;
}
idToData(node, index, parent, seeds);
};
}
function codeToData(node, seeds, seedKey, validate) {
if (validate) validate(node);
const lang = node.lang;
const shortLang = longToShortLanguages[lang] ?? lang;
if (!supportedLanguages.includes(shortLang))
throw Error(`On line ${
position.start(node).line
} '${shortLang}' is not a supported language.
Please use one of js, css, html, jsx, ts, tsx or py
`);
const fileId = `index${shortLang}`;
const id = seeds[fileId] ? seeds[fileId].id : '';
// the contents will be missing if there is an id preceding this code
// block.
if (!seeds[fileId]) {
seeds[fileId] = defaultFile(shortLang, id);
}
seeds[fileId][seedKey] = isEmpty(seeds[fileId][seedKey])
? node.value
: seeds[fileId][seedKey] + '\n' + node.value;
}
function idToData(node, index, parent, seeds) {
const id = getId(node);
// If this is reached, the node type is neither root nor code. If it is not
// an id, there must be a syntax error.
if (!id) {
throw Error(
'Unexpected syntax in seed/solution. Must be ::id{#id} or a code ' +
'block (```) \n'
);
}
const codeNode = parent.children[index + 1];
if (codeNode && is(codeNode, 'code')) {
const shortLang = longToShortLanguages[codeNode.lang] ?? codeNode.lang;
const fileKey = `index${shortLang}`;
if (seeds[fileKey]) throw Error('::id{#id}s must come before code blocks');
seeds[fileKey] = defaultFile(shortLang, id);
} else {
throw Error('::id{#id}s must come before code blocks');
}
}
module.exports.getFileVisitor = getFileVisitor;
module.exports.getFilenames = getFilenames;
@@ -0,0 +1,8 @@
// getId expects the image reference node to be the sole node in a paragraph
function getId(node) {
const { type, name, attributes } = node;
if (type !== 'leafDirective' || name !== 'id' || !attributes) return null;
return attributes.id;
}
module.exports = getId;
@@ -0,0 +1,50 @@
import { describe, beforeAll, it, expect } from 'vitest';
import parseFixture from '../../__fixtures__/parse-fixture';
import getId from './get-id';
describe('get-id', () => {
let idNode, imageNode, multipleChildrenNode;
beforeAll(async () => {
const withIdNode = await parseFixture('with-id-node.md');
idNode = withIdNode.children[0];
imageNode = withIdNode.children[1];
multipleChildrenNode = withIdNode.children[2];
});
it('should return a string', () => {
expect.assertions(1);
const actual = getId(idNode);
expect(typeof actual).toBe('string');
});
it('should get the expected identifier', () => {
expect.assertions(1);
const actual = getId(idNode);
expect(actual).toBe('html-key');
});
it('should return null if the node does contain an id', () => {
expect.assertions(1);
const actual = getId(imageNode);
expect(actual).toBeNull();
});
// TODO: bin this (and the json!) after development (it'll be a silly test
// once we're using directives)
it('should ignore image nodes', () => {
expect.assertions(1);
const actual = getId(imageNode);
expect(actual).toBeNull();
});
// TODO: bin this (and the json!) after development (it'll be a silly test
// once we're using directives)
// TODO: do we want to fail silently? Might it be better to output warnings
// or perhaps even stop the parser? Probably warnings if anything.
it('should ignore paragraphs that contain more than the id element', () => {
expect.assertions(1);
const actual = getId(multipleChildrenNode);
expect(actual).toBeNull();
});
});
@@ -0,0 +1,7 @@
const mdastToHtml = require('./mdast-to-html');
function getParagraphContent(node) {
return node.type === 'paragraph' ? mdastToHtml(node.children) : null;
}
module.exports.getParagraphContent = getParagraphContent;
@@ -0,0 +1,24 @@
import { describe, beforeAll, it, expect } from 'vitest';
import parseFixture from '../../__fixtures__/parse-fixture';
import { getParagraphContent } from './get-paragraph-content';
describe('getParagraphContent', () => {
let simpleAST;
beforeAll(async () => {
simpleAST = await parseFixture('simple.md');
});
it('should return the content of a paragraph node', () => {
const paragraphNode = simpleAST.children[1];
expect(paragraphNode.type).toBe('paragraph');
expect(getParagraphContent(paragraphNode)).toEqual('Paragraph 1');
});
it('should return null if the node is not a paragraph', () => {
const headingNode = simpleAST.children[0];
expect(headingNode.type).toBe('heading');
expect(getParagraphContent(headingNode)).toBeNull();
});
});
@@ -0,0 +1,58 @@
const find = require('unist-util-find');
const findAfter = require('unist-util-find-after');
const findAllAfter = require('unist-util-find-all-after');
const between = require('unist-util-find-all-between');
const { findAll } = require('./find-all');
const isMarker = node => {
if (node.children && node.children[0]) {
const child = node.children[0];
return (
child.type === 'text' &&
child.value.startsWith('--') &&
child.value.endsWith('--') &&
node.type === 'heading'
);
}
return false;
};
function _getSection(tree) {
return start => {
if (!start) return [];
const isEnd = node => {
return node.depth <= start.depth && isMarker(node);
};
const end = findAfter(tree, start, isEnd);
const targetNodes = end
? between(tree, start, end)
: findAllAfter(tree, start);
return targetNodes;
};
}
const startNode = (marker, depth) => ({
type: 'heading',
children: [
{
type: 'text',
value: marker
}
],
...((typeof depth === 'number' && { depth }) || {})
});
function getSection(tree, marker, depth) {
const start = find(tree, startNode(marker, depth));
return _getSection(tree)(start);
}
function getAllSections(tree, marker) {
const starts = findAll(tree, startNode(marker));
return starts.map(_getSection(tree));
}
module.exports = { getSection, getAllSections, isMarker };
@@ -0,0 +1,58 @@
import { describe, beforeAll, it, expect } from 'vitest';
import isArray from 'lodash/isArray';
import { root } from 'mdast-builder';
import find from 'unist-util-find';
import parseFixture from '../../__fixtures__/parse-fixture';
import { getSection } from './get-section';
describe('getSection', () => {
let simpleAst, extraHeadingAst;
beforeAll(async () => {
extraHeadingAst = await parseFixture('with-extra-heading.md');
simpleAst = await parseFixture('simple.md');
});
it('should return an array', () => {
const actual = getSection(simpleAst, '--hints--');
expect(isArray(actual)).toBe(true);
});
it('should return an empty array if the marker is not present', () => {
const actual = getSection(simpleAst, '--not-a-marker--');
expect(isArray(actual)).toBe(true);
expect(actual.length).toBe(0);
});
it('should include any headings without markers', () => {
const actual = getSection(extraHeadingAst, '--description--');
expect(
find(root(actual), {
value: 'this should still be inside --description--'
})
).not.toBeUndefined();
});
it('should include the rest of the AST if there is no end marker', () => {
const actual = getSection(extraHeadingAst, '--solutions--');
expect(actual.length > 0).toBe(true);
expect(
find(root(actual), { value: 'body {\n background: white;\n}' })
).not.toBeUndefined();
});
it('should ignore a marker if the depth is not correct', () => {
const actual = getSection(extraHeadingAst, '--instructions--', 2);
expect(actual).toHaveLength(0);
});
it('should match the hints snapshot', () => {
const actual = getSection(simpleAst, '--hints--');
expect(actual).toMatchSnapshot();
});
it('should match the instructions snapshot', () => {
const actual = getSection(simpleAst, '--instructions--');
expect(actual).toMatchSnapshot();
});
});
@@ -0,0 +1,140 @@
const mdastToHTML = require('./mdast-to-html');
// Captures hanzi (pinyin) pairs (hanzi, optional whitespace, then pinyin parentheses)
const HANZI_PINYIN_PAIR = '([\u4e00-\u9fff]+)\\s*\\(([^)]+)\\)';
// Matches the BLANK placeholder
const BLANK_TOKEN = 'BLANK';
// Matches Chinese and English punctuation
const PUNCTUATION = '[,。?!!?,;:;:、]+';
// Matches Latin text with spaces
const OTHER_TEXT = '([a-zA-Z\\s]+)';
const HANZI_PINYIN_REGEX = new RegExp(
`${HANZI_PINYIN_PAIR}|${BLANK_TOKEN}|${PUNCTUATION}|${OTHER_TEXT}`,
'g'
);
/**
* Parses all hanzi-pinyin pairs from text
* @param {string} text - Text potentially containing multiple hanzi (pinyin) patterns
* @returns {Array<{hanzi: string, pinyin: string}>} Array of parsed pairs
*/
function parseHanziPinyinPairs(text) {
const pairs = [];
const regex = new RegExp(HANZI_PINYIN_REGEX);
let match;
while ((match = regex.exec(text)) !== null) {
if (match[1] && match[2]) {
pairs.push({
hanzi: match[1].trim(),
pinyin: match[2].trim()
});
}
}
return pairs;
}
/**
* Custom handler for Chinese inline code to render as ruby elements
* Matches hanzi-pinyin pairs, BLANK, and punctuation as separate elements
* @param {object} state - The state object from mdast-util-to-hast
* @param {object} node - The inlineCode node
* @returns {object|Array<object>} Hast element node or array of nodes
*/
function chineseInlineCodeHandler(state, node) {
const rubyPairs = parseHanziPinyinPairs(node.value);
if (rubyPairs.length > 0) {
const matches = [...node.value.matchAll(HANZI_PINYIN_REGEX)];
const nodes = matches.map(fullMatch => {
if (fullMatch[1] && fullMatch[2]) {
return {
type: 'element',
tagName: 'ruby',
properties: {},
children: [
{ type: 'text', value: fullMatch[1].trim() },
{
type: 'element',
tagName: 'rp',
properties: {},
children: [{ type: 'text', value: '(' }]
},
{
type: 'element',
tagName: 'rt',
properties: {},
children: [{ type: 'text', value: fullMatch[2].trim() }]
},
{
type: 'element',
tagName: 'rp',
properties: {},
children: [{ type: 'text', value: ')' }]
}
]
};
}
// Other captures (BLANK, punctuation, other text including spaces) should preserve exactly
return { type: 'text', value: fullMatch[0] };
});
return nodes.length === 1 ? nodes[0] : nodes;
}
// If static text, return code
return {
type: 'element',
tagName: 'span',
properties: { className: 'highlighted-text' },
children: [{ type: 'text', value: node.value }]
};
}
/**
* Custom handler for inline code to render as span elements
* @param {object} state - The state object from mdast-util-to-hast
* @param {object} node - The inlineCode node
* @returns {object} Hast element node
*/
function spanInlineCodeHandler(state, node) {
return {
type: 'element',
tagName: 'span',
properties: { className: 'highlighted-text' },
children: [{ type: 'text', value: node.value }]
};
}
const spanOrRubyOptions = {
handlers: {
inlineCode: chineseInlineCodeHandler
}
};
const spanOptions = {
handlers: {
inlineCode: spanInlineCodeHandler
}
};
const createMdastToHtml = lang => {
if (lang === 'zh-CN') {
return x => mdastToHTML(x, spanOrRubyOptions);
} else if (lang === 'en-US' || lang === 'es') {
return x => mdastToHTML(x, spanOptions);
} else {
return mdastToHTML;
}
};
module.exports = {
parseHanziPinyinPairs,
createMdastToHtml
};
@@ -0,0 +1,292 @@
import { describe, it, expect } from 'vitest';
import { createMdastToHtml, parseHanziPinyinPairs } from './i18n-stringify';
describe('parseHanziPinyinPairs', () => {
it('should parse single hanzi-pinyin pair', () => {
const withSpaceSeparator = parseHanziPinyinPairs('你好 (nǐ hǎo)');
expect(withSpaceSeparator).toHaveLength(1);
expect(withSpaceSeparator[0]).toMatchObject({
hanzi: '你好',
pinyin: 'nǐ hǎo'
});
const withoutSpaceSeparator = parseHanziPinyinPairs('你好(nǐ hǎo)');
expect(withoutSpaceSeparator).toHaveLength(1);
expect(withoutSpaceSeparator[0]).toMatchObject({
hanzi: '你好',
pinyin: 'nǐ hǎo'
});
});
it('should parse multiple hanzi-pinyin pairs', () => {
const withSpaceSeparator = parseHanziPinyinPairs(
'你好 (nǐ hǎo),我是王华 (wǒ shì Wang Hua)'
);
expect(withSpaceSeparator).toHaveLength(2);
expect(withSpaceSeparator[0]).toMatchObject({
hanzi: '你好',
pinyin: 'nǐ hǎo'
});
expect(withSpaceSeparator[1]).toMatchObject({
hanzi: '我是王华',
pinyin: 'wǒ shì Wang Hua'
});
const withoutSpaceSeparator = parseHanziPinyinPairs(
'你好(nǐ hǎo),我是王华(wǒ shì Wang Hua)'
);
expect(withoutSpaceSeparator).toHaveLength(2);
expect(withoutSpaceSeparator[0]).toMatchObject({
hanzi: '你好',
pinyin: 'nǐ hǎo'
});
expect(withoutSpaceSeparator[1]).toMatchObject({
hanzi: '我是王华',
pinyin: 'wǒ shì Wang Hua'
});
});
it('should return empty array for text without pairs', () => {
const result = parseHanziPinyinPairs('你好');
expect(result).toHaveLength(0);
});
});
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 render BLANK tokens and punctuation marks as plain text', () => {
const toHtml = createMdastToHtml('zh-CN');
const withoutSpacesAroundBlanks = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value:
'你好 (nǐ hǎo)BLANK是王华 (shì Wang Hua),请问你 (qǐng wèn nǐ)BLANK什么名字 (shén me míng zi)'
}
]
}
];
expect(toHtml(withoutSpacesAroundBlanks)).toBe(
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby>BLANK<ruby>是王华<rp>(</rp><rt>shì Wang Hua</rt><rp>)</rp></ruby><ruby>请问你<rp>(</rp><rt>qǐng wèn nǐ</rt><rp>)</rp></ruby>BLANK<ruby>什么名字<rp>(</rp><rt>shén me míng zi</rt><rp>)</rp></ruby></p>'
);
const withSpacesAroundBlanks = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value:
'你好 (nǐ hǎo) BLANK 是王华 (shì Wang Hua),请问你 (qǐng wèn nǐ) BLANK 什么名字 (shén me míng zi)'
}
]
}
];
expect(toHtml(withSpacesAroundBlanks)).toBe(
'<p><ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby> BLANK <ruby>是王华<rp>(</rp><rt>shì Wang Hua</rt><rp>)</rp></ruby><ruby>请问你<rp>(</rp><rt>qǐng wèn nǐ</rt><rp>)</rp></ruby> BLANK <ruby>什么名字<rp>(</rp><rt>shén me míng zi</rt><rp>)</rp></ruby></p>'
);
});
it('should render Latin words as plain text while applying ruby to hanzi-pinyin pairs', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value: '我是 (wǒ shì) UI 设计师 (shè jì shī)'
}
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p><ruby>我是<rp>(</rp><rt>wǒ shì</rt><rp>)</rp></ruby> UI <ruby>设计师<rp>(</rp><rt>shè jì shī</rt><rp>)</rp></ruby></p>'
);
});
it('should handle BLANK token and Latin word mix', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [
{
type: 'inlineCode',
value: '我 (wǒ) BLANK UI 设计师 (shè jì shī)'
}
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p><ruby>我<rp>(</rp><rt>wǒ</rt><rp>)</rp></ruby> BLANK UI <ruby>设计师<rp>(</rp><rt>shè jì shī</rt><rp>)</rp></ruby></p>'
);
});
it('should render multiple adjacent BLANK tokens in Chinese sentence', () => {
const toHtml = createMdastToHtml('zh-CN');
const nodes = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: 'BLANK BLANK,你好 (nǐ hǎo)' }]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p>BLANK BLANK<ruby>你好<rp>(</rp><rt>nǐ hǎo</rt><rp>)</rp></ruby></p>'
);
});
it('should fallback to span 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);
expect(actual).toBe(
'<p><span class="highlighted-text">你好</span> and <span class="highlighted-text">nǐ hǎo</span></p>'
);
});
it('should render inline code as span when lang is en-US', () => {
const toHtml = createMdastToHtml('en-US');
const nodes = [
{
type: 'paragraph',
children: [
{ type: 'text', value: 'This is ' },
{ type: 'inlineCode', value: 'highlighted text' },
{ type: 'text', value: '.' }
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p>This is <span class="highlighted-text">highlighted text</span>.</p>'
);
});
it('should render inline code as span when lang is es', () => {
const toHtml = createMdastToHtml('es');
const nodes = [
{
type: 'paragraph',
children: [
{ type: 'text', value: 'Esto texto ' },
{ type: 'inlineCode', value: 'está resaltado' },
{ type: 'text', value: '.' }
]
}
];
const actual = toHtml(nodes);
expect(actual).toBe(
'<p>Esto texto <span class="highlighted-text">está resaltado</span>.</p>'
);
});
// NOTE: The mobile app looks for the 'highlighted-text' class to apply highlighted styles.
// Notify the mobile team if the class name is changed or removed.
it("should include the 'highlighted-text' class when rendering as span", () => {
const toHtml = createMdastToHtml('en-US');
const nodes = [
{
type: 'paragraph',
children: [{ type: 'inlineCode', value: 'highlighted' }]
}
];
const actual = toHtml(nodes);
expect(actual).toMatch(/class="highlighted-text"/);
});
it('should render as regular code when lang is not zh-CN, en-US, or es', () => {
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>');
});
it('should render as regular code when lang is not defined', () => {
const toHtml = createMdastToHtml();
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>');
});
});
@@ -0,0 +1,18 @@
const hastToHTML = require('hast-util-to-html');
const { root } = require('mdast-builder');
const mdastToHast = require('mdast-util-to-hast');
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, ...hastOptions }),
{
allowDangerousHtml: true
}
);
}
module.exports = mdastToHTML;
@@ -0,0 +1,42 @@
import { describe, beforeAll, it, expect } from 'vitest';
import parseFixture from '../../__fixtures__/parse-fixture';
import mdastToHTML from './mdast-to-html';
describe('mdast-to-html', () => {
let mdastMixedNodes, singleNode, inlineHTMLNodes;
beforeAll(async () => {
const mdastMixedAST = await parseFixture('mixed-nodes.md');
const mdastInlineHTMLAST = await parseFixture('inline-html.md');
mdastMixedNodes = mdastMixedAST.children;
singleNode = mdastMixedAST.children[0];
inlineHTMLNodes = mdastInlineHTMLAST.children;
});
it('should return a string', () => {
expect.assertions(1);
const actual = mdastToHTML(mdastMixedNodes);
expect(typeof actual).toBe('string');
});
it('throws if it is not passed an array', () => {
expect.assertions(1);
expect(() => mdastToHTML(singleNode)).toThrow(
'mdastToHTML expects an array argument'
);
});
it('should not escape html', () => {
const actual = mdastToHTML(mdastMixedNodes);
expect(actual).toBe(`<p>Paragraph 1</p>
<p>Third <em>hint</em> with <code>code</code> and <code>inline code</code></p>`);
});
it('should put inline html inside the enclosing paragraph', () => {
const actual = mdastToHTML(inlineHTMLNodes);
expect(actual).toBe(
'<p><code> code in </code> code tags <em>emphasis</em> followed' +
' by <div><span>some nested html </span></div></p>'
);
});
});
@@ -0,0 +1,20 @@
const is = require('unist-util-is');
// TODO: specific tests for this would be nice, even though it is somewhat
// covered by the plugins that use it.
function splitOnThematicBreak(nodes) {
return nodes.reduce(
(prev, curr) => {
if (is(curr, 'thematicBreak')) {
return [...prev, []];
} else {
const first = prev.slice(0, -1);
const last = prev.slice(-1)[0];
return [...first, [...last, curr]];
}
},
[[]]
);
}
exports.splitOnThematicBreak = splitOnThematicBreak;
@@ -0,0 +1,128 @@
const { findAll } = require('./utils/find-all');
const { isMarker } = require('./utils/get-section');
const VALID_MARKERS = [
// Level 1
'# --after-all--',
'# --after-each--',
'# --assignment--',
'# --before-all--',
'# --before-each--',
'# --description--',
'# --explanation--',
'# --fillInTheBlank--',
'# --hints--',
'# --instructions--',
'# --interactive--',
'# --notes--',
'# --questions--',
'# --quizzes--',
'# --scene--',
'# --seed--',
'# --solutions--',
'# --transcript--',
// Level 2
'## --answers--',
'## --blanks--',
'## --quiz--',
'## --seed-contents--',
'## --sentence--',
'## --text--',
'## --video-solution--',
// Level 3
'### --audio-id--',
'### --feedback--',
'### --question--',
// Level 4
'#### --answer--',
'#### --audio--',
'#### --distractors--',
'#### --text--'
];
// Special markers that should not be used as headings
const NON_HEADING_MARKERS = ['--fcc-editable-region--'];
function validateSections() {
function transformer(tree) {
const allMarkers = findAll(tree, isMarker);
const invalidMarkerNames = [];
const invalidHeadingLevels = [];
const nonHeadingMarkersAsHeadings = [];
const errors = [];
for (const markerNode of allMarkers) {
const markerValue = markerNode.children[0].value;
const headingLevel = markerNode.depth;
const fullMarker = '#'.repeat(headingLevel) + ' ' + markerValue;
if (NON_HEADING_MARKERS.includes(markerValue)) {
nonHeadingMarkersAsHeadings.push(fullMarker);
continue;
}
if (!VALID_MARKERS.includes(fullMarker)) {
const markerExistsAtAnyLevel = VALID_MARKERS.some(validMarker =>
validMarker.endsWith(markerValue)
);
if (markerExistsAtAnyLevel) {
const validLevels = VALID_MARKERS.filter(validMarker =>
validMarker.endsWith(markerValue)
).map(validMarker => validMarker.split(' ')[0]); // Extract the # symbols
invalidHeadingLevels.push({
fullMarker,
markerValue,
validLevels
});
} else {
invalidMarkerNames.push(markerValue);
}
}
}
if (invalidMarkerNames.length > 0) {
errors.push(
`Invalid marker names: ${invalidMarkerNames.map(m => `"${m}"`).join(', ')}.`
);
}
if (nonHeadingMarkersAsHeadings.length > 0) {
errors.push(
`Non-heading markers should not be used as headings: ${nonHeadingMarkersAsHeadings.map(m => `"${m}"`).join(', ')}.`
);
}
if (invalidHeadingLevels.length > 0) {
const levelErrors = invalidHeadingLevels.map(
({ fullMarker, markerValue, validLevels }) => {
const validText =
validLevels.length === 1
? `${validLevels[0]} ${markerValue}`
: validLevels
.map(level => `${level} ${markerValue}`)
.join(' or ');
return `"${fullMarker}" should be "${validText}"`;
}
);
errors.push(`Invalid heading levels: ${levelErrors.join(', ')}.`);
}
if (errors.length > 0) {
throw new Error(errors.join('\n'));
}
}
return transformer;
}
module.exports = validateSections;
module.exports.VALID_MARKERS = VALID_MARKERS;
module.exports.NON_HEADING_MARKERS = NON_HEADING_MARKERS;
@@ -0,0 +1,161 @@
import { describe, it, expect } from 'vitest';
import unified from 'unified';
import remark from 'remark-parse';
import frontmatter from 'remark-frontmatter';
import addFrontmatter from './add-frontmatter';
import validateSections, {
VALID_MARKERS,
NON_HEADING_MARKERS
} from './validate-sections';
const processor = unified()
.use(remark)
.use(frontmatter, ['yaml'])
.use(addFrontmatter)
.use(validateSections);
describe('validate-sections plugin', () => {
it('should pass when all section markers are valid', () => {
const file = [
'---',
'id: test',
'title: Test',
'---',
'',
...VALID_MARKERS.map(marker => `${marker}\nDummy content.`),
...NON_HEADING_MARKERS.map(marker => `${marker}\nDummy content.`)
].join('\n');
expect(() => {
processor.runSync(processor.parse(file));
}).not.toThrow();
});
it('should throw error for invalid marker names', () => {
const file = `---
id: test
title: Test
---
# --descriptio--
Typo in marker name.
# --instructionss--
Another typo.
# -- instructions--
Another typo.
# --feedback---
Another typo.
# --invalid-marker--
Completely invalid marker.
`;
expect(() => {
processor.runSync(processor.parse(file));
}).toThrow(
'Invalid marker names: "--descriptio--", "--instructionss--", "-- instructions--", "--feedback---", "--invalid-marker--".'
);
});
it('should validate case-sensitive markers', () => {
const file = `---
id: test
title: Test
---
# --INSTRUCTIONS--
Wrong case.
# --Instructions--
Also wrong case.
`;
expect(() => {
processor.runSync(processor.parse(file));
}).toThrow('Invalid marker names: "--INSTRUCTIONS--", "--Instructions--".');
});
it('should throw error for correct marker at wrong heading level', () => {
const file = `---
id: test
title: Test
---
## --interactive--
Interactive should be at level 1, not 2.
### --seed-contents--
Seed contents should be at level 2, not 3.
`;
expect(() => {
processor.runSync(processor.parse(file));
}).toThrow(
'Invalid heading levels: "## --interactive--" should be "# --interactive--", "### --seed-contents--" should be "## --seed-contents--".'
);
});
it('should throw combined errors for invalid markers and wrong levels', () => {
const file = `---
id: test
title: Test
---
## --interactive--
Wrong level.
# --invalid-marker--
Invalid marker.
### --seed-contents--
Wrong level.
`;
expect(() => {
processor.runSync(processor.parse(file));
}).toThrow(
'Invalid marker names: "--invalid-marker--".\nInvalid heading levels: "## --interactive--" should be "# --interactive--", "### --seed-contents--" should be "## --seed-contents--".'
);
});
it('should throw error for fcc-editable-region when used as headings', () => {
const file = `---
id: test
title: Test
---
# --fcc-editable-region--
This should not be a heading.
## --fcc-editable-region--
This should also not be a heading.
`;
expect(() => {
processor.runSync(processor.parse(file));
}).toThrow(
'Non-heading markers should not be used as headings: "# --fcc-editable-region--", "## --fcc-editable-region--".'
);
});
it('should throw error for markers valid at multiple levels but used at an invalid level', () => {
const file = `---
id: test
title: Test
---
### --text--
This marker is valid at level 2 or level 4, but not at level 3.
`;
expect(() => {
processor.runSync(processor.parse(file));
}).toThrow(
'Invalid heading levels: "### --text--" should be "## --text-- or #### --text--".'
);
});
});