From ae8f9fef457a1ae24bf9d193b36002fc041738c5 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Sat, 28 Mar 2026 20:22:50 +0100 Subject: [PATCH] feat: add JavaScript learning section with starter lessons and sidebar section headers Implementation following plan: - S01: Foundation: schema, section config, and router - S02: Install CodeMirror JavaScript language support - S03: Create JavaScript lesson JSON files (variables, DOM, events) - S04: Register JavaScript lessons in module stores - S05: Add JavaScript validation logic - S06: Add JavaScript mode to LessonEngine preview rendering - S07: Add JavaScript mode to CodeEditor - S08: Update app.js for JavaScript mode support - S09: Update navigation HTML and CSS theming for JavaScript section - S10: Add section grouping headers in sidebar navigation - S11: Update and write tests --- lessons/50-js-variables.json | 139 +++++++++++++++++++++++ lessons/51-js-dom.json | 139 +++++++++++++++++++++++ lessons/52-js-events.json | 118 +++++++++++++++++++ package-lock.json | 7 +- package.json | 1 + schemas/code-crispies-module-schema.json | 6 +- src/app.js | 67 ++++++++++- src/config/lessons.js | 27 +++++ src/config/sections.js | 8 ++ src/helpers/renderer.js | 17 +++ src/helpers/router.js | 2 +- src/helpers/validator.js | 76 +++++++++++++ src/impl/CodeEditor.js | 4 +- src/impl/LessonEngine.js | 48 ++++++++ src/index.html | 15 +-- src/main.css | 106 +++++++++++++++++ tests/unit/lessons.test.js | 6 +- tests/unit/router.test.js | 4 +- tests/unit/sections.test.js | 20 ++-- tests/unit/validator.test.js | 80 +++++++++++++ 20 files changed, 863 insertions(+), 27 deletions(-) create mode 100644 lessons/50-js-variables.json create mode 100644 lessons/51-js-dom.json create mode 100644 lessons/52-js-events.json diff --git a/lessons/50-js-variables.json b/lessons/50-js-variables.json new file mode 100644 index 0000000..937701c --- /dev/null +++ b/lessons/50-js-variables.json @@ -0,0 +1,139 @@ +{ + "$schema": "../schemas/code-crispies-module-schema.json", + "id": "js-variables", + "title": "JS Variables", + "description": "Learn to declare variables with let and const, and work with basic data types in JavaScript.", + "mode": "javascript", + "difficulty": "beginner", + "lessons": [ + { + "id": "js-const", + "title": "Constants", + "description": "Use const to declare a variable that cannot be reassigned. Constants are the default choice for most values in modern JavaScript.", + "task": "Declare a constant named name with the value \"Alice\"", + "previewHTML": "

Waiting...

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "", + "codeSuffix": "\ndocument.getElementById('out').textContent = name;", + "solution": "const name = \"Alice\";", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "const", + "message": "Use const to declare the variable" + }, + { + "type": "regex", + "value": "const\\s+name\\s*=", + "message": "Declare a constant called name" + }, + { + "type": "regex", + "value": "\"Alice\"|'Alice'|`Alice`", + "message": "Set the value to \"Alice\"" + } + ] + }, + { + "id": "js-let", + "title": "Let Variables", + "description": "Use let to declare variables that you plan to reassign later. Unlike const, a let variable can change its value.", + "task": "Declare a variable count with let set to 0, then reassign it to 5", + "previewHTML": "

Waiting...

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "", + "codeSuffix": "\ndocument.getElementById('out').textContent = count;", + "solution": "let count = 0;\ncount = 5;", + "previewContainer": "preview-area", + "validations": [ + { + "type": "regex", + "value": "let\\s+count\\s*=\\s*0", + "message": "Start with let count = 0;" + }, + { + "type": "regex", + "value": "count\\s*=\\s*5", + "message": "Reassign count to 5" + } + ] + }, + { + "id": "js-string", + "title": "Template Literals", + "description": "Template literals use backticks ` and ${} to embed expressions inside strings. This makes building dynamic text much easier than string concatenation.", + "task": "Create a constant msg using a template literal: `Hello, ${name}!`", + "previewHTML": "

Waiting...

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "const name = \"World\";\n", + "codeSuffix": "\ndocument.getElementById('out').textContent = msg;", + "solution": "const msg = `Hello, ${name}!`;", + "previewContainer": "preview-area", + "validations": [ + { + "type": "regex", + "value": "const\\s+msg\\s*=", + "message": "Declare a constant called msg" + }, + { + "type": "contains", + "value": "${name}", + "message": "Use ${name} inside backticks to embed the variable" + }, + { + "type": "regex", + "value": "`.*\\$\\{name\\}.*`", + "message": "Wrap the whole string in backticks `" + } + ] + }, + { + "id": "js-array", + "title": "Arrays", + "description": "Arrays store ordered lists of values in square brackets. Access items by index (starting at 0) and use .length to get the count.", + "task": "Create a constant colors with an array: [\"red\", \"green\", \"blue\"]", + "previewHTML": "

Waiting...

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "", + "codeSuffix": "\ndocument.getElementById('out').textContent = colors.join(', ');", + "solution": "const colors = [\"red\", \"green\", \"blue\"];", + "previewContainer": "preview-area", + "validations": [ + { + "type": "regex", + "value": "const\\s+colors\\s*=", + "message": "Declare a constant called colors" + }, + { + "type": "contains", + "value": "[", + "message": "Use square brackets [ to create an array" + }, + { + "type": "regex", + "value": "(\"red\"|'red'|`red`)", + "message": "Include \"red\" in the array" + }, + { + "type": "regex", + "value": "(\"green\"|'green'|`green`)", + "message": "Include \"green\" in the array" + }, + { + "type": "regex", + "value": "(\"blue\"|'blue'|`blue`)", + "message": "Include \"blue\" in the array" + } + ] + } + ] +} diff --git a/lessons/51-js-dom.json b/lessons/51-js-dom.json new file mode 100644 index 0000000..60f3c27 --- /dev/null +++ b/lessons/51-js-dom.json @@ -0,0 +1,139 @@ +{ + "$schema": "../schemas/code-crispies-module-schema.json", + "id": "js-dom", + "title": "JS DOM", + "description": "Learn to select and modify HTML elements using JavaScript DOM methods like querySelector and textContent.", + "mode": "javascript", + "difficulty": "beginner", + "lessons": [ + { + "id": "js-query", + "title": "querySelector", + "description": "Use document.querySelector() to find the first element matching a CSS selector. It returns a single element you can then modify.", + "task": "Select the h1 element and store it in a constant called title", + "previewHTML": "

Hello

Waiting...

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "", + "codeSuffix": "\ndocument.getElementById('out').textContent = title.tagName;", + "solution": "const title = document.querySelector('h1');", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "querySelector", + "message": "Use document.querySelector() to select an element" + }, + { + "type": "regex", + "value": "querySelector\\(['\"`]h1['\"`]\\)", + "message": "Pass 'h1' as the selector" + }, + { + "type": "regex", + "value": "const\\s+title\\s*=", + "message": "Store the result in a constant called title" + } + ] + }, + { + "id": "js-text", + "title": "textContent", + "description": "The textContent property lets you read or change the text inside an element. Setting it replaces all existing text.", + "task": "Select the .msg element and set its textContent to \"Done!\"", + "previewHTML": "

Waiting...

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "", + "codeSuffix": "", + "solution": "document.querySelector('.msg').textContent = \"Done!\";", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "querySelector", + "message": "Use querySelector to find the element" + }, + { + "type": "contains", + "value": "textContent", + "message": "Use the textContent property to change the text" + }, + { + "type": "regex", + "value": "(\"Done!\"|'Done!'|`Done!`)", + "message": "Set the text to \"Done!\"" + } + ] + }, + { + "id": "js-style", + "title": "Inline Styles", + "description": "Access the style property to set inline CSS on an element. CSS properties with dashes become camelCase: background-color becomes backgroundColor.", + "task": "Select the .box element and set its style.color to \"coral\"", + "previewHTML": "

Style me!

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .box { font-size: 1.5rem; font-weight: bold; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "", + "codeSuffix": "", + "solution": "document.querySelector('.box').style.color = \"coral\";", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "querySelector", + "message": "Use querySelector to find the element" + }, + { + "type": "contains", + "value": ".style.", + "message": "Use the .style property to set CSS" + }, + { + "type": "regex", + "value": "style\\.color\\s*=", + "message": "Set style.color on the element" + }, + { + "type": "regex", + "value": "(\"coral\"|'coral'|`coral`)", + "message": "Set the color to \"coral\"" + } + ] + }, + { + "id": "js-classlist", + "title": "classList", + "description": "The classList property provides methods to add, remove, or toggle CSS classes on an element without touching other classes.", + "task": "Select the .card element and add the class \"active\" using classList.add()", + "previewHTML": "
Toggle me
", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .card { padding: 1rem; border: 2px solid gray; border-radius: 8px; } .active { border-color: coral; background: #fff0ee; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "", + "codeSuffix": "", + "solution": "document.querySelector('.card').classList.add(\"active\");", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "classList", + "message": "Use the classList property" + }, + { + "type": "regex", + "value": "classList\\.add\\(", + "message": "Call classList.add() to add a class" + }, + { + "type": "regex", + "value": "(\"active\"|'active'|`active`)", + "message": "Add the class \"active\"" + } + ] + } + ] +} diff --git a/lessons/52-js-events.json b/lessons/52-js-events.json new file mode 100644 index 0000000..2e0357f --- /dev/null +++ b/lessons/52-js-events.json @@ -0,0 +1,118 @@ +{ + "$schema": "../schemas/code-crispies-module-schema.json", + "id": "js-events", + "title": "JS Events", + "description": "Learn to respond to user interactions with addEventListener for clicks, input changes, and keyboard events.", + "mode": "javascript", + "difficulty": "beginner", + "lessons": [ + { + "id": "js-click", + "title": "Click Events", + "description": "Use addEventListener('click', ...) to run code when a user clicks an element. The first argument is the event name, the second is a callback function.", + "task": "Add a click listener to the .btn element that sets the .msg text to \"Clicked!\"", + "previewHTML": "

Waiting...

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .btn { padding: 0.5rem 1rem; border: none; background: steelblue; color: white; border-radius: 4px; cursor: pointer; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "const btn = document.querySelector('.btn');\nconst msg = document.querySelector('.msg');\n\n", + "codeSuffix": "", + "solution": "btn.addEventListener('click', () => {\n msg.textContent = \"Clicked!\";\n});", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "addEventListener", + "message": "Use addEventListener to listen for events" + }, + { + "type": "regex", + "value": "addEventListener\\(['\"`]click['\"`]", + "message": "Listen for the 'click' event" + }, + { + "type": "contains", + "value": "textContent", + "message": "Use textContent to update the text" + }, + { + "type": "regex", + "value": "(\"Clicked!\"|'Clicked!'|`Clicked!`)", + "message": "Set the text to \"Clicked!\"" + } + ] + }, + { + "id": "js-toggle", + "title": "Toggle Classes", + "description": "Combine events with classList.toggle() to switch a class on and off. Each click adds the class if missing, or removes it if present.", + "task": "Add a click listener to .btn that toggles the class \"on\" on .lamp", + "previewHTML": "
đź’ˇ
", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; text-align: center; } .btn { padding: 0.5rem 1rem; border: none; background: steelblue; color: white; border-radius: 4px; cursor: pointer; } .lamp { font-size: 3rem; margin-top: 1rem; opacity: 0.3; transition: opacity 0.3s; } .lamp.on { opacity: 1; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "const btn = document.querySelector('.btn');\nconst lamp = document.querySelector('.lamp');\n\n", + "codeSuffix": "", + "solution": "btn.addEventListener('click', () => {\n lamp.classList.toggle('on');\n});", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "addEventListener", + "message": "Use addEventListener to listen for events" + }, + { + "type": "regex", + "value": "addEventListener\\(['\"`]click['\"`]", + "message": "Listen for the 'click' event" + }, + { + "type": "regex", + "value": "classList\\.toggle\\(", + "message": "Use classList.toggle() to switch the class" + }, + { + "type": "regex", + "value": "(\"on\"|'on'|`on`)", + "message": "Toggle the class \"on\"" + } + ] + }, + { + "id": "js-input", + "title": "Input Events", + "description": "The input event fires every time the value of an input field changes. Use event.target.value to read the current value.", + "task": "Add an input listener to .field that sets .out text to the input's value", + "previewHTML": "

Echo:

", + "previewBaseCSS": "body { font-family: system-ui, sans-serif; padding: 1rem; } .field { padding: 0.5rem; border: 2px solid #ccc; border-radius: 4px; font-size: 1rem; width: 100%; box-sizing: border-box; }", + "sandboxCSS": "", + "initialCode": "", + "codePrefix": "const field = document.querySelector('.field');\nconst out = document.querySelector('.out');\n\n", + "codeSuffix": "", + "solution": "field.addEventListener('input', (event) => {\n out.textContent = event.target.value;\n});", + "previewContainer": "preview-area", + "validations": [ + { + "type": "contains", + "value": "addEventListener", + "message": "Use addEventListener to listen for events" + }, + { + "type": "regex", + "value": "addEventListener\\(['\"`]input['\"`]", + "message": "Listen for the 'input' event" + }, + { + "type": "contains", + "value": "textContent", + "message": "Use textContent to update the output" + }, + { + "type": "regex", + "value": "(event|e|evt)\\.target\\.value", + "message": "Read the input value with event.target.value" + } + ] + } + ] +} diff --git a/package-lock.json b/package-lock.json index 510a9c5..df8012b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@codemirror/commands": "^6.10.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", @@ -208,9 +209,9 @@ } }, "node_modules/@codemirror/lang-javascript": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", - "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", + "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", diff --git a/package.json b/package.json index 9386db1..a644707 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@codemirror/commands": "^6.10.1", "@codemirror/lang-css": "^6.3.1", "@codemirror/lang-html": "^6.4.11", + "@codemirror/lang-javascript": "^6.2.5", "@codemirror/lang-markdown": "^6.5.0", "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", diff --git a/schemas/code-crispies-module-schema.json b/schemas/code-crispies-module-schema.json index 2f2fc5b..8df3094 100644 --- a/schemas/code-crispies-module-schema.json +++ b/schemas/code-crispies-module-schema.json @@ -19,8 +19,8 @@ }, "mode": { "type": "string", - "enum": ["css", "tailwind", "html", "markdown"], - "description": "Whether this module teaches CSS, Tailwind, HTML, or Markdown" + "enum": ["css", "tailwind", "html", "markdown", "javascript"], + "description": "Whether this module teaches CSS, Tailwind, HTML, Markdown, or JavaScript" }, "difficulty": { "type": "string", @@ -60,7 +60,7 @@ }, "mode": { "type": "string", - "enum": ["css", "tailwind", "html", "markdown"], + "enum": ["css", "tailwind", "html", "markdown", "javascript"], "description": "Override module mode for individual lessons" }, "tailwindConfig": { diff --git a/src/app.js b/src/app.js index e069860..bb87e6b 100644 --- a/src/app.js +++ b/src/app.js @@ -578,6 +578,11 @@ function updateEditorForMode(mode) { label: "Markdown Editor", cmMode: "markdown" }, + javascript: { + placeholder: "// Write your JavaScript here...", + label: "JavaScript Editor", + cmMode: "javascript" + }, playground: { placeholder: "\n\n", label: "HTML & CSS", @@ -1493,6 +1498,64 @@ This is \`inline code\`. + `, + javascript: ` +
+

JavaScript is the programming language of the web. It adds interactivity to HTML pages—responding to clicks, updating content dynamically, validating forms, and much more. Every modern browser includes a JavaScript engine, making it the most widely deployed programming language in the world.

+

These beginner lessons cover the fundamentals: declaring variables, selecting and modifying DOM elements, and handling user events. Each concept builds on the previous one, giving you the tools to make any web page interactive.

+
+ +
+
+

Variables & Data Types

+

JavaScript uses const for values that won't change and let for values that will. Template literals with backticks make it easy to embed expressions in strings using \${...} syntax.

+

Arrays store ordered collections in square brackets. Objects store key-value pairs in curly braces. These are the building blocks of every JavaScript program.

+ Learn JS Variables +
+
+
+
const name = "Alice";
+let count = 0;
+count = count + 1;
+
+const msg = \`Hello, \${name}!\`;
+const colors = ["red", "green"];
+
+
+
+ +
+
+

DOM Manipulation

+

The DOM (Document Object Model) is how JavaScript sees your HTML. Use document.querySelector() to find elements by CSS selector, then modify them with properties like textContent, style, and classList.

+ Practice DOM Methods +
+
+
+
const title = document.querySelector('h1');
+title.textContent = "New Title";
+title.style.color = "coral";
+title.classList.add("active");
+
+
+
+ +
+
+

Event Handling

+

Events let your code respond to user actions. Use addEventListener() to run a function when something happens—a click, a keystroke, or an input change. The callback receives an event object with details about what happened.

+ Handle Events +
+
+
+
const btn = document.querySelector('.btn');
+
+btn.addEventListener('click', () => {
+  alert('Clicked!');
+});
+
+
+
` }; @@ -2310,7 +2373,7 @@ function showLandingPage() { */ function renderFooterLessonLinks() { const modules = lessonEngine.modules || []; - const sectionGroups = { css: [], html: [] }; + const sectionGroups = { css: [], html: [], javascript: [] }; modules.forEach((module) => { if (module.excludeFromProgress) return; @@ -2347,7 +2410,7 @@ function renderFooterLessonLinks() { * Update progress indicators on landing page */ function updateLandingProgress() { - ["css", "html", "markdown"].forEach((sectionId) => { // tailwind temporarily disabled + ["css", "html", "markdown", "javascript"].forEach((sectionId) => { // tailwind temporarily disabled const progressEl = document.getElementById(`${sectionId}-progress`); if (progressEl) { const sectionModules = getModulesBySection(lessonEngine.modules, sectionId); diff --git a/src/config/lessons.js b/src/config/lessons.js index 33f422c..1e118d8 100644 --- a/src/config/lessons.js +++ b/src/config/lessons.js @@ -31,6 +31,9 @@ import filtersEN from "../../lessons/11-filters.json"; import positioningEN from "../../lessons/12-positioning.json"; import pseudoElementsEN from "../../lessons/13-pseudo-elements.json"; import markdownBasicsEN from "../../lessons/40-markdown-basics.json"; +import jsVariablesEN from "../../lessons/50-js-variables.json"; +import jsDomEN from "../../lessons/51-js-dom.json"; +import jsEventsEN from "../../lessons/52-js-events.json"; import playgroundEN from "../../lessons/98-playground.json"; import goodbyeEN from "../../lessons/99-goodbye.json"; @@ -165,6 +168,10 @@ const moduleStoreEN = [ htmlTablesEN, // Markdown markdownBasicsEN, + // JavaScript + jsVariablesEN, + jsDomEN, + jsEventsEN, // Outro goodbyeEN, playgroundEN @@ -206,6 +213,10 @@ const moduleStoreDE = [ htmlTablesDE, // Markdown markdownBasicsEN, // Using EN fallback until translated + // JavaScript + jsVariablesEN, // Using EN fallback until translated + jsDomEN, // Using EN fallback until translated + jsEventsEN, // Using EN fallback until translated // Outro goodbyeEN, playgroundEN @@ -247,6 +258,10 @@ const moduleStorePL = [ htmlTablesPL, // Markdown markdownBasicsEN, // Using EN fallback until translated + // JavaScript + jsVariablesEN, // Using EN fallback until translated + jsDomEN, // Using EN fallback until translated + jsEventsEN, // Using EN fallback until translated // Outro goodbyeEN, playgroundEN @@ -288,6 +303,10 @@ const moduleStoreES = [ htmlTablesES, // Markdown markdownBasicsEN, // Using EN fallback until translated + // JavaScript + jsVariablesEN, // Using EN fallback until translated + jsDomEN, // Using EN fallback until translated + jsEventsEN, // Using EN fallback until translated // Outro goodbyeEN, playgroundEN @@ -329,6 +348,10 @@ const moduleStoreAR = [ htmlTablesAR, // Markdown markdownBasicsEN, // Using EN fallback until translated + // JavaScript + jsVariablesEN, // Using EN fallback until translated + jsDomEN, // Using EN fallback until translated + jsEventsEN, // Using EN fallback until translated // Outro goodbyeEN, playgroundEN @@ -370,6 +393,10 @@ const moduleStoreUK = [ htmlTablesUK, // Markdown markdownBasicsEN, // Using EN fallback until translated + // JavaScript + jsVariablesEN, // Using EN fallback until translated + jsDomEN, // Using EN fallback until translated + jsEventsEN, // Using EN fallback until translated // Outro goodbyeEN, playgroundEN diff --git a/src/config/sections.js b/src/config/sections.js index 29f8f08..edaba25 100644 --- a/src/config/sections.js +++ b/src/config/sections.js @@ -31,6 +31,13 @@ export const sections = { description: "Lightweight markup language for formatting text", color: "#5b8dd9", order: 4 + }, + javascript: { + id: "javascript", + title: "JavaScript", + description: "Interactive scripting for web pages", + color: "#f0db4f", + order: 5 } }; @@ -65,6 +72,7 @@ export function getModuleSection(module) { if (mode === "html") return "html"; if (mode === "tailwind") return "tailwind"; if (mode === "markdown") return "markdown"; + if (mode === "javascript") return "javascript"; return "css"; } diff --git a/src/helpers/renderer.js b/src/helpers/renderer.js index 665cb61..6177c28 100644 --- a/src/helpers/renderer.js +++ b/src/helpers/renderer.js @@ -2,6 +2,7 @@ * Renderer - Handles UI updates for the CSS learning platform */ import { t } from "../i18n.js"; +import { getModuleSection, getSection, getSectionList } from "../config/sections.js"; /** * Compute lesson difficulty based on lesson structure @@ -72,8 +73,24 @@ export function renderModuleList(container, modules, onSelectModule, onSelectLes } } + // Group modules by section for headers + let currentSectionId = null; + // Create list items for each module modules.forEach((module) => { + // Insert section header when section changes + const sectionId = getModuleSection(module); + if (sectionId !== currentSectionId && !module.excludeFromProgress) { + currentSectionId = sectionId; + const section = getSection(sectionId); + if (section) { + const header = document.createElement("h3"); + header.className = "sidebar-section-header"; + header.textContent = section.title; + header.style.borderLeftColor = section.color; + container.appendChild(header); + } + } // Create module container // Use native
/ for expand/collapse const moduleContainer = document.createElement("details"); diff --git a/src/helpers/router.js b/src/helpers/router.js index d297791..fe9f6fe 100644 --- a/src/helpers/router.js +++ b/src/helpers/router.js @@ -27,7 +27,7 @@ export const RouteType = { /** * Valid section IDs */ -const SECTIONS = ["css", "html", "markdown"]; // tailwind temporarily disabled +const SECTIONS = ["css", "html", "markdown", "javascript"]; // tailwind temporarily disabled /** * Valid language codes for URL-based switching diff --git a/src/helpers/validator.js b/src/helpers/validator.js index 262b2eb..d1aa1ea 100644 --- a/src/helpers/validator.js +++ b/src/helpers/validator.js @@ -10,6 +10,8 @@ export function validateUserCode(userCode, lesson) { return validateHtmlCode(userCode, lesson); case "tailwind": return validateTailwindClasses(userCode, lesson); + case "javascript": + return validateJavaScriptCode(userCode, lesson); case "css": default: return validateCssCode(userCode, lesson); @@ -204,6 +206,80 @@ function validateHtmlCode(userHtml, lesson) { return result; } +/** + * Validate user JavaScript code against the lesson requirements + * @param {string} userCode - User submitted JavaScript code + * @param {Object} lesson - The current lesson object + * @returns {Object} Validation result with isValid and message properties + */ +function validateJavaScriptCode(userCode, lesson) { + if (!lesson || !lesson.validations) { + return { isValid: true, message: "No validations specified for this lesson." }; + } + + const validations = lesson.validations; + + let result = { + isValid: true, + validCases: 0, + totalCases: validations.length, + message: "Your CODE looks CRISPY!" + }; + + for (const validation of validations) { + const { type, value, message, options } = validation; + let validationPassed = false; + + switch (type) { + case "contains": + validationPassed = containsValidation(userCode, value, options); + if (!validationPassed) { + result = { + ...result, + isValid: false, + message: message || `Your code should include "${value}".` + }; + } + break; + + case "not_contains": + validationPassed = !containsValidation(userCode, value, options); + if (!validationPassed) { + result = { + ...result, + isValid: false, + message: message || `Your code should not include "${value}".` + }; + } + break; + + case "regex": + validationPassed = regexValidation(userCode, value, options); + if (!validationPassed) { + result = { + ...result, + isValid: false, + message: message || "Your code does not match the expected pattern." + }; + } + break; + + default: + console.warn(`Unknown JavaScript 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." }; diff --git a/src/impl/CodeEditor.js b/src/impl/CodeEditor.js index 8584fad..5c79b92 100644 --- a/src/impl/CodeEditor.js +++ b/src/impl/CodeEditor.js @@ -8,6 +8,7 @@ import { history } from "@codemirror/commands"; import { html } from "@codemirror/lang-html"; import { css } from "@codemirror/lang-css"; import { markdown } from "@codemirror/lang-markdown"; +import { javascript } from "@codemirror/lang-javascript"; import { autocompletion } from "@codemirror/autocomplete"; import { abbreviationTracker, expandAbbreviation } from "@emmetio/codemirror6-plugin"; import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; @@ -181,7 +182,8 @@ export class CodeEditor { const fullDoc = prefix + initialValue + suffix; // Get language extension based on mode - const langExtension = this.mode === "html" ? html() : this.mode === "markdown" ? markdown() : css(); + const langExtension = + this.mode === "html" ? html() : this.mode === "javascript" ? javascript() : this.mode === "markdown" ? markdown() : css(); // Create read-only zones decorations const readOnlyMark = Decoration.mark({ class: "cm-readonly-zone" }); diff --git a/src/impl/LessonEngine.js b/src/impl/LessonEngine.js index e836b2d..bc19907 100644 --- a/src/impl/LessonEngine.js +++ b/src/impl/LessonEngine.js @@ -256,6 +256,30 @@ export class LessonEngine { ${htmlWithClasses} + `; + } else if (mode === "javascript") { + // For JavaScript mode, user code runs as a script against previewHTML + const { codePrefix, codeSuffix } = this.currentLesson; + const fullScript = `${codePrefix || ""}${this.userCode || ""}${codeSuffix || ""}`; + html = ` + + + + + + + + + ${previewHTML || ""} + + + `; } else if (mode === "markdown") { // For Markdown mode, parse user code to HTML @@ -382,6 +406,30 @@ export class LessonEngine { ${htmlWithClasses} + `; + } else if (mode === "javascript") { + // For JavaScript mode, solution code runs as a script against previewHTML + const { codePrefix, codeSuffix } = this.currentLesson; + const fullScript = `${codePrefix || ""}${solutionCode}${codeSuffix || ""}`; + html = ` + + + + + + + + + ${previewHTML || ""} + + + `; } else if (mode === "markdown") { // For Markdown mode, parse solution to HTML diff --git a/src/index.html b/src/index.html index f49de28..2d1c6e4 100644 --- a/src/index.html +++ b/src/index.html @@ -77,6 +77,7 @@ HTML Markdown + JavaScript Reference @@ -178,6 +179,12 @@

Lightweight markup for formatting text

+ +
JS
+

JavaScript

+

Interactive scripting for web pages

+ +

Best on desktop or tablet (landscape). Mobile works, but larger screens make coding easier. @@ -194,13 +201,6 @@

Achievements

Earn badges as you master new skills. Track your learning milestones.

-
- - - -

JavaScript

-

Interactive JavaScript lessons with live code execution and DOM manipulation.

-
@@ -478,6 +478,7 @@ CSS HTML + JavaScript diff --git a/src/main.css b/src/main.css index 93c119f..736d8b2 100644 --- a/src/main.css +++ b/src/main.css @@ -291,6 +291,14 @@ kbd { background: #5b8dd9; } +[data-section="javascript"] .logo h1 .code-text { + color: #d4a017; +} + +[data-section="javascript"] .logo h1 .crispies-text { + background: #d4a017; +} + .help-toggle { width: 28px; height: 28px; @@ -1244,6 +1252,22 @@ nav.sidebar-section:not(.sidebar-nav-mobile) { animation: milestone-pop 0.5s ease-out; } +/* Sidebar section grouping headers */ +.sidebar-section-header { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--light-text); + padding: 0.75rem 0.75rem 0.25rem; + margin: 0.5rem 0 0; + border-left: 3px solid transparent; +} + +.sidebar-section-header:first-child { + margin-top: 0; +} + /* Module List in Sidebar */ .module-list { /* No max-height - parent nav.sidebar-section handles overflow */ @@ -3618,6 +3642,14 @@ input:checked + .toggle-slider::before { --section-color-rgb: 91, 141, 217; } +/* JavaScript Section - Gold */ +[data-section="javascript"] { + --section-color: #d4a017; + --section-color-light: #e0b840; + --section-color-dark: #b08610; + --section-color-rgb: 212, 160, 23; +} + /* Apply section colors to nav links */ .nav-link[data-section="css"] { color: #d95a8a; @@ -3635,6 +3667,10 @@ input:checked + .toggle-slider::before { color: #5b8dd9; } +.nav-link[data-section="javascript"] { + color: #d4a017; +} + .nav-link[data-section="css"]:hover, .nav-link[data-section="css"].active { background: rgba(217, 90, 138, 0.1); @@ -3659,6 +3695,12 @@ input:checked + .toggle-slider::before { color: #4070b8; } +.nav-link[data-section="javascript"]:hover, +.nav-link[data-section="javascript"].active { + background: rgba(212, 160, 23, 0.1); + color: #b08610; +} + /* Hint section colors */ body[data-section="css"] .hint { background: rgba(217, 90, 138, 0.3); @@ -3696,6 +3738,15 @@ body[data-section="markdown"] .hint-progress { background: #5b8dd9; } +body[data-section="javascript"] .hint { + background: rgba(212, 160, 23, 0.3); + border-left-color: #e0b840; +} + +body[data-section="javascript"] .hint-progress { + background: #d4a017; +} + /* RTL hint border */ [dir="rtl"] body[data-section="css"] .hint { border-right-color: #a98cd6; @@ -3713,6 +3764,10 @@ body[data-section="markdown"] .hint-progress { border-right-color: #7ba3e5; } +[dir="rtl"] body[data-section="javascript"] .hint { + border-right-color: #e0b840; +} + /* Reference nav link colors */ .ref-nav-link[data-ref="css"], .ref-nav-link[data-ref="selectors"], @@ -3816,6 +3871,24 @@ body[data-section="markdown"] .cm-editor .cm-activeLine { background-color: rgba(91, 141, 217, 0.08) !important; } +body[data-section="javascript"] .cm-editor .cm-content { + caret-color: #d4a017 !important; +} + +body[data-section="javascript"] .cm-editor .cm-cursor, +body[data-section="javascript"] .cm-editor .cm-dropCursor { + border-left-color: #d4a017 !important; +} + +body[data-section="javascript"] .cm-editor .cm-selectionBackground, +body[data-section="javascript"] .cm-editor .cm-content ::selection { + background-color: rgba(212, 160, 23, 0.25) !important; +} + +body[data-section="javascript"] .cm-editor .cm-activeLine { + background-color: rgba(212, 160, 23, 0.08) !important; +} + /* Module pill section colors */ body[data-section="css"] .module-pill { background: rgba(217, 90, 138, 0.1); @@ -3853,6 +3926,15 @@ body[data-section="markdown"] .module-pill .level-indicator { color: #4070b8; } +body[data-section="javascript"] .module-pill { + background: rgba(212, 160, 23, 0.1); + color: #d4a017; +} + +body[data-section="javascript"] .module-pill .level-indicator { + color: #b08610; +} + /* Code block border section colors */ body[data-section="css"] .code-block { border-color: rgba(217, 90, 138, 0.4); @@ -3870,6 +3952,10 @@ body[data-section="markdown"] .code-block { border-color: rgba(91, 141, 217, 0.4); } +body[data-section="javascript"] .code-block { + border-color: rgba(212, 160, 23, 0.4); +} + /* Section code block CodeMirror syntax highlighting overrides */ body[data-section="css"] .code-block .cm-editor .cm-line { color: #c9c0e0; @@ -3887,6 +3973,10 @@ body[data-section="markdown"] .code-block .cm-editor .cm-line { color: #c0d0e8; } +body[data-section="javascript"] .code-block .cm-editor .cm-line { + color: #e0d8b0; +} + /* Task instruction bubble section colors */ [data-section="css"] .task-instruction { background: rgba(217, 90, 138, 0.92); @@ -3904,6 +3994,10 @@ body[data-section="markdown"] .code-block .cm-editor .cm-line { background: rgba(91, 141, 217, 0.92); } +[data-section="javascript"] .task-instruction { + background: rgba(212, 160, 23, 0.92); +} + /* Section page progress bar colors */ body[data-section="css"] .section-progress-bar .progress-fill { background: #d95a8a; @@ -3921,6 +4015,10 @@ body[data-section="markdown"] .section-progress-bar .progress-fill { background: #5b8dd9; } +body[data-section="javascript"] .section-progress-bar .progress-fill { + background: #d4a017; +} + /* Section page header colors */ [data-section="css"] .section-hero h1 { color: #d95a8a; @@ -3938,6 +4036,10 @@ body[data-section="markdown"] .section-progress-bar .progress-fill { color: #5b8dd9; } +[data-section="javascript"] .section-hero h1 { + color: #d4a017; +} + /* Lesson title h2 section colors */ body[data-section="css"] #lesson-title { color: #d95a8a; @@ -3955,6 +4057,10 @@ body[data-section="markdown"] #lesson-title { color: #5b8dd9; } +body[data-section="javascript"] #lesson-title { + color: #d4a017; +} + /* Section and Reference footer - override landing-footer styles */ .section-footer.landing-footer, .reference-footer.landing-footer { diff --git a/tests/unit/lessons.test.js b/tests/unit/lessons.test.js index ec6ef70..0d78ae7 100644 --- a/tests/unit/lessons.test.js +++ b/tests/unit/lessons.test.js @@ -19,6 +19,10 @@ describe("Lessons Config Module", () => { expect(moduleIds).toContain("css-basic-selectors"); expect(moduleIds).toContain("box-model"); expect(moduleIds).toContain("flexbox"); + // JavaScript modules + expect(moduleIds).toContain("js-variables"); + expect(moduleIds).toContain("js-dom"); + expect(moduleIds).toContain("js-events"); }); test("should have mode set on each lesson", async () => { @@ -27,7 +31,7 @@ describe("Lessons Config Module", () => { modules.forEach((module) => { module.lessons.forEach((lesson) => { expect(lesson.mode).toBeDefined(); - expect(["html", "css", "tailwind", "markdown", "playground"]).toContain(lesson.mode); + expect(["html", "css", "tailwind", "markdown", "javascript", "playground"]).toContain(lesson.mode); }); }); }); diff --git a/tests/unit/router.test.js b/tests/unit/router.test.js index 61f835a..22ead88 100644 --- a/tests/unit/router.test.js +++ b/tests/unit/router.test.js @@ -56,7 +56,8 @@ describe("Router", () => { test.each([ ["css", "css"], ["html", "html"], - ["markdown", "markdown"] + ["markdown", "markdown"], + ["javascript", "javascript"] ])("parseHash_SectionId_%s_ReturnsSectionRoute", (sectionId, expectedId) => { window.location.hash = `#${sectionId}`; const result = parseHash(); @@ -220,6 +221,7 @@ describe("Router", () => { expect(ids).toContain("css"); expect(ids).toContain("html"); expect(ids).toContain("markdown"); + expect(ids).toContain("javascript"); }); test("getSectionIds_MutatingCopy_DoesNotAffectOriginal", () => { diff --git a/tests/unit/sections.test.js b/tests/unit/sections.test.js index 2d8a680..e82d016 100644 --- a/tests/unit/sections.test.js +++ b/tests/unit/sections.test.js @@ -3,12 +3,13 @@ import { sections, getSection, getSectionList, getModuleSection, getModulesBySec describe("Sections Config", () => { describe("sections constant", () => { - test("sections_AllDefined_HasFourSections", () => { - expect(Object.keys(sections)).toHaveLength(4); + test("sections_AllDefined_HasFiveSections", () => { + expect(Object.keys(sections)).toHaveLength(5); expect(sections).toHaveProperty("css"); expect(sections).toHaveProperty("html"); expect(sections).toHaveProperty("tailwind"); expect(sections).toHaveProperty("markdown"); + expect(sections).toHaveProperty("javascript"); }); test("sections_EachSection_HasRequiredFields", () => { @@ -27,7 +28,8 @@ describe("Sections Config", () => { ["css", "CSS"], ["html", "HTML"], ["tailwind", "Tailwind CSS"], - ["markdown", "Markdown"] + ["markdown", "Markdown"], + ["javascript", "JavaScript"] ])("getSection_%s_ReturnsCorrectSection", (id, expectedTitle) => { const section = getSection(id); expect(section).not.toBeNull(); @@ -51,7 +53,7 @@ describe("Sections Config", () => { describe("getSectionList", () => { test("getSectionList_Default_ReturnsSortedByOrder", () => { const list = getSectionList(); - expect(list).toHaveLength(4); + expect(list).toHaveLength(5); // Verify sorted by order for (let i = 1; i < list.length; i++) { @@ -64,9 +66,9 @@ describe("Sections Config", () => { expect(list[0].id).toBe("css"); }); - test("getSectionList_Default_MarkdownIsLast", () => { + test("getSectionList_Default_JavaScriptIsLast", () => { const list = getSectionList(); - expect(list[list.length - 1].id).toBe("markdown"); + expect(list[list.length - 1].id).toBe("javascript"); }); test("getSectionList_Default_ContainsAllSections", () => { @@ -76,6 +78,7 @@ describe("Sections Config", () => { expect(ids).toContain("html"); expect(ids).toContain("tailwind"); expect(ids).toContain("markdown"); + expect(ids).toContain("javascript"); }); }); @@ -89,7 +92,8 @@ describe("Sections Config", () => { ["css", "css"], ["html", "html"], ["tailwind", "tailwind"], - ["markdown", "markdown"] + ["markdown", "markdown"], + ["javascript", "javascript"] ])("getModuleSection_Mode%s_InfersCorrectSection", (mode, expectedSection) => { const module = { mode }; expect(getModuleSection(module)).toBe(expectedSection); @@ -104,7 +108,7 @@ describe("Sections Config", () => { }); test("getModuleSection_UnknownMode_DefaultsToCss", () => { - expect(getModuleSection({ mode: "javascript" })).toBe("css"); + expect(getModuleSection({ mode: "unknown-mode" })).toBe("css"); }); test("getModuleSection_ExplicitSectionOverridesMode_UsesSection", () => { diff --git a/tests/unit/validator.test.js b/tests/unit/validator.test.js index 9f4b74e..bf00599 100644 --- a/tests/unit/validator.test.js +++ b/tests/unit/validator.test.js @@ -226,6 +226,86 @@ describe("CSS Validator", () => { }); }); +describe("JavaScript Validator", () => { + describe("validateUserCode with mode: javascript", () => { + it("should validate contains correctly for JavaScript", () => { + const userCode = 'const name = "Alice";'; + const lesson = { + mode: "javascript", + validations: [{ type: "contains", value: "const", message: "Use const" }] + }; + + const result = validateUserCode(userCode, lesson); + expect(result.isValid).toBe(true); + expect(result.validCases).toBe(1); + }); + + it("should validate regex correctly for JavaScript", () => { + const userCode = 'const name = "Alice";'; + const lesson = { + mode: "javascript", + validations: [{ type: "regex", value: 'const\\s+name\\s*=', message: "Declare name" }] + }; + + const result = validateUserCode(userCode, lesson); + expect(result.isValid).toBe(true); + }); + + it("should validate not_contains correctly for JavaScript", () => { + const userCode = 'const name = "Alice";'; + const lesson = { + mode: "javascript", + validations: [{ type: "not_contains", value: "var", message: "Do not use var" }] + }; + + const result = validateUserCode(userCode, lesson); + expect(result.isValid).toBe(true); + + const failCode = 'var name = "Alice";'; + const failResult = validateUserCode(failCode, lesson); + expect(failResult.isValid).toBe(false); + expect(failResult.message).toBe("Do not use var"); + }); + + it("should return invalid for missing code", () => { + const userCode = ""; + const lesson = { + mode: "javascript", + validations: [{ type: "contains", value: "const", message: "Use const" }] + }; + + const result = validateUserCode(userCode, lesson); + expect(result.isValid).toBe(false); + }); + + it("should pass with no validations", () => { + const userCode = 'const x = 1;'; + const lesson = { mode: "javascript" }; + + const result = validateUserCode(userCode, lesson); + expect(result.isValid).toBe(true); + expect(result.message).toContain("No validations specified"); + }); + + it("should handle multiple validations with early return on failure", () => { + const userCode = 'const name = "Alice";'; + const lesson = { + mode: "javascript", + validations: [ + { type: "contains", value: "const", message: "Use const" }, + { type: "contains", value: "let", message: "Use let" }, + { type: "contains", value: "name", message: "Declare name" } + ] + }; + + const result = validateUserCode(userCode, lesson); + expect(result.isValid).toBe(false); + expect(result.message).toBe("Use let"); + expect(result.validCases).toBe(1); + }); + }); +}); + describe("HTML Validator", () => { describe("validateUserCode with mode: html", () => { it("should validate element_exists correctly", () => {