From e09f550d913933f5c8ab2c525ee03a72d8b6b7a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Nov 2025 10:48:20 +0000 Subject: [PATCH 1/8] Add fill-in-the-blank quiz type (issue #9) Implements a new fill-in-the-blank question type using [[answer]] syntax, as requested in issue #9. This allows quiz authors to create questions where users type in answers rather than selecting from multiple choices. Key features: - Double square bracket syntax [[answer]] marks blanks in questions - Supports single or multiple blanks per question - Case-insensitive answer validation (trimmed and lowercased) - Markdown formatting works around blanks - HTML-escaped answers in data attributes for security - Full integration with existing quiz features (content sections, progress tracking, localStorage) - CSS styling for inline text inputs with visual feedback - Shows correct answers as placeholders when show_correct is enabled Implementation details: - Plugin automatically detects quiz type based on content ([[...]] patterns) - Python: New _process_fill_in_blank_quiz() method handles parsing and HTML generation - JavaScript: Extended validation logic to compare input values with data-answer attributes - CSS: New .quiz-blank-input styles for inline text fields - Tests: 11 comprehensive tests covering various scenarios All 57 tests passing. --- CLAUDE.md | 62 ++++++-- mkdocs_quiz/css/quiz.css | 59 +++++++ mkdocs_quiz/js/quiz.js | 326 ++++++++++++++++++++++++++++++--------- mkdocs_quiz/plugin.py | 162 +++++++++++++++++++ tests/test_plugin.py | 247 +++++++++++++++++++++++++++++ 5 files changed, 773 insertions(+), 83 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28d7581..889a6d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -MkDocs Quiz is a plugin for MkDocs that creates interactive quizzes directly in markdown documentation. It processes custom `` tags in markdown files and converts them to interactive HTML/JS quiz elements. +MkDocs Quiz is a plugin for MkDocs that creates interactive quizzes directly in markdown documentation. It processes custom `` tags in markdown files and converts them to interactive HTML/JS quiz elements. Supports both multiple-choice and fill-in-the-blank question types. ## Architecture @@ -19,6 +19,25 @@ This is a MkDocs plugin that hooks into the MkDocs build pipeline: 2. `on_page_markdown()` - Processes markdown to convert quiz tags to placeholders and stores quiz HTML 3. `on_page_content()` - Replaces placeholders with actual quiz HTML and injects CSS/JS assets +### Quiz Types + +The plugin supports two types of quizzes: + +1. **Multiple-choice quizzes**: Use checkbox syntax (`- [x]` for correct, `- [ ]` for incorrect) + - Single correct answer → radio buttons + - Multiple correct answers → checkboxes + - Auto-submit option for single-choice (default: enabled) + +2. **Fill-in-the-blank quizzes**: Use double square brackets (`[[answer]]`) to mark blanks + - Supports single or multiple blanks in one question + - Case-insensitive validation (trimmed whitespace) + - Markdown formatting works around blanks + - Always requires explicit submit button + +The plugin automatically detects which type based on the content: +- If `[[...]]` patterns are found → fill-in-the-blank +- Otherwise → multiple-choice (requires checkbox items) + ### Quiz Processing Flow 1. **Code block masking** (`_mask_code_blocks`): @@ -28,18 +47,28 @@ This is a MkDocs plugin that hooks into the MkDocs build pipeline: 2. **Markdown parsing** (`on_page_markdown`): - Regex pattern `(.*?)` finds quiz blocks - Each quiz is passed to `_process_quiz()` method - - Quiz syntax uses markdown checkbox lists: `- [x]` for correct answers, `- [ ]` for incorrect - - Question is everything before the first checkbox answer - - Content section (optional) is everything after the last answer - - Single correct answer = radio buttons; multiple correct = checkboxes + - `_process_quiz()` detects quiz type using `_is_fill_in_blank_quiz()` + - **Multiple-choice**: Uses checkbox lists (`- [x]` correct, `- [ ]` incorrect) + - Question is everything before the first checkbox answer + - Content section (optional) is everything after the last answer + - Single correct answer = radio buttons; multiple correct = checkboxes + - **Fill-in-the-blank**: Uses `[[answer]]` patterns + - Question text with blanks replaced by text inputs + - Correct answers stored in `data-answer` attributes (HTML-escaped) + - Content section separated by empty line - Quizzes replaced with placeholders (``) in markdown -3. **HTML generation** (`_process_quiz`): - - Parses quiz lines to extract question, answers, and content - - Converts question and answers from markdown to HTML using `markdown_converter` - - Generates form HTML with proper input types (radio/checkbox) - - Adds `correct` attribute to correct answers (used by JS) - - Content section is hidden until quiz is answered +3. **HTML generation** (`_process_quiz` and `_process_fill_in_blank_quiz`): + - **Multiple-choice**: Parses quiz lines to extract question, answers, and content + - Converts question and answers from markdown to HTML using `markdown_converter` + - Generates form HTML with proper input types (radio/checkbox) + - Adds `correct` attribute to correct answers (used by JS) + - **Fill-in-the-blank**: Replaces `[[answer]]` with text inputs + - Uses HTML comment placeholders (``) during markdown conversion + - Replaces placeholders with `` + - Stores correct answers in `data-answer` attributes (HTML-escaped) + - Adds `autocomplete="off"` to prevent browser autofill + - Content section is hidden until quiz is answered (both types) - Each quiz gets unique ID for deep linking (`id="quiz-N"`) - Quiz HTML is stored in `_quiz_storage` dict keyed by page path @@ -60,11 +89,18 @@ The [quiz.js](mkdocs_quiz/js/quiz.js) file: - Creates progress sidebar automatically when multiple quizzes exist - Dispatches `quizProgressUpdate` custom events for integration - **Form handling**: Attaches submit handlers to all `.quiz` forms on page load -- **Validation**: Validates selected answers against `[correct]` attribute +- **Validation**: + - **Multiple-choice**: Validates selected answers against `[correct]` attribute + - **Fill-in-the-blank**: Compares input values with `data-answer` attributes + - Case-insensitive comparison using `normalizeAnswer()` (trim + lowercase) + - All blanks must be correct for quiz to pass - **Visual feedback**: Shows/hides content section, adds `.correct` and `.wrong` classes -- **Auto-submit**: If enabled and single-choice (radio), submits on selection change + - **Fill-in-the-blank**: Wrong answers show correct answer as placeholder if `show_correct` enabled +- **Auto-submit**: If enabled and single-choice (radio), submits on selection change (multiple-choice only) - **Persistence**: Restores quiz state from localStorage on page load + - **Fill-in-the-blank**: Saves user input values in `selectedValues` array - **Reset functionality**: "Try Again" button to reset individual quizzes (if not disabled after submit) + - **Fill-in-the-blank**: Clears input values and removes styling - **Helper**: `resetFieldset()` clears previous styling before re-validation ### Configuration Options diff --git a/mkdocs_quiz/css/quiz.css b/mkdocs_quiz/css/quiz.css index e0f1274..8e4f28b 100644 --- a/mkdocs_quiz/css/quiz.css +++ b/mkdocs_quiz/css/quiz.css @@ -357,3 +357,62 @@ input[type="radio"].wrong { color: #f44336; background-color: rgba(244, 67, 54, 0.15); } + +/* Fill-in-the-blank quiz styles */ +.quiz-blank-input { + border: none; + border-bottom: 2px solid var(--md-default-fg-color--light, #999); + background-color: transparent; + padding: 0.2rem 0.5rem; + font-size: inherit; + font-family: inherit; + color: var(--md-default-fg-color); + transition: + border-color 0.2s, + background-color 0.2s; + min-width: 100px; + outline: none; +} + +.quiz-blank-input:focus { + border-bottom-color: var(--md-primary-fg-color, #1976d2); + background-color: var(--md-default-bg-color--light, rgba(0, 0, 0, 0.02)); +} + +.quiz-blank-input.correct { + border-bottom-color: #198754; + background-color: rgba(25, 135, 84, 0.1); + color: #198754; +} + +.quiz-blank-input.wrong { + border-bottom-color: #dc3545; + background-color: rgba(220, 53, 69, 0.1); + color: #dc3545; +} + +.quiz-blank-input:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +/* Dark mode adjustments for fill-in-the-blank */ +[data-md-color-scheme="slate"] .quiz-blank-input { + border-bottom-color: rgba(255, 255, 255, 0.3); +} + +[data-md-color-scheme="slate"] .quiz-blank-input:focus { + background-color: rgba(255, 255, 255, 0.05); +} + +[data-md-color-scheme="slate"] .quiz-blank-input.correct { + border-bottom-color: #4caf50; + background-color: rgba(76, 175, 80, 0.15); + color: #4caf50; +} + +[data-md-color-scheme="slate"] .quiz-blank-input.wrong { + border-bottom-color: #f44336; + background-color: rgba(244, 67, 54, 0.15); + color: #f44336; +} diff --git a/mkdocs_quiz/js/quiz.js b/mkdocs_quiz/js/quiz.js index f87a013..3921850 100644 --- a/mkdocs_quiz/js/quiz.js +++ b/mkdocs_quiz/js/quiz.js @@ -540,6 +540,9 @@ // Get quiz ID from the quiz div itself const quizId = quiz.id; + // Check if this is a fill-in-the-blank quiz + const isFillBlank = quiz.hasAttribute("data-quiz-type") && quiz.getAttribute("data-quiz-type") === "fill-blank"; + // Prevent anchor link from triggering page navigation/reload const headerLink = quiz.querySelector(".quiz-header-link"); if (headerLink) { @@ -562,12 +565,111 @@ form.appendChild(resetButton); } + // Helper function to normalize answers (trim whitespace, case-insensitive) + function normalizeAnswer(answer) { + return answer.trim().toLowerCase(); + } + // Restore quiz state from localStorage if available if (quizId && quizTracker.quizzes[quizId]) { const savedState = quizTracker.quizzes[quizId]; const section = quiz.querySelector("section"); - const allAnswers = fieldset.querySelectorAll('input[name="answer"]'); - const correctAnswers = fieldset.querySelectorAll('input[name="answer"][correct]'); + + if (isFillBlank) { + // Restore fill-in-the-blank quiz state + const blankInputs = quiz.querySelectorAll(".quiz-blank-input"); + + if (savedState.answered) { + // Restore input values based on saved values + if (savedState.selectedValues && savedState.selectedValues.length > 0) { + blankInputs.forEach((input, index) => { + if (savedState.selectedValues[index] !== undefined) { + input.value = savedState.selectedValues[index]; + } + }); + } + + if (savedState.correct) { + // Show correct feedback + section.classList.remove("hidden"); + feedbackDiv.classList.remove("hidden", "incorrect"); + feedbackDiv.classList.add("correct"); + feedbackDiv.textContent = "Correct answer!"; + + // Mark all inputs as correct + blankInputs.forEach((input) => { + input.classList.add("correct"); + }); + + // Disable inputs if disable-after-submit is enabled + if (quiz.hasAttribute("data-disable-after-submit")) { + blankInputs.forEach((input) => { + input.disabled = true; + }); + if (submitButton) { + submitButton.disabled = true; + } + resetButton.classList.add("hidden"); + } else { + resetButton.classList.remove("hidden"); + if (submitButton) { + submitButton.classList.add("hidden"); + } + } + } else { + // Restore incorrect answer state + section.classList.remove("hidden"); + + // Mark wrong/correct inputs + blankInputs.forEach((input) => { + const userAnswer = normalizeAnswer(input.value); + const correctAnswer = normalizeAnswer(input.getAttribute("data-answer")); + + if (userAnswer === correctAnswer) { + input.classList.add("correct"); + } else { + input.classList.add("wrong"); + } + }); + + // Show correct answers if show-correct is enabled + if (quiz.hasAttribute("data-show-correct")) { + blankInputs.forEach((input) => { + if (!input.classList.contains("correct")) { + // Show correct answer as placeholder or after the input + const correctAnswer = input.getAttribute("data-answer"); + input.placeholder = correctAnswer; + } + }); + } + + // Show incorrect feedback + feedbackDiv.classList.remove("hidden", "correct"); + feedbackDiv.classList.add("incorrect"); + const canRetry = !quiz.hasAttribute("data-disable-after-submit"); + feedbackDiv.textContent = canRetry ? "Incorrect answer. Please try again." : "Incorrect answer."; + + // Disable inputs if disable-after-submit is enabled + if (quiz.hasAttribute("data-disable-after-submit")) { + blankInputs.forEach((input) => { + input.disabled = true; + }); + if (submitButton) { + submitButton.disabled = true; + } + resetButton.classList.add("hidden"); + } else { + resetButton.classList.remove("hidden"); + if (submitButton) { + submitButton.classList.add("hidden"); + } + } + } + } + } else { + // Restore multiple-choice quiz state (existing code) + const allAnswers = fieldset.querySelectorAll('input[name="answer"]'); + const correctAnswers = fieldset.querySelectorAll('input[name="answer"][correct]'); if (savedState.answered) { // Restore selected answers based on saved values @@ -660,10 +762,11 @@ } } } + } } - // Auto-submit on radio button change if enabled - if (quiz.hasAttribute("data-auto-submit")) { + // Auto-submit on radio button change if enabled (not for fill-in-blank) + if (!isFillBlank && quiz.hasAttribute("data-auto-submit")) { let radioButtons = fieldset.querySelectorAll('input[type="radio"]'); radioButtons.forEach((radio) => { const handler = () => { @@ -676,14 +779,25 @@ // Reset button handler const resetHandler = () => { - // Clear all selections - const allInputs = fieldset.querySelectorAll('input[name="answer"]'); - allInputs.forEach((input) => { - input.checked = false; - input.disabled = false; - }); - // Reset colors - resetFieldset(fieldset); + if (isFillBlank) { + // Clear fill-in-the-blank inputs + const blankInputs = quiz.querySelectorAll(".quiz-blank-input"); + blankInputs.forEach((input) => { + input.value = ""; + input.disabled = false; + input.classList.remove("correct", "wrong"); + input.placeholder = ""; + }); + } else { + // Clear all selections + const allInputs = fieldset.querySelectorAll('input[name="answer"]'); + allInputs.forEach((input) => { + input.checked = false; + input.disabled = false; + }); + // Reset colors + resetFieldset(fieldset); + } // Hide content section let section = quiz.querySelector("section"); section.classList.add("hidden"); @@ -706,81 +820,153 @@ const submitHandler = (event) => { event.preventDefault(); - let selectedAnswers = form.querySelectorAll('input[name="answer"]:checked'); - let correctAnswers = fieldset.querySelectorAll('input[name="answer"][correct]'); - // Check if all correct answers are selected - let is_correct = selectedAnswers.length === correctAnswers.length; - Array.from(selectedAnswers).forEach((answer) => { - if (!answer.hasAttribute("correct")) { - is_correct = false; - } - }); + let is_correct = false; + let selectedValues = []; let section = quiz.querySelector("section"); - // Always show the content section after submission - section.classList.remove("hidden"); - if (is_correct) { - resetFieldset(fieldset); - // Only mark the correct answers in green (don't highlight wrong answers) - const allAnswers = fieldset.querySelectorAll('input[name="answer"]'); - allAnswers.forEach((answer) => { - if (answer.hasAttribute("correct")) { - answer.parentElement.classList.add("correct"); + if (isFillBlank) { + // Handle fill-in-the-blank quiz + const blankInputs = quiz.querySelectorAll(".quiz-blank-input"); + is_correct = true; + + // Collect user answers and validate + blankInputs.forEach((input) => { + const userAnswer = normalizeAnswer(input.value); + const correctAnswer = normalizeAnswer(input.getAttribute("data-answer")); + selectedValues.push(input.value); // Save original value, not normalized + + // Remove previous classes + input.classList.remove("correct", "wrong"); + + if (userAnswer === correctAnswer) { + input.classList.add("correct"); + } else { + input.classList.add("wrong"); + is_correct = false; } }); - // Show correct feedback - feedbackDiv.classList.remove("hidden", "incorrect"); - feedbackDiv.classList.add("correct"); - feedbackDiv.textContent = "Correct answer!"; + + // Always show the content section after submission + section.classList.remove("hidden"); + + if (is_correct) { + // Show correct feedback + feedbackDiv.classList.remove("hidden", "incorrect"); + feedbackDiv.classList.add("correct"); + feedbackDiv.textContent = "Correct answer!"; + } else { + // Show incorrect feedback + feedbackDiv.classList.remove("hidden", "correct"); + feedbackDiv.classList.add("incorrect"); + const canRetry = !quiz.hasAttribute("data-disable-after-submit"); + feedbackDiv.textContent = canRetry ? "Incorrect answer. Please try again." : "Incorrect answer."; + + // Show correct answers if show-correct is enabled + if (quiz.hasAttribute("data-show-correct")) { + blankInputs.forEach((input) => { + if (!input.classList.contains("correct")) { + const correctAnswer = input.getAttribute("data-answer"); + input.placeholder = correctAnswer; + } + }); + } + } + + // Disable quiz after submission if option is enabled + if (quiz.hasAttribute("data-disable-after-submit")) { + blankInputs.forEach((input) => { + input.disabled = true; + }); + if (submitButton) { + submitButton.disabled = true; + } + resetButton.classList.add("hidden"); + } else { + // Show reset button and hide submit button + resetButton.classList.remove("hidden"); + if (submitButton) { + submitButton.classList.add("hidden"); + } + } } else { - resetFieldset(fieldset); - // Mark wrong fields with colors + // Handle multiple-choice quiz (existing code) + let selectedAnswers = form.querySelectorAll('input[name="answer"]:checked'); + let correctAnswers = fieldset.querySelectorAll('input[name="answer"][correct]'); + // Check if all correct answers are selected + is_correct = selectedAnswers.length === correctAnswers.length; Array.from(selectedAnswers).forEach((answer) => { if (!answer.hasAttribute("correct")) { - answer.parentElement.classList.add("wrong"); - } else { - answer.parentElement.classList.add("correct"); + is_correct = false; } }); - // If show-correct is enabled, also show all correct answers - if (quiz.hasAttribute("data-show-correct")) { - correctAnswers.forEach((answer) => { - answer.parentElement.classList.add("correct"); + + // Always show the content section after submission + section.classList.remove("hidden"); + + if (is_correct) { + resetFieldset(fieldset); + // Only mark the correct answers in green (don't highlight wrong answers) + const allAnswers = fieldset.querySelectorAll('input[name="answer"]'); + allAnswers.forEach((answer) => { + if (answer.hasAttribute("correct")) { + answer.parentElement.classList.add("correct"); + } + }); + // Show correct feedback + feedbackDiv.classList.remove("hidden", "incorrect"); + feedbackDiv.classList.add("correct"); + feedbackDiv.textContent = "Correct answer!"; + } else { + resetFieldset(fieldset); + // Mark wrong fields with colors + Array.from(selectedAnswers).forEach((answer) => { + if (!answer.hasAttribute("correct")) { + answer.parentElement.classList.add("wrong"); + } else { + answer.parentElement.classList.add("correct"); + } }); + // If show-correct is enabled, also show all correct answers + if (quiz.hasAttribute("data-show-correct")) { + correctAnswers.forEach((answer) => { + answer.parentElement.classList.add("correct"); + }); + } + // Show incorrect feedback + feedbackDiv.classList.remove("hidden", "correct"); + feedbackDiv.classList.add("incorrect"); + // Only show "Please try again" if the quiz is not disabled after submission + const canRetry = !quiz.hasAttribute("data-disable-after-submit"); + feedbackDiv.textContent = canRetry ? "Incorrect answer. Please try again." : "Incorrect answer."; + } + + // Get selected values to save + selectedValues = Array.from(selectedAnswers).map((input) => input.value); + + // Disable quiz after submission if option is enabled + if (quiz.hasAttribute("data-disable-after-submit")) { + const allInputs = fieldset.querySelectorAll('input[name="answer"]'); + allInputs.forEach((input) => { + input.disabled = true; + }); + if (submitButton) { + submitButton.disabled = true; + } + // Hide reset button if disable-after-submit is enabled + resetButton.classList.add("hidden"); + } else { + // Show reset button and hide submit button + resetButton.classList.remove("hidden"); + if (submitButton) { + submitButton.classList.add("hidden"); + } } - // Show incorrect feedback - feedbackDiv.classList.remove("hidden", "correct"); - feedbackDiv.classList.add("incorrect"); - // Only show "Please try again" if the quiz is not disabled after submission - const canRetry = !quiz.hasAttribute("data-disable-after-submit"); - feedbackDiv.textContent = canRetry ? "Incorrect answer. Please try again." : "Incorrect answer."; } // Update tracker if (quizId) { - // Get selected values to save - const selectedValues = Array.from(selectedAnswers).map((input) => input.value); quizTracker.markQuiz(quizId, is_correct, selectedValues); } - - // Disable quiz after submission if option is enabled - if (quiz.hasAttribute("data-disable-after-submit")) { - const allInputs = fieldset.querySelectorAll('input[name="answer"]'); - allInputs.forEach((input) => { - input.disabled = true; - }); - if (submitButton) { - submitButton.disabled = true; - } - // Hide reset button if disable-after-submit is enabled - resetButton.classList.add("hidden"); - } else { - // Show reset button and hide submit button - resetButton.classList.remove("hidden"); - if (submitButton) { - submitButton.classList.add("hidden"); - } - } }; addTrackedEventListener(form, "submit", submitHandler); }); diff --git a/mkdocs_quiz/plugin.py b/mkdocs_quiz/plugin.py index 95740e7..d6cc555 100644 --- a/mkdocs_quiz/plugin.py +++ b/mkdocs_quiz/plugin.py @@ -76,10 +76,19 @@ def get_markdown_converter() -> md.Markdown: # Optional content section (supports full markdown) # Can include **bold**, *italic*, `code`, etc. # +# +# Fill-in-the-blank format: +# +# 2 + 2 = [[4]] +# +# Optional content section +# QUIZ_START_TAG = "" QUIZ_END_TAG = "" QUIZ_REGEX = r"(.*?)" +# Pattern to match fill-in-the-blank placeholders: [[answer]] +FILL_BLANK_REGEX = r"\[\[([^\]]+)\]\]" # Old v0.x syntax patterns (no longer supported) OLD_SYNTAX_PATTERNS = [ @@ -278,6 +287,155 @@ def _parse_quiz_question_and_answers( return question_text, all_answers, correct_answers, content_start_index + def _is_fill_in_blank_quiz(self, quiz_content: str) -> bool: + """Check if quiz contains fill-in-the-blank patterns. + + Args: + quiz_content: The content inside the quiz tags. + + Returns: + True if the quiz contains [[answer]] patterns, False otherwise. + """ + return bool(re.search(FILL_BLANK_REGEX, quiz_content)) + + def _process_fill_in_blank_quiz( + self, quiz_content: str, quiz_id: int, options: dict[str, bool] + ) -> str: + """Process a fill-in-the-blank quiz. + + Args: + quiz_content: The content inside the quiz tags. + quiz_id: The unique ID for this quiz. + options: Quiz options (show_correct, auto_submit, disable_after_submit, auto_number). + + Returns: + The HTML representation of the fill-in-the-blank quiz. + + Raises: + ValueError: If the quiz format is invalid. + """ + # Dedent the quiz content to handle indented quizzes + quiz_content = dedent(quiz_content) + + # Extract all correct answers and their positions + answers = [] + answer_positions = [] + + for match in re.finditer(FILL_BLANK_REGEX, quiz_content): + answers.append(match.group(1).strip()) + answer_positions.append((match.start(), match.end())) + + if not answers: + raise ValueError("Fill-in-the-blank quiz must have at least one blank") + + # Split content into question and content sections + # Look for an empty line to separate question from content + lines = quiz_content.split("\n") + question_lines = [] + content_start_index = len(lines) + + # Find the first empty line after the question text + for i, line in enumerate(lines): + if not line.strip() and i > 0: + # Check if there's content after this empty line + has_content_after = any(l.strip() for l in lines[i + 1 :]) + if has_content_after: + content_start_index = i + 1 + question_lines = lines[:i] + break + + # If no empty line found, everything is the question + if not question_lines: + question_lines = lines + content_start_index = len(lines) + + question_text = "\n".join(question_lines) + + # Replace [[answer]] patterns with input fields + input_counter = 0 + + def replace_with_input(match: re.Match[str]) -> str: + nonlocal input_counter + answer = match.group(1).strip() + input_id = f"quiz-{quiz_id}-blank-{input_counter}" + # Store the correct answer as a data attribute, HTML-escaped + escaped_answer = html.escape(answer) + input_html = ( + f'' + ) + input_counter += 1 + return input_html + + # Convert question markdown to HTML first, but preserve [[...]] patterns temporarily + # by replacing them with placeholders + placeholders = {} + placeholder_counter = 0 + + def create_placeholder(match: re.Match[str]) -> str: + nonlocal placeholder_counter + # Use HTML comment as placeholder - won't be affected by markdown + placeholder = f"" + placeholders[placeholder] = match.group(0) + placeholder_counter += 1 + return placeholder + + # Replace blanks with placeholders before markdown conversion + question_with_placeholders = re.sub(FILL_BLANK_REGEX, create_placeholder, question_text) + + # Convert markdown to HTML + question_html = convert_inline_markdown(question_with_placeholders) + + # Now replace placeholders with actual input fields + for placeholder, original in placeholders.items(): + match = re.match(FILL_BLANK_REGEX, original) + if match: + input_html = replace_with_input(match) + question_html = question_html.replace(placeholder, input_html) + + # Get content section + content_lines = lines[content_start_index:] + content_html = "" + if content_lines and any(l.strip() for l in content_lines): + content_text = "\n".join(content_lines) + converter = get_markdown_converter() + converter.reset() + content_html = converter.convert(content_text) + + # Build data attributes + data_attrs = ['data-quiz-type="fill-blank"'] + if options["show_correct"]: + data_attrs.append('data-show-correct="true"') + if options["disable_after_submit"]: + data_attrs.append('data-disable-after-submit="true"') + attrs = " ".join(data_attrs) + + # Generate quiz ID for linking + quiz_header_id = f"quiz-{quiz_id}" + + # If auto_number is enabled, add a header with the question number + question_header = "" + if options["auto_number"]: + question_number = quiz_id + 1 + question_header = f'

Question {question_number}

' + + quiz_html = dedent(f""" +
+ # + {question_header} +
+ {question_html} +
+
+ + +
+ +
+ """).strip() + + return quiz_html + def _generate_answer_html( self, all_answers: list[str], correct_answers: list[str], quiz_id: int ) -> tuple[list[str], bool]: @@ -507,6 +665,10 @@ def _process_quiz(self, quiz_content: str, quiz_id: int, options: dict[str, bool Raises: ValueError: If the quiz format is invalid. """ + # Check if this is a fill-in-the-blank quiz + if self._is_fill_in_blank_quiz(quiz_content): + return self._process_fill_in_blank_quiz(quiz_content, quiz_id, options) + # Dedent the quiz content to handle indented quizzes (e.g., in content tabs) quiz_content = dedent(quiz_content) diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c8891f9..d1f8ca4 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1154,3 +1154,250 @@ def test_old_syntax_in_code_block_not_detected( assert "Real question?" in html_result # Old syntax should remain in code block assert "" in html_result + + +def test_fill_in_blank_single( + plugin: MkDocsQuizPlugin, mock_page: Page, mock_config: MkDocsConfig +) -> None: + """Test processing a fill-in-the-blank quiz with a single blank.""" + markdown = """ + +2 + 2 = [[4]] + +""" + # Process markdown phase + markdown_result = plugin.on_page_markdown(markdown, mock_page, mock_config) + # Process content phase (convert placeholders to actual HTML) + result = plugin.on_page_content(markdown_result, page=mock_page, config=mock_config, files=None) # type: ignore[arg-type] + assert result is not None + + # Should contain fill-blank quiz marker + assert 'data-quiz-type="fill-blank"' in result + # Should contain text input + assert 'type="text"' in result + assert 'class="quiz-blank-input"' in result + # Should contain the correct answer in data attribute + assert 'data-answer="4"' in result + # Should have the question text + assert "2 + 2 =" in result + + +def test_fill_in_blank_multiple( + plugin: MkDocsQuizPlugin, mock_page: Page, mock_config: MkDocsConfig +) -> None: + """Test processing a fill-in-the-blank quiz with multiple blanks.""" + markdown = """ + +The capital of France is [[Paris]] and the capital of Spain is [[Madrid]]. + +""" + # Process markdown phase + markdown_result = plugin.on_page_markdown(markdown, mock_page, mock_config) + # Process content phase (convert placeholders to actual HTML) + result = plugin.on_page_content(markdown_result, page=mock_page, config=mock_config, files=None) # type: ignore[arg-type] + assert result is not None + + # Should contain fill-blank quiz marker + assert 'data-quiz-type="fill-blank"' in result + # Should contain two text inputs + assert result.count('type="text"') == 2 + assert result.count('class="quiz-blank-input"') == 2 + # Should contain both correct answers + assert 'data-answer="Paris"' in result + assert 'data-answer="Madrid"' in result + # Should have the question text + assert "The capital of France is" in result + assert "and the capital of Spain is" in result + + +def test_fill_in_blank_with_markdown( + plugin: MkDocsQuizPlugin, mock_page: Page, mock_config: MkDocsConfig +) -> None: + """Test fill-in-the-blank with markdown formatting around blanks.""" + markdown = """ + +The **bold** answer is [[correct]] and the *italic* one is [[also correct]]. + +""" + # Process markdown phase + markdown_result = plugin.on_page_markdown(markdown, mock_page, mock_config) + # Process content phase (convert placeholders to actual HTML) + result = plugin.on_page_content(markdown_result, page=mock_page, config=mock_config, files=None) # type: ignore[arg-type] + assert result is not None + + # Should process markdown + assert "bold" in result + assert "italic" in result + # Should contain correct answers + assert 'data-answer="correct"' in result + assert 'data-answer="also correct"' in result + + +def test_fill_in_blank_with_content( + plugin: MkDocsQuizPlugin, mock_page: Page, mock_config: MkDocsConfig +) -> None: + """Test fill-in-the-blank with content section.""" + markdown = """ + +2 + 2 = [[4]] + +That's correct! Basic arithmetic. + +""" + # Process markdown phase + markdown_result = plugin.on_page_markdown(markdown, mock_page, mock_config) + # Process content phase (convert placeholders to actual HTML) + result = plugin.on_page_content(markdown_result, page=mock_page, config=mock_config, files=None) # type: ignore[arg-type] + assert result is not None + + # Should contain fill-blank quiz marker + assert 'data-quiz-type="fill-blank"' in result + # Should have content section with the explanation + assert "Basic arithmetic" in result + assert '