Compare commits
No commits in common. "704e4cdfd187c70b277c01f82cc532de9b99ee1e" and "f14d352657b10fc7bc67b0580089452f0d8b61e6" have entirely different histories.
704e4cdfd1
...
f14d352657
29 changed files with 20 additions and 13490 deletions
39
AGENTS.md
39
AGENTS.md
|
|
@ -107,35 +107,12 @@ chromium binary, fetched on first setup:
|
||||||
uv run playwright install chromium
|
uv run playwright install chromium
|
||||||
```
|
```
|
||||||
|
|
||||||
Always invoke as `uv run pytest -m e2e ...` (excluded from the default
|
Run with `uv run pytest -m e2e`. Excluded from the default fast suite
|
||||||
fast suite via the `e2e` marker). Other forms crash Chromium under the
|
via the `e2e` marker.
|
||||||
macOS sandbox; only this exact invocation is exempt.
|
|
||||||
|
|
||||||
## Editor bundle (CodeMirror 6)
|
**Sandbox note:** Chromium needs Mach-port IPC on macOS, which the
|
||||||
|
Claude Code sandbox blocks. When running e2e tests from a sandboxed
|
||||||
The in-browser code editor on the blueprint config / overlay script /
|
agent session, pass `dangerouslyDisableSandbox: true` on the
|
||||||
files-modal textareas is bundled from `l4d2web/scripts/editor-src/`
|
`uv run pytest -m e2e` invocation (the symptom of a sandboxed run is
|
||||||
via esbuild and committed pre-built to
|
a `FATAL` Chromium crash with `Permission denied (1100)` on Mach port
|
||||||
`l4d2web/l4d2web/static/vendor/editor.bundle.js`. Source lives under
|
rendezvous, not a missing-binary or network error).
|
||||||
`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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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.
|
|
||||||
|
|
@ -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`.
|
|
||||||
|
|
@ -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%;
|
|
||||||
}
|
|
||||||
|
|
@ -33,19 +33,6 @@
|
||||||
background to read. Don't redefine these in the dark-mode block. */
|
background to read. Don't redefine these in the dark-mode block. */
|
||||||
--color-button-primary: #1d4ed8;
|
--color-button-primary: #1d4ed8;
|
||||||
--color-button-danger: #b42318;
|
--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) {
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
@ -64,14 +51,6 @@
|
||||||
--color-focus: #bfdbfe;
|
--color-focus: #bfdbfe;
|
||||||
--color-log-bg: #111827;
|
--color-log-bg: #111827;
|
||||||
--color-log-text: #e5e7eb;
|
--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
|
|
@ -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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
@ -281,29 +281,12 @@
|
||||||
saveBtn: editorDialog.querySelector(".files-editor-save"),
|
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) {
|
function setEditorTitle(text) {
|
||||||
editorEls.title.textContent = text;
|
editorEls.title.textContent = text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateByteCount() {
|
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`;
|
editorEls.byteCount.textContent = `UTF-8 · ${bytes} bytes`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -359,7 +342,7 @@
|
||||||
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
|
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
|
||||||
editorEls.filename.value = "";
|
editorEls.filename.value = "";
|
||||||
editorEls.filename.disabled = false;
|
editorEls.filename.disabled = false;
|
||||||
setEditorValue("");
|
editorEls.contentBox.value = "";
|
||||||
editorEls.contentBox.disabled = false;
|
editorEls.contentBox.disabled = false;
|
||||||
editorEls.renameHint.hidden = true;
|
editorEls.renameHint.hidden = true;
|
||||||
editorEls.textPanel.hidden = false;
|
editorEls.textPanel.hidden = false;
|
||||||
|
|
@ -392,14 +375,14 @@
|
||||||
editor.mode = "text";
|
editor.mode = "text";
|
||||||
editorEls.textPanel.hidden = false;
|
editorEls.textPanel.hidden = false;
|
||||||
editorEls.binaryPanel.hidden = true;
|
editorEls.binaryPanel.hidden = true;
|
||||||
setEditorValue("Loading…");
|
editorEls.contentBox.value = "Loading…";
|
||||||
editorEls.contentBox.disabled = true;
|
editorEls.contentBox.disabled = true;
|
||||||
|
|
||||||
const r = await fetchJson(
|
const r = await fetchJson(
|
||||||
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
|
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
|
||||||
);
|
);
|
||||||
if (r.ok && r.body) {
|
if (r.ok && r.body) {
|
||||||
setEditorValue(r.body.content);
|
editorEls.contentBox.value = r.body.content;
|
||||||
editorEls.contentBox.disabled = false;
|
editorEls.contentBox.disabled = false;
|
||||||
updateByteCount();
|
updateByteCount();
|
||||||
updateSaveEnabled();
|
updateSaveEnabled();
|
||||||
|
|
@ -478,7 +461,7 @@
|
||||||
// Text-flavor create → /save with no new_path.
|
// Text-flavor create → /save with no new_path.
|
||||||
const r = await postJson(`${baseUrl}/files/save`, {
|
const r = await postJson(`${baseUrl}/files/save`, {
|
||||||
path: newRel,
|
path: newRel,
|
||||||
content: getEditorValue(),
|
content: editorEls.contentBox.value,
|
||||||
});
|
});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
editorDialog.close();
|
editorDialog.close();
|
||||||
|
|
@ -493,7 +476,7 @@
|
||||||
// For files, a plain /save overwrite is fine.
|
// For files, a plain /save overwrite is fine.
|
||||||
const r2 = await postJson(`${baseUrl}/files/save`, {
|
const r2 = await postJson(`${baseUrl}/files/save`, {
|
||||||
path: newRel,
|
path: newRel,
|
||||||
content: getEditorValue(),
|
content: editorEls.contentBox.value,
|
||||||
});
|
});
|
||||||
if (r2.ok) {
|
if (r2.ok) {
|
||||||
editorDialog.close();
|
editorDialog.close();
|
||||||
|
|
@ -508,7 +491,7 @@
|
||||||
const altered = withCollisionSuffix(newRel);
|
const altered = withCollisionSuffix(newRel);
|
||||||
const r2 = await postJson(`${baseUrl}/files/save`, {
|
const r2 = await postJson(`${baseUrl}/files/save`, {
|
||||||
path: altered,
|
path: altered,
|
||||||
content: getEditorValue(),
|
content: editorEls.contentBox.value,
|
||||||
});
|
});
|
||||||
if (r2.ok) {
|
if (r2.ok) {
|
||||||
editorDialog.close();
|
editorDialog.close();
|
||||||
|
|
@ -525,7 +508,7 @@
|
||||||
if (editor.mode === "text") {
|
if (editor.mode === "text") {
|
||||||
const payload = {
|
const payload = {
|
||||||
path: editor.originalPath,
|
path: editor.originalPath,
|
||||||
content: getEditorValue(),
|
content: editorEls.contentBox.value,
|
||||||
};
|
};
|
||||||
if (renaming) payload.new_path = newRel;
|
if (renaming) payload.new_path = newRel;
|
||||||
const r = await postJson(`${baseUrl}/files/save`, payload);
|
const r = await postJson(`${baseUrl}/files/save`, payload);
|
||||||
|
|
|
||||||
32
l4d2web/l4d2web/static/vendor/README.md
vendored
32
l4d2web/l4d2web/static/vendor/README.md
vendored
|
|
@ -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.
|
|
||||||
12
l4d2web/l4d2web/static/vendor/editor.bundle.js
vendored
12
l4d2web/l4d2web/static/vendor/editor.bundle.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,2 +0,0 @@
|
||||||
6700b694fe25837f52e77c780d88f3eb5aef2a1591dc461c26efa3fa9724290b editor.bundle.js
|
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
|
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
|
||||||
{% endfor %}</pre>
|
{% endfor %}</pre>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<button type="submit">Save blueprint</button>
|
<button type="submit">Save blueprint</button>
|
||||||
|
|
@ -92,5 +92,4 @@
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
|
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
|
||||||
{% include "_editor_assets.html" %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@
|
||||||
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
|
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
|
||||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||||
<label>Bash script
|
<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>
|
</label>
|
||||||
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
|
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
|
||||||
{% if not latest_build_is_running %}
|
{% if not latest_build_is_running %}
|
||||||
|
|
@ -168,23 +168,14 @@
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<label class="files-editor-field">
|
<label class="files-editor-field">
|
||||||
<span class="files-field-label">Filename</span>
|
<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>
|
</label>
|
||||||
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code> → <code class="files-rename-to"></code>.</p>
|
<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">
|
<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">
|
<label class="files-editor-field">
|
||||||
<span class="files-field-label">Content</span>
|
<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>
|
</label>
|
||||||
<div class="files-editor-meta muted">
|
<div class="files-editor-meta muted">
|
||||||
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
|
<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>
|
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% include "_editor_assets.html" %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
|
||||||
|
|
@ -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())
|
|
||||||
2
l4d2web/scripts/editor-src/.gitignore
vendored
2
l4d2web/scripts/editor-src/.gitignore
vendored
|
|
@ -1,2 +0,0 @@
|
||||||
node_modules/
|
|
||||||
meta.json
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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 };
|
|
||||||
605
l4d2web/scripts/editor-src/package-lock.json
generated
605
l4d2web/scripts/editor-src/package-lock.json
generated
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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: "//" } },
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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}"
|
|
||||||
|
|
@ -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"')
|
second = body.find('name="overlay_ids" value="1"')
|
||||||
assert first != -1 and second != -1
|
assert first != -1 and second != -1
|
||||||
assert first < second
|
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
|
|
||||||
|
|
|
||||||
|
|
@ -277,16 +277,3 @@ def test_permissions_admin_can_edit_any(app, alice_id, admin_id) -> None:
|
||||||
with session_scope() as s:
|
with session_scope() as s:
|
||||||
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
|
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
|
||||||
assert overlay.script == "echo admin"
|
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
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue