feat: add HTML lessons mode and side-by-side comparison UI

- Add HTML mode support with new validation types (element_exists,
  element_count, attribute_value, element_text, parent_child, sibling)
- Create 3 HTML lesson modules: Elements, Forms Basic, Forms Validation
- Implement side-by-side preview comparison (Your Output vs Expected)
- Add merge animation with "Perfect Match!" overlay on validation success
- Render expected output from solutionCode field in lesson JSON
- Update schema to support HTML mode and solutionCode
- Reorder modules: HTML first, then CSS, then Tailwind
- Update tests for new functionality
This commit is contained in:
2025-12-21 22:12:00 +01:00
parent 394490c003
commit 50c4d51523
15 changed files with 1136 additions and 66 deletions

View File

@@ -15,7 +15,7 @@ let feedbackTimeout = null;
*/
export function renderModuleList(container, modules, onSelectModule, onSelectLesson) {
// Clear the container
container.innerHTML = "<h3>CSS Lessons</h3>";
container.innerHTML = "<h3>Lessons</h3>";
// Get user progress from localStorage
const progressData = localStorage.getItem("codeCrispies.progress");

View File

@@ -5,13 +5,205 @@
export function validateUserCode(userCode, lesson) {
const mode = lesson.mode || "css";
if (mode === "tailwind") {
return validateTailwindClasses(userCode, lesson);
} else {
return validateCssCode(userCode, lesson);
switch (mode) {
case "html":
return validateHtmlCode(userCode, lesson);
case "tailwind":
return validateTailwindClasses(userCode, lesson);
case "css":
default:
return validateCssCode(userCode, lesson);
}
}
/**
* Validate user HTML code against the lesson requirements
* @param {string} userHtml - User submitted HTML code
* @param {Object} lesson - The current lesson object
* @returns {Object} Validation result with isValid and message properties
*/
function validateHtmlCode(userHtml, lesson) {
if (!lesson || !lesson.validations) {
return { isValid: true, message: "No validations specified for this lesson." };
}
// Parse the HTML using DOMParser
const parser = new DOMParser();
const doc = parser.parseFromString(userHtml, "text/html");
// Check for parse errors (DOMParser doesn't throw, but inserts error elements for XML)
// For HTML mode, it's more lenient, so we mainly check validations
const validations = lesson.validations;
let result = {
isValid: true,
validCases: 0,
totalCases: validations.length,
message: "Your HTML looks great!"
};
for (const validation of validations) {
const { type, value, message } = validation;
let validationPassed = false;
switch (type) {
case "element_exists":
// value is a CSS selector string
validationPassed = doc.querySelector(value) !== null;
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Missing element: ${value}`
};
}
break;
case "element_count":
// value is { selector: string, count?: number, min?: number }
const elements = doc.querySelectorAll(value.selector);
if (value.count !== undefined) {
validationPassed = elements.length === value.count;
} else if (value.min !== undefined) {
validationPassed = elements.length >= value.min;
} else {
validationPassed = elements.length > 0;
}
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Expected ${value.count || value.min + "+"} ${value.selector} element(s)`
};
}
break;
case "attribute_value":
// value is { selector: string, attr: string, value: any }
const el = doc.querySelector(value.selector);
if (!el) {
validationPassed = false;
} else if (value.value === true) {
// Check attribute exists (boolean attribute like "required")
validationPassed = el.hasAttribute(value.attr);
} else if (value.value === null) {
// Check attribute exists with any value
validationPassed = el.hasAttribute(value.attr);
} else {
// Check attribute has specific value
validationPassed = el.getAttribute(value.attr) === value.value;
}
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Element ${value.selector} should have ${value.attr} attribute`
};
}
break;
case "element_text":
// value is { selector: string, text: string }
const textEl = doc.querySelector(value.selector);
validationPassed = textEl && textEl.textContent.includes(value.text);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Element ${value.selector} should contain "${value.text}"`
};
}
break;
case "parent_child":
// value is { parent: string, child: string }
const parentEl = doc.querySelector(value.parent);
validationPassed = parentEl && parentEl.querySelector(value.child) !== null;
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `${value.child} should be inside ${value.parent}`
};
}
break;
case "sibling":
// value is { first: string, then: string }
const firstSibling = doc.querySelector(value.first);
if (!firstSibling) {
validationPassed = false;
} else {
// Check if "then" element comes after "first" element
let nextEl = firstSibling.nextElementSibling;
while (nextEl) {
if (nextEl.matches(value.then)) {
validationPassed = true;
break;
}
nextEl = nextEl.nextElementSibling;
}
}
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `${value.then} should follow ${value.first}`
};
}
break;
// Fall back to text-based validations for simple checks
case "contains":
validationPassed = containsValidation(userHtml, value);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Your HTML should include "${value}"`
};
}
break;
case "not_contains":
validationPassed = !containsValidation(userHtml, value);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || `Your HTML should not include "${value}"`
};
}
break;
case "regex":
validationPassed = regexValidation(userHtml, value);
if (!validationPassed) {
result = {
...result,
isValid: false,
message: message || "Your HTML doesn't match the expected pattern"
};
}
break;
default:
console.warn(`Unknown HTML validation type: ${type}`);
validationPassed = true;
}
if (validationPassed) {
result.validCases++;
} else {
return result;
}
}
result.validCases = validations.length;
return result;
}
function validateTailwindClasses(userClasses, lesson) {
if (!lesson || !lesson.validations) {
return { isValid: true, message: "No validations specified for this lesson." };