@codemirror/lang-bash is not an official package. cm6's official path to bash highlighting is @codemirror/legacy-modes/mode/shell wrapped in StreamLanguage.define(), matching the same mechanism we use for the custom srccfg mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
20 KiB
Textarea Code Editor v2 (CodeMirror 6) — Design
Status: Approved 2026-05-17. Supersedes 2026-05-16-textarea-code-editor-design.md
(historical reference only — its functional requirements remain authoritative;
its architectural choices, CodeJar + Prism over contenteditable, are stale).
History. A 35-commit implementation of the v1 design was rolled back in
commit f14d352 after four classes of bug surfaced (copy collapses
multi-line selections, Enter sometimes needs two presses, autocomplete
fails silently at end-of-line, manual caret save/restore is unreliable).
All four are characteristic of contenteditable + tokenized DOM and
proved resistant to incremental fixes. The full post-mortem lives in
2026-05-17-textarea-editor-handoff.md.
Goal
Upgrade three plain-text <textarea> fields in l4d2web into a
lightweight code editor with syntax highlighting and identifier-as-you-type
autocomplete. Motivating case: the blueprint config field, where users
edit Source-engine server.cfg-style content but have to remember
hundreds of L4D2 cvar names from memory.
Architecture
CodeMirror 6 is the editing engine. Pre-built into a single IIFE bundle
that exposes a narrow façade (window.__editor.mount(textarea, opts)).
The bundle is produced by esbuild running locally and committed under
static/vendor/. No build step on prod; a git pull ships the
artifact verbatim. This matches the existing "vendor + pin + SHA-record"
pattern (currently used for none-yet, but planned for parity with the
v1 attempt's vendoring of CodeJar/Prism).
Why CodeMirror 6 over the v1 stack: the bug classes that killed v1 are exactly what CodeMirror 6's editor surface has been hardened against over years — copy/paste across token DOM, IME composition, selection preservation across re-highlight, caret-between-nodes edge cases. We trade a one-time build pipeline addition (Node + esbuild locally) for the elimination of an entire bug class.
Form-bridge pattern: submit-time copy
CodeMirror 6 owns the live document. The original textarea stays in the DOM as the named form field (display:none) but acts as a submit-time courier, not a live mirror. Two concrete bridges:
- HTML form POSTs (
blueprint_detail.html, script overlay):editor.jsinstalls a capture-phasesubmitlistener on the enclosing<form>that doestextarea.value = controller.getValue()exactly once per submission. - JSON-save fetch (files-editor modal):
files-overlay.jscallscontroller.getValue()at save time instead of readingtextarea.value.
┌──── HTML form POST ──────────────────┐
│ textarea.value (written at submit) │
▲ │
┌────────────────────┴──────────────────────────────────┐ │
│ <textarea name="config" data-editor-language= │ │
│ "srccfg" hidden>…seed…</textarea> │ │
└──────────────────────────┬────────────────────────────┘ │
│ hidden, courier-only │
mounted next to│ ▲ on form `submit` (capture): │
│ │ textarea.value = │
│ │ view.state.doc.toString() │
▼ │ │
┌────────────────────────────┴──────────────────────┐ │
│ <div class="editor-shell"> ◄── EditorView (cm6) ─┤ │
│ cm-content (contenteditable, hardened by cm6) │ │
│ </div> │ │
└───────────────────────────────────────────────────┘ │
│ controller.getValue() │
▼ called at JSON-save time │
fetch('/files/save', …) ────────────────┘
Why submit-time copy and not a per-keystroke live mirror: live mirroring
keeps two sources of truth (cm6 doc + textarea) in sync, fires
input events on the hidden textarea on every keystroke (a semantic
muddle — those events historically mean "the user typed", here they
also mean "cm6 dispatched"), and requires guard code to prevent
mirror writes from re-entering cm6. Submit-time copy collapses to a
single source of truth (cm6 owns the doc), with the textarea written
once per submission. Cost: 5 read sites + 3 write sites in
files-overlay.js switch from textarea.value to controller.getValue()
/ controller.setContent() — bounded, mechanical, single file.
Subsystems
| Unit | Purpose | Depends on |
|---|---|---|
editor.js |
Un-bundled. Bootstraps cm6 on every textarea[data-editor-language], hides the textarea, installs the submit-capture handler, exposes per-textarea controllers. |
bundled cm6 + tokens.css |
editor.bundle.js |
Pre-built cm6 + extensions + custom srccfg grammar + light/dark themes, exposed as window.__editor (façade). |
esbuild + npm deps |
editor.css |
Skins cm6 classes (.cm-keyword, .cm-string, gutter, selection, caret) against tokens.css CSS variables. |
tokens.css |
srccfg-vocab.json |
Autocomplete corpus (~2196 cvars/cmds). | build-vocab.py reading cvar_list |
_editor_assets.html |
Jinja partial injecting nonce-tagged <script>/<link> tags. Loaded only on pages that mount an editor. |
g.csp_nonce |
Call sites
| Template | Line | Language | Save path |
|---|---|---|---|
l4d2web/l4d2web/templates/blueprint_detail.html |
52 | srccfg |
HTML form POST → blueprint_routes.update_blueprint_form |
l4d2web/l4d2web/templates/overlay_detail.html |
25 | bash |
HTML form POST → overlay script update route |
l4d2web/l4d2web/templates/overlay_detail.html |
178 | auto (filename-derived; dropdown override) |
fetch('/files/save', …) from files-overlay.js |
Editor module contract
esbuild bundles editor-entry.js to a single IIFE that exposes
exactly one global, window.__editor:
window.__editor = {
mount(textarea, {language, vocab}) → controller,
// controller exposes:
// getValue(): string // live cm6 doc.toString()
// setContent(text): void // replace doc, no input event fired
// setLanguage(name): void // swap language Compartment contents
// destroy(): void // tear down the EditorView
};
The narrowness is deliberate. cm6's full API surface is huge; only this façade couples to the rest of the codebase. Future upgrades (cm7, swap back to overlay, etc.) only need to preserve this contract.
editor.js (un-bundled, in static/js/) is the only consumer:
document.querySelectorAll('textarea[data-editor-language]')- For each: hide the textarea, mount the controller, register a single
capture-phase
submithandler on the enclosing<form>(if any) that callstextarea.value = controller.getValue(). Forms with multiple editors share one listener. - Files-editor modal: expose a named alias
window.__filesEditorpointing at the controller mounted on the modal's textarea, sofiles-overlay.jscan call.getValue()at save time and.setContent(text)on file-open without touching the textarea directly. - If
window.__editoris undefined (bundle failed to load), leave the textarea visible — graceful no-JS fallback. The browser submits the raw textarea value exactly like today.
Languages
| Language | Source | Notes |
|---|---|---|
srccfg |
srccfg-mode.js — StreamLanguage.define(…), ~30 LOC |
Tokens: comment //…, string "…", number, keyword (exec, alias, bind), identifier. Linewise. Tied to srccfg-vocab.json for autocomplete. |
bash |
@codemirror/legacy-modes/mode/shell via StreamLanguage.define() |
Official cm6 port of the CodeMirror 5 shell mode. Same loading mechanism as srccfg. |
auto |
Resolved on mount from filename input | .cfg → srccfg, .sh → bash, otherwise plain. Re-evaluated on filename input change while dropdown sits in "auto" state. |
plain |
No language extension | Editor still mounts so the language <select> remains usable. |
StreamLanguage chosen over Lezer: .cfg content is linewise, has no
nesting, no indentation rules, no folding semantics worth modeling.
StreamLanguage's character-stream API maps directly onto the regex
grammar we already designed for v1. Lezer's incremental + structure-aware
machinery has nothing here to act on; deferred as a future-work upgrade
only if richer features land.
Autocomplete
cm6's @codemirror/autocomplete (autocompletion() extension) wired
to a CompletionSource:
- On every keystroke, fetch the word fragment at the caret via
context.matchBefore(/[A-Za-z0-9_]{2,}/). - Return
{from, options}whereoptionsis an array of{label, info}from case-insensitive prefix → substring match against the active language's vocab. - Sort: prefix matches first (shortest first), substring matches second; cap at 50; render top 8.
cm6 handles popup positioning, keybindings (↑/↓ navigate, Enter accept, Esc dismiss), and rendering. We do not re-implement the popup — the v1 attempt's hand-rolled popup was exactly the layer this architecture eliminates.
Tab is bound to "accept" via a custom keymap entry; Enter likewise.
Both override the defaults (insert tab, insert newline) only when
the popup is open — cm6's acceptCompletion command returns false
when the popup is closed, falling back to the default key behavior.
Vocabulary
l4d2web/scripts/build-vocab.py reads the existing repo-root file
cvar_list (~230 KB, 2196 entries, columnar dump from a live L4D2
server with the project's SourceMod plugins loaded). Parser:
-
Skip the 2-line header (
cvar list+ divider). -
Split each row on
:with limit 3 — descriptions sometimes contain:characters. -
Categorize: rows with
cmdin column 2 →commands; others →cvars. -
Write
l4d2web/l4d2web/static/data/srccfg-vocab.json:{ "version": 1, "generated_from": "cvar_list", "cvars": [{"name": "sv_cheats", "desc": "Allow cheat cvars (0/1)"}, …], "commands": [{"name": "exec", "desc": "Execute a .cfg file"}, …] }
Idempotent. Header comment records the source-file SHA + regeneration command.
Lazy fetch in editor.js on first srccfg editor mount; cached on
window.__srccfgVocab so multiple editors on the same page share the
load.
No engine-internal trimming in v1. Prefix-match naturally surfaces what users actually type; the file ends up ~160 KB JSON which is well within budget. Trim pass available as future work if it ever matters.
Theme
Two cm6 themes built with EditorView.theme({...}, {dark: …}),
authored under editor-src/theme-{light,dark}.js. Both keyed to CSS
custom properties:
:root {
--cm-bg: var(--bg-surface);
--cm-fg: var(--fg-primary);
--cm-keyword: var(--syntax-keyword);
--cm-string: var(--syntax-string);
--cm-comment: var(--syntax-comment);
--cm-number: var(--syntax-number);
--cm-selection: var(--bg-selection);
/* … */
}
The site has no explicit light/dark toggle today; tokens.css:38
already uses @media (prefers-color-scheme: dark) to swap palette
variables OS-driven. The editor follows the same model: cm6's active
theme swapped via a Compartment driven by
window.matchMedia('(prefers-color-scheme: dark)') (with a change
listener for live OS-level swaps). tokens.css gains the syntax-color
variables that v1 added (and f14d352 removed); we re-introduce them
inside both the default block and the prefers-color-scheme: dark
block, exposed to cm6 via editor.css.
Build pipeline (new in this codebase)
The first JS build step in the repo. Lives in l4d2web/scripts/,
runs locally on demand. Outputs commit to static/vendor/. No CI
integration (the repo has no CI today); deploys are still
git pull-shaped.
l4d2web/scripts/
build-editor.sh # bash: cd into editor-src/; npm ci; npx esbuild …
build-vocab.py # python: parse ../../cvar_list → srccfg-vocab.json
editor-src/
package.json # cm6 deps, pinned
package-lock.json
editor-entry.js # imports + façade
srccfg-mode.js # StreamLanguage.define({ token(stream) { … } })
theme-light.js
theme-dark.js
README.md # build instructions, dep list, SHA recording
editor-src/node_modules/ is .gitignored. Dev rebuild needs only
node + npm. Bundle outputs land at
l4d2web/l4d2web/static/vendor/editor.bundle.{js,css} with
editor.bundle.sha256 for integrity.
CSP & asset layout
CSP today is strict: default-src 'self'; script-src 'self' 'nonce-…'
(l4d2web/l4d2web/app.py:101). All editor assets self-hosted; all
<script> tags carry nonce="{{ g.csp_nonce }}". No third-party
origins.
l4d2web/l4d2web/static/
vendor/
editor.bundle.js # cm6 + extensions + srccfg-mode + themes, IIFE
editor.bundle.css # cm6 base styles (extracted by esbuild)
editor.bundle.sha256
README.md # versions, build command, integrity hashes
css/
editor.css # CSS-variable bridge: tokens.css → cm6 classes
tokens.css # gains --syntax-* + --cm-* variables (light/dark)
js/
editor.js # un-bundled glue
data/
srccfg-vocab.json # generated, committed
l4d2web/l4d2web/templates/
_editor_assets.html # nonce'd <link>/<script> tags
Files touched
| File | Change |
|---|---|
l4d2web/l4d2web/templates/blueprint_detail.html |
Add data-editor-language="srccfg" to the config textarea (line 52); include _editor_assets.html partial |
l4d2web/l4d2web/templates/overlay_detail.html |
Add data-editor-language="bash" to script textarea (line 25); add data-editor-language="auto" + language <select> to files-editor textarea (line 178); include partial |
l4d2web/l4d2web/templates/_editor_assets.html |
New Jinja partial with nonce'd asset tags |
l4d2web/l4d2web/static/vendor/editor.bundle.{js,css} |
New, committed build artifacts |
l4d2web/l4d2web/static/vendor/editor.bundle.sha256 |
New, integrity record |
l4d2web/l4d2web/static/vendor/README.md |
New, build command + dep versions + SHAs |
l4d2web/l4d2web/static/js/editor.js |
New ~100 LOC: mount loop + submit-capture handler + files-modal façade |
l4d2web/l4d2web/static/css/editor.css |
New CSS-variable bridge |
l4d2web/l4d2web/static/css/tokens.css |
Add --syntax-* and --cm-* light/dark variables |
l4d2web/l4d2web/static/data/srccfg-vocab.json |
New, generated, committed |
l4d2web/l4d2web/static/js/files-overlay.js |
5 reads of editorEls.contentBox.value (lines 289, 464, 479, 494, 511) → window.__filesEditor.getValue(); 3 writes (lines 345, 378, 385) → window.__filesEditor.setContent(…). Language dropdown calls window.__filesEditor.setLanguage(…). |
l4d2web/scripts/build-editor.sh |
New, bundle build script |
l4d2web/scripts/build-vocab.py |
New, cvar_list → JSON parser |
l4d2web/scripts/editor-src/ |
New: package.json, package-lock.json, editor-entry.js, srccfg-mode.js, theme-{light,dark}.js, .gitignore excluding node_modules/ |
l4d2web/tests/test_blueprints.py, l4d2web/tests/test_script_overlay_routes.py |
Extend: assert editor markup in GET responses; assert form POST contract preserved |
l4d2web/tests/e2e/test_editor.py |
New Playwright: type→popup→Tab-accept→assert value; plus copy regression gate |
AGENTS.md |
Add node + npm as dev dependency for editor rebuilds |
cvar_list |
Untouched input; documented in srccfg-vocab.json header |
Untouched by design: l4d2web/l4d2web/blueprint_routes.py, all DB
code, all route code. The form-POST contract and the
fetch('/files/save') JSON contract are unchanged.
Test strategy
Three layers, shallow to deep:
- Form-contract pytest — verify the form POST shape is identical
to today's: same field names, same accepted values, same redirect
behavior. The textarea remains the named form field (just hidden);
the submit-capture handler is what produces its value. Pre-write
these before bundle work; they pin the contract so the obvious
failure mode of submit-time copy (missing the submit event,
handler order issue, form-without-
<form>) surfaces immediately. - HTML-assertion pytest — verify GET on each detail page contains
the expected
data-editor-language="…"attribute and the editor asset block. Cheap, no browser. - Playwright e2e (
test_editor.py) — the canonical gate from the handoff doc:- Login as
dev/devdevdev. - Visit
/blueprints/1(seeded byscripts/dev-server.py). - Type
sv_chein the editor. - Assert the autocomplete popup appears with
sv_cheats. - Press Tab.
- Assert the hidden textarea's value, after dispatching a
submitevent on the form, containssv_cheats. - Submit form; assert DB row reflects the value.
- Copy regression gate (bug class 1 from v1): select multiple lines, dispatch a copy event, assert clipboard content has line breaks. cm6 handles this correctly out of the box; the test guards against future regressions.
- Login as
Verification
# 1. Bundle build (one-time per dep change)
cd l4d2web/scripts/editor-src && npm ci && cd ..
./build-editor.sh # produces static/vendor/editor.bundle.*
./build-vocab.py # produces static/data/srccfg-vocab.json
# 2. Local smoke
./scripts/dev-server.py # http://127.0.0.1:5051 (dev / devdevdev)
# Walk through: /blueprints/1, /overlays/1, /overlays/2 (files modal)
# 3. Fast suite
uv run pytest # excludes -m e2e
# 4. Browser suite (one-time `uv run playwright install chromium`;
# Bash needs dangerouslyDisableSandbox: true because Chromium's
# Mach-port IPC is sandbox-blocked)
uv run pytest -m e2e
Open / Closed
- Closed in v2: line numbers, in-editor search, multi-cursor,
bracket matching, theme switching beyond the global toggle,
SourcePawn highlighting, bash buffer-context autocomplete,
per-server live
cvarlistcapture, vocab trimming. - Open as additive future work: line numbers (cm6 ships them as
a one-line
import + extension); search (cm6 built-in); multi-cursor (cm6 built-in); SourcePawn grammar; per-server cvar augmentation; Lezer-based srccfg grammar if structure-aware features ever land.
cm6 brings several closed-in-v2 features as ~5-LOC additions; that is itself an argument for the chosen architecture, but explicitly not shipped here. The v1 spec's stance on scope still holds: ship the minimum surface, validate the architecture, accrete features later.
Reusable patterns referenced
l4d2web/l4d2web/static/js/blueprint-overlay-picker.js— progressive enhancement via DOM-manipulated form fields. This editor follows the same pattern: native form serialization, no submit interception except the synchronous textarea-value-copy step.l4d2web/l4d2web/app.py:86—g.csp_nonceaccessor for template<script>tags.l4d2web/l4d2web/static/js/files-overlay.js— JSON-fetch save path. Continues to drive the save; reads/writes route through the editor façade instead of directly hittingtextarea.value.