How this is builtGroundworkHow this is built
11 / 26 written chapters

I5 · Part 2 · How it runs

An editor inside a web page

CodeMirror, a worker full of tools, files that live in localStorage, and links that carry code.

9 min read1 diagram10 sections
10On this page
  1. One component, three jobs
  2. CodeMirror, assembled by hand
  3. The tools worker
  4. Settings
  5. The command palette
  6. Files and tabs
  7. Modals
  8. Share links
  9. Run history, Live mode and inline results
  10. The web preview

One component, three jobs

The playground, every problem page and the mock interview room all render the same component, components/practice/PracticeWorkspace.tsx. At about 1,800 lines it is the second-largest component in the repository, after the whiteboard, and it decides its shape from two flags. With isFree set and no interview it is the playground: a project of files, a Live switch, share links and run history. Without it, it is a problem page, with a brief on the left (Description, Tests and Hints tabs), a Submit button and the solved state, stamped on the page with a pill to the next problem. Inside the mock interview it drops the navigation, the saved code and the debugger.

Under the editor sits one output panel with up to seven tabs: Console, Debug, Problems, History, Input, Preview and Test Result. Which ones appear depends on the mode. History, Input and Preview belong to the playground; Test Result belongs to problems; Debug appears only after a debug run. On problem pages a drag handle resizes the editor between 220 and 900 pixels, and the height is saved.

CodeMirror, assembled by hand

The editor is CodeMirror 6 through @uiw/react-codemirror, with basicSetup={false}. components/practice/CodeEditor.tsx lists every extension itself: line numbers, history, bracket matching and closing, autocompletion, folding, rectangular selection, search in a top panel, selection-match highlighting, and the inline-results field from lib/editor/inline.ts.

Anything that can change after the editor exists goes in a compartment, and there are seven: language, lint, wrap, tab size, indent guides, Vim and minimap. Changing a setting reconfigures one compartment in a single transaction, so the document, the undo history and the cursor all survive. Vim keys and the minimap are dynamic imports; nobody downloads them until they are switched on.

Language support is lazy as well. lib/codeLanguages.ts knows 17 languages, and each entry has a support() function that imports its grammar only when that language is picked. Twelve use a Lezer grammar package. Kotlin, Swift, C#, Ruby and Lua use CodeMirror's legacy stream modes, wrapped in StreamLanguage.define.

The tools worker

MAIN THREAD CodeMirror 6 editor lint: 500 ms after typing stops format: Shift+Alt+F, or on save fix: from the command palette instrument: the Debug button a reply for text that has since changed is thrown away TOOLS WORKER lib/editor/toolsWorker.ts format: Prettier, babel or typescript lint JavaScript: ESLint, 48 rules lint TypeScript: strict type check fix: ESLint autofix instrument: debugger rewrite each library loads on first use request, id n reply, id n
Formatting, linting and type-checking are CPU work on the whole document. Doing them in a worker means a slow lint never delays a keystroke.

lib/editor/tools.ts owns one module worker and a map of pending promises keyed by a counter. Each call posts { id, type, code, lang }, and the reply resolves whichever promise has that id. The worker answers four request types.

  • format imports Prettier's standalone build with the estree plugin and either the babel or the typescript parser, formats at a print width of 100 with the chosen tab width, and hands back the new cursor position so the caret stays where it was.
  • lint for JavaScript runs eslint-linter-browserify with a flat config of 48 rules: 34 errors, 13 warnings and one turned off. The globals include the browser, worker and ES2021 sets, plus the site's own assert, compare, readline and __loopGuard. Where ESLint offers a fix, the diagnostic carries a Fix action.
  • lint for TypeScript is not ESLint at all. It builds a real ts.createProgram over an in-memory host with strict: true, fetching the 46 lib files from /wasm/typescript-lib/, and reports syntactic and semantic diagnostics with their ts(code) numbers.
  • instrument rewrites code for the step debugger, which has its own chapter.

The lint source waits for the browser to go idle before its first request (up to two seconds), so loading ESLint does not compete with the page's own start-up. After every reply it checks that the document and the language are still the ones it sent. If not, it returns nothing rather than painting squiggles on the wrong text. Formatting does the same check before it replaces the document.

Settings

lib/editor/settings.ts holds eight settings, saved together under one localStorage key and merged over the defaults on load, so a setting added later still gets a value.

SettingDefaultWhat it does
formatOnSaveonCmd/Ctrl+S runs Prettier first, for JavaScript and TypeScript
runOnSaveoffCmd/Ctrl+S also runs the code
lintonESLint or the type checker as you type
vimoffLoads @replit/codemirror-vim
minimapoffLoads @replit/codemirror-minimap
indentGuidesonIndentation markers
wrapoffLine wrapping
tabSize22 or 4; anything else saved is read back as 2

