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
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:
@@ -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 BLANK,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>'
|
||||
);
|
||||
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"><div>This is NOT an interactive element</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"><div>This is an interactive element</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"><div>This is an interactive element</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"><div>Main instructions code</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--".'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user