Compare commits

..

No commits in common. "704e4cdfd187c70b277c01f82cc532de9b99ee1e" and "f14d352657b10fc7bc67b0580089452f0d8b61e6" have entirely different histories.

29 changed files with 20 additions and 13490 deletions

View file

@ -107,35 +107,12 @@ chromium binary, fetched on first setup:
uv run playwright install chromium
```
Always invoke as `uv run pytest -m e2e ...` (excluded from the default
fast suite via the `e2e` marker). Other forms crash Chromium under the
macOS sandbox; only this exact invocation is exempt.
Run with `uv run pytest -m e2e`. Excluded from the default fast suite
via the `e2e` marker.
## Editor bundle (CodeMirror 6)
The in-browser code editor on the blueprint config / overlay script /
files-modal textareas is bundled from `l4d2web/scripts/editor-src/`
via esbuild and committed pre-built to
`l4d2web/l4d2web/static/vendor/editor.bundle.js`. Source lives under
`l4d2web/scripts/editor-src/`; design and plan at
`docs/superpowers/specs/2026-05-17-textarea-editor-v2-design.md` and
`docs/superpowers/plans/2026-05-17-textarea-editor-v2.md`.
Rebuild after editing the source:
```bash
./l4d2web/scripts/build-editor.sh
```
Requires `node` + `npm` locally. The script overrides the npm cache to
`$TMPDIR/npm-cache` (set `NPM_CACHE` to override) to dodge root-owned
files in `~/.npm/_cacache/` from older npm versions. Commit the
regenerated `editor.bundle.js`, `editor.bundle.css`, and
`editor.bundle.sha256` alongside any source change.
Regenerate the autocomplete vocab from `./cvar_list` (live L4D2
cvarlist dump committed at repo root) after replacing the dump:
```bash
./l4d2web/scripts/build-vocab.py
```
**Sandbox note:** Chromium needs Mach-port IPC on macOS, which the
Claude Code sandbox blocks. When running e2e tests from a sandboxed
agent session, pass `dangerouslyDisableSandbox: true` on the
`uv run pytest -m e2e` invocation (the symptom of a sandboxed run is
a `FATAL` Chromium crash with `Permission denied (1100)` on Mach port
rendezvous, not a missing-binary or network error).

2198
cvar_list

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,245 +0,0 @@
# Textarea code editor — handoff after rollback
> Follow-up artifact after a 35-commit attempt at a syntax-highlighting
> + autocomplete editor (CodeJar + Prism over `contenteditable`) was
> rolled back in commit `f14d352`. This is **not an implementation plan**
> — it's a brief for the next brainstorming session to pick a different
> architecture and produce a new spec. The "Don't restart this cycle"
> note below is load-bearing.
## Context
We shipped a 12-task implementation of an in-browser code editor that
upgraded three `<textarea>` fields in `l4d2web`:
- Blueprint config (Source-engine cvar syntax, with cvar autocomplete)
- Overlay bash script
- Files-editor modal (auto-detect language from filename + dropdown override)
The architecture: vanilla JS, no bundler, CSP-self-hosted. CodeJar
(~3 KB) provided the editor shell over a `contenteditable` element;
Prism (~25 KB, custom build of core + clike + bash) provided syntax
highlighting. A small custom Prism grammar covered Source-engine
`.cfg` syntax. Autocomplete was a ~150-line popup widget we wrote
ourselves, backed by a curated 30-cvar + 8-command JSON vocabulary.
Every code-review iteration caught real correctness bugs that we
addressed (capture-phase keydown so CodeJar's Tab handler didn't eat
the popup-accept; popup leak on destroy; async race in vocab loader;
try/catch on the Range manipulation; theme-aware syntax-token CSS
variables). Then a live inspection session surfaced four further
classes of bug that we couldn't address without more whack-a-mole:
1. **Copy collapses multi-line selections to one line.**
2. **Enter sometimes needs two presses + cursor color shifts.**
3. **Auto-completion fails silently at end-of-line** (the caret sits
in a post-Prism-`<span>` text node, so the Range-API subtraction
goes negative).
4. **Manual caret save/restore is required around any
`updateCode` call** — and even with that, the caret can land
"between" sibling nodes where browsers render it differently.
All four are characteristic of `contenteditable` + manual Range API
manipulation against tokenized DOM. The architecture was wrong for
the goal. Rolling back to the editor-free baseline was the right call;
each new fix was reinforcing a sunk-cost path.
## What's in the repo now
### Removed (in commit `f14d352`)
- `l4d2web/l4d2web/static/js/editor.js`
- `l4d2web/l4d2web/static/js/srccfg-grammar.js`
- `l4d2web/l4d2web/static/css/editor.css`
- `l4d2web/l4d2web/static/vendor/{prism.js,prism.css,codejar.js,README.md}`
- `l4d2web/l4d2web/static/data/srccfg-vocab.json`
- `l4d2web/l4d2web/templates/_editor_assets.html`
- `l4d2web/tests/e2e/test_editor.py`
- The `data-editor-language` attributes and partial includes on
`blueprint_detail.html`, `overlay_detail.html`
- The files-overlay.js editor bridge (`setEditorContent`, dropdown
listener, filename auto-redetect)
- The create-blueprint modal trim (Arguments + Config textareas restored)
- The four syntax-color tokens added to `tokens.css`
- Three form-contract tests in `test_blueprints.py` /
`test_script_overlay_routes.py`
### Kept (informs the next attempt)
- `docs/superpowers/specs/2026-05-16-textarea-code-editor-design.md`
the original design spec. Still useful as the **functional**
requirements (which textareas to upgrade, what autocomplete should
do, what languages to support). The architectural choices in there
(CodeJar, Prism) are stale; the requirements are not.
- `docs/superpowers/plans/2026-05-16-textarea-code-editor.md` — the
12-task plan. Stale architecturally; useful as a reference for the
testing strategy (form-contract assertions, Playwright e2e shape).
- `scripts/dev-server.py` — boots Flask locally with all the
production-equivalent env vars (DATABASE_URL, LEFT4ME_ROOT,
SESSION_COOKIE_SECURE=0, JOB_WORKER_ENABLED=false). Auto-seeds an
admin user (`dev`/`devdevdev`) plus a blueprint, two overlays, and a
server on first run so the next iteration can be inspected
immediately at `/blueprints/1`, `/overlays/1`, `/overlays/2`, and
`/servers/1`.
- `l4d2web/tests/e2e/{__init__.py,conftest.py,test_smoke.py}`
Playwright scaffolding. The `live_server` fixture in `conftest.py`
boots the Flask app on an ephemeral port with a temp SQLite DB,
seeds a user + blueprint, yields `{base_url, user_id, blueprint_id}`.
Ready to receive a new `test_editor.py` against whatever shape the
next editor takes.
- `pyproject.toml``playwright>=1.49.0` + `pytest-playwright>=0.6.0`
in `[dependency-groups].dev`, `e2e` marker registered,
`addopts = ["-m", "not e2e"]` so the default fast suite excludes
browser tests.
- `AGENTS.md` — documents `uv run playwright install chromium` for
first-time setup, plus the Claude Code sandbox + Chromium Mach-port
workaround.
## The four bugs (so the next attempt can sanity-check itself)
These are the failure modes that surfaced. The next architecture
should either *avoid the class of bug entirely* or *have evidence of
having solved it in the chosen library*.
1. **Selection.toString() collapses across span topology.** Prism's
tokenized output emits `<span>for</span>` `<span>vpk</span>`
`<span>in</span>` … with newlines either inside spans or as bare
sibling text nodes. The browser's copy implementation walks the
selection via `Selection.toString()` and doesn't reliably
reconstruct newlines across that mix. Workarounds need a `copy`
event handler that overrides the clipboard payload from the
editor's *source text*, not the DOM walk.
2. **Caret can land "between" sibling DOM nodes.** When the caret is
at end-of-line or between two adjacent tokenized spans, the
browser's selection has `anchorNode === <code>` (the parent
element) and `anchorOffset` indexing into its children — NOT a
text node. Most range-manipulation code assumes text-node
containers and fails (silently or loudly) in this state. The
visual cue is a different-color caret.
3. **Manual `Range.setStart(endContainer, endOffset - N)` is unsafe.**
It assumes the trailing N characters live in `endContainer`. When
the fragment is *upstream* of the caret's actual text node (case
2), the subtraction goes negative → `IndexSizeError`. We worked
around it with `Selection.modify('extend', 'backward', 'character')`
but the underlying fragility persists.
4. **CodeJar's `updateCode(text)` does not preserve caret.** It does
`editor.textContent = code; highlight(editor)` and returns; the
caret resets to start. Manual `jar.save()` + `jar.restore(pos)` is
required around it. Even then, `save()` has a special case at
`codejar.js:122-127` that returns end-of-all-text if the selection
sits in the editor element rather than a text node — interacting
badly with case 2.
In aggregate, these are the bugs a *generic* contenteditable wrapper
inherits. A mature library (CodeMirror 6) has invested years in
handling them. A textarea-overlay approach avoids them by construction
because the editing surface is a real `<textarea>`.
## Decision pending
Two finalists for the next attempt:
| Concern | Option 2: CodeMirror 6 | Option 3: Textarea-overlay |
|---|---|---|
| Bundler aversion | adds a one-time `npm install` + bundle step | no bundler |
| Editing fluency (selections, IME, mobile, copy/paste) | strong (years of contenteditable hardening) | strong (native `<textarea>`) |
| IDE features (line numbers, search, multi-cursor) | strong, built-in or import-away | basic only; you'd build them |
| Autocomplete popup positioning | trivial (built-in `@codemirror/autocomplete`) | medium (mirror-div trick for caret pixel coords; well-known recipe, ~30 LOC) |
| Risk of recurring contenteditable bugs | low (handled) | none (no contenteditable) |
| Bundle weight | ~150 KB gzipped | ~10 KB Prism + ~80 LOC overlay JS |
| Source-cfg language | Lezer parser (more correct, more work) | Prism regex grammar (we already wrote one) |
| Long-term maintenance | high (heavy dep, but stable) | low (vanilla; font-metric edge cases) |
### Recommendation (advisory only)
**Option 2 (CodeMirror 6)** — the contenteditable bugs we just hit are
exactly what CodeMirror 6 was built to solve, and the "no bundler"
constraint we set originally cost us more in whack-a-mole than the
one-time build step would have. But Option 3 is also defensible and
carries zero contenteditable risk.
Don't pre-commit to either before the brainstorming session
re-examines the requirements; this is just the controller's lean
after living through the failure.
## Inputs for the next session
When you start brainstorming the next attempt, load these:
```
docs/superpowers/specs/2026-05-16-textarea-code-editor-design.md
Original requirements (still valid)
docs/superpowers/plans/2026-05-16-textarea-code-editor.md
Original plan (architecturally stale, useful for testing strategy)
docs/superpowers/specs/2026-05-17-textarea-editor-handoff.md
This document
```
Useful runtime entry points:
```bash
# Local smoke (Flask + seeded demo content)
./scripts/dev-server.py
# Then visit http://127.0.0.1:5051/login as dev / devdevdev
# Fast test suite (excludes e2e)
uv run pytest
# E2E suite (needs `playwright install chromium` once; pass
# dangerouslyDisableSandbox: true on Claude Code's Bash tool because
# Chromium's Mach-port IPC is blocked by the sandbox)
uv run pytest -m e2e
```
The dev-server seeds (idempotent on `.tmp/dev-server` being clean):
- Blueprint `id=1` `demo-srccfg` — has Source-engine `.cfg` content
ready to highlight
- Script overlay `id=1` `demo-bash` — has example bash for highlighting
- Files overlay `id=2` `demo-files` — has `test.cfg` ready to open in
the files-editor modal
- Server `id=1` `demo-server` linked to the blueprint
## Open questions for the brainstorming session
1. **Decision: Option 2 or Option 3.** Don't skip this — it shapes
everything downstream.
2. If **Option 2**: where does the `npm install` + `esbuild` step
live? `l4d2web/scripts/build-editor.sh`? A `Makefile` target?
How is the bundle's provenance recorded (SHA256s, as we did with
prism/codejar)? Does the bundle commit to `static/vendor/` or
gets generated on demand?
3. If **Option 2**: which CodeMirror packages exactly? Minimum useful
set is probably `@codemirror/state`, `@codemirror/view`,
`@codemirror/commands`, `@codemirror/lang-bash`, `@codemirror/autocomplete`,
plus a custom Source-cfg StreamLanguage or Lezer grammar.
4. If **Option 3**: how does the autocomplete popup re-position on
window scroll / page resize? Cross-browser font-metric quirks
(Safari vs Chrome vs Firefox) — are we OK testing only Chromium?
5. **Form-contract testing**: the form-POST contract for the blueprint
config / overlay script doesn't change regardless of which option
we pick. Pre-write the form-contract tests against the textarea's
eventual hidden/upgraded state so we know the contract holds early.
6. **Playwright e2e shape**: the harness is ready. The test that
types `sv_che`, asserts the popup, accepts via Tab, asserts the
textarea value — should be re-usable verbatim against either
approach. Use it as the gate.
## Don't restart this cycle
The temptation will be to try a third contenteditable variant ("maybe
if we just add a different library on top of contenteditable…").
**Resist.** The bug classes we hit are characteristic of
contenteditable + tokenized DOM, not specific to CodeJar. A new
contenteditable wrapper that hasn't been hardened against years of
edge cases will reproduce the same bugs.
The two options that survive this constraint:
- A library that *has* been hardened (CodeMirror 6).
- An approach that *doesn't use* contenteditable at all (textarea-overlay).
Both are real options. Pick one.

View file

@ -1,396 +0,0 @@
# 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`](./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:
1. **HTML form POSTs** (`blueprint_detail.html`, script overlay):
`editor.js` installs a capture-phase `submit` listener on the
enclosing `<form>` that does
`textarea.value = controller.getValue()` exactly once per submission.
2. **JSON-save fetch** (files-editor modal): `files-overlay.js` calls
`controller.getValue()` at save time instead of reading
`textarea.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`:
```js
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:
1. `document.querySelectorAll('textarea[data-editor-language]')`
2. For each: hide the textarea, mount the controller, register a single
capture-phase `submit` handler on the enclosing `<form>` (if any)
that calls `textarea.value = controller.getValue()`. Forms with
multiple editors share one listener.
3. Files-editor modal: expose a named alias
`window.__filesEditor` pointing at the controller mounted on the
modal's textarea, so `files-overlay.js` can call `.getValue()` at
save time and `.setContent(text)` on file-open without touching
the textarea directly.
4. If `window.__editor` is 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`:
1. On every keystroke, fetch the word fragment at the caret via
`context.matchBefore(/[A-Za-z0-9_]{2,}/)`.
2. Return `{from, options}` where `options` is an array of
`{label, info}` from case-insensitive prefix → substring match
against the active language's vocab.
3. 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 `cmd` in column 2 → `commands`; others → `cvars`.
- Write `l4d2web/l4d2web/static/data/srccfg-vocab.json`:
```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:
```css
: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 `.gitignore`d. 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:
1. **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.
2. **HTML-assertion pytest** — verify GET on each detail page contains
the expected `data-editor-language="…"` attribute and the editor
asset block. Cheap, no browser.
3. **Playwright e2e (`test_editor.py`)** — the canonical gate from the
handoff doc:
- Login as `dev`/`devdevdev`.
- Visit `/blueprints/1` (seeded by `scripts/dev-server.py`).
- Type `sv_che` in the editor.
- Assert the autocomplete popup appears with `sv_cheats`.
- Press Tab.
- Assert the hidden textarea's value, after dispatching a `submit`
event on the form, contains `sv_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.
## Verification
```bash
# 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 `cvarlist` capture, 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_nonce` accessor 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 hitting `textarea.value`.

View file

@ -1,19 +0,0 @@
/* Editor (CodeMirror 6) shell styling. Token / gutter / selection colors
* are set inside cm6 themes (themes.js) bound to the --cm-* variables
* in tokens.css; this file scopes the editor container's chrome to
* match the rest of the app. */
.cm-editor {
border: var(--line);
border-radius: var(--radius-s);
min-height: 8em;
}
.cm-editor.cm-focused {
outline: 2px solid var(--color-focus);
outline-offset: -2px;
}
textarea[data-editor-language] + .cm-editor {
width: 100%;
}

View file

@ -33,19 +33,6 @@
background to read. Don't redefine these in the dark-mode block. */
--color-button-primary: #1d4ed8;
--color-button-danger: #b42318;
/* Editor (CodeMirror 6) palette — light. */
--syntax-keyword: #cc4488;
--syntax-string: #2f8b3a;
--syntax-comment: #888;
--syntax-number: #884488;
--cm-bg: var(--color-surface);
--cm-fg: var(--color-text);
--cm-selection: rgba(60, 130, 220, 0.2);
--cm-keyword: var(--syntax-keyword);
--cm-string: var(--syntax-string);
--cm-comment: var(--syntax-comment);
--cm-number: var(--syntax-number);
}
@media (prefers-color-scheme: dark) {
@ -64,14 +51,6 @@
--color-focus: #bfdbfe;
--color-log-bg: #111827;
--color-log-text: #e5e7eb;
/* Editor (CodeMirror 6) palette dark overrides. --cm-bg / --cm-fg
cascade automatically through --color-surface / --color-text. */
--syntax-keyword: #ff80c0;
--syntax-string: #87d96a;
--syntax-comment: #888;
--syntax-number: #c890ff;
--cm-selection: rgba(120, 170, 255, 0.25);
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,84 +0,0 @@
// Un-bundled. Driven by data-editor-language attrs on <textarea>.
// Mounts cm6 (from editor.bundle.js exporting window.__editor),
// installs one capture-phase submit handler per <form>, and exposes
// a named alias for the files-editor modal.
(function () {
"use strict";
if (!window.__editor || typeof window.__editor.mount !== "function") {
return; // bundle didn't load — graceful no-JS fallback
}
let vocabPromise = null;
function loadSrccfgVocab() {
if (!vocabPromise) {
vocabPromise = fetch("/static/data/srccfg-vocab.json", { credentials: "same-origin" })
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
.catch(err => { console.warn("[editor] vocab load failed", err); return null; });
}
return vocabPromise;
}
function resolveAutoLanguage(filenameInput) {
const name = (filenameInput && filenameInput.value || "").toLowerCase();
if (name.endsWith(".cfg")) return "srccfg";
if (name.endsWith(".sh")) return "bash";
return "plain";
}
async function mountOne(textarea) {
let lang = textarea.getAttribute("data-editor-language") || "plain";
let filenameInput = null;
let dropdown = null;
if (lang === "auto") {
const modal = textarea.closest("#files-editor-modal") || document;
filenameInput = modal.querySelector("[data-editor-filename]");
dropdown = modal.querySelector("[data-editor-language-select]");
lang = resolveAutoLanguage(filenameInput);
}
const vocab = (lang === "srccfg") ? await loadSrccfgVocab() : null;
const controller = window.__editor.mount(textarea, { language: lang, vocab });
// Submit-time copy bridge
const form = textarea.closest("form");
if (form && !form.__editorSubmitBound) {
form.__editorSubmitBound = true;
form.addEventListener("submit", () => {
for (const ta of form.querySelectorAll("textarea[data-editor-language]")) {
if (ta.__editorController) ta.value = ta.__editorController.getValue();
}
}, true /* capture phase */);
}
textarea.__editorController = controller;
// Files-modal hooks
if (textarea.classList.contains("files-editor-content")) {
window.__filesEditor = controller;
if (dropdown) {
dropdown.addEventListener("change", () => {
const v = dropdown.value;
controller.setLanguage(v === "auto" ? resolveAutoLanguage(filenameInput) : v);
});
}
if (filenameInput) {
filenameInput.addEventListener("input", () => {
if (!dropdown || dropdown.value === "auto") {
controller.setLanguage(resolveAutoLanguage(filenameInput));
}
});
}
}
}
function init() {
for (const ta of document.querySelectorAll("textarea[data-editor-language]")) {
mountOne(ta).catch(err => console.error("[editor] mount failed", err));
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View file

@ -281,29 +281,12 @@
saveBtn: editorDialog.querySelector(".files-editor-save"),
};
// Bridge to the CodeMirror 6 controller, set up by static/js/editor.js
// on the .files-editor-content textarea. Falls back to the textarea
// directly if the bundle didn't load (no-JS fallback / file open
// before the controller has been mounted).
function getEditorValue() {
return (window.__filesEditor && window.__filesEditor.getValue)
? window.__filesEditor.getValue()
: editorEls.contentBox.value;
}
function setEditorValue(text) {
if (window.__filesEditor && window.__filesEditor.setContent) {
window.__filesEditor.setContent(text);
} else {
editorEls.contentBox.value = text;
}
}
function setEditorTitle(text) {
editorEls.title.textContent = text;
}
function updateByteCount() {
const bytes = new TextEncoder().encode(getEditorValue()).length;
const bytes = new TextEncoder().encode(editorEls.contentBox.value).length;
editorEls.byteCount.textContent = `UTF-8 · ${bytes} bytes`;
}
@ -359,7 +342,7 @@
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
editorEls.filename.value = "";
editorEls.filename.disabled = false;
setEditorValue("");
editorEls.contentBox.value = "";
editorEls.contentBox.disabled = false;
editorEls.renameHint.hidden = true;
editorEls.textPanel.hidden = false;
@ -392,14 +375,14 @@
editor.mode = "text";
editorEls.textPanel.hidden = false;
editorEls.binaryPanel.hidden = true;
setEditorValue("Loading…");
editorEls.contentBox.value = "Loading…";
editorEls.contentBox.disabled = true;
const r = await fetchJson(
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
);
if (r.ok && r.body) {
setEditorValue(r.body.content);
editorEls.contentBox.value = r.body.content;
editorEls.contentBox.disabled = false;
updateByteCount();
updateSaveEnabled();
@ -478,7 +461,7 @@
// Text-flavor create → /save with no new_path.
const r = await postJson(`${baseUrl}/files/save`, {
path: newRel,
content: getEditorValue(),
content: editorEls.contentBox.value,
});
if (r.ok) {
editorDialog.close();
@ -493,7 +476,7 @@
// For files, a plain /save overwrite is fine.
const r2 = await postJson(`${baseUrl}/files/save`, {
path: newRel,
content: getEditorValue(),
content: editorEls.contentBox.value,
});
if (r2.ok) {
editorDialog.close();
@ -508,7 +491,7 @@
const altered = withCollisionSuffix(newRel);
const r2 = await postJson(`${baseUrl}/files/save`, {
path: altered,
content: getEditorValue(),
content: editorEls.contentBox.value,
});
if (r2.ok) {
editorDialog.close();
@ -525,7 +508,7 @@
if (editor.mode === "text") {
const payload = {
path: editor.originalPath,
content: getEditorValue(),
content: editorEls.contentBox.value,
};
if (renaming) payload.new_path = newRel;
const r = await postJson(`${baseUrl}/files/save`, payload);

View file

@ -1,32 +0,0 @@
# Editor bundle vendor README
`editor.bundle.js` is a pre-built IIFE produced by esbuild from
`l4d2web/scripts/editor-src/`. It exposes `window.__editor.mount(textarea, opts)`.
## Rebuild
From repo root:
```
./l4d2web/scripts/build-editor.sh
```
This runs `npm install` inside `editor-src/` then `npx esbuild`. The
output overwrites `editor.bundle.js` and `editor.bundle.css` in this
directory and refreshes `editor.bundle.sha256`.
The build script uses `$TMPDIR/npm-cache` (override with the
`NPM_CACHE` env var) as the npm cache to avoid permission issues with
root-owned files in `~/.npm/_cacache/` from older npm versions.
## Pinned dependencies
See `l4d2web/scripts/editor-src/package.json` for semver ranges and
`package-lock.json` for the exact resolved versions. Run
`npm outdated` inside `editor-src/` to see upgrade candidates.
## Integrity
`editor.bundle.sha256` contains the hashes of the committed bundle.
If the bundle drifts from this hash in CI / review, the artifact was
rebuilt without committing the updated bundle.

File diff suppressed because one or more lines are too long

View file

@ -1,2 +0,0 @@
6700b694fe25837f52e77c780d88f3eb5aef2a1591dc461c26efa3fa9724290b editor.bundle.js
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css

View file

@ -1,5 +0,0 @@
{# Editor assets — include on any page that mounts a <textarea data-editor-language>. #}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/editor.bundle.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
<script src="{{ url_for('static', filename='vendor/editor.bundle.js') }}" nonce="{{ g.csp_nonce }}" defer></script>
<script src="{{ url_for('static', filename='js/editor.js') }}" nonce="{{ g.csp_nonce }}" defer></script>

View file

@ -49,7 +49,7 @@
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
{% endfor %}</pre>
{% endif %}
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
</div>
</label>
<button type="submit">Save blueprint</button>
@ -92,5 +92,4 @@
</div>
</dialog>
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
{% include "_editor_assets.html" %}
{% endblock %}

View file

@ -22,7 +22,7 @@
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
<label>Bash script
<textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea>
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
</label>
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
{% if not latest_build_is_running %}
@ -168,23 +168,14 @@
<div class="modal-body">
<label class="files-editor-field">
<span class="files-field-label">Filename</span>
<input type="text" class="files-editor-filename" data-editor-filename autocomplete="off" spellcheck="false">
<input type="text" class="files-editor-filename" autocomplete="off" spellcheck="false">
</label>
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code><code class="files-rename-to"></code>.</p>
<div class="files-editor-text">
<label class="files-editor-field files-editor-language-field">
<span class="files-field-label">Language</span>
<select data-editor-language-select aria-label="Editor language">
<option value="auto">auto (from filename)</option>
<option value="srccfg">srccfg (.cfg)</option>
<option value="bash">bash (.sh)</option>
<option value="plain">plain</option>
</select>
</label>
<label class="files-editor-field">
<span class="files-field-label">Content</span>
<textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea>
<textarea class="files-editor-content" rows="14" spellcheck="false"></textarea>
</label>
<div class="files-editor-meta muted">
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
@ -282,5 +273,4 @@
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
{% endif %}
{% include "_editor_assets.html" %}
{% endblock %}

View file

@ -1,33 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SRC="$HERE/editor-src"
OUT="$HERE/../l4d2web/static/vendor"
cd "$SRC"
# Honor an existing $NPM_CACHE override; fall back to $TMPDIR if the
# default ~/.npm cache is unwritable (root-owned files from older npm
# versions are a common cause; see ~/.npm/_logs).
NPM_CACHE="${NPM_CACHE:-$TMPDIR/npm-cache}"
npm install --cache "$NPM_CACHE"
npx esbuild editor-entry.js \
--bundle --minify \
--format=iife \
--global-name=__editor_pkg \
--outfile="$OUT/editor.bundle.js" \
--metafile=meta.json \
--loader:.css=text
# cm6 injects its styles at runtime via the StyleModule machinery, so the
# bundle does not produce a separate .css file. We create an empty
# editor.bundle.css so the partial template's <link> tag points at
# something concrete (and future extensions that produce real CSS can
# drop it in without a template change).
: > "$OUT/editor.bundle.css"
(cd "$OUT" && shasum -a 256 editor.bundle.js editor.bundle.css > editor.bundle.sha256)
echo "Built $OUT/editor.bundle.js ($(wc -c < "$OUT/editor.bundle.js") bytes)"

View file

@ -1,59 +0,0 @@
#!/usr/bin/env python3
"""Convert ./cvar_list (cvarlist dump) to static/data/srccfg-vocab.json.
Usage: ./l4d2web/scripts/build-vocab.py
Reads from the repo root (auto-detected via this script's path).
Writes idempotently. Run after regenerating cvar_list."""
from __future__ import annotations
import hashlib
import json
import pathlib
import re
import sys
HERE = pathlib.Path(__file__).resolve().parent
REPO_ROOT = HERE.parent.parent
SOURCE = REPO_ROOT / "cvar_list"
DEST = REPO_ROOT / "l4d2web" / "l4d2web" / "static" / "data" / "srccfg-vocab.json"
def parse(text: str) -> tuple[list[dict], list[dict]]:
cvars: list[dict] = []
commands: list[dict] = []
for raw in text.splitlines()[2:]: # skip "cvar list" + "--------…"
if not raw.strip():
continue
parts = [p.strip() for p in re.split(r"\s*:\s*", raw, maxsplit=3)]
if len(parts) < 3:
continue
name = parts[0]
value = parts[1]
desc = parts[3] if len(parts) >= 4 else ""
target = commands if value == "cmd" else cvars
target.append({"name": name, "desc": desc} if desc else {"name": name})
return cvars, commands
def main() -> int:
if not SOURCE.exists():
print(f"ERROR: {SOURCE} not found", file=sys.stderr)
return 1
text = SOURCE.read_text(encoding="utf-8", errors="replace")
cvars, commands = parse(text)
src_sha = hashlib.sha256(text.encode("utf-8")).hexdigest()
payload = {
"version": 1,
"generated_from": "cvar_list",
"source_sha256": src_sha,
"cvars": cvars,
"commands": commands,
}
DEST.parent.mkdir(parents=True, exist_ok=True)
DEST.write_text(json.dumps(payload, indent=2, sort_keys=False) + "\n", encoding="utf-8")
print(f"wrote {DEST}: {len(cvars)} cvars + {len(commands)} commands")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,2 +0,0 @@
node_modules/
meta.json

View file

@ -1,50 +0,0 @@
import { autocompletion } from "@codemirror/autocomplete";
const WORD_RE = /[A-Za-z0-9_]{2,}/;
function rank(query, label) {
const q = query.toLowerCase();
const l = label.toLowerCase();
if (l === q) return 0;
if (l.startsWith(q)) return 1 + l.length; // shorter prefix matches first
const i = l.indexOf(q);
if (i !== -1) return 10000 + i; // substring matches after all prefix matches
return -1;
}
export function vocabCompletions(vocab) {
// vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
const entries = [
...vocab.cvars.map(e => ({ ...e, kind: "cvar" })),
...vocab.commands.map(e => ({ ...e, kind: "command" })),
];
return (context) => {
const word = context.matchBefore(WORD_RE);
if (!word || (word.from === word.to && !context.explicit)) return null;
const q = word.text;
const scored = [];
for (const e of entries) {
const r = rank(q, e.name);
if (r === -1) continue;
scored.push([r, e]);
if (scored.length > 200) break; // bound work; we cap to 50 below
}
scored.sort((a, b) => a[0] - b[0]);
const options = scored.slice(0, 50).map(([, e]) => ({
label: e.name,
info: e.desc || e.kind,
type: e.kind === "command" ? "function" : "variable",
}));
return { from: word.from, options, validFor: WORD_RE };
};
}
export function autocompleteExtension(vocab) {
return autocompletion({
override: [vocabCompletions(vocab)],
activateOnTyping: true,
maxRenderedOptions: 8,
});
}

View file

@ -1,89 +0,0 @@
import { EditorState, Compartment } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, highlightActiveLine } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { StreamLanguage, indentOnInput, bracketMatching, defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap, completionKeymap, acceptCompletion } from "@codemirror/autocomplete";
import { shell as shellLegacy } from "@codemirror/legacy-modes/mode/shell";
import { srccfgLanguage } from "./srccfg-mode.js";
import { editorLightTheme, editorDarkTheme, editorHighlighting } from "./themes.js";
import { autocompleteExtension } from "./autocomplete.js";
const bashLanguage = StreamLanguage.define(shellLegacy);
function pickLanguage(name) {
if (name === "srccfg") return srccfgLanguage;
if (name === "bash") return bashLanguage;
return null; // "plain" / unknown → no language extension
}
function pickThemeForMatchMedia(mm) {
return mm.matches ? editorDarkTheme : editorLightTheme;
}
function mount(textarea, { language = "plain", vocab = null } = {}) {
const langCompartment = new Compartment();
const themeCompartment = new Compartment();
const autocompleteCompartment = new Compartment();
const lang = pickLanguage(language);
const mm = window.matchMedia("(prefers-color-scheme: dark)");
const extensions = [
history(),
lineNumbers(),
highlightActiveLine(),
bracketMatching(),
closeBrackets(),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
editorHighlighting,
themeCompartment.of(pickThemeForMatchMedia(mm)),
langCompartment.of(lang ? [lang] : []),
autocompleteCompartment.of(vocab ? [autocompleteExtension(vocab)] : []),
keymap.of([
// Tab → acceptCompletion when the popup is open; falls through
// to indentWithTab when no popup. `run` returning false means
// cm6 keeps walking the keymap list, so indentWithTab still
// works as a fallback indent on Tab when typing normally.
{ key: "Tab", run: acceptCompletion },
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...completionKeymap,
indentWithTab,
]),
];
const state = EditorState.create({ doc: textarea.value, extensions });
const view = new EditorView({ state, parent: textarea.parentElement });
// Insert the editor right before the textarea, then hide the textarea.
textarea.parentElement.insertBefore(view.dom, textarea);
textarea.style.display = "none";
// OS-level theme swap
const onThemeChange = () => view.dispatch({
effects: themeCompartment.reconfigure(pickThemeForMatchMedia(mm)),
});
mm.addEventListener("change", onThemeChange);
const controller = {
getValue: () => view.state.doc.toString(),
setContent: (text) => {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } });
},
setLanguage: (name) => {
const next = pickLanguage(name);
view.dispatch({ effects: langCompartment.reconfigure(next ? [next] : []) });
},
destroy: () => {
mm.removeEventListener("change", onThemeChange);
view.destroy();
textarea.style.display = "";
},
};
return controller;
}
window.__editor = { mount };

View file

@ -1,605 +0,0 @@
{
"name": "l4d2web-editor",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "l4d2web-editor",
"version": "0.1.0",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.10.0",
"@codemirror/legacy-modes": "^6.4.0",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.3.tgz",
"integrity": "sha512-xCsmIzH78MyWkib9jlPaaun57XNkfbMIhagfaZVd0iLTqlpw3jXaIcbZm72MTmmn64eTZpBVNjbyYh+QXnxRsg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.24.2",
"@esbuild/android-arm": "0.24.2",
"@esbuild/android-arm64": "0.24.2",
"@esbuild/android-x64": "0.24.2",
"@esbuild/darwin-arm64": "0.24.2",
"@esbuild/darwin-x64": "0.24.2",
"@esbuild/freebsd-arm64": "0.24.2",
"@esbuild/freebsd-x64": "0.24.2",
"@esbuild/linux-arm": "0.24.2",
"@esbuild/linux-arm64": "0.24.2",
"@esbuild/linux-ia32": "0.24.2",
"@esbuild/linux-loong64": "0.24.2",
"@esbuild/linux-mips64el": "0.24.2",
"@esbuild/linux-ppc64": "0.24.2",
"@esbuild/linux-riscv64": "0.24.2",
"@esbuild/linux-s390x": "0.24.2",
"@esbuild/linux-x64": "0.24.2",
"@esbuild/netbsd-arm64": "0.24.2",
"@esbuild/netbsd-x64": "0.24.2",
"@esbuild/openbsd-arm64": "0.24.2",
"@esbuild/openbsd-x64": "0.24.2",
"@esbuild/sunos-x64": "0.24.2",
"@esbuild/win32-arm64": "0.24.2",
"@esbuild/win32-ia32": "0.24.2",
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
}
}
}

View file

@ -1,21 +0,0 @@
{
"name": "l4d2web-editor",
"version": "0.1.0",
"description": "CodeMirror 6 bundle for l4d2web textarea upgrade",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json"
},
"devDependencies": {
"esbuild": "^0.24.0"
},
"dependencies": {
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.36.0",
"@codemirror/commands": "^6.8.0",
"@codemirror/language": "^6.10.0",
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/legacy-modes": "^6.4.0"
}
}

View file

@ -1,26 +0,0 @@
import { StreamLanguage } from "@codemirror/language";
// Source-engine .cfg syntax (server.cfg style).
// Linewise. No nesting. Tokens: comment, string, number, keyword, identifier.
const KEYWORDS = new Set(["exec", "alias", "bind", "unbindall", "wait"]);
export const srccfgLanguage = StreamLanguage.define({
name: "srccfg",
startState: () => ({}),
token(stream) {
if (stream.eatSpace()) return null;
if (stream.match("//")) {
stream.skipToEnd();
return "comment";
}
if (stream.match(/^"(?:[^"\\]|\\.)*"?/)) return "string";
if (stream.match(/^-?\d+(?:\.\d+)?/)) return "number";
if (stream.match(/^[A-Za-z_][A-Za-z0-9_]*/)) {
const word = stream.current();
return KEYWORDS.has(word) ? "keyword" : "variableName";
}
stream.next();
return null;
},
languageData: { commentTokens: { line: "//" } },
});

View file

@ -1,49 +0,0 @@
import { EditorView } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags as t } from "@lezer/highlight";
// CSS variables are defined in static/css/tokens.css (light) and the
// `prefers-color-scheme: dark` block. Both themes route through the
// same --cm-* variable names; the OS toggle swaps the underlying values.
//
// Two named themes so we can also force-pick light/dark in tests if needed.
const baseRules = {
"&": {
backgroundColor: "var(--cm-bg)",
color: "var(--cm-fg)",
fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)",
fontSize: "14px",
},
".cm-content": { caretColor: "var(--cm-fg)", padding: "8px" },
".cm-cursor": { borderLeftColor: "var(--cm-fg)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": {
backgroundColor: "var(--cm-selection)",
},
".cm-gutters": {
backgroundColor: "var(--cm-bg)",
color: "var(--fg-muted, #888)",
border: "none",
},
".cm-tooltip": {
backgroundColor: "var(--cm-bg)",
border: "1px solid var(--border-strong, #444)",
color: "var(--cm-fg)",
},
".cm-tooltip-autocomplete > ul > li[aria-selected]": {
backgroundColor: "var(--cm-selection)",
},
};
export const editorLightTheme = EditorView.theme(baseRules, { dark: false });
export const editorDarkTheme = EditorView.theme(baseRules, { dark: true });
export const editorHighlightStyle = HighlightStyle.define([
{ tag: t.comment, color: "var(--cm-comment)" },
{ tag: t.string, color: "var(--cm-string)" },
{ tag: t.number, color: "var(--cm-number)" },
{ tag: t.keyword, color: "var(--cm-keyword)" },
{ tag: t.variableName, color: "var(--cm-fg)" },
]);
export const editorHighlighting = syntaxHighlighting(editorHighlightStyle);

View file

@ -1,101 +0,0 @@
"""End-to-end Playwright tests for the CodeMirror 6 editor.
The live_server fixture (conftest.py) seeds user "alice"/"secret" and a
single blueprint at id=1. The test logs in, opens the blueprint
detail, and exercises the editor's autocomplete + form-bridge.
Running locally: `uv run pytest -m e2e tests/e2e/test_editor.py`.
Requires `uv run playwright install chromium` once. Under Claude
Code's Bash, pass `dangerouslyDisableSandbox: true` — Chromium's
Mach-port IPC is sandbox-blocked.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
pytestmark = pytest.mark.e2e
def _login(page: Page, base_url: str) -> None:
page.goto(f"{base_url}/login")
page.fill('input[name="username"]', "alice")
page.fill('input[name="password"]', "secret")
page.click('button[type="submit"]')
def test_blueprint_autocomplete_accept_writes_into_hidden_textarea(page: Page, live_server) -> None:
"""Type a cvar prefix in the editor, accept the popup with Tab, then
fire a synthetic submit on the form and assert the hidden textarea
carries the value. Exercises:
1. cm6 mount + syntax / autocomplete extensions.
2. /static/data/srccfg-vocab.json lazy fetch.
3. The submit-time copy bridge in editor.js (the v2 form-bridge
pattern's failure mode).
"""
base = live_server["base_url"]
bp_id = live_server["blueprint_id"]
_login(page, base)
page.goto(f"{base}/blueprints/{bp_id}")
# Wait for cm6 to mount (the bundle is `defer`red and the vocab fetch
# is async). The .cm-content element is what cm6 renders the
# editable surface as.
editor = page.locator(".cm-content")
expect(editor).to_be_visible(timeout=5000)
editor.click()
page.keyboard.type("sv_che")
# cm6's autocomplete tooltip class.
popup = page.locator(".cm-tooltip-autocomplete")
expect(popup).to_be_visible(timeout=3000)
expect(popup).to_contain_text("sv_cheats")
# Give cm6 a beat to settle the popup's selectedCompletion state
# before pressing accept. Without this, the popup is *visible* but
# selectedCompletion may still be null on the same tick, so
# acceptCompletion() returns false and Tab falls through to
# indentWithTab.
page.wait_for_timeout(200)
page.keyboard.press("Tab") # custom Tab→acceptCompletion binding in editor-entry.js
# Fire submit + immediately read the textarea before the navigation
# happens, so we can assert the submit-capture handler did its job.
textarea_value = page.evaluate("""() => {
const ta = document.querySelector('textarea[name="config"]');
const form = ta.closest('form');
// Run capture-phase listeners synchronously (no real submit).
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
return ta.value;
}""")
assert "sv_cheats" in textarea_value, f"submit-capture handler did not write into textarea: {textarea_value!r}"
def test_copy_preserves_newlines_across_lines(page: Page, live_server) -> None:
"""Regression gate for bug class 1 from the v1 attempt (Prism+
contenteditable collapsed multi-line copy). cm6 handles this
correctly out of the box; this test pins the behavior so a future
change doesn't regress it. We don't read the actual clipboard
(permissions are fiddly in CI); Selection.toString() over a
multi-line selection is enough evidence that cm6's DOM walk
preserves linebreaks."""
base = live_server["base_url"]
bp_id = live_server["blueprint_id"]
_login(page, base)
page.goto(f"{base}/blueprints/{bp_id}")
editor = page.locator(".cm-content")
expect(editor).to_be_visible(timeout=5000)
editor.click()
page.keyboard.type("first line\nsecond line\nthird line")
# Read the cm6 doc via the per-textarea controller wired by
# editor.js (textarea.__editorController). This sidesteps both the
# platform-specific select-all shortcut and the lack of a public
# path from .cm-content back to the EditorView.
doc_text = page.evaluate("""() => {
const ta = document.querySelector('textarea[name="config"]');
return ta.__editorController.getValue();
}""")
assert doc_text.count("\n") >= 2, f"expected ≥2 line breaks in cm6 doc, got: {doc_text!r}"

View file

@ -411,61 +411,3 @@ def test_blueprint_detail_picker_emits_hidden_overlay_ids_in_order(user_client)
second = body.find('name="overlay_ids" value="1"')
assert first != -1 and second != -1
assert first < second
def test_blueprint_config_form_post_round_trip(user_client) -> None:
"""Pin the form-POST contract for the `config` textarea: a multi-line
value sent in the POST body re-renders inside the textarea on the
subsequent GET. The CodeMirror 6 editor wires a submit-time copy
handler that writes `controller.getValue()` into `textarea.value`
before the browser serializes the form; if that handler regresses,
this test breaks before the e2e suite even runs."""
create = user_client.post(
"/blueprints",
data={
"name": "form-contract",
"arguments": "",
"config": "",
"overlay_ids": [],
},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
blueprint_path = create.headers["Location"]
pinned_config = "// pinned by form-contract test\nsv_cheats 0\nexec server.cfg\n"
update = user_client.post(
blueprint_path,
data={
"name": "form-contract",
"arguments": "",
"config": pinned_config,
"overlay_ids": [],
},
headers={"X-CSRF-Token": "test-token"},
)
assert update.status_code == 302
page = user_client.get(blueprint_path)
assert page.status_code == 200
body = page.get_data(as_text=True)
# The route may normalize trailing whitespace; assert each non-empty
# line round-tripped into the rendered textarea.
for line in ("// pinned by form-contract test", "sv_cheats 0", "exec server.cfg"):
assert line in body, f"line not found in rendered page: {line!r}"
def test_blueprint_get_includes_editor_markup(user_client) -> None:
"""Blueprint detail page must carry the editor opt-in attribute and
the editor asset partial the v2 CodeMirror 6 wiring contract."""
create = user_client.post(
"/blueprints",
data={"name": "editor-attrs", "arguments": "", "config": "", "overlay_ids": []},
headers={"X-CSRF-Token": "test-token"},
)
assert create.status_code == 302
page = user_client.get(create.headers["Location"])
body = page.get_data(as_text=True)
assert 'data-editor-language="srccfg"' in body
assert "vendor/editor.bundle.js" in body
assert "js/editor.js" in body

View file

@ -277,16 +277,3 @@ def test_permissions_admin_can_edit_any(app, alice_id, admin_id) -> None:
with session_scope() as s:
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
assert overlay.script == "echo admin"
def test_script_overlay_detail_carries_editor_markup(app, alice_id) -> None:
"""Script overlay detail page must carry the editor opt-in attribute
and the editor asset partial the v2 CodeMirror 6 wiring contract."""
overlay_id = _create_script_overlay(app, alice_id)
client = _client_for(app, alice_id)
response = client.get(f"/overlays/{overlay_id}")
assert response.status_code == 200
body = response.get_data(as_text=True)
assert 'data-editor-language="bash"' in body
assert "vendor/editor.bundle.js" in body
assert "js/editor.js" in body