Text size (11 to 24 pixels, starting at 14.5) and fullscreen are component state, not settings, so they reset on reload.

The command palette

Cmd/Ctrl+Shift+P or F1 opens CommandPalette.tsx. It holds 20 fixed commands (run, save, format, fix, next problem, go to line, find, fold, unfold, the view toggles, Vim, lint, the two on-save switches, indent size, text size and fullscreen), three file commands in the playground, and one command per language. That makes 40 in the playground and 35 on a problem page, where HTML and CSS are not offered. Matching is a small scoring function: a direct substring scores 1,000 minus its position, and a scattered subsequence scores 500 minus the gaps between letters, so "fmt" still finds "Format document".

Files and tabs

The playground is a project, defined in lib/playgroundProject.ts: a list of files, each with an id, a name, a language and its code, plus the id of the active one. It lives in localStorage under groundwork:playground:project. Typing saves it 300 ms after the last keystroke; Cmd/Ctrl+S saves at once. A project that fails validation is replaced by a fresh one, and a first load gathers any code saved by the older one-file-per-language playground into tabs, so nobody lost work when files arrived.

Names are kept unique by adding -2, -3 and so on. Renaming a file to a new extension switches its language, because langForName looks the extension up in the same 17-language table. Opening the HTML quick language creates index.html with a style.css and a script.js beside it when they are missing.

Closing a file that has code in it asks first. A file counts as touched when it is not empty and is not exactly one of the 28 starter templates. Closed files can be brought back with Reopen for ten seconds.

Modals

New file, rename and every confirmation use components/Modal.tsx, which wraps the native <dialog> element and opens it with showModal(). The browser then provides the focus trap, the Escape key and the inert page behind it; a click on the backdrop closes it too. ConfirmDialog puts focus on Cancel, so pressing Enter by reflex does not delete anything. NameDialog trims names to 60 characters and validates as you type.

components/practice/FileDialogs.tsx builds the two file dialogs on top. New file shows all 17 languages as a radio group and a live hint such as "Creates scratch.py, Python, runs here". A typed extension wins over the picked language. Rename selects the name up to the extension and says whether the new extension will switch the language. Both refuse slashes, a name that starts with a dot, and a name another file already has, compared without case.

Share packs every file as a [name, language, code] triple, turns it into JSON, compresses it with the browser's own CompressionStream("deflate-raw"), and writes it as base64url into the fragment: /practice?id=free#share=…. The fragment never reaches a server, so a shared program is stored nowhere except in the link. A link longer than 60,000 characters is refused as too big.

Opening a link reads it defensively in lib/shareLink.ts: at most 20 files, names cut to 60 characters, code to 200,000, and unknown languages dropped. The shared files are added as new tabs next to the reader's own, and the fragment is removed from the address bar.

Run history, Live mode and inline results

Every playground run of a runnable file is recorded by lib/runHistory.ts: the time, the file, its code, the duration, whether anything was logged as an error, and the first output line cut to 80 characters. The newest 15 are kept in localStorage. The History tab reads them through useSyncExternalStore, with a snapshot cached by the raw string so React sees a stable value. "Open this code" brings an old run back as a new tab.

Live mode, for JavaScript, TypeScript, HTML and CSS, runs the code 700 ms after typing stops. After any run in JavaScript, Python, Ruby, Lua, C or C++, groupByLine folds the console output into one result per source line and showInline draws it at the end of that line as a CodeMirror widget: the text cut to 90 characters, a count such as ×3 when the line ran more than once, and errors taking priority over logs.

Each runtime finds the line differently. The JavaScript worker records the stack line of a marker call placed just above the reader's code and subtracts it from each console.log's stack line. Python's print walks up the frames to the reader's file. Ruby's puts reads caller_locations. Lua's print asks debug.getinfo. Those three put an invisible \x02line\x03 marker in front of each printed line, which the worker strips before display. In C and C++ only compiler diagnostics carry a line. TypeScript and PHP get no inline results.

The web preview

Running an HTML file, or a stylesheet or script one links to, calls buildPage in lib/webPreview.ts. It replaces each <link href> and <script src> that names a project file with the file's contents inline, escaping any closing tag inside them, and puts a small bridge script at the top of the head. The bridge forwards console calls, errors, unhandled rejections and the load event to the parent with postMessage. The page renders in an iframe with srcdoc and sandbox="allow-scripts allow-modals allow-forms". The parent accepts a message only when it carries the preview's tag and comes from that iframe's window.

Where the reader's work lives

Nothing in this chapter talks to a server. Files, settings, run history and standard input are in localStorage; a share link carries its code in the fragment. That keeps the editor free to run and impossible to leak from, and it means clearing site data clears the playground. The /problems/[slug]/cases file for grading other languages is the one exception, and it is a static file made at build time.