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
This commit is contained in:
2026-03-28 20:22:50 +01:00
parent 372320b807
commit ae8f9fef45
20 changed files with 863 additions and 27 deletions

View File

@@ -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 <kbd>const</kbd> to declare a variable that cannot be reassigned. Constants are the default choice for most values in modern JavaScript.",
"task": "Declare a constant named <kbd>name</kbd> with the value <kbd>\"Alice\"</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"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 <kbd>const</kbd> to declare the variable"
},
{
"type": "regex",
"value": "const\\s+name\\s*=",
"message": "Declare a constant called <kbd>name</kbd>"
},
{
"type": "regex",
"value": "\"Alice\"|'Alice'|`Alice`",
"message": "Set the value to <kbd>\"Alice\"</kbd>"
}
]
},
{
"id": "js-let",
"title": "Let Variables",
"description": "Use <kbd>let</kbd> to declare variables that you plan to reassign later. Unlike <kbd>const</kbd>, a <kbd>let</kbd> variable can change its value.",
"task": "Declare a variable <kbd>count</kbd> with <kbd>let</kbd> set to <kbd>0</kbd>, then reassign it to <kbd>5</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"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 <kbd>let count = 0;</kbd>"
},
{
"type": "regex",
"value": "count\\s*=\\s*5",
"message": "Reassign count to <kbd>5</kbd>"
}
]
},
{
"id": "js-string",
"title": "Template Literals",
"description": "Template literals use backticks <kbd>`</kbd> and <kbd>${}</kbd> to embed expressions inside strings. This makes building dynamic text much easier than string concatenation.",
"task": "Create a constant <kbd>msg</kbd> using a template literal: <kbd>`Hello, ${name}!`</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"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 <kbd>msg</kbd>"
},
{
"type": "contains",
"value": "${name}",
"message": "Use <kbd>${name}</kbd> inside backticks to embed the variable"
},
{
"type": "regex",
"value": "`.*\\$\\{name\\}.*`",
"message": "Wrap the whole string in backticks <kbd>`</kbd>"
}
]
},
{
"id": "js-array",
"title": "Arrays",
"description": "Arrays store ordered lists of values in square brackets. Access items by index (starting at 0) and use <kbd>.length</kbd> to get the count.",
"task": "Create a constant <kbd>colors</kbd> with an array: <kbd>[\"red\", \"green\", \"blue\"]</kbd>",
"previewHTML": "<p id=\"out\">Waiting...</p>",
"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 <kbd>colors</kbd>"
},
{
"type": "contains",
"value": "[",
"message": "Use square brackets <kbd>[</kbd> to create an array"
},
{
"type": "regex",
"value": "(\"red\"|'red'|`red`)",
"message": "Include <kbd>\"red\"</kbd> in the array"
},
{
"type": "regex",
"value": "(\"green\"|'green'|`green`)",
"message": "Include <kbd>\"green\"</kbd> in the array"
},
{
"type": "regex",
"value": "(\"blue\"|'blue'|`blue`)",
"message": "Include <kbd>\"blue\"</kbd> in the array"
}
]
}
]
}

139
lessons/51-js-dom.json Normal file
View File

@@ -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 <kbd>document.querySelector()</kbd> to find the first element matching a CSS selector. It returns a single element you can then modify.",
"task": "Select the <kbd>h1</kbd> element and store it in a constant called <kbd>title</kbd>",
"previewHTML": "<h1>Hello</h1><p id=\"out\">Waiting...</p>",
"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 <kbd>document.querySelector()</kbd> to select an element"
},
{
"type": "regex",
"value": "querySelector\\(['\"`]h1['\"`]\\)",
"message": "Pass <kbd>'h1'</kbd> as the selector"
},
{
"type": "regex",
"value": "const\\s+title\\s*=",
"message": "Store the result in a constant called <kbd>title</kbd>"
}
]
},
{
"id": "js-text",
"title": "textContent",
"description": "The <kbd>textContent</kbd> property lets you read or change the text inside an element. Setting it replaces all existing text.",
"task": "Select the <kbd>.msg</kbd> element and set its <kbd>textContent</kbd> to <kbd>\"Done!\"</kbd>",
"previewHTML": "<p class=\"msg\">Waiting...</p>",
"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 <kbd>querySelector</kbd> to find the element"
},
{
"type": "contains",
"value": "textContent",
"message": "Use the <kbd>textContent</kbd> property to change the text"
},
{
"type": "regex",
"value": "(\"Done!\"|'Done!'|`Done!`)",
"message": "Set the text to <kbd>\"Done!\"</kbd>"
}
]
},
{
"id": "js-style",
"title": "Inline Styles",
"description": "Access the <kbd>style</kbd> property to set inline CSS on an element. CSS properties with dashes become camelCase: <kbd>background-color</kbd> becomes <kbd>backgroundColor</kbd>.",
"task": "Select the <kbd>.box</kbd> element and set its <kbd>style.color</kbd> to <kbd>\"coral\"</kbd>",
"previewHTML": "<p class=\"box\">Style me!</p>",
"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 <kbd>querySelector</kbd> to find the element"
},
{
"type": "contains",
"value": ".style.",
"message": "Use the <kbd>.style</kbd> property to set CSS"
},
{
"type": "regex",
"value": "style\\.color\\s*=",
"message": "Set <kbd>style.color</kbd> on the element"
},
{
"type": "regex",
"value": "(\"coral\"|'coral'|`coral`)",
"message": "Set the color to <kbd>\"coral\"</kbd>"
}
]
},
{
"id": "js-classlist",
"title": "classList",
"description": "The <kbd>classList</kbd> property provides methods to add, remove, or toggle CSS classes on an element without touching other classes.",
"task": "Select the <kbd>.card</kbd> element and add the class <kbd>\"active\"</kbd> using <kbd>classList.add()</kbd>",
"previewHTML": "<div class=\"card\">Toggle me</div>",
"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 <kbd>classList</kbd> property"
},
{
"type": "regex",
"value": "classList\\.add\\(",
"message": "Call <kbd>classList.add()</kbd> to add a class"
},
{
"type": "regex",
"value": "(\"active\"|'active'|`active`)",
"message": "Add the class <kbd>\"active\"</kbd>"
}
]
}
]
}

118
lessons/52-js-events.json Normal file
View File

@@ -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 <kbd>addEventListener('click', ...)</kbd> 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 <kbd>.btn</kbd> element that sets the <kbd>.msg</kbd> text to <kbd>\"Clicked!\"</kbd>",
"previewHTML": "<button class=\"btn\">Click me</button><p class=\"msg\">Waiting...</p>",
"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 <kbd>addEventListener</kbd> to listen for events"
},
{
"type": "regex",
"value": "addEventListener\\(['\"`]click['\"`]",
"message": "Listen for the <kbd>'click'</kbd> event"
},
{
"type": "contains",
"value": "textContent",
"message": "Use <kbd>textContent</kbd> to update the text"
},
{
"type": "regex",
"value": "(\"Clicked!\"|'Clicked!'|`Clicked!`)",
"message": "Set the text to <kbd>\"Clicked!\"</kbd>"
}
]
},
{
"id": "js-toggle",
"title": "Toggle Classes",
"description": "Combine events with <kbd>classList.toggle()</kbd> 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 <kbd>.btn</kbd> that toggles the class <kbd>\"on\"</kbd> on <kbd>.lamp</kbd>",
"previewHTML": "<button class=\"btn\">Toggle</button><div class=\"lamp\">💡</div>",
"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 <kbd>addEventListener</kbd> to listen for events"
},
{
"type": "regex",
"value": "addEventListener\\(['\"`]click['\"`]",
"message": "Listen for the <kbd>'click'</kbd> event"
},
{
"type": "regex",
"value": "classList\\.toggle\\(",
"message": "Use <kbd>classList.toggle()</kbd> to switch the class"
},
{
"type": "regex",
"value": "(\"on\"|'on'|`on`)",
"message": "Toggle the class <kbd>\"on\"</kbd>"
}
]
},
{
"id": "js-input",
"title": "Input Events",
"description": "The <kbd>input</kbd> event fires every time the value of an input field changes. Use <kbd>event.target.value</kbd> to read the current value.",
"task": "Add an input listener to <kbd>.field</kbd> that sets <kbd>.out</kbd> text to the input's value",
"previewHTML": "<input class=\"field\" placeholder=\"Type here...\"><p class=\"out\">Echo: </p>",
"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 <kbd>addEventListener</kbd> to listen for events"
},
{
"type": "regex",
"value": "addEventListener\\(['\"`]input['\"`]",
"message": "Listen for the <kbd>'input'</kbd> event"
},
{
"type": "contains",
"value": "textContent",
"message": "Use <kbd>textContent</kbd> to update the output"
},
{
"type": "regex",
"value": "(event|e|evt)\\.target\\.value",
"message": "Read the input value with <kbd>event.target.value</kbd>"
}
]
}
]
}

