(function () {
"use strict";
const content = window.LIQUID_JAVA_TUTORIAL;
const steps = [
{ id: "welcome", shortTitle: "Welcome" },
...content.lessons.map((lesson) => ({ id: lesson.id, shortTitle: lesson.shortTitle })),
];
const appScriptUrl = new URL(document.currentScript.src);
const basePath = appScriptUrl.pathname.slice(0, appScriptUrl.pathname.lastIndexOf("/") + 1);
const blankState = () => ({
currentStep: 0,
completed: [],
code: Object.fromEntries(content.lessons.map((lesson) => [lesson.id, lesson.exercise.starterCode])),
answers: {},
checkResults: {},
});
let state = blankState();
const screen = document.querySelector("#screen");
const navigation = document.querySelector("#step-navigation");
const resetButton = document.querySelector("#reset-button");
const brandLink = document.querySelector(".brand");
function stepIndexFromPath() {
const relativePath = window.location.pathname.startsWith(basePath)
? window.location.pathname.slice(basePath.length)
: "";
const route = decodeURIComponent(relativePath).replace(/^\/+|\/+$/g, "");
if (!route || route === "index.html" || route === "404.html") return 0;
return steps.findIndex((step) => step.id === route);
}
function pathForStep(index) {
const step = steps[index];
return step.id === "welcome" ? basePath : `${basePath}${encodeURIComponent(step.id)}`;
}
function updatePath(index, mode = "push") {
const path = pathForStep(index);
if (window.location.pathname === path) return;
window.history[`${mode}State`]({ step: steps[index].id }, "", path);
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
}
function render() {
renderNavigation();
const step = steps[state.currentStep];
document.title = step.id === "welcome" ? content.meta.title : `${step.shortTitle} · ${content.meta.title}`;
if (step.id === "welcome") renderWelcome();
else renderLesson(content.lessons.find((lesson) => lesson.id === step.id));
updateProgress();
window.Prism?.highlightAllUnder(screen);
window.scrollTo({ top: 0, behavior: "smooth" });
}
function renderNavigation() {
navigation.innerHTML = `
${steps
.map((step, index) => {
const active = index === state.currentStep;
const complete = state.completed.includes(step.id);
return `-
`;
})
.join("")}
`;
navigation.querySelectorAll("[data-step]").forEach((button) => {
button.addEventListener("click", () => goToStep(Number(button.dataset.step)));
});
}
function updateProgress() {
const completedCount = state.completed.length;
const percentage = Math.round((completedCount / steps.length) * 100);
document.querySelector("#progress-percent").textContent = `${percentage}%`;
document.querySelector("#progress-bar").style.width = `${percentage}%`;
document.querySelector("#progress-copy").textContent = completedCount
? `${completedCount} of ${steps.length} steps complete.`
: "Start when you are ready.";
}
function renderWelcome() {
screen.innerHTML = `
${escapeHtml(content.meta.eyebrow)}
Catch the bug
before it runs.
${escapeHtml(content.meta.introduction)}
What you’ll practice
${content.lessons
.map(
(lesson) => `-
${lesson.number}
${escapeHtml(lesson.shortTitle)}
`,
)
.join("")}
`;
document.querySelector("#begin-button").addEventListener("click", () => {
markComplete("welcome");
goToStep(1);
});
}
function renderLesson(lesson) {
const currentCode = state.code[lesson.id] || lesson.exercise.starterCode;
const checked = state.checkResults[lesson.id];
const isLastLesson = state.currentStep === steps.length - 1;
screen.innerHTML = `
${escapeHtml(lesson.concept.label)}
${escapeHtml(lesson.concept.explanation)}
${lesson.concept.notes.map((note) => `- ${escapeHtml(note)}
`).join("")}
${escapeHtml(lesson.concept.code)}
Try it yourself
${escapeHtml(lesson.exercise.title)}
${escapeHtml(lesson.exercise.prompt)}
Java
${
lesson.exercise.guide
? `
${escapeHtml(lesson.exercise.guide.caption)}
`
: ""
}
${renderExerciseFeedback(checked)}
One possible solutionCompare, don’t copy blindly.
${escapeHtml(lesson.exercise.solutionCode)}
Quick check
Explain what LiquidJava knows
${lesson.questions.map((question, index) => renderQuestion(question, index + 1)).join("")}
${renderFooterControls(state.currentStep, isLastLesson ? "Finish tutorial" : `Complete lesson ${lesson.number}`)}
`;
wireLesson(lesson);
wireQuestions(lesson.questions);
wireFooterControls(lesson.id, isLastLesson);
}
function renderExerciseFeedback(result) {
if (!result) return "Run the check when you are ready. This checker looks for the contract, not exact formatting.
";
if (result.passed) {
return 'Contract satisfied. Your annotations express the requested guarantee.
';
}
return `Almost there.
${result.messages.map((message) => `- ${escapeHtml(message)}
`).join("")}
`;
}
function wireLesson(lesson) {
const textarea = document.querySelector(`#code-${lesson.id}`);
const feedback = document.querySelector(`#exercise-feedback-${lesson.id}`);
const solutionButton = document.querySelector(`#solution-${lesson.id}`);
const solutionPanel = document.querySelector(`#solution-panel-${lesson.id}`);
const editor = window.CodeMirror
? window.CodeMirror.fromTextArea(textarea, {
mode: "text/x-java",
theme: "liquidjava",
lineNumbers: true,
lineWrapping: true,
indentUnit: 4,
tabSize: 4,
indentWithTabs: false,
matchBrackets: true,
autoCloseBrackets: true,
inputStyle: "textarea",
screenReaderLabel: `Editable Java exercise: ${lesson.exercise.title}`,
extraKeys: {
Tab(codeMirror) {
if (codeMirror.somethingSelected()) codeMirror.indentSelection("add");
else codeMirror.execCommand("insertSoftTab");
},
"Shift-Tab": "indentLess",
},
})
: null;
const getCode = () => (editor ? editor.getValue() : textarea.value);
const setCode = (value) => {
if (editor) editor.setValue(value);
else {
textarea.value = value;
textarea.dispatchEvent(new Event("input"));
}
};
const focusEditor = () => (editor ? editor.focus() : textarea.focus());
const handleCodeChange = () => {
state.code[lesson.id] = getCode();
delete state.checkResults[lesson.id];
feedback.className = "exercise-feedback";
feedback.innerHTML = renderExerciseFeedback(null);
};
if (editor) editor.on("change", handleCodeChange);
else textarea.addEventListener("input", handleCodeChange);
document.querySelector(`#check-${lesson.id}`).addEventListener("click", () => {
const failures = lesson.exercise.checks.filter((check) => !new RegExp(check.pattern, "m").test(getCode()));
const result = { passed: failures.length === 0, messages: failures.map((check) => check.message) };
state.checkResults[lesson.id] = result;
feedback.className = `exercise-feedback ${result.passed ? "is-success" : "is-error"}`;
feedback.innerHTML = renderExerciseFeedback(result);
feedback.scrollIntoView({ behavior: "smooth", block: "nearest" });
});
document.querySelector(`#reset-${lesson.id}`).addEventListener("click", () => {
setCode(lesson.exercise.starterCode);
focusEditor();
});
solutionButton.addEventListener("click", () => {
const isHidden = solutionPanel.hidden;
solutionPanel.hidden = !isHidden;
solutionButton.setAttribute("aria-expanded", String(isHidden));
solutionButton.textContent = isHidden ? "Hide solution" : "Show one solution";
});
}
function renderQuestion(question, number) {
const currentAnswer = state.answers[question.id] ?? "";
const feedback = questionFeedback(question, currentAnswer);
const heading = `${number}
${escapeHtml(question.prompt)}
`;
if (question.type === "text") {
return ``;
}
return ``;
}
function questionFeedback(question, value) {
if (value === "" || value === undefined) return "";
const correct = question.type === "text"
? question.accepted.some((answer) => answer.toLowerCase() === String(value).trim().toLowerCase())
: Number(value) === question.correct;
return `${correct ? "That’s right." : "Try once more."} ${escapeHtml(question.explanation || "")}
`;
}
function wireQuestions(questions) {
questions.forEach((question) => {
if (question.type === "text") {
const input = document.querySelector(`#answer-${question.id}`);
input.addEventListener("input", () => {
state.answers[question.id] = input.value;
document.querySelector(`#feedback-${question.id}`).innerHTML = questionFeedback(question, input.value);
});
return;
}
document.querySelectorAll(`input[name="${question.id}"]`).forEach((input) => {
input.addEventListener("change", () => {
state.answers[question.id] = input.value;
const feedback = document.querySelector(`#feedback-${question.id}`);
if (feedback) feedback.innerHTML = questionFeedback(question, input.value);
});
});
});
}
function renderFooterControls(stepIndex, nextLabel) {
return ``;
}
function wireFooterControls(stepId, isFinal = false) {
document.querySelector("#previous-button").addEventListener("click", () => goToStep(state.currentStep - 1));
document.querySelector("#next-button").addEventListener("click", () => {
markComplete(stepId);
if (isFinal) {
showFinishedState();
} else {
goToStep(Math.min(state.currentStep + 1, steps.length - 1));
}
});
}
function showFinishedState() {
const footer = document.querySelector(".lesson-footer");
footer.innerHTML = `
✓
Tutorial complete.You finished all four guided examples.
`;
document.querySelector("#review-button").addEventListener("click", () => goToStep(1));
updateProgress();
}
function markComplete(stepId) {
if (!state.completed.includes(stepId)) state.completed.push(stepId);
}
function goToStep(index, historyMode = "push") {
state.currentStep = Math.max(0, Math.min(index, steps.length - 1));
updatePath(state.currentStep, historyMode);
render();
document.querySelector("#tutorial-main").focus({ preventScroll: true });
}
resetButton.addEventListener("click", () => {
if (!window.confirm("Reset this tutorial session, including edited code and answers?")) return;
state = blankState();
goToStep(0, "replace");
});
brandLink.addEventListener("click", (event) => {
event.preventDefault();
goToStep(0);
});
window.addEventListener("popstate", () => {
const routeIndex = stepIndexFromPath();
state.currentStep = routeIndex >= 0 ? routeIndex : 0;
render();
document.querySelector("#tutorial-main").focus({ preventScroll: true });
});
const initialRouteIndex = stepIndexFromPath();
state.currentStep = initialRouteIndex >= 0 ? initialRouteIndex : 0;
if (initialRouteIndex < 0) updatePath(0, "replace");
render();
})();