The `\b` word boundary anchor prevents the optional minus from ever matching from positions where signed numbers naturally appear (` -1`, `(-1`, `=-1` all word-boundary-from-non-word and the `-?` fires zero chars). Negative numbers are tokenised via the operator class instead, which is the consistent behaviour the grammar already exhibits. Plan source block updated to match so a fresh regeneration produces the same file. Addresses Minor #1 from the Task 2 code review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1540 lines
54 KiB
Markdown
1540 lines
54 KiB
Markdown
# Textarea Code Editor Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Upgrade the blueprint config, overlay script, and files-editor textareas with a reusable vanilla-JS code editor that does syntax highlighting and identifier autocomplete.
|
||
|
||
**Architecture:** One widget (`editor.js`) mounts on any `<textarea data-editor-language>`. The textarea stays in the DOM as the value carrier; a sibling `contenteditable` mirrors content back on every input. CodeJar handles the editor shell, Prism handles highlighting, a small custom popup handles autocomplete. No bundler, no build step — everything self-hosted under `/static/`.
|
||
|
||
**Tech Stack:** Vanilla JS (ES2020), Prism.js 1.x (vendored), CodeJar 4.x (vendored), Jinja, Flask, pytest, Playwright.
|
||
|
||
**Reference spec:** `docs/superpowers/specs/2026-05-16-textarea-code-editor-design.md`
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
| File | Action | Responsibility |
|
||
|---|---|---|
|
||
| `l4d2web/l4d2web/static/vendor/prism.js` | Create (vendor) | Prism core + `clike` + `bash` (custom build from prismjs.com/download) |
|
||
| `l4d2web/l4d2web/static/vendor/prism.css` | Create (vendor) | Prism default theme |
|
||
| `l4d2web/l4d2web/static/vendor/codejar.js` | Create (vendor) | CodeJar editor shell (UMD release) |
|
||
| `l4d2web/l4d2web/static/vendor/README.md` | Create | Pinned URLs + versions + SHA256 for each vendored asset |
|
||
| `l4d2web/l4d2web/static/js/srccfg-grammar.js` | Create | `Prism.languages.srccfg` token regexes |
|
||
| `l4d2web/l4d2web/static/js/editor.js` | Create | Widget: mount, sync, autocomplete popup, language switch, auto-detection |
|
||
| `l4d2web/l4d2web/static/css/editor.css` | Create | `.editor-shell`, `.editor-code`, `.editor-popup` styles |
|
||
| `l4d2web/l4d2web/static/data/srccfg-vocab.json` | Create | Curated cvars/commands for autocomplete |
|
||
| `l4d2web/l4d2web/templates/_editor_assets.html` | Create | Jinja partial: link/script tags with nonces |
|
||
| `l4d2web/l4d2web/templates/blueprint_detail.html` | Modify (~line 52, end of `content` block) | Add `data-editor-language="srccfg"`; include partial |
|
||
| `l4d2web/l4d2web/templates/overlay_detail.html` | Modify (~lines 25, 178, end of `content` block) | Bash editor for script form; auto editor + dropdown for files-editor; include partial |
|
||
| `l4d2web/l4d2web/static/js/files-overlay.js` | Modify (~lines 345, 385) | Replace direct `editorEls.contentBox.value = …` with `editor.setValue(…)` + `editor.setLanguage(…)` |
|
||
| `l4d2web/pyproject.toml` | Modify | Add `playwright` to dev deps |
|
||
| `l4d2web/tests/e2e/__init__.py` | Create | Marker file |
|
||
| `l4d2web/tests/e2e/conftest.py` | Create | Pytest fixture booting Flask app on an ephemeral port |
|
||
| `l4d2web/tests/e2e/test_editor.py` | Create | Playwright test: type `sv_che`, accept popup, assert textarea value |
|
||
| `l4d2web/tests/test_blueprints.py` | Modify | Add two form-contract tests for the upgraded textarea |
|
||
| `l4d2web/tests/test_overlay_creation.py` | Modify (or wherever overlay-script form is tested) | Add one form-contract test for bash editor presence |
|
||
| `AGENTS.md` (repo root) | Modify | One-line note: run `playwright install chromium` to set up e2e |
|
||
|
||
**Untouched by design:**
|
||
- `l4d2web/l4d2web/routes/blueprint_routes.py` — form contract unchanged
|
||
- `l4d2web/l4d2web/app.py` — CSP already permits nonce'd scripts
|
||
- The files-overlay save path (`/files/save`) — JSON shape unchanged
|
||
|
||
---
|
||
|
||
## Task 1: Vendor Prism + CodeJar with provenance
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/l4d2web/static/vendor/prism.js`
|
||
- Create: `l4d2web/l4d2web/static/vendor/prism.css`
|
||
- Create: `l4d2web/l4d2web/static/vendor/codejar.js`
|
||
- Create: `l4d2web/l4d2web/static/vendor/README.md`
|
||
|
||
- [ ] **Step 1: Assemble the Prism bundle via curl**
|
||
|
||
Prism ships per-component files at `cdn.jsdelivr.net/npm/prismjs@1.29.0/`. We concatenate three components — `core` (required), `clike` (bash depends on it), `bash` — into a single self-contained `prism.js`. Theme CSS is grabbed separately.
|
||
|
||
```bash
|
||
mkdir -p l4d2web/l4d2web/static/vendor
|
||
VER=1.29.0
|
||
BASE=https://cdn.jsdelivr.net/npm/prismjs@${VER}
|
||
|
||
# Concatenate core + clike + bash into one file, in the required load order.
|
||
{
|
||
echo "/* Prism v${VER} — core + clike + bash, assembled from ${BASE}/components/ */"
|
||
curl -fsSL "${BASE}/components/prism-core.min.js"
|
||
echo # newline separator between components
|
||
curl -fsSL "${BASE}/components/prism-clike.min.js"
|
||
echo
|
||
curl -fsSL "${BASE}/components/prism-bash.min.js"
|
||
} > l4d2web/l4d2web/static/vendor/prism.js
|
||
|
||
# Theme CSS (the default "prism" theme).
|
||
curl -fsSL -o l4d2web/l4d2web/static/vendor/prism.css \
|
||
"${BASE}/themes/prism.min.css"
|
||
```
|
||
|
||
Verify:
|
||
|
||
```bash
|
||
ls -la l4d2web/l4d2web/static/vendor/prism.{js,css}
|
||
# Prism's minified bundle renames `Prism` → `e`, so grep for the
|
||
# bash-specific shebang regex that ships in the bash grammar instead.
|
||
grep -cE 'shebang|languages\.bash' l4d2web/l4d2web/static/vendor/prism.js
|
||
```
|
||
|
||
Expected: `prism.js` around 15–25 KB; `prism.css` around 1–3 KB. Grep count ≥1 (confirming the bash grammar got included — both `shebang` token and the language attachment are present in the minified bundle).
|
||
|
||
- [ ] **Step 2: Download CodeJar from unpkg**
|
||
|
||
CodeJar publishes a browser-ready bundle to npm. unpkg/jsdelivr serve it directly:
|
||
|
||
```bash
|
||
VER=4.0.0
|
||
curl -fsSL -o l4d2web/l4d2web/static/vendor/codejar.js \
|
||
"https://cdn.jsdelivr.net/npm/codejar@${VER}/dist/codejar.js"
|
||
```
|
||
|
||
Inspect the first 5 lines to confirm it's a valid JS module:
|
||
|
||
```bash
|
||
head -5 l4d2web/l4d2web/static/vendor/codejar.js
|
||
```
|
||
|
||
If the file uses ES module `export` syntax (`export function CodeJar` or `export { CodeJar }`), strip the export keyword and append a global shim so `<script>` tags can load it directly:
|
||
|
||
```bash
|
||
# Strip ESM exports, then expose CodeJar on window.
|
||
sed -i.bak \
|
||
-e 's/^export function /function /' \
|
||
-e 's/^export { CodeJar }.*$//' \
|
||
-e 's/^export { CodeJar as default }.*$//' \
|
||
l4d2web/l4d2web/static/vendor/codejar.js
|
||
rm -f l4d2web/l4d2web/static/vendor/codejar.js.bak
|
||
|
||
cat >> l4d2web/l4d2web/static/vendor/codejar.js <<'EOF'
|
||
|
||
// Browser global shim: surface CodeJar on window so non-module
|
||
// <script> tags can call it.
|
||
window.CodeJar = CodeJar;
|
||
EOF
|
||
```
|
||
|
||
If the CDN serves a UMD/IIFE bundle that already exposes `window.CodeJar`, the sed lines are no-ops and the final `window.CodeJar = CodeJar` will fail. In that case, open the file with `head -20` to confirm CodeJar is already on window, and either remove the shim or wrap it in a `typeof CodeJar !== 'undefined'` check. **If unsure, BLOCK and report what the file actually contains so the controller can advise.**
|
||
|
||
- [ ] **Step 3: Record SHA256s and source URLs in README.md**
|
||
|
||
Create `l4d2web/l4d2web/static/vendor/README.md`:
|
||
|
||
```markdown
|
||
# Vendored static assets
|
||
|
||
All third-party JS/CSS shipped under `/static/vendor/` is committed
|
||
verbatim from the upstream releases below. The strict CSP
|
||
(`default-src 'self'`) means we cannot load these from CDNs.
|
||
|
||
| File | Upstream | Version | SHA256 |
|
||
|---|---|---|---|
|
||
| `prism.js` | jsdelivr concat: prism-core.min.js + prism-clike.min.js + prism-bash.min.js | v1.29.0 | `<sha>` |
|
||
| `prism.css` | https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css | v1.29.0 | `<sha>` |
|
||
| `codejar.js` | https://cdn.jsdelivr.net/npm/codejar@4.0.0/codejar.js + ESM-strip + browser-global shim | v4.0.0 | `<sha>` |
|
||
|
||
## Regenerating
|
||
|
||
- **Prism:** Re-run the three-component concat in Task 1 Step 1 of
|
||
`docs/superpowers/plans/2026-05-16-textarea-code-editor.md` with
|
||
an updated `VER`, then re-download the theme CSS.
|
||
- **CodeJar:** Re-download from jsdelivr per the same plan's Task 1
|
||
Step 2, strip ESM exports, re-append the `window.CodeJar` shim.
|
||
|
||
Bump the version + SHA columns in this table after any update.
|
||
```
|
||
|
||
Then fill in the SHA256s:
|
||
|
||
```bash
|
||
cd l4d2web/l4d2web/static/vendor
|
||
for f in prism.js prism.css codejar.js; do
|
||
echo "$f: $(shasum -a 256 "$f" | cut -d' ' -f1)"
|
||
done
|
||
```
|
||
|
||
Paste each SHA into the table.
|
||
|
||
- [ ] **Step 4: Verify the asset files load**
|
||
|
||
```bash
|
||
ls -la l4d2web/l4d2web/static/vendor/
|
||
```
|
||
|
||
Expected output includes `codejar.js`, `prism.css`, `prism.js`, `README.md`. File sizes: prism.js around 20–30 KB; prism.css around 1–3 KB; codejar.js around 3–5 KB.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/vendor/
|
||
git commit -m "vendor(editor): pin Prism v1.29.0 + CodeJar v4.0.0
|
||
|
||
Self-host the editor dependencies under /static/vendor/ since the strict
|
||
CSP forbids CDN loading. README records source URLs, versions, and
|
||
SHA256s for each file."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: srccfg Prism grammar
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/l4d2web/static/js/srccfg-grammar.js`
|
||
|
||
The grammar defines five token classes for Source-engine `.cfg` syntax: comment, string, number, keyword (`exec`, `alias`, `bind`), and a generic identifier. Prism applies CSS classes like `token comment`, `token string`, etc. — Task 5 styles them.
|
||
|
||
- [ ] **Step 1: Write the grammar**
|
||
|
||
Create `l4d2web/l4d2web/static/js/srccfg-grammar.js`:
|
||
|
||
```js
|
||
// Prism grammar for Source-engine config files (server.cfg-style).
|
||
// Tokens: comment, string, number, keyword, identifier. Purely visual —
|
||
// no semantic validation of cvars or values.
|
||
(function (Prism) {
|
||
if (!Prism) return;
|
||
Prism.languages.srccfg = {
|
||
comment: /\/\/.*/,
|
||
string: {
|
||
pattern: /"(?:\\.|[^"\\])*"/,
|
||
greedy: true,
|
||
},
|
||
keyword: /\b(?:exec|alias|bind|unbind|toggle)\b/,
|
||
number: /\b\d+(?:\.\d+)?\b/,
|
||
identifier: /\b[a-zA-Z_][a-zA-Z0-9_]*\b/,
|
||
operator: /[+\-;]/,
|
||
};
|
||
})(typeof window !== "undefined" ? window.Prism : undefined);
|
||
```
|
||
|
||
- [ ] **Step 2: Manual verification (deferred until Task 6 is wired)**
|
||
|
||
This file is verified end-to-end once Task 6 renders a blueprint detail page with the editor mounted. For now, sanity-check it parses by opening any HTML page that loads prism.js + this file (or test in Node):
|
||
|
||
```bash
|
||
node -e "
|
||
const window = {};
|
||
const fs = require('fs');
|
||
eval(fs.readFileSync('l4d2web/l4d2web/static/vendor/prism.js', 'utf8'));
|
||
eval(fs.readFileSync('l4d2web/l4d2web/static/js/srccfg-grammar.js', 'utf8'));
|
||
console.log(Object.keys(window.Prism.languages.srccfg));
|
||
"
|
||
```
|
||
|
||
Expected output: `[ 'comment', 'string', 'keyword', 'number', 'identifier', 'operator' ]`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/js/srccfg-grammar.js
|
||
git commit -m "feat(editor): add Prism grammar for Source-engine .cfg syntax
|
||
|
||
Five token classes (comment, string, keyword, number, identifier) plus
|
||
operators. Purely visual highlighting; no semantic validation of cvar
|
||
names or values."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Editor stylesheet
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/l4d2web/static/css/editor.css`
|
||
|
||
The widget needs to visually replace the textarea without layout shift. The contenteditable inherits the textarea's monospace look from `tokens.css`. Popup is absolutely positioned near the caret.
|
||
|
||
- [ ] **Step 1: Inspect existing textarea styling**
|
||
|
||
```bash
|
||
grep -n "textarea" l4d2web/l4d2web/static/css/components.css | head -5
|
||
grep -n "monospace\|--font-mono\|--color-" l4d2web/l4d2web/static/css/tokens.css | head -10
|
||
```
|
||
|
||
Note the font-family, color, border, and background tokens that textareas use today. Use those same tokens in editor.css so the upgraded surface matches.
|
||
|
||
- [ ] **Step 2: Write editor.css**
|
||
|
||
Create `l4d2web/l4d2web/static/css/editor.css`:
|
||
|
||
```css
|
||
/* Code editor widget — paired with editor.js. Mounted as a sibling of
|
||
any <textarea data-editor-language>. The textarea is hidden but stays
|
||
in the DOM for form submission and JS .value reads. */
|
||
|
||
.editor-shell {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
|
||
.editor-code {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 6em;
|
||
padding: 0.5em 0.75em;
|
||
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
||
font-size: 0.95em;
|
||
line-height: 1.45;
|
||
color: var(--color-fg, #e6e6e6);
|
||
background: var(--color-bg-input, #1b1b1b);
|
||
border: 1px solid var(--color-border, #333);
|
||
border-radius: var(--radius-input, 4px);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
overflow: auto;
|
||
outline: none;
|
||
caret-color: var(--color-caret, #fff);
|
||
}
|
||
|
||
.editor-code:focus {
|
||
border-color: var(--color-focus, #6ab0ff);
|
||
}
|
||
|
||
/* Prism token colors — override defaults to match the site palette. */
|
||
.editor-code .token.comment { color: var(--color-muted, #888); font-style: italic; }
|
||
.editor-code .token.string { color: var(--color-string, #c2e886); }
|
||
.editor-code .token.keyword { color: var(--color-keyword, #ff7b72); font-weight: 600; }
|
||
.editor-code .token.number { color: var(--color-number, #f0b86e); }
|
||
.editor-code .token.operator { color: var(--color-muted, #888); }
|
||
.editor-code .token.identifier { color: inherit; }
|
||
|
||
/* Autocomplete popup. */
|
||
.editor-popup {
|
||
position: absolute;
|
||
z-index: 1000;
|
||
max-height: 14em;
|
||
overflow-y: auto;
|
||
min-width: 14em;
|
||
margin: 0;
|
||
padding: 0.25em 0;
|
||
list-style: none;
|
||
font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
|
||
font-size: 0.9em;
|
||
background: var(--color-bg-popover, #222);
|
||
border: 1px solid var(--color-border, #333);
|
||
border-radius: var(--radius-input, 4px);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||
}
|
||
|
||
.editor-popup-item {
|
||
padding: 0.25em 0.75em;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.editor-popup-item.is-active {
|
||
background: var(--color-bg-popover-active, #2d4f7c);
|
||
}
|
||
|
||
.editor-popup-item .name { color: var(--color-fg, #e6e6e6); }
|
||
.editor-popup-item .desc { color: var(--color-muted, #888); margin-left: 0.5em; }
|
||
|
||
/* Files-editor language dropdown. */
|
||
.editor-language-select {
|
||
margin-left: 0.5em;
|
||
}
|
||
```
|
||
|
||
If `tokens.css` doesn't define some of the variables referenced (e.g. `--color-bg-input`), the `var(name, fallback)` form falls back gracefully. After Step 1 you'll know which token names are real; replace any fallback-only ones with values you've grepped.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/css/editor.css
|
||
git commit -m "style(editor): add stylesheet for editor shell + Prism tokens + popup
|
||
|
||
Defines .editor-shell, .editor-code, .editor-popup. Reuses tokens.css
|
||
variables where present so the editor matches the site palette."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Editor widget core (mount + textarea sync, no autocomplete yet)
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/l4d2web/static/js/editor.js`
|
||
|
||
The widget mounts on every `<textarea data-editor-language>`, hides the textarea, creates a sibling contenteditable, mounts CodeJar with Prism highlighting, and pipes content back to the textarea on every input. Autocomplete is added in Task 8; this task lands a working "highlight as you type" experience.
|
||
|
||
- [ ] **Step 1: Write the widget skeleton**
|
||
|
||
Create `l4d2web/l4d2web/static/js/editor.js`:
|
||
|
||
```js
|
||
// Code editor widget. Mounts on any <textarea data-editor-language>.
|
||
// The textarea stays in the DOM (display:none) and the widget mirrors
|
||
// content back into it on every input — form submission and JS code
|
||
// that reads textarea.value (e.g. files-overlay.js) keep working.
|
||
//
|
||
// Public per-instance API (attached to the textarea as ._codeEditor):
|
||
// - setValue(text)
|
||
// - setLanguage(name) // "srccfg" | "bash" | "plain" | "auto"
|
||
// - getValue()
|
||
// - destroy()
|
||
(function () {
|
||
"use strict";
|
||
|
||
const LANG_BY_EXT = {
|
||
cfg: "srccfg",
|
||
sh: "bash",
|
||
bash: "bash",
|
||
};
|
||
|
||
function resolveAutoLanguage(filename) {
|
||
if (!filename) return "plain";
|
||
const m = /\.([a-zA-Z0-9]+)$/.exec(filename);
|
||
if (!m) return "plain";
|
||
return LANG_BY_EXT[m[1].toLowerCase()] || "plain";
|
||
}
|
||
|
||
function highlightFor(lang) {
|
||
return function (editorEl) {
|
||
if (lang === "plain" || !window.Prism || !window.Prism.languages[lang]) {
|
||
// Plain mode or grammar missing: leave textContent alone.
|
||
editorEl.innerHTML = editorEl.textContent
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<");
|
||
return;
|
||
}
|
||
window.Prism.highlightElement(editorEl);
|
||
};
|
||
}
|
||
|
||
function mount(textarea) {
|
||
if (textarea._codeEditor) return textarea._codeEditor;
|
||
|
||
const requested = textarea.dataset.editorLanguage || "plain";
|
||
let language =
|
||
requested === "auto"
|
||
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
|
||
: requested;
|
||
|
||
// Build the visible editor.
|
||
const shell = document.createElement("div");
|
||
shell.className = "editor-shell";
|
||
const code = document.createElement("code");
|
||
code.className = "editor-code language-" + language;
|
||
code.setAttribute("contenteditable", "true");
|
||
code.setAttribute("spellcheck", "false");
|
||
code.textContent = textarea.value;
|
||
shell.appendChild(code);
|
||
textarea.parentNode.insertBefore(shell, textarea);
|
||
textarea.style.display = "none";
|
||
|
||
// CodeJar mounts on the contenteditable and re-runs the highlighter
|
||
// on each input while preserving caret position.
|
||
const jar = window.CodeJar(code, highlightFor(language), { tab: " " });
|
||
|
||
jar.onUpdate(function (value) {
|
||
// Mirror back to the underlying textarea so form POST and any
|
||
// .value readers see the current content.
|
||
textarea.value = value;
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
|
||
const instance = {
|
||
textarea,
|
||
shell,
|
||
code,
|
||
jar,
|
||
language,
|
||
setValue: function (text) {
|
||
jar.updateCode(text);
|
||
textarea.value = text;
|
||
},
|
||
getValue: function () {
|
||
return jar.toString();
|
||
},
|
||
setLanguage: function (name) {
|
||
const next =
|
||
name === "auto"
|
||
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
|
||
: name;
|
||
if (next === instance.language) return;
|
||
instance.language = next;
|
||
code.className = "editor-code language-" + next;
|
||
jar.updateOptions({});
|
||
// Replace the highlighter by recreating it via updateCode (CodeJar
|
||
// doesn't expose setHighlight directly).
|
||
// Trick: stash the new highlighter on the closure and rerun.
|
||
code.__highlighter = highlightFor(next);
|
||
code.__highlighter(code);
|
||
},
|
||
destroy: function () {
|
||
jar.destroy();
|
||
shell.remove();
|
||
textarea.style.display = "";
|
||
delete textarea._codeEditor;
|
||
},
|
||
};
|
||
|
||
textarea._codeEditor = instance;
|
||
return instance;
|
||
}
|
||
|
||
// For "auto" language: look for a filename input near the textarea
|
||
// (the files-editor modal). Returns the <input> or null.
|
||
function findFilenameInput(textarea) {
|
||
const modal = textarea.closest("dialog, .modal, body");
|
||
if (!modal) return null;
|
||
return modal.querySelector(".files-editor-filename");
|
||
}
|
||
|
||
function mountAll(root) {
|
||
const scope = root || document;
|
||
scope.querySelectorAll("textarea[data-editor-language]").forEach(mount);
|
||
}
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", function () {
|
||
mountAll(document);
|
||
});
|
||
} else {
|
||
mountAll(document);
|
||
}
|
||
|
||
// Re-export for callers that need to mount editors created later (e.g.
|
||
// the files-editor modal which is in the static DOM but only used after
|
||
// user interaction — the initial mount is still correct, but exposing
|
||
// this hook lets future code mount dynamically-inserted editors).
|
||
window.l4d2Editor = { mount, mountAll };
|
||
})();
|
||
```
|
||
|
||
- [ ] **Step 2: Patch the `setLanguage` hot-path**
|
||
|
||
CodeJar's `updateOptions` API doesn't replace the highlighter function — the constructor captured it by closure. Reading the source: CodeJar stores the highlight callback as `highlight` in its options, then calls it on every input. We need a workaround:
|
||
|
||
Replace the `setLanguage` body with a tear-down-and-remount strategy that preserves caret if possible (for our use case — switching from srccfg to bash in the files-editor — losing caret is acceptable since the user just clicked a dropdown):
|
||
|
||
```js
|
||
setLanguage: function (name) {
|
||
const next =
|
||
name === "auto"
|
||
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
|
||
: name;
|
||
if (next === instance.language) return;
|
||
const currentText = jar.toString();
|
||
jar.destroy();
|
||
instance.language = next;
|
||
code.className = "editor-code language-" + next;
|
||
code.textContent = currentText;
|
||
instance.jar = window.CodeJar(code, highlightFor(next), { tab: " " });
|
||
instance.jar.onUpdate(function (value) {
|
||
textarea.value = value;
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 3: Commit (no test yet — manual smoke runs after Task 6)**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/js/editor.js
|
||
git commit -m "feat(editor): widget core — mount, sync, language switch
|
||
|
||
Mounts on <textarea data-editor-language>, hides the textarea, renders
|
||
content in a contenteditable sibling with Prism highlighting via
|
||
CodeJar. Mirrors content back to textarea.value on every input so form
|
||
POST and existing JS readers keep working unchanged. Exposes
|
||
setValue/setLanguage/getValue on textarea._codeEditor for callers."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Editor assets Jinja partial
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/l4d2web/templates/_editor_assets.html`
|
||
|
||
Three pages need the same five script/link tags. Centralise into a partial.
|
||
|
||
- [ ] **Step 1: Write the partial**
|
||
|
||
Create `l4d2web/l4d2web/templates/_editor_assets.html`:
|
||
|
||
```jinja
|
||
{# Editor asset bundle — include on any page that mounts a
|
||
<textarea data-editor-language>. Order matters: prism + codejar load
|
||
first, then the srccfg grammar registers itself on window.Prism, then
|
||
editor.js scans the DOM and mounts. #}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/prism.css') }}">
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/editor.css') }}">
|
||
<script src="{{ url_for('static', filename='vendor/prism.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
|
||
<script src="{{ url_for('static', filename='vendor/codejar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
|
||
<script src="{{ url_for('static', filename='js/srccfg-grammar.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
|
||
<script src="{{ url_for('static', filename='js/editor.js') }}" defer nonce="{{ g.csp_nonce }}"></script>
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/templates/_editor_assets.html
|
||
git commit -m "feat(editor): add Jinja partial for editor asset includes
|
||
|
||
Five script/link tags consolidated so call-site templates only need a
|
||
single {% include '_editor_assets.html' %} to enable the widget."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Wire blueprint config to use the editor
|
||
|
||
**Files:**
|
||
- Modify: `l4d2web/l4d2web/templates/blueprint_detail.html` (line 52, end of `content` block)
|
||
- Modify: `l4d2web/tests/test_blueprints.py`
|
||
|
||
- [ ] **Step 1: Write the failing form-contract test**
|
||
|
||
Add to `l4d2web/tests/test_blueprints.py` (after the existing form-update test around line 254):
|
||
|
||
```python
|
||
def test_blueprint_detail_renders_editor_assets(user_client) -> None:
|
||
with session_scope() as session:
|
||
user = session.scalar(select(User).where(User.username == "alice"))
|
||
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
||
session.add(blueprint)
|
||
session.flush()
|
||
blueprint_id = blueprint.id
|
||
|
||
response = user_client.get(f"/blueprints/{blueprint_id}")
|
||
assert response.status_code == 200
|
||
body = response.get_data(as_text=True)
|
||
# Editor opts the textarea in via a data-attribute.
|
||
assert 'data-editor-language="srccfg"' in body
|
||
# All editor assets are referenced.
|
||
assert "static/vendor/prism.js" in body
|
||
assert "static/vendor/codejar.js" in body
|
||
assert "static/js/srccfg-grammar.js" in body
|
||
assert "static/js/editor.js" in body
|
||
assert "static/css/editor.css" in body
|
||
# Scripts are nonce'd (CSP regression guard).
|
||
assert 'nonce="' in body
|
||
|
||
|
||
def test_blueprint_config_form_post_still_round_trips(user_client) -> None:
|
||
# The editor is a visual layer; the form POST contract must be
|
||
# unaffected. This guards against accidentally renaming `config` or
|
||
# dropping it from form serialization.
|
||
with session_scope() as session:
|
||
user = session.scalar(select(User).where(User.username == "alice"))
|
||
blueprint = Blueprint(user_id=user.id, name="bp", arguments="[]", config="[]")
|
||
session.add(blueprint)
|
||
session.flush()
|
||
blueprint_id = blueprint.id
|
||
|
||
update = user_client.post(
|
||
f"/blueprints/{blueprint_id}",
|
||
data={
|
||
"name": "bp",
|
||
"arguments": "",
|
||
"config": "sv_cheats 1\nmp_gamemode coop",
|
||
"overlay_ids": [],
|
||
},
|
||
headers={"X-CSRF-Token": "test-token"},
|
||
)
|
||
assert update.status_code == 302
|
||
|
||
with session_scope() as session:
|
||
bp = session.get(Blueprint, blueprint_id)
|
||
assert json.loads(bp.config) == ["sv_cheats 1", "mp_gamemode coop"]
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests to verify they fail**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest tests/test_blueprints.py::test_blueprint_detail_renders_editor_assets tests/test_blueprints.py::test_blueprint_config_form_post_still_round_trips -v
|
||
```
|
||
|
||
Expected: both fail. The first because `data-editor-language="srccfg"` isn't in the HTML yet. The second may pass already (form contract isn't broken) — if so, leave it as a regression guard for later tasks.
|
||
|
||
- [ ] **Step 3: Edit blueprint_detail.html**
|
||
|
||
At line 52, change:
|
||
|
||
```jinja
|
||
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
|
||
```
|
||
|
||
to:
|
||
|
||
```jinja
|
||
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
|
||
```
|
||
|
||
At the very end of `{% block content %}` (after the existing `<script src="...blueprint-overlay-picker.js" defer></script>` line near line 94), add the partial include:
|
||
|
||
```jinja
|
||
{% include '_editor_assets.html' %}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests to verify they pass**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest tests/test_blueprints.py::test_blueprint_detail_renders_editor_assets tests/test_blueprints.py::test_blueprint_config_form_post_still_round_trips -v
|
||
```
|
||
|
||
Expected: both PASS.
|
||
|
||
- [ ] **Step 5: Manual smoke (Chrome MCP or local browser)**
|
||
|
||
Start the dev server, log in, navigate to `/blueprints/<some_id>`. Expected:
|
||
- The config textarea is visually replaced by the editor (looks similar to a textarea — no shifted layout).
|
||
- Existing content (`exec foo.cfg`, cvar lines) is preserved.
|
||
- Typing renders highlighted tokens (comments in muted color, keywords like `exec` in keyword color, numbers in number color).
|
||
- Submitting the form persists the edited content (refresh confirms).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/templates/blueprint_detail.html l4d2web/tests/test_blueprints.py
|
||
git commit -m "feat(blueprint): mount srccfg editor on the config textarea
|
||
|
||
The textarea is preserved as the form field; the editor renders a
|
||
contenteditable sibling and mirrors content back on every input. Form
|
||
POST contract is untouched (covered by new round-trip test)."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Wire bash editor on overlay script form
|
||
|
||
**Files:**
|
||
- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (line 25, end of `content` block)
|
||
- Modify: `l4d2web/tests/test_script_overlay_routes.py`
|
||
|
||
- [ ] **Step 1: Write the failing form-contract test**
|
||
|
||
Append to `l4d2web/tests/test_script_overlay_routes.py`. The file already defines an `app` fixture, an `alice_id` fixture, a `_client_for(app, user_id)` helper, and a `_create_script_overlay(app, user_id, *, name)` helper. Reuse them:
|
||
|
||
```python
|
||
def test_script_overlay_detail_renders_bash_editor(app, alice_id) -> None:
|
||
overlay_id = _create_script_overlay(app, alice_id, name="bash-editor-test")
|
||
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 "static/js/editor.js" in body
|
||
assert 'nonce="' in body
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest tests/test_script_overlay_routes.py::test_script_overlay_detail_renders_bash_editor -v
|
||
```
|
||
|
||
Expected: FAIL — `data-editor-language="bash"` is not in the rendered HTML.
|
||
|
||
- [ ] **Step 3: Edit overlay_detail.html (line 25)**
|
||
|
||
Change:
|
||
|
||
```jinja
|
||
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
|
||
```
|
||
|
||
to:
|
||
|
||
```jinja
|
||
<textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea>
|
||
```
|
||
|
||
At the very end of `{% block content %}` (after the existing `<script src="...files-overlay.js" defer></script>` line near line 274, but outside the `{% if files_can_edit %}` block so it loads for script overlays too), add the partial include:
|
||
|
||
```jinja
|
||
{% include '_editor_assets.html' %}
|
||
```
|
||
|
||
- [ ] **Step 4: Run the test to verify it passes**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest tests/test_script_overlay_routes.py::test_script_overlay_detail_renders_bash_editor -v
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Manual smoke**
|
||
|
||
Navigate to a script-type overlay. Confirm bash highlighting: keywords like `for`, `if`, `done` should be highlighted; `$VAR` references colored; `#` comments muted.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/tests/test_script_overlay_routes.py
|
||
git commit -m "feat(overlay): mount bash editor on script overlay form
|
||
|
||
data-editor-language=bash opts the textarea in; the editor uses
|
||
Prism's stock bash grammar (no project-owned bash code)."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Seed srccfg vocabulary
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/l4d2web/static/data/srccfg-vocab.json`
|
||
|
||
A minimal but useful seed list. The full L4D2 cvar list has ~3000 entries; this seed covers the highest-traffic ones a user is likely to type. Augment later as needed.
|
||
|
||
- [ ] **Step 1: Write the seed vocab**
|
||
|
||
Create `l4d2web/l4d2web/static/data/srccfg-vocab.json`:
|
||
|
||
```json
|
||
{
|
||
"_comment": "Curated L4D2 cvars + commands for editor autocomplete. Regenerate by running `cvarlist` and `cmdlist` against a freshly-started L4D2 dedicated server with the project's common SourceMod plugins loaded, then hand-trimming engine internals nobody touches. Descriptions come from the trailing help text where present.",
|
||
"cvars": [
|
||
{"name": "sv_cheats", "desc": "Allow cheat cvars (0/1) — disables VAC"},
|
||
{"name": "sv_pure", "desc": "Pure-server enforcement (0=off, 1=loose, 2=strict)"},
|
||
{"name": "sv_consistency", "desc": "Force consistency on every client file (0/1)"},
|
||
{"name": "sv_alltalk", "desc": "Cross-team voice chat (0/1)"},
|
||
{"name": "sv_lan", "desc": "LAN-only server (0=internet, 1=LAN)"},
|
||
{"name": "sv_voiceenable", "desc": "Enable voice chat (0/1)"},
|
||
{"name": "sv_password", "desc": "Server join password (empty for open)"},
|
||
{"name": "sv_logflush", "desc": "Flush log file after every line (0/1)"},
|
||
{"name": "sv_minrate", "desc": "Minimum client bandwidth (bytes/sec)"},
|
||
{"name": "sv_maxrate", "desc": "Maximum client bandwidth (bytes/sec)"},
|
||
{"name": "sv_mincmdrate", "desc": "Minimum client command rate"},
|
||
{"name": "sv_maxcmdrate", "desc": "Maximum client command rate"},
|
||
{"name": "sv_minupdaterate", "desc": "Minimum server update rate"},
|
||
{"name": "sv_maxupdaterate", "desc": "Maximum server update rate"},
|
||
{"name": "sv_region", "desc": "Server browser region code"},
|
||
{"name": "sv_steamgroup", "desc": "Steam group ID for restricted servers"},
|
||
{"name": "sv_tags", "desc": "Comma-separated tags for the server browser"},
|
||
{"name": "hostname", "desc": "Server name shown in the browser"},
|
||
{"name": "rcon_password", "desc": "Remote-console admin password"},
|
||
{"name": "mp_gamemode", "desc": "Game mode (coop, versus, survival, scavenge, realism)"},
|
||
{"name": "mp_roundtime", "desc": "Round time limit (minutes)"},
|
||
{"name": "z_difficulty", "desc": "AI director difficulty (Easy/Normal/Hard/Impossible)"},
|
||
{"name": "director_no_specials", "desc": "Disable special-infected spawning (0/1)"},
|
||
{"name": "director_no_bosses", "desc": "Disable tank/witch spawning (0/1)"},
|
||
{"name": "director_panic_forever", "desc": "Endless horde panic event (0/1)"},
|
||
{"name": "nb_update_frequency", "desc": "Infected bot AI tick frequency"},
|
||
{"name": "fps_max", "desc": "Frame rate cap (0=uncapped)"},
|
||
{"name": "tickrate", "desc": "Server tickrate (engine-dependent ceiling)"},
|
||
{"name": "net_splitpacket_maxrate", "desc": "Maximum split-packet bandwidth"},
|
||
{"name": "decalfrequency", "desc": "Anti-spam delay between sprays (seconds)"}
|
||
],
|
||
"commands": [
|
||
{"name": "exec", "desc": "Execute a .cfg file"},
|
||
{"name": "alias", "desc": "Define a console-command alias"},
|
||
{"name": "bind", "desc": "Bind a key to a command"},
|
||
{"name": "unbind", "desc": "Remove a key binding"},
|
||
{"name": "toggle", "desc": "Flip a 0/1 cvar"},
|
||
{"name": "sm_cvar", "desc": "SourceMod: set a cvar bypassing sv_cheats restrictions"},
|
||
{"name": "echo", "desc": "Print to console"},
|
||
{"name": "say", "desc": "Send a chat message as the server"}
|
||
]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Validate JSON parses**
|
||
|
||
```bash
|
||
python3 -m json.tool l4d2web/l4d2web/static/data/srccfg-vocab.json > /dev/null && echo OK
|
||
```
|
||
|
||
Expected: `OK`.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/data/srccfg-vocab.json
|
||
git commit -m "data(editor): seed L4D2 cvar/command vocabulary
|
||
|
||
Hand-curated set of high-traffic cvars and commands sourced from the
|
||
existing l4d2-server-cvar-reference.md and common SourceMod usage.
|
||
Regeneration procedure documented in the file header."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Autocomplete popup in editor.js
|
||
|
||
**Files:**
|
||
- Modify: `l4d2web/l4d2web/static/js/editor.js`
|
||
|
||
Add: vocab lazy-loader, caret-position popup, keyboard navigation, accept/dismiss.
|
||
|
||
- [ ] **Step 1: Add the vocab loader**
|
||
|
||
Insert near the top of `editor.js`, after the `LANG_BY_EXT` constant:
|
||
|
||
```js
|
||
const VOCAB_URLS = {
|
||
srccfg: "/static/data/srccfg-vocab.json",
|
||
};
|
||
|
||
const vocabCache = {};
|
||
|
||
async function loadVocab(lang) {
|
||
if (vocabCache[lang]) return vocabCache[lang];
|
||
const url = VOCAB_URLS[lang];
|
||
if (!url) return [];
|
||
try {
|
||
const r = await fetch(url);
|
||
if (!r.ok) return [];
|
||
const data = await r.json();
|
||
const merged = []
|
||
.concat(data.cvars || [])
|
||
.concat(data.commands || []);
|
||
vocabCache[lang] = merged;
|
||
return merged;
|
||
} catch (err) {
|
||
console.warn("[editor] vocab load failed for " + lang, err);
|
||
vocabCache[lang] = [];
|
||
return [];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add the popup helpers**
|
||
|
||
Insert after `loadVocab`:
|
||
|
||
```js
|
||
const WORD_BEFORE_CARET = /[A-Za-z0-9_]{2,}$/;
|
||
|
||
function getCaretContext(codeEl) {
|
||
const sel = window.getSelection();
|
||
if (!sel || !sel.rangeCount) return null;
|
||
const range = sel.getRangeAt(0).cloneRange();
|
||
if (!codeEl.contains(range.endContainer)) return null;
|
||
// Build the text from start of the editor up to the caret.
|
||
const pre = range.cloneRange();
|
||
pre.selectNodeContents(codeEl);
|
||
pre.setEnd(range.endContainer, range.endOffset);
|
||
const textBefore = pre.toString();
|
||
const m = WORD_BEFORE_CARET.exec(textBefore);
|
||
if (!m) return null;
|
||
return {
|
||
fragment: m[0],
|
||
rect: range.getBoundingClientRect(),
|
||
};
|
||
}
|
||
|
||
function filterVocab(vocab, fragment) {
|
||
const lower = fragment.toLowerCase();
|
||
const prefix = [];
|
||
const substr = [];
|
||
for (const entry of vocab) {
|
||
const name = entry.name.toLowerCase();
|
||
if (name.startsWith(lower)) prefix.push(entry);
|
||
else if (name.includes(lower)) substr.push(entry);
|
||
if (prefix.length + substr.length >= 50) break;
|
||
}
|
||
return prefix.concat(substr).slice(0, 50);
|
||
}
|
||
|
||
function renderPopup(popup, items, activeIndex) {
|
||
popup.innerHTML = "";
|
||
const visible = items.slice(0, 8);
|
||
visible.forEach((entry, i) => {
|
||
const li = document.createElement("li");
|
||
li.className = "editor-popup-item" + (i === activeIndex ? " is-active" : "");
|
||
li.dataset.index = String(i);
|
||
const name = document.createElement("span");
|
||
name.className = "name";
|
||
name.textContent = entry.name;
|
||
li.appendChild(name);
|
||
if (entry.desc) {
|
||
const desc = document.createElement("span");
|
||
desc.className = "desc";
|
||
desc.textContent = "— " + entry.desc;
|
||
li.appendChild(desc);
|
||
}
|
||
popup.appendChild(li);
|
||
});
|
||
}
|
||
|
||
function positionPopup(popup, rect) {
|
||
popup.style.left = (window.scrollX + rect.left) + "px";
|
||
popup.style.top = (window.scrollY + rect.bottom + 2) + "px";
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Wire the popup into the editor instance**
|
||
|
||
Inside `mount(textarea)`, after the existing `jar.onUpdate(...)` block, add:
|
||
|
||
```js
|
||
let popup = null;
|
||
let popupItems = [];
|
||
let popupActive = 0;
|
||
|
||
function ensurePopup() {
|
||
if (popup) return popup;
|
||
popup = document.createElement("ul");
|
||
popup.className = "editor-popup";
|
||
popup.style.display = "none";
|
||
document.body.appendChild(popup);
|
||
popup.addEventListener("mousedown", function (e) {
|
||
e.preventDefault(); // keep caret in editor
|
||
const li = e.target.closest(".editor-popup-item");
|
||
if (!li) return;
|
||
acceptCompletion(popupItems[parseInt(li.dataset.index, 10)]);
|
||
});
|
||
return popup;
|
||
}
|
||
|
||
function hidePopup() {
|
||
if (popup) popup.style.display = "none";
|
||
popupItems = [];
|
||
}
|
||
|
||
function acceptCompletion(entry) {
|
||
if (!entry) return;
|
||
const ctx = getCaretContext(code);
|
||
if (!ctx) {
|
||
hidePopup();
|
||
return;
|
||
}
|
||
// Replace the trailing word fragment with the chosen identifier.
|
||
const sel = window.getSelection();
|
||
const range = sel.getRangeAt(0);
|
||
range.setStart(range.endContainer, range.endOffset - ctx.fragment.length);
|
||
range.deleteContents();
|
||
range.insertNode(document.createTextNode(entry.name));
|
||
range.collapse(false);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
// Force CodeJar to re-highlight + emit onUpdate.
|
||
jar.updateCode(jar.toString());
|
||
hidePopup();
|
||
}
|
||
|
||
async function refreshPopup() {
|
||
if (instance.language === "plain") {
|
||
hidePopup();
|
||
return;
|
||
}
|
||
const ctx = getCaretContext(code);
|
||
if (!ctx) {
|
||
hidePopup();
|
||
return;
|
||
}
|
||
const vocab = await loadVocab(instance.language);
|
||
if (!vocab.length) {
|
||
hidePopup();
|
||
return;
|
||
}
|
||
const filtered = filterVocab(vocab, ctx.fragment);
|
||
if (!filtered.length) {
|
||
hidePopup();
|
||
return;
|
||
}
|
||
popupItems = filtered;
|
||
popupActive = 0;
|
||
ensurePopup();
|
||
renderPopup(popup, popupItems, popupActive);
|
||
positionPopup(popup, ctx.rect);
|
||
popup.style.display = "";
|
||
}
|
||
|
||
code.addEventListener("input", refreshPopup);
|
||
code.addEventListener("blur", function () {
|
||
// Defer hide so a popup click can still register.
|
||
setTimeout(hidePopup, 100);
|
||
});
|
||
|
||
code.addEventListener("keydown", function (e) {
|
||
if (!popup || popup.style.display === "none" || !popupItems.length) return;
|
||
if (e.key === "ArrowDown") {
|
||
popupActive = (popupActive + 1) % Math.min(popupItems.length, 8);
|
||
renderPopup(popup, popupItems, popupActive);
|
||
e.preventDefault();
|
||
} else if (e.key === "ArrowUp") {
|
||
popupActive =
|
||
(popupActive - 1 + Math.min(popupItems.length, 8)) %
|
||
Math.min(popupItems.length, 8);
|
||
renderPopup(popup, popupItems, popupActive);
|
||
e.preventDefault();
|
||
} else if (e.key === "Tab" || e.key === "Enter") {
|
||
acceptCompletion(popupItems[popupActive]);
|
||
e.preventDefault();
|
||
} else if (e.key === "Escape") {
|
||
hidePopup();
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Manual smoke**
|
||
|
||
Open a blueprint detail page. Type `sv_che`. Expected:
|
||
- Popup appears below the caret listing `sv_cheats` (highlighted first).
|
||
- ↓ moves the highlight, ↑ moves it back.
|
||
- Tab inserts `sv_cheats` replacing `sv_che`.
|
||
- Esc dismisses without inserting.
|
||
- Clicking on a popup item inserts that item.
|
||
|
||
Try edge cases: typing `xyzzy` (no match) hides the popup; pressing Esc, then continuing to type re-opens it; switching languages via the files-editor dropdown disables srccfg autocomplete when set to bash (because there's no bash vocab URL).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/static/js/editor.js
|
||
git commit -m "feat(editor): add identifier autocomplete popup
|
||
|
||
Vocab loaded lazily from /static/data/<lang>-vocab.json on first
|
||
mount, cached in memory. Popup appears when the word fragment before
|
||
the caret has ≥2 word characters and matches the vocabulary. Prefix
|
||
matches rank ahead of substring matches; popup shows up to 8 with
|
||
scroll. ↑/↓ navigate, Tab/Enter accept, Esc dismisses."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Files-editor modal integration
|
||
|
||
**Files:**
|
||
- Modify: `l4d2web/l4d2web/templates/overlay_detail.html` (~line 178)
|
||
- Modify: `l4d2web/l4d2web/static/js/files-overlay.js` (~lines 345, 385)
|
||
|
||
The files-editor modal opens for a different file each time, so the widget needs `setValue(content)` and `setLanguage(name)` calls when the modal opens. The dropdown lets the user override the auto-detected language.
|
||
|
||
- [ ] **Step 1: Edit overlay_detail.html (around line 178)**
|
||
|
||
Locate the existing block:
|
||
|
||
```jinja
|
||
<label class="files-editor-field">
|
||
<span class="files-field-label">Content</span>
|
||
<textarea class="files-editor-content" rows="14" spellcheck="false"></textarea>
|
||
</label>
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```jinja
|
||
<label class="files-editor-field">
|
||
<span class="files-field-label">Content</span>
|
||
<textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea>
|
||
</label>
|
||
<label class="files-editor-field files-editor-language-field">
|
||
<span class="files-field-label">Language</span>
|
||
<select class="files-editor-language editor-language-select">
|
||
<option value="auto" selected>Auto (from filename)</option>
|
||
<option value="srccfg">Source config (.cfg)</option>
|
||
<option value="bash">Bash (.sh)</option>
|
||
<option value="plain">Plain text</option>
|
||
</select>
|
||
</label>
|
||
```
|
||
|
||
The `_editor_assets.html` include added in Task 7 already covers this template, so no additional script tags are needed.
|
||
|
||
- [ ] **Step 2: Bridge files-overlay.js to call setValue/setLanguage**
|
||
|
||
In `l4d2web/l4d2web/static/js/files-overlay.js`, locate the two places that assign to `editorEls.contentBox.value`:
|
||
|
||
- Around line 345 (`openEditorTextNew`): `editorEls.contentBox.value = "";`
|
||
- Around line 385 (`openEditorForFile`): `editorEls.contentBox.value = r.body.content;`
|
||
|
||
Replace each with a helper that goes through the editor when one is mounted:
|
||
|
||
Add this helper near the top of `files-overlay.js` (after the existing `editorDialog = document.getElementById(...)` line):
|
||
|
||
```js
|
||
function setEditorContent(text) {
|
||
const editor = editorEls.contentBox._codeEditor;
|
||
if (editor) {
|
||
editor.setValue(text);
|
||
editor.setLanguage("auto"); // re-derive from filename
|
||
} else {
|
||
editorEls.contentBox.value = text;
|
||
}
|
||
}
|
||
```
|
||
|
||
Then update the two call sites:
|
||
|
||
```js
|
||
// In openEditorTextNew (was: editorEls.contentBox.value = "";)
|
||
setEditorContent("");
|
||
|
||
// In openEditorForFile (was: editorEls.contentBox.value = r.body.content;)
|
||
setEditorContent(r.body.content);
|
||
```
|
||
|
||
The transient "Loading…" assignment around line 378 (`editorEls.contentBox.value = "Loading…";`) can also be swapped to `setEditorContent("Loading…");` for consistency.
|
||
|
||
- [ ] **Step 3: Wire the language dropdown**
|
||
|
||
Add right after the `setEditorContent` helper:
|
||
|
||
```js
|
||
const languageSelect = document.querySelector(".files-editor-language");
|
||
if (languageSelect) {
|
||
languageSelect.addEventListener("change", function () {
|
||
const editor = editorEls.contentBox._codeEditor;
|
||
if (editor) editor.setLanguage(languageSelect.value);
|
||
});
|
||
}
|
||
```
|
||
|
||
Also, when the filename input changes and the dropdown is still on `auto`, re-derive. Inside the existing filename-input handler (search for `editorEls.filename.addEventListener("input"`), append a call to `setEditorContent(jar.getValue())` — wait, that's not right; we just want to re-trigger the auto-detection. Use:
|
||
|
||
```js
|
||
// Append inside the existing filename input handler:
|
||
const _editor = editorEls.contentBox._codeEditor;
|
||
if (_editor && languageSelect && languageSelect.value === "auto") {
|
||
_editor.setLanguage("auto");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Manual smoke**
|
||
|
||
Open a files-type overlay. Click `+ new file` on the root row, name it `test.cfg`, paste some `sv_cheats 1`-style content. Expected:
|
||
- Editor mounts inside the modal.
|
||
- Language dropdown shows "Auto (from filename)".
|
||
- Content highlighted as srccfg (cvar tokens colored).
|
||
- Type `sv_che` → autocomplete popup with `sv_cheats`.
|
||
- Switch dropdown to "Bash (.sh)" → re-highlights with bash grammar (no autocomplete because no bash vocab).
|
||
- Switch dropdown to "Auto" → re-highlights as srccfg.
|
||
- Click Save → file is saved (verify by reopening it).
|
||
- Open the file again → editor loads content with srccfg highlighting (auto-detected from the .cfg extension).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/l4d2web/templates/overlay_detail.html l4d2web/l4d2web/static/js/files-overlay.js
|
||
git commit -m "feat(files-editor): mount auto-language editor + dropdown override
|
||
|
||
The modal textarea opts in with data-editor-language=auto; the editor
|
||
derives the language from the filename extension on each modal open.
|
||
A dropdown lets the user override (srccfg / bash / plain). The
|
||
existing fetch-based /files/save path is unchanged — files-overlay.js
|
||
keeps reading textarea.value, which the editor mirrors."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Playwright scaffolding
|
||
|
||
**Files:**
|
||
- Modify: `l4d2web/pyproject.toml`
|
||
- Create: `l4d2web/tests/e2e/__init__.py`
|
||
- Create: `l4d2web/tests/e2e/conftest.py`
|
||
- Modify: `AGENTS.md` (repo root)
|
||
|
||
- [ ] **Step 1: Inspect the existing dev-deps shape**
|
||
|
||
```bash
|
||
grep -A 20 "\[project.optional-dependencies\]\|\[dependency-groups\]\|\[tool.uv\]" l4d2web/pyproject.toml
|
||
```
|
||
|
||
Note which group dev deps live under (`dev`, `test`, etc.).
|
||
|
||
- [ ] **Step 2: Add playwright to dev deps**
|
||
|
||
Edit `l4d2web/pyproject.toml`. In the dev-deps group identified in Step 1, append:
|
||
|
||
```toml
|
||
"playwright>=1.49.0",
|
||
"pytest-playwright>=0.6.0",
|
||
```
|
||
|
||
- [ ] **Step 3: Install the dep + chromium binary**
|
||
|
||
```bash
|
||
cd l4d2web && uv sync
|
||
cd l4d2web && uv run playwright install chromium
|
||
```
|
||
|
||
Expected: `playwright install chromium` downloads the browser binary (a few hundred MB) and prints a "chromium installed" line.
|
||
|
||
- [ ] **Step 4: Configure pytest to register the e2e marker**
|
||
|
||
In `l4d2web/pyproject.toml`, find the `[tool.pytest.ini_options]` block (or create one). Ensure it contains:
|
||
|
||
```toml
|
||
[tool.pytest.ini_options]
|
||
markers = [
|
||
"e2e: end-to-end browser tests (slow, require chromium)",
|
||
]
|
||
```
|
||
|
||
If markers already exist, append the `e2e` entry to the list.
|
||
|
||
- [ ] **Step 5: Create the e2e test directory + conftest**
|
||
|
||
```bash
|
||
touch l4d2web/tests/e2e/__init__.py
|
||
```
|
||
|
||
Create `l4d2web/tests/e2e/conftest.py`:
|
||
|
||
```python
|
||
"""Pytest fixtures for end-to-end browser tests.
|
||
|
||
Boots the Flask app in a background thread on an ephemeral port and
|
||
yields the base URL. The app uses a temp SQLite DB so e2e runs don't
|
||
contaminate the dev database.
|
||
"""
|
||
|
||
import socket
|
||
import threading
|
||
from datetime import UTC, datetime
|
||
|
||
import pytest
|
||
from werkzeug.serving import make_server
|
||
|
||
from l4d2web.app import create_app
|
||
from l4d2web.auth import hash_password
|
||
from l4d2web.db import init_db, session_scope
|
||
from l4d2web.models import Blueprint, User
|
||
|
||
|
||
def _free_port() -> int:
|
||
s = socket.socket()
|
||
s.bind(("127.0.0.1", 0))
|
||
port = s.getsockname()[1]
|
||
s.close()
|
||
return port
|
||
|
||
|
||
@pytest.fixture(scope="function")
|
||
def live_server(tmp_path, monkeypatch):
|
||
db_path = tmp_path / "e2e.db"
|
||
db_url = f"sqlite:///{db_path}"
|
||
monkeypatch.setenv("DATABASE_URL", db_url)
|
||
app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"})
|
||
init_db()
|
||
|
||
with session_scope() as session:
|
||
user = User(
|
||
username="alice",
|
||
password_digest=hash_password("secret"),
|
||
admin=False,
|
||
)
|
||
session.add(user)
|
||
session.flush()
|
||
bp = Blueprint(
|
||
user_id=user.id, name="bp", arguments="[]", config="[]"
|
||
)
|
||
session.add(bp)
|
||
session.flush()
|
||
blueprint_id = bp.id
|
||
user_id = user.id
|
||
|
||
port = _free_port()
|
||
server = make_server("127.0.0.1", port, app)
|
||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||
thread.start()
|
||
try:
|
||
yield {
|
||
"base_url": f"http://127.0.0.1:{port}",
|
||
"user_id": user_id,
|
||
"blueprint_id": blueprint_id,
|
||
}
|
||
finally:
|
||
server.shutdown()
|
||
thread.join(timeout=2)
|
||
```
|
||
|
||
- [ ] **Step 6: Document playwright install in AGENTS.md**
|
||
|
||
Add a short subsection to the repo-root `AGENTS.md`, under whatever the existing "Local setup" section is named (grep for it; if there's no obvious section, append at end):
|
||
|
||
```markdown
|
||
### End-to-end tests
|
||
|
||
The Playwright-based browser tests under `l4d2web/tests/e2e/` need a
|
||
chromium binary, fetched on first setup:
|
||
|
||
```bash
|
||
cd l4d2web && uv run playwright install chromium
|
||
```
|
||
|
||
Run with `cd l4d2web && uv run pytest -m e2e`. Excluded from the
|
||
default fast suite via the `e2e` marker.
|
||
```
|
||
|
||
- [ ] **Step 7: Smoke-test the fixture (no real test yet)**
|
||
|
||
Create `l4d2web/tests/e2e/test_smoke.py`:
|
||
|
||
```python
|
||
import pytest
|
||
|
||
|
||
@pytest.mark.e2e
|
||
def test_live_server_boots(live_server) -> None:
|
||
import urllib.request
|
||
|
||
resp = urllib.request.urlopen(live_server["base_url"] + "/login")
|
||
assert resp.status == 200
|
||
```
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest -m e2e -v
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add l4d2web/pyproject.toml l4d2web/tests/e2e/ AGENTS.md
|
||
git commit -m "test(e2e): scaffold Playwright + live-server fixture
|
||
|
||
Adds playwright + pytest-playwright dev deps, an e2e marker, and a
|
||
fixture that boots the Flask app on an ephemeral port with a temp
|
||
SQLite DB. Smoke test confirms the live server is reachable."
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Playwright editor test (red → green)
|
||
|
||
**Files:**
|
||
- Create: `l4d2web/tests/e2e/test_editor.py`
|
||
- Modify (delete): `l4d2web/tests/e2e/test_smoke.py` (optional — keep if you like the heartbeat coverage)
|
||
|
||
- [ ] **Step 1: Write the failing editor test**
|
||
|
||
Create `l4d2web/tests/e2e/test_editor.py`:
|
||
|
||
```python
|
||
"""End-to-end test for the textarea code editor.
|
||
|
||
Logs in as the seed user, navigates to the blueprint detail page, types
|
||
`sv_che` into the editor, asserts the autocomplete popup appears with
|
||
`sv_cheats` highlighted, accepts via Tab, and asserts the underlying
|
||
textarea now contains `sv_cheats`.
|
||
"""
|
||
|
||
import pytest
|
||
from playwright.sync_api import expect, sync_playwright
|
||
|
||
|
||
@pytest.mark.e2e
|
||
def test_editor_autocomplete_inserts_cvar(live_server) -> None:
|
||
base = live_server["base_url"]
|
||
blueprint_id = live_server["blueprint_id"]
|
||
|
||
with sync_playwright() as p:
|
||
browser = p.chromium.launch()
|
||
ctx = browser.new_context()
|
||
page = ctx.new_page()
|
||
|
||
# Log in.
|
||
page.goto(f"{base}/login")
|
||
page.fill('input[name="username"]', "alice")
|
||
page.fill('input[name="password"]', "secret")
|
||
page.click('button[type="submit"]')
|
||
expect(page).to_have_url(f"{base}/dashboard", timeout=5000)
|
||
|
||
# Navigate to the seeded blueprint.
|
||
page.goto(f"{base}/blueprints/{blueprint_id}")
|
||
|
||
# Editor mounts on DOMContentLoaded; the contenteditable replaces
|
||
# the textarea visually. Wait for it.
|
||
editor = page.locator(".editor-code").first
|
||
expect(editor).to_be_visible(timeout=5000)
|
||
|
||
# Focus the editor and type a cvar prefix.
|
||
editor.click()
|
||
page.keyboard.type("sv_che")
|
||
|
||
# The popup should appear and contain sv_cheats.
|
||
popup = page.locator(".editor-popup")
|
||
expect(popup).to_be_visible(timeout=2000)
|
||
expect(popup).to_contain_text("sv_cheats")
|
||
|
||
# Accept via Tab.
|
||
page.keyboard.press("Tab")
|
||
|
||
# The hidden textarea (form field) must now contain the cvar.
|
||
textarea_value = page.evaluate(
|
||
"() => document.querySelector('textarea[name=config]').value"
|
||
)
|
||
assert "sv_cheats" in textarea_value
|
||
|
||
browser.close()
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to verify it fails (or passes)**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest l4d2web/tests/e2e/test_editor.py -v
|
||
```
|
||
|
||
If all prior tasks landed correctly, this should PASS. If it fails, debug:
|
||
- Run with `--headed` (add `p.chromium.launch(headless=False, slow_mo=300)`) to watch the browser.
|
||
- Check the console for JS errors via Playwright's `page.on('console', print)`.
|
||
|
||
- [ ] **Step 3: Run the full default suite to confirm no regressions**
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest -v
|
||
```
|
||
|
||
Expected: all existing tests pass (the new form-contract tests from Tasks 6/7 are included).
|
||
|
||
```bash
|
||
cd l4d2web && uv run pytest -m e2e -v
|
||
```
|
||
|
||
Expected: e2e suite passes.
|
||
|
||
- [ ] **Step 4: Final commit**
|
||
|
||
```bash
|
||
git add l4d2web/tests/e2e/test_editor.py
|
||
git commit -m "test(e2e): editor autocomplete end-to-end
|
||
|
||
Logs in, navigates to a blueprint, types sv_che, asserts the popup
|
||
appears with sv_cheats, accepts via Tab, and asserts the form
|
||
textarea's value contains the inserted cvar."
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review (run after writing the plan; do not commit a separate review file)
|
||
|
||
Spec coverage:
|
||
|
||
- [x] Blueprint config textarea — Task 6
|
||
- [x] Overlay script (bash) textarea — Task 7
|
||
- [x] Files-editor modal textarea — Task 10
|
||
- [x] Auto language detection from filename — Task 10
|
||
- [x] Language dropdown in files-editor — Task 10
|
||
- [x] `srccfg-grammar.js` — Task 2
|
||
- [x] CodeJar + Prism vendored — Task 1
|
||
- [x] `_editor_assets.html` partial — Task 5
|
||
- [x] `editor.css` dedicated stylesheet — Task 3
|
||
- [x] `srccfg-vocab.json` curated vocab — Task 8
|
||
- [x] Lazy vocab loading + caching — Task 9 (`loadVocab` + `vocabCache`)
|
||
- [x] Autocomplete trigger + filter + keyboard + mouse — Task 9
|
||
- [x] Form-contract tests (GET + POST round-trip) — Task 6 (blueprint), Task 7 (overlay)
|
||
- [x] Playwright scaffold + e2e test — Tasks 11 + 12
|
||
- [x] No backend code changes — confirmed: `blueprint_routes.py`, `app.py`, `/files/save` route untouched
|
||
|
||
Closed items in spec (line numbers, multi-cursor, etc.) — out of scope by design.
|
||
|
||
Open issue noted during planning:
|
||
|
||
- **CodeJar caret preservation across `setLanguage`** — Task 4 Step 2 explicitly accepts caret-loss on language switch (rare action, no UX regression). If a future task needs caret-preserved language switching, expect to introduce a thin wrapper that re-mounts CodeJar while recording the previous selection offset.
|
||
- **Bash autocomplete vocab** — none in v1, by spec. The popup simply won't appear when language is `bash`. `VOCAB_URLS` is structured to make adding a `bash` entry trivial later.
|
||
|
||
---
|
||
|
||
## Reference snippets and where to look them up
|
||
|
||
- **Form contract POST shape** — `l4d2web/l4d2web/routes/blueprint_routes.py:117-142` reads `request.form.get("config")` and splits on newlines. Do not change this in any task; the editor preserves the textarea's `name="config"` and pipes value back on input.
|
||
- **CSP nonce accessor** — `l4d2web/l4d2web/app.py:84-86` exposes `g.csp_nonce` via `before_request`. All editor `<script>` tags use it; no inline scripts anywhere.
|
||
- **Existing progressive-enhancement pattern** — `l4d2web/l4d2web/static/js/blueprint-overlay-picker.js:20-26,63` manipulates hidden inputs directly. The editor follows the same shape: no submit interception, just DOM mirroring.
|
||
- **Files-editor save path** — `l4d2web/l4d2web/static/js/files-overlay.js:450-557`. JSON-fetch POST of `{path, content}` where `content` is `editorEls.contentBox.value`. The editor's `onUpdate` callback keeps `.value` in sync; the save call site needs no changes (only the `value =` *assignments* at lines 345 + 385 do).
|