├── .gitignore ├── package.json ├── questions.md ├── questions.json ├── .github └── workflows │ └── build-release.yml ├── server.py ├── format_answers.py ├── LICENSE ├── README.md ├── server.js ├── convert-questions-md-to-json.js └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | answers.json 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "learn_quiz-task", 3 | "version": "1.0.0", 4 | "description": "A quiz engine for Learn", 5 | "homepage": "https://github.com/CodeSignal/learn_quiz-task#readme", 6 | "bugs": { 7 | "url": "https://github.com/CodeSignal/learn_quiz-task/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/CodeSignal/learn_quiz-task.git" 12 | }, 13 | "license": "ISC", 14 | "author": "CodeSignal", 15 | "type": "commonjs", 16 | "main": "server.js", 17 | "scripts": { 18 | "test": "echo \"Error: no test specified\" && exit 1", 19 | "start": "node server.js" 20 | }, 21 | "dependencies": { 22 | "ws": "^8.18.3" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /questions.md: -------------------------------------------------------------------------------- 1 | __Type__ 2 | 3 | Quiz 4 | 5 | # Quiz Time! 6 | ## Let's get started! 7 | 8 | ## Questions 9 | 10 | --- 11 | 12 | __Question Type__ 13 | 14 | Multiple Choice (Single Answer) 15 | 16 | __Question__ 17 | 18 | When was the American Civil War? 19 | 20 | __Options__ 21 | 22 | - 1796-1803 23 | - 1810-1814 24 | - 1861-1865 25 | - 1939-1945 26 | 27 | __Correct Answer__ 28 | 29 | - 1861-1865 30 | 31 | --- 32 | 33 | __Question Type__ 34 | 35 | Multiple Choice (Single Answer) 36 | 37 | __Question__ 38 | 39 | What is the capital of France? 40 | 41 | __Options__ 42 | 43 | - Paris 44 | - London 45 | - Berlin 46 | - Madrid 47 | 48 | __Correct Answer__ 49 | 50 | - Paris 51 | 52 | --- 53 | 54 | __Question Type__ 55 | 56 | Multiple Choice (Multiple Answers) 57 | 58 | __Question__ 59 | 60 | What is your favorite food? 61 | 62 | __Options__ 63 | 64 | - Pizza 65 | - Popcorn 66 | - Plantains 67 | - Poy 68 | 69 | __Correct Answers__ 70 | 71 | - Pizza 72 | - Popcorn 73 | 74 | 75 | -------------------------------------------------------------------------------- /questions.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Quiz Time!", 3 | "showProgressBar": "bottom", 4 | "startSurveyText": "Let's get started!", 5 | "showNavigationButtons": false, 6 | "elements": [{ 7 | "type": "radiogroup", 8 | "name": "civilwar", 9 | "title": "When was the American Civil War?", 10 | "isRequired": true, 11 | "choices": [ 12 | "1796-1803", "1810-1814", "1861-1865", "1939-1945" 13 | ], 14 | "correctAnswer": "1861-1865" 15 | }, { 16 | "type": "radiogroup", 17 | "name": "capital", 18 | "title": "What is the capital of France?", 19 | "isRequired": true, 20 | "choices": [ 21 | "Paris", "London", "Berlin", "Madrid" 22 | ], 23 | "correctAnswer": "Paris" 24 | }, { 25 | "type": "checkbox", 26 | "name": "favoriteFood", 27 | "title": "What is your favorite food?", 28 | "choices": [ 29 | "Pizza", 30 | "Popcorn", 31 | "Plantains", 32 | "Poy" 33 | ], 34 | "isRequired": true, 35 | "correctAnswer": ["Pizza", "Popcorn"] 36 | }] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | permissions: 8 | contents: write 9 | 10 | jobs: 11 | build-and-release: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: '20' 22 | cache: 'npm' 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Archive build output 28 | run: tar -czf dist.tar.gz * 29 | 30 | - name: Upload build artifact (for workflow logs) 31 | uses: actions/upload-artifact@v4 32 | with: 33 | name: dist 34 | path: dist 35 | 36 | - name: Upload asset to existing release 37 | uses: ncipollo/release-action@v1 38 | with: 39 | token: ${{ secrets.GITHUB_TOKEN }} 40 | tag: ${{ github.event.release.tag_name }} 41 | artifacts: dist.tar.gz 42 | allowUpdates: true 43 | omitBodyDuringUpdate: true 44 | omitNameDuringUpdate: true 45 | -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | from http.server import HTTPServer, SimpleHTTPRequestHandler 2 | import json 3 | 4 | class RequestHandler(SimpleHTTPRequestHandler): 5 | def send_json_response(self, status_code: int, data: dict) -> None: 6 | self.send_response(status_code) 7 | self.send_header('Content-type', 'application/json') 8 | self.send_header('Access-Control-Allow-Origin', '*') # Configure as needed 9 | self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') 10 | self.end_headers() 11 | self.wfile.write(json.dumps(data).encode('utf-8')) 12 | 13 | def do_GET(self): 14 | return SimpleHTTPRequestHandler.do_GET(self) 15 | 16 | def do_POST(self): 17 | if self.path == '/answers': 18 | content_length = int(self.headers['Content-Length']) 19 | post_data = self.rfile.read(content_length) 20 | 21 | data = json.loads(post_data) 22 | formatted_data = json.dumps(data, indent=2) 23 | with open('answers.json', 'w') as f: 24 | f.write(formatted_data) 25 | 26 | self.send_json_response(200, {'status': 'success'}) 27 | else: 28 | self.send_json_response(404, {'error': 'Not Found'}) 29 | 30 | def run_server(port: int = 3000) -> None: 31 | try: 32 | server_address = ('', port) 33 | httpd = HTTPServer(server_address, RequestHandler) 34 | print(f"Server running on port {port}...") 35 | httpd.serve_forever() 36 | except Exception as e: 37 | print(f"Failed to start server: {str(e)}") 38 | exit(1) 39 | 40 | if __name__ == '__main__': 41 | run_server() 42 | -------------------------------------------------------------------------------- /format_answers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | # Load the JSON files 4 | try: 5 | with open('questions.json', 'r') as f: 6 | questions_data = json.load(f) 7 | except: 8 | print("Error: Cannot find questions.json file") 9 | exit(1) 10 | 11 | try: 12 | with open('answers.json', 'r') as f: 13 | answers_data = json.load(f) 14 | except: 15 | answers_data = {} 16 | 17 | print(f"# Quiz Results") 18 | 19 | print(f"The user was asked the folling questions. A response of 'NO RESPONSE' indicates that the user did not respond to the question.\n") 20 | 21 | # Calculate total questions and number correct 22 | total_questions = len(questions_data['elements']) 23 | correct_answers = sum(1 for answer in answers_data.values() if hasattr(answer, 'get') and answer.get('isCorrect', False)) 24 | 25 | print(f"## SCORE:\n{correct_answers} out of {total_questions} questions correct ({(correct_answers/total_questions)*100:.1f}%)\n") 26 | print(f"## QUESTIONS:\n") 27 | 28 | # Create a dictionary mapping question names to their titles and correct answers 29 | questions = { 30 | q['name']: { 31 | 'title': q['title'], 32 | 'correctAnswer': q.get('correctAnswer', 'Unknown') 33 | } for q in questions_data['elements'] 34 | } 35 | 36 | # Format and display each question with its answer 37 | for q_name, q_info in questions.items(): 38 | print(f"### {q_info['title']}") 39 | 40 | # Get the corresponding answer if it exists 41 | answer_data = answers_data.get(q_name, {}) 42 | user_answer = answer_data.get('value', 'NO RESPONSE') 43 | expected_answer = q_info['correctAnswer'] # Get correct answer from questions data 44 | is_correct = answer_data.get('isCorrect', False) 45 | 46 | # Print formatted results 47 | print(f"- user response: {user_answer}") 48 | print(f"- expected response: {expected_answer}") 49 | print(f"- is correct? {'yes' if is_correct else 'no'}") 50 | print("") 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Elastic License 2.0 2 | 3 | URL: https://www.elastic.co/licensing/elastic-license 4 | 5 | ## Acceptance 6 | 7 | By using the software, you agree to all of the terms and conditions below. 8 | 9 | ## Copyright License 10 | 11 | The licensor grants you a non-exclusive, royalty-free, worldwide, 12 | non-sublicensable, non-transferable license to use, copy, distribute, make 13 | available, and prepare derivative works of the software, in each case subject to 14 | the limitations and conditions below. 15 | 16 | ## Limitations 17 | 18 | You may not provide the software to third parties as a hosted or managed 19 | service, where the service provides users with access to any substantial set of 20 | the features or functionality of the software. 21 | 22 | You may not move, change, disable, or circumvent the license key functionality 23 | in the software, and you may not remove or obscure any functionality in the 24 | software that is protected by the license key. 25 | 26 | You may not alter, remove, or obscure any licensing, copyright, or other notices 27 | of the licensor in the software. Any use of the licensor’s trademarks is subject 28 | to applicable law. 29 | 30 | ## Patents 31 | 32 | The licensor grants you a license, under any patent claims the licensor can 33 | license, or becomes able to license, to make, have made, use, sell, offer for 34 | sale, import and have imported the software, in each case subject to the 35 | limitations and conditions in this license. This license does not cover any 36 | patent claims that you cause to be infringed by modifications or additions to 37 | the software. If you or your company make any written claim that the software 38 | infringes or contributes to infringement of any patent, your patent license for 39 | the software granted under these terms ends immediately. If your company makes 40 | such a claim, your patent license ends immediately for work on behalf of your 41 | company. 42 | 43 | ## Notices 44 | 45 | You must ensure that anyone who gets a copy of any part of the software from you 46 | also gets a copy of these terms. 47 | 48 | If you modify the software, you must include in any modified copies of the 49 | software prominent notices stating that you have modified the software. 50 | 51 | ## No Other Rights 52 | 53 | These terms do not imply any licenses other than those expressly granted in 54 | these terms. 55 | 56 | ## Termination 57 | 58 | If you use the software in violation of these terms, such use is not licensed, 59 | and your licenses will automatically terminate. If the licensor provides you 60 | with a notice of your violation, and you cease all violation of this license no 61 | later than 30 days after you receive that notice, your licenses will be 62 | reinstated retroactively. However, if you violate these terms after such 63 | reinstatement, any additional violation of these terms will cause your licenses 64 | to terminate automatically and permanently. 65 | 66 | ## No Liability 67 | 68 | *As far as the law allows, the software comes as is, without any warranty or 69 | condition, and the licensor will not be liable to you for any damages arising 70 | out of these terms or the use or nature of the software, under any kind of 71 | legal claim.* 72 | 73 | ## Definitions 74 | 75 | The **licensor** is the entity offering these terms, and the **software** is the 76 | software the licensor makes available under these terms, including any portion 77 | of it. 78 | 79 | **you** refers to the individual or entity agreeing to these terms. 80 | 81 | **your company** is any legal entity, sole proprietorship, or other kind of 82 | organization that you work for, plus all organizations that have control over, 83 | are under the control of, or are under common control with that 84 | organization. **control** means ownership of substantially all the assets of an 85 | entity, or the power to direct its management and policies by vote, contract, or 86 | otherwise. Control can be direct or indirect. 87 | 88 | **your licenses** are all the licenses granted to you for the software under 89 | these terms. 90 | 91 | **use** means anything you do with the software requiring one of your licenses. 92 | 93 | **trademark** means trademarks, service marks, and similar rights. 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A simple quiz experience to use inside of Learn 2 | 3 | ## How to use 4 | 5 | 1. Clone the repository 6 | 2. Add questions to the `questions.json` file 7 | 3. Run `node server.js` to start the server 8 | 4. Open `localhost:3000` in your browser 9 | 10 | Note: the legacy way of doing it was `python server.py` but python does not support websockets out of the box where Node.js does. So `server.py` is deprecated in favor of `server.js`. 11 | 12 | ## How to add questions 13 | 14 | Questions are stored in the `questions.json` file. The questions are stored in an array of objects. Each object represents a question and has the following properties: 15 | 16 | - `name`: The name of the question 17 | - `title`: The title of the question 18 | - `type`: The type of question 19 | 20 | See the [SurveyJS documentation](https://surveyjs.io/form-library/examples/text-entry-question/reactjs) for the different question types and their properties. There are a lot of them, so I won't go into detail here. 21 | 22 | ## How to run the solution 23 | As the user interacts with the quiz, the answers are stored in the `answers.json` file. The answers are stored in an array of objects. Each object represents a question and has the following properties: 24 | 25 | - `name`: The name of the question 26 | - `value`: The value of the question 27 | - `correctAnswer`: The correct answer to the question 28 | - `isCorrect`: Whether the answer is correct 29 | 30 | To run the solution, run `python format_answers.py`. This will read the `questions.json` and `answers.json` files and format the answers into a human readable format. 31 | 32 | ## How to display the correct/incorrect state to the user 33 | First, you must use the `node` version of the server (`server.js`). It exposes a websocket and a `/validate` endpoint you can POST to. It will request the HTML to annotate the UX. 34 | 35 | If you want the Web UX to display the correct/incorrect state to the user, you can make a request to the quiz server: 36 | ``` 37 | curl -X POST localhost:3000/validate &> /dev/null 38 | ``` 39 | 40 | ## How to use as a standalone solution 41 | 42 | The 2 main dependencies required for this project are python and node. Both can be gotten in the python base images, since every base image contains node as well. A few files need to be created to make the solution work standalone. 43 | 44 | An example can be found [here](https://app-staging.codesignal.dev/question/NzuLaf2PfcWuxhmAD/) 45 | ### setup.sh 46 | 47 | ```bash 48 | if [ -f questions.json ]; then 49 | wget https://github.com/CodeSignal/learn_quiz-task/releases/latest/download/dist.tar.gz 50 | 51 | mkdir -p learn_quiz 52 | cd learn_quiz 53 | tar xzf ../dist.tar.gz 54 | 55 | cp ../questions.json questions.json 56 | 57 | npm start 58 | exit 0 59 | fi 60 | ``` 61 | 62 | ### run_solution.sh 63 | 64 | ```bash 65 | #!/bin/bash 66 | 67 | # force all clients (just the one for the preview window) to update 68 | # their display and show the correctness of the answers 69 | curl -X POST localhost:3000/validate &> /dev/null 70 | 71 | # process the answers given to assess if the learner correctly 72 | # completed the quiz 73 | python3 format_answers.py 74 | 75 | exit 0 76 | ``` 77 | 78 | ### Questions.json Format 79 | This is the format used by Survey.js and an example is in `questions.json`. But we sometimes want to use Markdown to define the questions (example: `questions.md`). You can convert the Markdown to JSON if you prefer. In that case, you can convert it to JSON: 80 | 81 | ```bash 82 | node convert-questions-md-to-json.js -i questions.md -o questions.json 83 | ``` 84 | 85 | ### View settings 86 | 87 | The expected setup in the view settings (present either on the course level or on the task level): 88 | - Task Preview: "Full Screen" 89 | - Task Preview URL Header: "Hidden" 90 | - Refresh Preview on Run: "Disabled" 91 | - Preview Loading Message: "Custom" 92 | - Custom Preview Loading Message: "Starting Quiz..." 93 | - Reset Session Button Location: "Preview" 94 | - Preview Position: "top:10px; right:10px" 95 | 96 | ## FAQ 97 | 98 | ### my questions aren't showing up in the survey 99 | Are you sure you didn't store your particular questions in a path different than the one used in the `setup.sh` file? 100 | 101 | ### I'm getting errors after copying the exact scripts provided here 102 | Ensure you are using the latest release of this repository. While the script assume version 0.7, it could very well be that a bug was fixed somewhere down the line and a newer release fixes the issues. 103 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const WebSocket = require('ws'); 5 | 6 | // Store active WebSocket connections 7 | const clients = new Set(); 8 | 9 | class RequestHandler { 10 | constructor(req, res) { 11 | this.req = req; 12 | this.res = res; 13 | } 14 | 15 | sendJsonResponse(statusCode, data) { 16 | this.res.writeHead(statusCode, { 17 | 'Content-Type': 'application/json', 18 | 'Access-Control-Allow-Origin': '*', 19 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS' 20 | }); 21 | this.res.end(JSON.stringify(data)); 22 | } 23 | 24 | handleGet() { 25 | // Serve static files like SimpleHTTPRequestHandler 26 | const url = new URL(this.req.url, `http://${this.req.headers.host}`); 27 | const filePath = path.join(process.cwd(), url.pathname === '/' ? 'index.html' : url.pathname); 28 | 29 | fs.readFile(filePath, (err, data) => { 30 | if (err) { 31 | if (err.code === 'ENOENT') { 32 | this.sendJsonResponse(404, { error: 'Not Found' }); 33 | } else { 34 | this.sendJsonResponse(500, { error: 'Internal Server Error' }); 35 | } 36 | return; 37 | } 38 | 39 | // Determine content type based on file extension 40 | const ext = path.extname(filePath); 41 | let contentType = 'text/html'; 42 | 43 | switch (ext) { 44 | case '.js': 45 | contentType = 'text/javascript'; 46 | break; 47 | case '.css': 48 | contentType = 'text/css'; 49 | break; 50 | case '.json': 51 | contentType = 'application/json'; 52 | break; 53 | case '.png': 54 | contentType = 'image/png'; 55 | break; 56 | case '.jpg': 57 | contentType = 'image/jpg'; 58 | break; 59 | } 60 | 61 | this.res.writeHead(200, { 'Content-Type': contentType }); 62 | this.res.end(data); 63 | }); 64 | } 65 | 66 | handlePost() { 67 | if (this.req.url === '/answers') { 68 | let body = ''; 69 | 70 | this.req.on('data', (chunk) => { 71 | body += chunk.toString(); 72 | }); 73 | 74 | this.req.on('end', () => { 75 | try { 76 | const data = JSON.parse(body); 77 | const formattedData = JSON.stringify(data, null, 2); 78 | 79 | fs.writeFile('answers.json', formattedData, (err) => { 80 | if (err) { 81 | this.sendJsonResponse(500, { error: 'Failed to write file' }); 82 | return; 83 | } 84 | this.sendJsonResponse(200, { status: 'success' }); 85 | }); 86 | } catch (e) { 87 | this.sendJsonResponse(400, { error: 'Invalid JSON' }); 88 | } 89 | }); 90 | } else if (this.req.url === '/validate') { 91 | // Send message to all connected WebSocket clients 92 | clients.forEach(client => { 93 | if (client.readyState === WebSocket.OPEN) { 94 | client.send(JSON.stringify({ type: 'validate' })); 95 | } 96 | }); 97 | 98 | this.sendJsonResponse(200, { 99 | status: 'success', 100 | message: 'Validation message sent to all connected clients', 101 | clientCount: clients.size 102 | }); 103 | } else { 104 | this.sendJsonResponse(404, { error: 'Not Found' }); 105 | } 106 | } 107 | } 108 | 109 | function runServer(port = 3000) { 110 | try { 111 | const server = http.createServer((req, res) => { 112 | const handler = new RequestHandler(req, res); 113 | 114 | if (req.method === 'GET') { 115 | handler.handleGet(); 116 | } else if (req.method === 'POST') { 117 | handler.handlePost(); 118 | } else if (req.method === 'OPTIONS') { 119 | // Handle CORS preflight requests 120 | res.writeHead(204, { 121 | 'Access-Control-Allow-Origin': '*', 122 | 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 123 | 'Access-Control-Allow-Headers': 'Content-Type' 124 | }); 125 | res.end(); 126 | } else { 127 | handler.sendJsonResponse(405, { error: 'Method Not Allowed' }); 128 | } 129 | }); 130 | 131 | // Create WebSocket server using the ws module 132 | const wss = new WebSocket.Server({ server }); 133 | 134 | wss.on('connection', (ws, req) => { 135 | // Add new client to the Set 136 | clients.add(ws); 137 | console.log('WebSocket connection established, total clients:', clients.size); 138 | 139 | // Handle WebSocket connection close 140 | ws.on('close', () => { 141 | clients.delete(ws); 142 | console.log('WebSocket connection closed, remaining clients:', clients.size); 143 | }); 144 | 145 | // Handle errors 146 | ws.on('error', (error) => { 147 | console.error('WebSocket error:', error); 148 | clients.delete(ws); 149 | }); 150 | }); 151 | 152 | server.listen(port, () => { 153 | console.log(`Server running on port ${port}...`); 154 | console.log(`WebSocket server available at ws://localhost:${port}`); 155 | }); 156 | 157 | server.on('error', (e) => { 158 | console.error(`Failed to start server: ${e.message}`); 159 | process.exit(1); 160 | }); 161 | } catch (e) { 162 | console.error(`Failed to start server: ${e.message}`); 163 | process.exit(1); 164 | } 165 | } 166 | 167 | if (require.main === module) { 168 | runServer(); 169 | } 170 | 171 | module.exports = { runServer }; 172 | -------------------------------------------------------------------------------- /convert-questions-md-to-json.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* 3 | Converts questions.md → questions.json in the SurveyJS schema used by this project. 4 | - Parses sections separated by --- 5 | - Supports question types: "Multiple Choice (Single Answer)", "Multiple Choice (Multiple Answers)" 6 | - Defaults when missing in markdown: 7 | title = "Quiz" 8 | startSurveyText = "Let's get started!" 9 | showProgressBar = "bottom" 10 | showNavigationButtons = false 11 | isRequired = true 12 | */ 13 | const fs = require('fs'); 14 | const path = require('path'); 15 | 16 | const WORKDIR = __dirname; 17 | 18 | function parseCliArgs(argv) { 19 | const args = { input: null, output: null, help: false }; 20 | for (let i = 2; i < argv.length; i++) { 21 | const a = argv[i]; 22 | if (a === '-h' || a === '--help') { args.help = true; continue; } 23 | if (a === '-i' || a === '--input') { args.input = argv[++i]; continue; } 24 | if (a === '-o' || a === '--output') { args.output = argv[++i]; continue; } 25 | if (!args.input) { args.input = a; continue; } 26 | if (!args.output) { args.output = a; continue; } 27 | } 28 | return args; 29 | } 30 | 31 | function resolvePathMaybe(p) { 32 | if (!p) return null; 33 | return path.isAbsolute(p) ? p : path.resolve(process.cwd(), p); 34 | } 35 | 36 | function readFileSafe(filePath) { 37 | try { 38 | return fs.readFileSync(filePath, 'utf8'); 39 | } catch (err) { 40 | console.error(`Failed to read ${filePath}:`, err.message); 41 | process.exit(1); 42 | } 43 | } 44 | 45 | function writeFileSafe(filePath, data) { 46 | try { 47 | fs.writeFileSync(filePath, data, 'utf8'); 48 | } catch (err) { 49 | console.error(`Failed to write ${filePath}:`, err.message); 50 | process.exit(1); 51 | } 52 | } 53 | 54 | function toCamelName(title) { 55 | if (!title) return 'question'; 56 | const words = title 57 | .toLowerCase() 58 | .replace(/[^a-z0-9\s]/g, ' ') 59 | .trim() 60 | .split(/\s+/) 61 | .slice(0, 6); // cap to keep names short 62 | if (words.length === 0) return 'question'; 63 | const [first, ...rest] = words; 64 | return [first, ...rest.map((w) => w.charAt(0).toUpperCase() + w.slice(1))].join(''); 65 | } 66 | 67 | function parseList(lines, startIdx) { 68 | const items = []; 69 | let i = startIdx; 70 | while (i < lines.length) { 71 | const line = lines[i].trim(); 72 | if (!line) { i++; continue; } 73 | if (line.startsWith('__') || line === '---') break; 74 | if (line.startsWith('- ')) { 75 | items.push(line.slice(2).trim()); 76 | i++; 77 | continue; 78 | } 79 | break; 80 | } 81 | return { items, nextIdx: i }; 82 | } 83 | 84 | function extractAfterLabel(lines, label, startIdx) { 85 | const labelIdx = lines.findIndex((l, idx) => idx >= startIdx && l.trim().toLowerCase() === label.toLowerCase()); 86 | if (labelIdx === -1) return { value: null, index: startIdx }; 87 | let i = labelIdx + 1; 88 | while (i < lines.length && !lines[i].trim()) i++; 89 | if (i >= lines.length) return { value: null, index: labelIdx + 1 }; 90 | return { value: lines[i].trim(), index: i + 1 }; 91 | } 92 | 93 | function extractListAfterLabel(lines, label, startIdx) { 94 | const labelIdx = lines.findIndex((l, idx) => idx >= startIdx && l.trim().toLowerCase() === label.toLowerCase()); 95 | if (labelIdx === -1) return { items: [], index: startIdx }; 96 | let i = labelIdx + 1; 97 | // skip empty lines 98 | while (i < lines.length && !lines[i].trim()) i++; 99 | const { items, nextIdx } = parseList(lines, i); 100 | return { items, index: nextIdx }; 101 | } 102 | 103 | function parseMarkdown(md) { 104 | const lines = md.split(/\r?\n/); 105 | 106 | // Title: first H1 107 | let title = 'Quiz'; 108 | for (const line of lines) { 109 | const m = line.match(/^#\s+(.+)/); 110 | if (m) { title = m[1].trim(); break; } 111 | } 112 | 113 | // Start text: first H2 that is not "Questions" 114 | let startSurveyText = "Let's get started!"; 115 | for (const line of lines) { 116 | const m = line.match(/^##\s+(.+)/); 117 | if (m) { 118 | const val = m[1].trim(); 119 | if (val.toLowerCase() !== 'questions') { startSurveyText = val; break; } 120 | } 121 | } 122 | 123 | // Split questions by --- 124 | const blocks = md 125 | .split(/\n-{3,}\n/g) 126 | .map((b) => b.trim()) 127 | .filter((b) => b.length > 0) 128 | .slice(1); // drop preface section before first question divider if present 129 | 130 | const elements = []; 131 | 132 | for (const block of blocks) { 133 | const blines = block.split(/\r?\n/); 134 | // Question Type 135 | const qTypeLabel = '__Question Type__'; 136 | let { value: qType } = extractAfterLabel(blines, qTypeLabel, 0); 137 | qType = (qType || '').toLowerCase(); 138 | 139 | let type = 'radiogroup'; 140 | let multiple = false; 141 | if (qType.includes('multiple answers')) { type = 'checkbox'; multiple = true; } 142 | else if (qType.includes('single answer')) { type = 'radiogroup'; multiple = false; } 143 | 144 | // Question text 145 | const { value: questionText } = extractAfterLabel(blines, '__Question__', 0); 146 | // Options 147 | const { items: options } = extractListAfterLabel(blines, '__Options__', 0); 148 | // Answers 149 | const answersLabel = multiple ? '__Correct Answers__' : '__Correct Answer__'; 150 | let { items: correctItems } = extractListAfterLabel(blines, answersLabel, 0); 151 | 152 | // Fallbacks 153 | const titleText = questionText || 'Question'; 154 | const name = toCamelName(titleText); 155 | const isRequired = true; 156 | 157 | const base = { 158 | type, 159 | name, 160 | title: titleText, 161 | }; 162 | 163 | if (Array.isArray(options) && options.length) { 164 | base.choices = options; 165 | } else { 166 | base.choices = []; 167 | } 168 | 169 | base.isRequired = isRequired; 170 | 171 | if (multiple) { 172 | base.correctAnswer = correctItems && correctItems.length ? correctItems : []; 173 | } else { 174 | const ans = correctItems && correctItems.length ? correctItems[0] : ''; 175 | base.correctAnswer = ans; 176 | } 177 | 178 | elements.push(base); 179 | } 180 | 181 | const result = { 182 | title, 183 | showProgressBar: 'bottom', 184 | startSurveyText, 185 | showNavigationButtons: false, 186 | elements, 187 | }; 188 | 189 | return result; 190 | } 191 | 192 | function printHelp() { 193 | console.log(`Usage: node convert-questions-md-to-json.js [options] [input.md] [output.json] 194 | 195 | Options: 196 | -i, --input Path to input markdown (default: questions.md) 197 | -o, --output Path to output json (default: questions.json) 198 | -h, --help Show this help 199 | `); 200 | } 201 | 202 | function main() { 203 | const args = parseCliArgs(process.argv); 204 | if (args.help) { printHelp(); process.exit(0); } 205 | 206 | const inputPath = resolvePathMaybe(args.input) || path.join(WORKDIR, 'questions.md'); 207 | const outputPath = resolvePathMaybe(args.output) || path.join(WORKDIR, 'questions.json'); 208 | 209 | const md = readFileSafe(inputPath); 210 | const json = parseMarkdown(md); 211 | const output = JSON.stringify(json, null, 2); 212 | writeFileSafe(outputPath, output + '\n'); 213 | console.log(`Converted ${path.basename(inputPath)} → ${path.basename(outputPath)}`); 214 | } 215 | 216 | if (require.main === module) { 217 | main(); 218 | } 219 | 220 | 221 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |