Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/app/components/LessonPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { useAuth } from '../context/AuthContext';
import { useEnvironment } from '../context/EnvironmentContext';
import { useLessonSEO } from '../hooks/useLessonSEO';
import { toUnixUsername } from '../../lib/username';
import { renderInlineMarkdown } from '../../lib/renderInlineMarkdown';
import { TerminalState } from '../data/terminalEngine';
import { renderInlineMarkdown, stripInlineMarkdown } from '../../lib/renderInlineMarkdown';
import { TerminalState, createInitialState } from '../data/terminalEngine';
import { TerminalEmulator } from './TerminalEmulator';
import { Button } from './ui/button';
import { AiTutorPanel } from './ai/AiTutorPanel';
Expand Down Expand Up @@ -212,11 +212,18 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: {

const effectiveInstruction =
lesson.exercise?.instructionByEnv?.[selectedEnv] ?? lesson.exercise?.instruction ?? '';
const setup = lesson.exercise?.setup;
const setupNote = setup ? [stripInlineMarkdown(setup.note)] : [];
const welcomeMessage = lesson.exercise
? exerciseCompleted
? [`📚 ${lesson.title}`, ``, `✓ Exercice déjà complété — « Suivant » pour continuer, ou pratique librement ci-dessous.`, ``]
: [`📚 ${lesson.title}`, ``, `Exercice : ${effectiveInstruction}`, ``]
? [`📚 ${lesson.title}`, ``, ...setupNote, `✓ Exercice déjà complété — « Suivant » pour continuer, ou pratique librement ci-dessous.`, ``]
: [`📚 ${lesson.title}`, ``, ...setupNote, `Exercice : ${stripInlineMarkdown(effectiveInstruction)}`, ``]
: [`📚 ${lesson.title}`, ``, `Terminal libre — pratiquez les commandes ci-dessous.`, ``];
// Read once per terminal mount (lesson change or « Réinitialiser » remount).
const buildInitialState = useCallback(
() => (setup ? setup.apply(createInitialState()) : createInitialState()),
[setup],
);

return (
<div className="h-full flex flex-col bg-[var(--github-bg)] text-[var(--github-text-primary)] overflow-hidden">
Expand Down Expand Up @@ -297,10 +304,10 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: {
</div>

{exerciseMessage ? (
<p className="text-emerald-400 text-sm">{exerciseMessage}</p>
<p className="text-emerald-400 text-sm">{renderInlineMarkdown(exerciseMessage)}</p>
) : (
<p className="text-[var(--github-text-primary)] text-sm">
{lesson.exercise.instructionByEnv?.[selectedEnv] ?? lesson.exercise.instruction}
{renderInlineMarkdown(effectiveInstruction)}
</p>
)}

Expand Down Expand Up @@ -331,7 +338,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: {
aria-label="Indice"
className="mt-2 text-amber-400 text-xs font-mono bg-amber-500/5 border border-amber-500/20 rounded px-3 py-2"
>
💡 {lesson.exercise.hintByEnv?.[selectedEnv] ?? lesson.exercise.hint}
💡 {renderInlineMarkdown(lesson.exercise.hintByEnv?.[selectedEnv] ?? lesson.exercise.hint)}
</p>
)}
</div>
Expand Down Expand Up @@ -382,6 +389,7 @@ function LessonContent({ mod, lesson, moduleId, lessonId }: {
className="flex-1 min-h-0"
username={terminalUsername}
environment={selectedEnv}
initialState={buildInitialState}
/>
</div>
</div>
Expand Down
21 changes: 15 additions & 6 deletions src/app/components/TerminalEmulator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,17 @@ interface TerminalEmulatorProps {
username?: string;
/** Active environment — controls prompt style and display. Defaults to 'linux'. */
environment?: SelectedEnvironment;
/** Builds the state the terminal starts from (lesson setup). Read once, on mount. */
initialState?: () => TerminalState;
}

/**
* Output keeps its spacing: `git branch` indents non-current branches, `git
* status` uses tabs, `ps` aligns columns. HTML collapses all of that by
* default. Long unbroken tokens (URLs, paths) still wrap instead of overflowing.
*/
const OUTPUT_TEXT = 'whitespace-pre-wrap break-words [word-break:break-word]';

let lineCounter = 0;
const nextId = () => ++lineCounter;

Expand Down Expand Up @@ -137,8 +146,8 @@ function useCoarsePointer(): boolean {
return coarse;
}

export function TerminalEmulator({ onCommand, welcomeMessage, className = '', username, environment = 'linux' }: TerminalEmulatorProps) {
const [termState, setTermState] = useState<TerminalState>(createInitialState);
export function TerminalEmulator({ onCommand, welcomeMessage, className = '', username, environment = 'linux', initialState }: TerminalEmulatorProps) {
const [termState, setTermState] = useState<TerminalState>(initialState ?? createInitialState);
const [lines, setLines] = useState<TerminalLine[]>(() => {
const welcome = welcomeMessage ?? ENV_MOTD[environment];
return welcome.map((text) => ({ id: nextId(), type: 'info' as const, text }));
Expand Down Expand Up @@ -346,13 +355,13 @@ export function TerminalEmulator({ onCommand, welcomeMessage, className = '', us
<span className="text-[var(--github-text-primary)] break-all">{line.text}</span>
</div>
) : line.type === 'error' ? (
<div className="text-[var(--github-red)]">{line.text}</div>
<div className={`${OUTPUT_TEXT} text-[var(--github-red)]`}>{line.text}</div>
) : line.type === 'success' ? (
<div className="text-[#3fb950]">{line.text}</div>
<div className={`${OUTPUT_TEXT} text-[#3fb950]`}>{line.text}</div>
) : line.type === 'info' ? (
<div className="text-[#58a6ff]">{line.text}</div>
<div className={`${OUTPUT_TEXT} text-[#58a6ff]`}>{line.text}</div>
) : (
<div className="text-[var(--github-text-primary)]">{line.text}</div>
<div className={`${OUTPUT_TEXT} text-[var(--github-text-primary)]`}>{line.text}</div>
)}
</div>
))}
Expand Down
23 changes: 22 additions & 1 deletion src/app/data/curriculum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
validateAiHelpContext, validateAiHelpValidate, validateAiHelpDebug, validateAiHelpSecurity,
validateAiHelpClaudeCli, validateAiHelpCareers, validateAiHelpSenior, validateAiHelpWorkflow,
} from './validators';
import {
gitRepoEmpty, gitRepoWithCommit, gitRepoWithBranch, gitRepoWithRemote, sshDirectory,
type LessonSetup,
} from './lessonSetup';
export type BlockType = 'text' | 'code' | 'tip' | 'warning' | 'info';

export interface ContentBlock {
Expand All @@ -37,6 +41,11 @@ export interface Exercise {
/** env is passed by LessonPage from EnvironmentContext. */
validate: (command: string, env?: EnvId) => boolean;
successMessage: string;
/**
* Terminal state the exercise starts from (e.g. an initialised Git repository).
* Absent = the default filesystem. See lessonSetup.ts.
*/
setup?: LessonSetup;
}

export interface Lesson {
Expand Down Expand Up @@ -1092,6 +1101,7 @@ export const curriculum: Module[] = [
windows: 'Tapez: Get-Acl $HOME | Format-List',
},
validate: validateSecurityPermissions,
setup: sshDirectory,
successMessage: 'Excellent ! Vous intégrez maintenant la sécurité dans votre gestion de fichiers.',
},
},
Expand Down Expand Up @@ -1775,7 +1785,7 @@ export const curriculum: Module[] = [
},
hint: 'Faites d\'abord "cd projets" si ce n\'est pas déjà fait, puis "./script.sh"',
hintByEnv: {
windows: 'Tapez ".\\script.sh" ou "bash script.sh"',
windows: 'Faites d\'abord "cd projets", puis ".\\script.sh" ou "bash script.sh"',
},
validate: validateScripts,
successMessage: 'Bravo ! Vous venez d\'exécuter votre premier script bash.',
Expand Down Expand Up @@ -2275,6 +2285,7 @@ export const curriculum: Module[] = [
instruction: 'Ajoutez tous les fichiers du répertoire courant à la zone de staging avec `git add .`.',
hint: 'Tapez: git add .',
validate: validateGitAddCommit,
setup: gitRepoEmpty,
successMessage: 'Fichiers stagés ! Maintenant vous pouvez les committer avec git commit -m "message".',
},
},
Expand Down Expand Up @@ -2312,6 +2323,7 @@ export const curriculum: Module[] = [
instruction: 'Affichez le statut de votre dépôt avec `git status`.',
hint: 'Tapez: git status',
validate: validateGitStatusLog,
setup: gitRepoWithCommit,
successMessage: 'Vous savez lire l\'état de votre dépôt. git status sera votre commande la plus utilisée au quotidien.',
},
},
Expand Down Expand Up @@ -2353,6 +2365,7 @@ export const curriculum: Module[] = [
instruction: 'Visualisez les différences actuelles dans votre dépôt avec `git diff`.',
hint: 'Tapez: git diff',
validate: validateGitDiffGitignore,
setup: gitRepoWithCommit,
successMessage: 'Vous savez lire un diff Git. Les lignes en vert (+) sont les ajouts, en rouge (-) les suppressions.',
},
},
Expand Down Expand Up @@ -2393,6 +2406,7 @@ export const curriculum: Module[] = [
instruction: 'Créez une nouvelle branche `feature/ma-feature` et basculez dessus avec `git checkout -b feature/ma-feature`.',
hint: 'Tapez: git checkout -b feature/ma-feature',
validate: validateGitBranch,
setup: gitRepoWithCommit,
successMessage: 'Branche créée et activée ! Vous développez maintenant en isolation totale de main.',
},
},
Expand Down Expand Up @@ -2430,6 +2444,7 @@ export const curriculum: Module[] = [
instruction: 'Fusionnez la branche `feature/ma-feature` dans la branche courante avec `git merge feature/ma-feature`.',
hint: 'Tapez: git merge feature/ma-feature',
validate: validateGitMerge,
setup: gitRepoWithBranch('feature/ma-feature'),
successMessage: 'Fusion réussie ! Le travail de la branche est maintenant intégré. C\'est le coeur du workflow Git en entreprise.',
},
},
Expand Down Expand Up @@ -2484,6 +2499,7 @@ export const curriculum: Module[] = [
instruction: 'Ajoutez un remote `origin` pointant vers `https://github.com/user/mon-projet.git` avec `git remote add origin https://github.com/user/mon-projet.git`.',
hint: 'Tapez: git remote add origin https://github.com/user/mon-projet.git',
validate: validateGitRemote,
setup: gitRepoWithCommit,
successMessage: 'Remote ajouté ! Votre dépôt local est maintenant connecté à GitHub.',
},
},
Expand Down Expand Up @@ -2528,6 +2544,7 @@ export const curriculum: Module[] = [
instruction: 'Envoyez vos commits vers GitHub avec `git push -u origin main`.',
hint: 'Tapez: git push -u origin main',
validate: validateGitPushPull,
setup: gitRepoWithRemote,
successMessage: 'Push réussi ! Vos commits sont maintenant sur GitHub, visibles par toute votre équipe.',
},
},
Expand Down Expand Up @@ -2605,6 +2622,7 @@ export const curriculum: Module[] = [
instruction: 'Simulez le début d\'un workflow PR : créez une branche `feature/nouvelle-feature` avec `git checkout -b feature/nouvelle-feature`.',
hint: 'Tapez: git checkout -b feature/nouvelle-feature',
validate: validatePullRequests,
setup: gitRepoWithCommit,
successMessage: 'Branche feature créée ! Dans un vrai projet, vous développeriez ici puis ouvreriez une PR vers main.',
},
},
Expand Down Expand Up @@ -2651,6 +2669,7 @@ export const curriculum: Module[] = [
instruction: 'Fusionnez la branche `feature/ma-feature` avec un **merge commit explicite** (option `--no-ff`) : `git merge --no-ff feature/ma-feature`.',
hint: 'Tapez: git merge --no-ff feature/ma-feature',
validate: validateMergeStrategies,
setup: gitRepoWithBranch('feature/ma-feature'),
successMessage: 'Merge commit créé ! Votre branche reste identifiable dans l\'historique — utile pour retrouver le contexte d\'une feature 6 mois plus tard.',
},
},
Expand Down Expand Up @@ -2692,6 +2711,7 @@ export const curriculum: Module[] = [
instruction: 'Fusionnez la branche `feature/nouvelle-feature` dans la branche courante avec `git merge feature/nouvelle-feature`.',
hint: 'Tapez: git merge feature/nouvelle-feature',
validate: validateConflicts,
setup: gitRepoWithBranch('feature/nouvelle-feature'),
successMessage: 'Fusion effectuée ! En cas de conflit réel, vous savez maintenant comment les identifier et les résoudre.',
},
},
Expand Down Expand Up @@ -2737,6 +2757,7 @@ export const curriculum: Module[] = [
instruction: 'Vérifiez l\'état de votre dépôt git avant un push avec `git status`.',
hint: 'Tapez: git status',
validate: validateGithubActions,
setup: gitRepoWithRemote,
successMessage: 'Parfait ! Avant chaque push, vérifiez toujours l\'état de votre dépôt. GitHub Actions fera ensuite tourner automatiquement vos tests et votre build.',
},
},
Expand Down
113 changes: 113 additions & 0 deletions src/app/data/lessonSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Lesson setups — the terminal state a lesson starts from.
*
* Every lesson mounts a fresh terminal (`createInitialState`). That is right for
* most lessons, but wrong for the ones that teach a command which only makes
* sense in a prepared context: `git status` needs a repository, `git merge
* feature/x` needs a branch called `feature/x`, `ls -la ~/.ssh` needs a `.ssh`
* directory. Without a setup the learner typed exactly what the lesson asked,
* got `fatal: not a git repository` in red, and was still told "Exercice
* complété" — the validator only reads the command string (check-up W4b,
* 23 September 2026: 33 of the 46 desyncs were Git lessons).
*
* A setup is a pure function of the initial state, plus a one-line note shown
* in the terminal welcome message so the learner knows what is already there.
*/
import type { DirectoryNode, FSNode, GitCommit, GitState, TerminalState } from './commands/types';

export interface LessonSetup {
/** Pure: returns a new state, never mutates the one it receives. */
apply: (state: TerminalState) => TerminalState;
/** Shown in the welcome message, e.g. "Dépôt Git prêt dans ~/projets". */
note: string;
}

// Fixed hashes and dates keep the prepared history deterministic (tests, and
// the same screen for every learner).
const INITIAL_COMMIT: GitCommit = {
hash: 'a3f8c12',
message: 'feat: premier commit du projet',
author: 'user',
date: '2026-01-15',
};

const PROJECT_DIR = ['home', 'user', 'projets'];

function withGit(state: TerminalState, git: Partial<GitState>): TerminalState {
return {
...state,
cwd: PROJECT_DIR,
git: {
initialized: true,
branch: 'main',
branches: ['main'],
stagedFiles: [],
commits: [],
remotes: {},
...git,
},
};
}

/** Returns a copy of `root` with `node` placed at `path` (parents must exist). */
function withNode(root: DirectoryNode, path: string[], node: FSNode): DirectoryNode {
const [head, ...rest] = path;
if (rest.length === 0) return { ...root, children: { ...root.children, [head]: node } };
const child = root.children[head];
if (!child || child.type !== 'directory') return root;
return { ...root, children: { ...root.children, [head]: withNode(child, rest, node) } };
}

function file(content: string, permissions: string): FSNode {
return { type: 'file', content, permissions, owner: 'user', group: 'user', size: content.length };
}

/** A fresh repository: nothing committed yet, files ready to be staged. */
export const gitRepoEmpty: LessonSetup = {
apply: (s) => withGit(s, {}),
note: 'Dépôt Git prêt dans ~/projets (initialisé, aucun commit).',
};

/** A repository with one commit on `main`. */
export const gitRepoWithCommit: LessonSetup = {
apply: (s) => withGit(s, { commits: [INITIAL_COMMIT] }),
note: 'Dépôt Git prêt dans ~/projets (branche main, 1 commit).',
};

/** A repository whose `main` has one commit and an extra branch to merge. */
export function gitRepoWithBranch(branch: string): LessonSetup {
return {
apply: (s) => withGit(s, { commits: [INITIAL_COMMIT], branches: ['main', branch] }),
note: `Dépôt Git prêt dans ~/projets (vous êtes sur main, la branche ${branch} existe).`,
};
}

/** A repository with one commit and an `origin` remote. */
export const gitRepoWithRemote: LessonSetup = {
apply: (s) =>
withGit(s, {
commits: [INITIAL_COMMIT],
remotes: { origin: 'https://github.com/user/mon-projet.git' },
}),
note: 'Dépôt Git prêt dans ~/projets (1 commit, remote origin configuré).',
};

/** A `~/.ssh` directory with the permissions a correct setup has (700 / 600 / 644). */
export const sshDirectory: LessonSetup = {
apply: (s) => ({
...s,
root: withNode(s.root, ['home', 'user', '.ssh'], {
type: 'directory',
permissions: 'drwx------',
owner: 'user',
group: 'user',
children: {
// Deliberately not shaped like a key: secret scanners flag the PEM header even on a fake.
id_ed25519: file('(clé privée simulée — une vraie clé ne se partage et ne s\'affiche jamais)', '-rw-------'),
'id_ed25519.pub': file('ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISimulatedKeyOnly user@terminal-lab', '-rw-r--r--'),
known_hosts: file('github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAISimulatedHostKey', '-rw-r--r--'),
},
}),
}),
note: 'Un dossier ~/.ssh (simulé) contient une paire de clés.',
};
13 changes: 13 additions & 0 deletions src/lib/renderInlineMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,16 @@ export function renderInlineMarkdown(text: string): ReactNode[] {
return <span key={i}>{part}</span>;
});
}

/**
* Plain-text twin of `renderInlineMarkdown`, for surfaces that cannot render
* markup — the terminal welcome message showed the instruction's backticks and
* asterisks literally (check-up P1, 23 September 2026). Same token rules: only
* a complete `` `code` `` or `**bold**` span loses its markers; a lone
* backtick or asterisk stays verbatim.
*/
export function stripInlineMarkdown(text: string): string {
return text.replace(INLINE_TOKEN_RX, (part) =>
part.startsWith('`') ? part.slice(1, -1) : part.slice(2, -2),
);
}
Loading
Loading