The plan template (and verbatim implementation) listed five of the six editor asset URLs in the structural test — vendor/prism.css was omitted. If a future change drops the Prism stylesheet from the partial, syntax tokens lose their color rules silently and the test still passes. Add the missing assertion and update the plan to match. Addresses Minor #1 from the Task 6 code review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1556 lines
56 KiB
Markdown
1556 lines
56 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. */
|
||
|
||
/* Token swaps from spec to match tokens.css:
|
||
--color-fg → --color-text (text color token)
|
||
--color-bg-input → --color-surface (textarea uses --color-surface)
|
||
--radius-input → --radius-s (closest available radius token)
|
||
--color-bg-popover → --color-surface (surface layer for overlays)
|
||
*/
|
||
|
||
.editor-shell {
|
||
position: relative;
|
||
width: 100%;
|
||
}
|
||
|
||
.editor-code {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 6em;
|
||
padding: var(--space-s) var(--space-m);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 0.875rem;
|
||
line-height: 1.5;
|
||
color: var(--color-text, #e6e6e6);
|
||
background: var(--color-surface, #1b1b1b);
|
||
border: 1px solid var(--color-border, #333);
|
||
border-radius: var(--radius-s, 4px);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
overflow: auto;
|
||
outline: none;
|
||
}
|
||
|
||
.editor-code:focus {
|
||
border-color: var(--color-focus, #6ab0ff);
|
||
}
|
||
|
||
/* Prism token colors — override defaults to match the site palette.
|
||
--color-string / --color-keyword / --color-number / --color-bg-popover-active
|
||
are added to tokens.css in both :root and the dark-mode block, so the
|
||
editor inherits the site's light/dark theming without per-token fallbacks. */
|
||
.editor-code .token.comment { color: var(--color-muted, #888); font-style: italic; }
|
||
.editor-code .token.string { color: var(--color-string); }
|
||
.editor-code .token.keyword { color: var(--color-keyword); font-weight: 600; }
|
||
.editor-code .token.number { color: var(--color-number); }
|
||
.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-surface, #222);
|
||
border: 1px solid var(--color-border, #333);
|
||
border-radius: var(--radius-s, 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);
|
||
}
|
||
|
||
.editor-popup-item .name { color: var(--color-text, #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;
|
||
}
|
||
```
|
||
|
||
The token-swap comment at the top records the four `tokens.css` reconciliations applied during the original audit pass. The five-line block following the Prism-token comment block also requires the matching token additions to `tokens.css` (both `:root` and `@media (prefers-color-scheme: dark)`): see the immediately following sub-step.
|
||
|
||
**Step 2b: Add the syntax-highlighting tokens to `tokens.css`**
|
||
|
||
```css
|
||
/* Inside :root (light mode) */
|
||
--color-string: #0a3069; /* dark blue */
|
||
--color-keyword: #cf222e; /* red */
|
||
--color-number: #0550ae; /* medium blue */
|
||
--color-bg-popover-active: #e5e7eb; /* light gray — visible on white surface */
|
||
|
||
/* Inside @media (prefers-color-scheme: dark) */
|
||
--color-string: #a5d6ff; /* light blue */
|
||
--color-keyword: #ff7b72; /* salmon */
|
||
--color-number: #79c0ff; /* light blue, different hue from string */
|
||
--color-bg-popover-active: #374151; /* lifted gray on dark surface */
|
||
```
|
||
|
||
These are GitHub-style code-syntax colors tuned to ≥4.5:1 contrast on each theme's surface. Confirm placement matches the file's existing block structure (alphabetical-within-block in the current file).
|
||
|
||
- [ ] **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 9; this task lands a working "highlight as you type" experience.
|
||
|
||
- [x] **Step 1: Write `editor.js` (consolidated final version)**
|
||
|
||
Create `l4d2web/l4d2web/static/js/editor.js`:
|
||
|
||
Key design decisions baked into the final file (both the initial skeleton and the `setLanguage` fix merged into one):
|
||
|
||
- `findFilenameInput` is hoisted above `mount` so the initial `language` resolution can reference it without a forward-reference hazard.
|
||
- `attachOnUpdate(jarInstance)` is a small helper called at construction time AND inside `setLanguage`'s remount, avoiding a duplicated callback body.
|
||
- All method bodies in `instance` use `instance.jar` (not the captured `jar` variable) so `setValue`/`getValue`/`destroy` always operate on the *current* jar after a `setLanguage` swap.
|
||
- `setLanguage` uses tear-down-and-remount (not `updateOptions`/`code.__highlighter`) because CodeJar captures its highlight callback by closure at construction time and provides no API to swap it on a live instance.
|
||
|
||
```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);
|
||
};
|
||
}
|
||
|
||
// For "auto" language: look for a filename input inside the nearest
|
||
// enclosing dialog or .modal. Returns the <input> or null.
|
||
// Intentionally does NOT walk up to <body>: an "auto" textarea
|
||
// outside a modal scope degrades to "plain" rather than picking up
|
||
// some unrelated .files-editor-filename elsewhere in the document.
|
||
function findFilenameInput(textarea) {
|
||
const scope = textarea.closest("dialog, .modal");
|
||
if (!scope) return null;
|
||
return scope.querySelector(".files-editor-filename");
|
||
}
|
||
|
||
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: " " });
|
||
|
||
function attachOnUpdate(jarInstance) {
|
||
jarInstance.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 }));
|
||
});
|
||
}
|
||
|
||
attachOnUpdate(jar);
|
||
|
||
const instance = {
|
||
textarea,
|
||
shell,
|
||
code,
|
||
jar,
|
||
language,
|
||
setValue: function (text) {
|
||
// updateCode does not invoke the onUpdate mirror, so fire the
|
||
// same textarea sync + input event here for consistency. Any
|
||
// listener watching the textarea sees external setValue calls
|
||
// (e.g. files-overlay loading a file into the modal) the same
|
||
// way it sees user typing.
|
||
instance.jar.updateCode(text);
|
||
textarea.value = text;
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
},
|
||
getValue: function () {
|
||
return instance.jar.toString();
|
||
},
|
||
setLanguage: function (name) {
|
||
const next =
|
||
name === "auto"
|
||
? resolveAutoLanguage(findFilenameInput(textarea)?.value)
|
||
: name;
|
||
if (next === instance.language) return;
|
||
// CodeJar captures its highlight callback by closure at
|
||
// construction time — there is no API to swap it on a live
|
||
// instance. Tear down and remount with the new highlighter.
|
||
// Caret position is lost on switch; acceptable since this is
|
||
// triggered by the user clicking the language dropdown.
|
||
const currentText = instance.jar.toString();
|
||
instance.jar.destroy();
|
||
instance.language = next;
|
||
code.className = "editor-code language-" + next;
|
||
code.textContent = currentText;
|
||
instance.jar = window.CodeJar(code, highlightFor(next), { tab: " " });
|
||
attachOnUpdate(instance.jar);
|
||
},
|
||
destroy: function () {
|
||
instance.jar.destroy();
|
||
shell.remove();
|
||
textarea.style.display = "";
|
||
delete textarea._codeEditor;
|
||
},
|
||
};
|
||
|
||
textarea._codeEditor = instance;
|
||
return instance;
|
||
}
|
||
|
||
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
|
||
// (the files-editor modal exists in the static DOM but is only
|
||
// shown after user interaction — initial mount is correct, but
|
||
// exposing this hook lets future code mount dynamically-inserted
|
||
// editors if needed).
|
||
window.l4d2Editor = { mount, mountAll };
|
||
})();
|
||
```
|
||
|
||
- **Manual verification note:** No smoke test in this task — first live render happens in Task 6 when the blueprint config textarea gets `data-editor-language="srccfg"` and the asset partial is included.
|
||
|
||
---
|
||
|
||
## 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/prism.css" 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).
|