diff --git a/CLAUDE.md b/CLAUDE.md index 569bbfb..10d1b94 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,26 @@ 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,20 +48,30 @@ 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 horizontal rule (`---`) - 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` - - Uses the same `markdown_extensions` configured in `mkdocs.yml` for conversion - - This enables features like `pymdownx.superfences`, `pymdownx.highlight`, admonitions, etc. - - 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` + - Uses the same `markdown_extensions` configured in `mkdocs.yml` for conversion + - This enables features like `pymdownx.superfences`, `pymdownx.highlight`, admonitions, etc. + - 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 @@ -63,11 +93,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/README.md b/README.md index d539038..ba90bd0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern MkDocs plugin to create interactive quizzes directly in your markdown d ## Features - ✨ **Simple markdown syntax** - Create quizzes using GitHub-flavored markdown checkboxes -- 🎯 **Single and multiple choice** - One correct answer = radio buttons, multiple = checkboxes +- 🎯 **Multiple quiz types** - Single choice (radio), multiple choice (checkboxes), and fill-in-the-blank - ⚡ **Instant feedback** - Visual indicators show correct/incorrect answers - 📊 **Progress tracking** - Automatic progress sidebar and results panel, with confetti 🎉 - 💾 **Results saved** - Answers are saved to the browser's local storage diff --git a/docs/examples.md b/docs/examples.md index 38fd2f7..d20cbc3 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -112,6 +112,108 @@ The content section is optional: ``` +## Fill-in-the-Blank Quizzes + +Fill-in-the-blank quizzes allow users to type answers into text fields. These are perfect for recall-based questions where users need to remember specific terms, values, or concepts. + +### Single Blank + +Use double square brackets `[[answer]]` to create a blank: + +=== "Example" + + + The capital of France is [[Paris]]. + + +=== "Syntax" + + ```markdown + + The capital of France is [[Paris]]. + + ``` + +### Multiple Blanks + +You can include multiple blanks in a single question: + +=== "Example" + + + Python was created by [[Guido van Rossum]] and first released in [[1991]]. + + +=== "Syntax" + + ```markdown + + Python was created by [[Guido van Rossum]] and first released in [[1991]]. + + ``` + +### Fill-in-the-Blank with Content + +To add optional content (explanations, additional information) to fill-in-the-blank quizzes, use a horizontal rule `---` to separate the question from the content: + +=== "Example" + + + 2 + 2 = [[4]] + + --- + That's correct! Basic arithmetic is fundamental to programming. + + +=== "Syntax" + + ```markdown + + 2 + 2 = [[4]] + + --- + That's correct! Basic arithmetic is fundamental to programming. + + ``` + +### Complex Fill-in-the-Blank + +Fill-in-the-blank quizzes support markdown formatting around the blanks and rich content after the horizontal rule: + +=== "Example" + + + Some markdown: + + The answer is [[foo]]. + + Another answer is [[bar]]. + + --- + This *content* is only shown after answering. + + It can have **bold**, `code`, and other markdown formatting. + + +=== "Syntax" + + ```markdown + + Some markdown: + + The answer is [[foo]]. + + Another answer is [[bar]]. + + --- + This *content* is only shown after answering. + + It can have **bold**, `code`, and other markdown formatting. + + ``` + +**Note:** Answers are case-insensitive and whitespace is trimmed. So "Paris", "paris", and " PARIS " are all accepted. + ## Answer Syntax Variations All of these checkbox formats are supported: diff --git a/docs/index.md b/docs/index.md index 3e87745..13c0631 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ A modern MkDocs plugin to create interactive quizzes directly in your markdown d ## Features - ✨ **Simple markdown syntax** - Create quizzes using GitHub-flavored markdown checkboxes -- 🎯 **Single and multiple choice** - One correct answer = radio buttons, multiple = checkboxes +- 🎯 **Multiple quiz types** - Single choice (radio), multiple choice (checkboxes), and fill-in-the-blank - ⚡ **Instant feedback** - Visual indicators show correct/incorrect answers - 📊 **Progress tracking** - Automatic progress sidebar and results panel, with confetti :tada: - 💾 **Results saved** - Answers are saved to the browser's local storage 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 8fbc3c3..22856c5 100644 --- a/mkdocs_quiz/js/quiz.js +++ b/mkdocs_quiz/js/quiz.js @@ -165,6 +165,7 @@ feedbackDiv.classList.add("hidden"); feedbackDiv.classList.remove("correct", "incorrect"); feedbackDiv.textContent = ""; + feedbackDiv.innerHTML = ""; // Show submit button, hide reset button if (submitButton) { @@ -498,6 +499,31 @@ }); } + // Initialize tracker + quizTracker.init(); + + // Translate template elements + translateTemplateElements(); + + // Reposition sidebar for Material theme TOC integration + // Must run on every page load to support instant navigation + repositionSidebar(); + + // Create sidebar after page loads + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + translateTemplateElements(); + repositionSidebar(); + quizTracker.createSidebar(); + initializeResultsDiv(); + initializeIntroResetButtons(); + }); + } else { + quizTracker.createSidebar(); + initializeResultsDiv(); + initializeIntroResetButtons(); + } + // Initialize results div reset button function initializeResultsDiv() { const resultsDiv = document.getElementById("quiz-results"); @@ -527,43 +553,152 @@ }); } - // Initialize all quiz elements on the page - function initializeQuizzes() { - document.querySelectorAll(".quiz").forEach((quiz) => { - let form = quiz.querySelector("form"); - let fieldset = form.querySelector("fieldset"); - let submitButton = form.querySelector('button[type="submit"]'); - let feedbackDiv = form.querySelector(".quiz-feedback"); + document.querySelectorAll(".quiz").forEach((quiz) => { + let form = quiz.querySelector("form"); + let fieldset = form.querySelector("fieldset"); + let submitButton = form.querySelector('button[type="submit"]'); + let feedbackDiv = form.querySelector(".quiz-feedback"); + + // 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) { + const handler = (e) => { + // Let the browser handle the anchor navigation normally + // This prevents Material for MkDocs from intercepting it as a page navigation + e.stopPropagation(); + }; + addTrackedEventListener(headerLink, "click", handler); + } - // Get quiz ID from the quiz div itself - const quizId = quiz.id; + // Create reset button (initially hidden) + let resetButton = document.createElement("button"); + resetButton.type = "button"; + resetButton.className = "quiz-button quiz-reset-button hidden"; + resetButton.textContent = t("Try Again"); + if (submitButton) { + submitButton.parentNode.insertBefore(resetButton, submitButton.nextSibling); + } else { + form.appendChild(resetButton); + } - // Prevent anchor link from triggering page navigation/reload - const headerLink = quiz.querySelector(".quiz-header-link"); - if (headerLink) { - const handler = (e) => { - // Let the browser handle the anchor navigation normally - // This prevents Material for MkDocs from intercepting it as a page navigation - e.stopPropagation(); - }; - addTrackedEventListener(headerLink, "click", handler); - } + // Helper function to normalize answers (trim whitespace, case-insensitive) + function normalizeAnswer(answer) { + return answer.trim().toLowerCase(); + } - // Create reset button (initially hidden) - let resetButton = document.createElement("button"); - resetButton.type = "button"; - resetButton.className = "quiz-button quiz-reset-button hidden"; - resetButton.textContent = t("Try Again"); - if (submitButton) { - submitButton.parentNode.insertBefore(resetButton, submitButton.nextSibling); - } else { - form.appendChild(resetButton); - } + // Restore quiz state from localStorage if available + if (quizId && quizTracker.quizzes[quizId]) { + const savedState = quizTracker.quizzes[quizId]; + const section = quiz.querySelector("section"); - // Restore quiz state from localStorage if available - if (quizId && quizTracker.quizzes[quizId]) { - const savedState = quizTracker.quizzes[quizId]; - const section = quiz.querySelector("section"); + 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 + if (section) { + section.classList.remove("hidden"); + } + feedbackDiv.classList.remove("hidden", "incorrect"); + feedbackDiv.classList.add("correct"); + feedbackDiv.textContent = t("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 + if (section) { + 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 incorrect feedback with detailed list + feedbackDiv.classList.remove("hidden", "correct"); + feedbackDiv.classList.add("incorrect"); + const canRetry = !quiz.hasAttribute("data-disable-after-submit"); + const feedbackText = canRetry ? t("Incorrect answer. Please try again.") : t("Incorrect answer."); + + // Show correct answers if show-correct is enabled + if (quiz.hasAttribute("data-show-correct")) { + let feedbackHTML = feedbackText + ""; + feedbackDiv.innerHTML = feedbackHTML; + } else { + feedbackDiv.textContent = feedbackText; + } + + // 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 { + // Keep submit button visible for editing and resubmission + resetButton.classList.remove("hidden"); + } + } + } + } else { + // Restore multiple-choice quiz state (existing code) const allAnswers = fieldset.querySelectorAll('input[name="answer"]'); const correctAnswers = fieldset.querySelectorAll('input[name="answer"][correct]'); @@ -579,7 +714,9 @@ if (savedState.correct) { // Show the content section - section.classList.remove("hidden"); + if (section) { + section.classList.remove("hidden"); + } // Only mark the correct answers in green (don't highlight wrong answers) allAnswers.forEach((input) => { @@ -616,7 +753,9 @@ ); // Show the content section for incorrect answers too - section.classList.remove("hidden"); + if (section) { + section.classList.remove("hidden"); + } // Mark selected answers selectedInputs.forEach((input) => { @@ -659,25 +798,33 @@ } } } + } - // Auto-submit on radio button change if enabled - if (quiz.hasAttribute("data-auto-submit")) { - let radioButtons = fieldset.querySelectorAll('input[type="radio"]'); - radioButtons.forEach((radio) => { - const handler = (e) => { - // Prevent any default behavior that might cause page scroll - e.preventDefault(); - // Trigger form submission with a properly configured event - // cancelable: true allows preventDefault to work - // bubbles: true ensures proper event propagation - form.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true })); - }; - addTrackedEventListener(radio, "change", handler); - }); - } + // 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 = (e) => { + e.preventDefault(); // Prevent page scroll to top + // Trigger form submission with proper event options + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }; + addTrackedEventListener(radio, "change", handler); + }); + } - // Reset button handler - const resetHandler = () => { + // Reset button handler + const resetHandler = () => { + 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) => { @@ -686,43 +833,127 @@ }); // Reset colors resetFieldset(fieldset); - // Hide content section - let section = quiz.querySelector("section"); + } + // Hide content section + let section = quiz.querySelector("section"); + if (section) { section.classList.add("hidden"); - // Hide feedback message - feedbackDiv.classList.add("hidden"); - feedbackDiv.classList.remove("correct", "incorrect"); - feedbackDiv.textContent = ""; - // Show submit button, hide reset button - if (submitButton) { - submitButton.disabled = false; - submitButton.classList.remove("hidden"); + } + // Hide feedback message + feedbackDiv.classList.add("hidden"); + feedbackDiv.classList.remove("correct", "incorrect"); + feedbackDiv.textContent = ""; + feedbackDiv.innerHTML = ""; + // Show submit button, hide reset button + if (submitButton) { + submitButton.disabled = false; + submitButton.classList.remove("hidden"); + } + resetButton.classList.add("hidden"); + // Update tracker + if (quizId) { + quizTracker.resetQuiz(quizId); + } + }; + addTrackedEventListener(resetButton, "click", resetHandler); + + const submitHandler = (event) => { + event.preventDefault(); + event.stopPropagation(); // Prevent Material theme from intercepting form submission + let is_correct = false; + let selectedValues = []; + let section = quiz.querySelector("section"); + + 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; + } + }); + + // Always show the content section after submission + if (section) { + section.classList.remove("hidden"); } - resetButton.classList.add("hidden"); - // Update tracker - if (quizId) { - quizTracker.resetQuiz(quizId); + + if (is_correct) { + // Show correct feedback + feedbackDiv.classList.remove("hidden", "incorrect"); + feedbackDiv.classList.add("correct"); + feedbackDiv.textContent = t("Correct answer!"); + } else { + // Show incorrect feedback with detailed list + feedbackDiv.classList.remove("hidden", "correct"); + feedbackDiv.classList.add("incorrect"); + const canRetry = !quiz.hasAttribute("data-disable-after-submit"); + + // Build detailed feedback with bullet list + const feedbackText = canRetry ? t("Incorrect answer. Please try again.") : t("Incorrect answer."); + + // Show correct answers if show-correct is enabled + if (quiz.hasAttribute("data-show-correct")) { + let feedbackHTML = feedbackText + ""; + feedbackDiv.innerHTML = feedbackHTML; + } else { + feedbackDiv.textContent = feedbackText; + } } - }; - addTrackedEventListener(resetButton, "click", resetHandler); - const submitHandler = (event) => { - // Prevent default form submission behavior - event.preventDefault(); - // Stop propagation to prevent Material for MkDocs instant navigation from intercepting - event.stopPropagation(); + // 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 { + // For fill-in-blank, keep submit button visible so users can edit and resubmit + // Only show reset button as an alternative + resetButton.classList.remove("hidden"); + } + } else { + // 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 - let is_correct = selectedAnswers.length === correctAnswers.length; + is_correct = selectedAnswers.length === correctAnswers.length; Array.from(selectedAnswers).forEach((answer) => { if (!answer.hasAttribute("correct")) { is_correct = false; } }); - let section = quiz.querySelector("section"); + // Always show the content section after submission - section.classList.remove("hidden"); + if (section) { + section.classList.remove("hidden"); + } if (is_correct) { resetFieldset(fieldset); @@ -761,12 +992,8 @@ feedbackDiv.textContent = canRetry ? t("Incorrect answer. Please try again.") : t("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); - } + // 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")) { @@ -786,10 +1013,15 @@ submitButton.classList.add("hidden"); } } - }; - addTrackedEventListener(form, "submit", submitHandler); - }); - } + } + + // Update tracker + if (quizId) { + quizTracker.markQuiz(quizId, is_correct, selectedValues); + } + }; + addTrackedEventListener(form, "submit", submitHandler); + }); function resetFieldset(fieldset) { Array.from(fieldset.children).forEach((child) => { @@ -797,35 +1029,6 @@ }); } - // Main initialization function that sets up everything on a page - function initializePage() { - // Initialize tracker - quizTracker.init(); - - // Translate template elements - translateTemplateElements(); - - // Reposition sidebar for Material theme TOC integration - repositionSidebar(); - - // Create sidebar - quizTracker.createSidebar(); - - // Initialize results div and intro buttons - initializeResultsDiv(); - initializeIntroResetButtons(); - - // Initialize all quiz elements - initializeQuizzes(); - } - - // Run initialization when DOM is ready - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", initializePage); - } else { - initializePage(); - } - // Material for MkDocs instant navigation support // Cleanup and reinitialize when navigating between pages if (typeof document$ !== "undefined") { @@ -839,8 +1042,7 @@ // Material theme with instant navigation is active window._mkdocsQuizSubscription = document$.subscribe(() => { cleanup(); // Remove old event listeners to prevent memory leaks - // Reinitialize everything for the new page - initializePage(); + // The IIFE will re-run when the new page content loads }); } })(); // End of IIFE diff --git a/mkdocs_quiz/plugin.py b/mkdocs_quiz/plugin.py index 2560f56..a84ed66 100644 --- a/mkdocs_quiz/plugin.py +++ b/mkdocs_quiz/plugin.py @@ -120,11 +120,21 @@ def get_markdown_converter(config: MkDocsConfig | None = None) -> md.Markdown: # Can include **bold**, *italic*, `code`, etc. # # +# Fill-in-the-blank format: +# +# 2 + 2 = [[4]] +# +# --- +# Optional content section +# +# # Note: Asterisk bullets (* [x], * [ ]) are also supported. 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 = [ @@ -409,6 +419,165 @@ 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], + t: TranslationManager, + config: MkDocsConfig | None = None, + ) -> 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). + t: Translation manager for this page. + config: Optional MkDocs config to get markdown extensions from. + + 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 a horizontal rule (---) to separate question from content + lines = quiz_content.split("\n") + question_lines = [] + content_start_index = len(lines) + + # Find the first horizontal rule (---) + for i, line in enumerate(lines): + if line.strip() == "---": + # Found separator, everything before is question, everything after is content + question_lines = lines[:i] + content_start_index = i + 1 + break + + # If no horizontal rule 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 using configured markdown extensions + question_html = convert_inline_markdown(question_with_placeholders, config) + + # Now replace placeholders with actual input fields + for placeholder, original in placeholders.items(): + blank_match: re.Match[str] | None = re.match(FILL_BLANK_REGEX, original) + if blank_match: + input_html = replace_with_input(blank_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(line.strip() for line in content_lines): + content_text = "\n".join(content_lines) + # Use configured markdown extensions for content section + converter = get_markdown_converter(config) + 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_text = t.get("Question {n}", n=question_number) + question_header = f'

{question_text}

' + + # Get translated submit button text + submit_text = t.get("Submit") + + 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]: @@ -671,6 +840,10 @@ def _process_quiz( 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, t, config) + # 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 0e0292e..102c971 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1156,6 +1156,321 @@ def test_old_syntax_in_code_block_not_detected( 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 separated by horizontal rule.""" + 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 '