7
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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: "<style>\n /* CSS here */\n</style>\n\n<!-- HTML here -->",
label: "HTML & CSS",
@@ -1493,6 +1498,64 @@ This is \`inline code\`.</code></pre>
</div>
</div>
</div>
`,
javascript: `
<div class="section-overview">
<p><strong>JavaScript</strong> 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.</p>
<p>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.</p>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Variables & Data Types</h2>
<p>JavaScript uses <code>const</code> for values that won't change and <code>let</code> for values that will. Template literals with backticks make it easy to embed expressions in strings using <code>\${...}</code> syntax.</p>
<p>Arrays store ordered collections in square brackets. Objects store key-value pairs in curly braces. These are the building blocks of every JavaScript program.</p>
<a href="#js-variables/0" class="topic-link">Learn JS Variables</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const name = "Alice";
let count = 0;
count = count + 1;
const msg = \`Hello, \${name}!\`;
const colors = ["red", "green"];</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>DOM Manipulation</h2>
<p>The DOM (Document Object Model) is how JavaScript sees your HTML. Use <code>document.querySelector()</code> to find elements by CSS selector, then modify them with properties like <code>textContent</code>, <code>style</code>, and <code>classList</code>.</p>
<a href="#js-dom/0" class="topic-link">Practice DOM Methods</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const title = document.querySelector('h1');
title.textContent = "New Title";
title.style.color = "coral";
title.classList.add("active");</code></pre>
</div>
</div>
</div>
<div class="topic-row">
<div class="topic-text">
<h2>Event Handling</h2>
<p>Events let your code respond to user actions. Use <code>addEventListener()</code> 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.</p>
<a href="#js-events/0" class="topic-link">Handle Events</a>
</div>
<div class="topic-code">
<div class="code-block">
<pre><code>const btn = document.querySelector('.btn');
btn.addEventListener('click', () => {
alert('Clicked!');
});</code></pre>
</div>
</div>
</div>
`
};
@@ -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);

View File

@@ -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

View File

@@ -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";
}

View File

@@ -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 <details>/<summary> for expand/collapse
const moduleContainer = document.createElement("details");

View File

@@ -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

View File

@@ -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." };

View File

@@ -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" });

View File

@@ -256,6 +256,30 @@ export class LessonEngine {
${htmlWithClasses}
</body>
</html>
`;
} 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 = `
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${previewHTML || ""}
<script>
try {
${fullScript}
} catch (e) {
document.body.innerHTML += '<pre style="color:red">' + e.message + '</pre>';
}
</script>
</body>
</html>
`;
} else if (mode === "markdown") {
// For Markdown mode, parse user code to HTML
@@ -382,6 +406,30 @@ export class LessonEngine {
${htmlWithClasses}
</body>
</html>
`;
} 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 = `
<!DOCTYPE html>
<html>
<head>
<style>html, body { min-height: 100%; margin: 0; }</style>
<style>${previewBaseCSS || ""}</style>
<style>${sandboxCSS || ""}</style>
</head>
<body>
${previewHTML || ""}
<script>
try {
${fullScript}
} catch (e) {
document.body.innerHTML += '<pre style="color:red">' + e.message + '</pre>';
}
</script>
</body>
</html>
`;
} else if (mode === "markdown") {
// For Markdown mode, parse solution to HTML

View File

@@ -77,6 +77,7 @@
<a href="#html" class="nav-link" data-section="html">HTML</a>
<!-- <a href="#tailwind" class="nav-link" data-section="tailwind">Tailwind</a> -->
<a href="#markdown" class="nav-link" data-section="markdown">Markdown</a>
<a href="#javascript" class="nav-link" data-section="javascript">JavaScript</a>
<a href="#reference/css" class="nav-link nav-link-ref" data-section="reference">Reference</a>
</nav>
<button id="auth-trigger-header" class="btn btn-outline btn-sm" data-i18n="authLogin">Log In</button>
@@ -178,6 +179,12 @@
<p data-i18n="landingMarkdownDesc">Lightweight markup for formatting text</p>
<span class="section-card-progress" id="markdown-progress"></span>
</a>
<a href="#javascript" class="section-card" data-section="javascript">
<div class="section-card-icon" style="background: #f0db4f; color: #333">JS</div>
<h3>JavaScript</h3>
<p data-i18n="landingJsDesc">Interactive scripting for web pages</p>
<span class="section-card-progress" id="javascript-progress"></span>
</a>
</div>
<p class="device-notice" data-i18n-html="deviceNotice">
<strong>Best on desktop or tablet (landscape).</strong> Mobile works, but larger screens make coding easier.
@@ -194,13 +201,6 @@
<h3 data-i18n="comingSoonAchievementsTitle">Achievements</h3>
<p data-i18n="comingSoonAchievementsText">Earn badges as you master new skills. Track your learning milestones.</p>
</article>
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</span>
<h3 data-i18n="comingSoonJsTitle">JavaScript</h3>
<p data-i18n="comingSoonJsText">Interactive JavaScript lessons with live code execution and DOM manipulation.</p>
</article>
<article class="coming-soon-card">
<span class="coming-soon-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
@@ -478,6 +478,7 @@
<a href="#css" class="sidebar-nav-link" data-section="css">CSS</a>
<a href="#html" class="sidebar-nav-link" data-section="html">HTML</a>
<!-- <a href="#tailwind" class="sidebar-nav-link" data-section="tailwind">Tailwind</a> -->
<a href="#javascript" class="sidebar-nav-link" data-section="javascript">JavaScript</a>
<button id="auth-trigger-mobile" class="sidebar-nav-link sidebar-auth-link" data-i18n="authLogin">Log In</button>
</nav>

View File

@@ -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 {

View File

@@ -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);
});
});
});

View File

@@ -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", () => {

View File

@@ -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", () => {

View File

@@ -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", () => {