Compare commits
No commits in common. "ead4bd1aa4019f4468bad74c82d1e19c52eb3fe6" and "e5ce4e9fc8c9a2ad03dc09fbfffadb340b306a21" have entirely different histories.
ead4bd1aa4
...
e5ce4e9fc8
26 changed files with 12 additions and 3414 deletions
19
AGENTS.md
19
AGENTS.md
|
|
@ -97,22 +97,3 @@ Typical commands (once components exist):
|
|||
|
||||
- If a requested change conflicts with this file, follow explicit user instruction.
|
||||
- If plans and code diverge, update plans or flag the mismatch clearly.
|
||||
|
||||
## End-to-end tests
|
||||
|
||||
The Playwright-based browser tests under `l4d2web/tests/e2e/` need a
|
||||
chromium binary, fetched on first setup:
|
||||
|
||||
```bash
|
||||
uv run playwright install chromium
|
||||
```
|
||||
|
||||
Run with `uv run pytest -m e2e`. Excluded from the default fast suite
|
||||
via the `e2e` marker.
|
||||
|
||||
**Sandbox note:** Chromium needs Mach-port IPC on macOS, which the
|
||||
Claude Code sandbox blocks. When running e2e tests from a sandboxed
|
||||
agent session, pass `dangerouslyDisableSandbox: true` on the
|
||||
`uv run pytest -m e2e` invocation (the symptom of a sandboxed run is
|
||||
a `FATAL` Chromium crash with `Permission denied (1100)` on Mach port
|
||||
rendezvous, not a missing-binary or network error).
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,175 +0,0 @@
|
|||
# Textarea Code Editor (Highlighting + Autocomplete) Design
|
||||
|
||||
**Goal:** Upgrade three plain-text `<textarea>` fields in left4me into a
|
||||
lightweight code editor with syntax highlighting and identifier-as-you-type
|
||||
autocomplete. The motivating case is the blueprint config field, where users
|
||||
edit Source-engine `server.cfg`-style content but have to remember hundreds of
|
||||
L4D2 cvar names from memory.
|
||||
|
||||
**Architecture:** One reusable vanilla-JS widget. Each `<textarea>` opts in via
|
||||
a `data-editor-language` attribute. The textarea stays in the DOM as the value
|
||||
carrier — the widget mounts a `contenteditable` sibling that mirrors content
|
||||
back into the textarea on every input, so HTML form submission and existing
|
||||
JS that reads `textarea.value` keep working unchanged. Library stack is two
|
||||
single-file self-hosted dependencies (CodeJar + Prism) plus a small custom
|
||||
grammar — no bundler, no build step.
|
||||
|
||||
---
|
||||
|
||||
## Call sites
|
||||
|
||||
| Template | Line | Language | Save path |
|
||||
|---|---|---|---|
|
||||
| `l4d2web/l4d2web/templates/blueprint_detail.html` | 52 | `srccfg` (preset) | HTML form POST → `blueprint_routes.update_blueprint_form` |
|
||||
| `l4d2web/l4d2web/templates/overlay_detail.html` | 25 | `bash` (preset) | HTML form POST → overlay script update route |
|
||||
| `l4d2web/l4d2web/templates/overlay_detail.html` | 178 | `auto` (filename-derived; dropdown override) | `fetch('/files/save', {path, content})` from `files-overlay.js:450-557` |
|
||||
|
||||
## Widget contract
|
||||
|
||||
Every textarea with `data-editor-language="<lang>"` gets upgraded on
|
||||
`DOMContentLoaded`:
|
||||
|
||||
- Sibling `<div class="editor-shell">` holding `<code class="editor-code"
|
||||
contenteditable="true">`, initialized with the textarea's value.
|
||||
- Textarea set to `display: none` (kept in DOM as form/value carrier).
|
||||
- CodeJar mounted on the `<code>`, with highlighter →
|
||||
`Prism.highlightElement` for the active language.
|
||||
- CodeJar `onUpdate(code)` writes back to `textarea.value` and dispatches an
|
||||
`input` event on the textarea.
|
||||
- Floating autocomplete `<ul>` positioned at the caret via
|
||||
`getSelection().getRangeAt(0).getBoundingClientRect()`.
|
||||
- Exposes `setLanguage(name)` for the files-editor language dropdown.
|
||||
|
||||
If JS fails to bootstrap, the textarea is shown (matching today's behavior) —
|
||||
graceful no-JS fallback for free.
|
||||
|
||||
## Autocomplete behavior
|
||||
|
||||
- **Trigger:** word fragment before caret matches `[A-Za-z0-9_]{2,}` and has
|
||||
≥1 hit in the active language's vocabulary.
|
||||
- **Filter:** case-insensitive prefix match first, then substring match. Cap
|
||||
50 candidates; render top 8 with scroll for the rest.
|
||||
- **Keys (popup open):** ↑/↓ navigate · Tab/Enter accept · Esc close · any
|
||||
other key continues editing and re-filters.
|
||||
- **Mouse:** click to accept.
|
||||
- **Display:** identifier line + muted description line if `desc` present.
|
||||
|
||||
## Languages
|
||||
|
||||
- **`srccfg`** — custom Prism grammar (~30 lines). Tokens: comment (`//…`),
|
||||
string (`"…"` with escapes), number, keyword (`exec | alias | bind`),
|
||||
identifier. Purely visual — no semantic validation.
|
||||
- **`bash`** — Prism's stock `bash` grammar. No project-owned code.
|
||||
- **`auto`** — sentinel resolved on mount from the filename: `.cfg → srccfg`,
|
||||
`.sh → bash`, otherwise `plain`. Re-evaluated when the filename input
|
||||
changes (only while the dropdown is in its initial auto state).
|
||||
- **`plain`** — no highlighter, no autocomplete. The widget still mounts so
|
||||
the language dropdown remains usable; setting language back to `srccfg`
|
||||
or `bash` re-activates highlighting.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
Lives at `l4d2web/l4d2web/static/data/srccfg-vocab.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"cvars": [{"name": "sv_cheats", "desc": "Allow cheat cvars (0/1)"}, …],
|
||||
"commands": [{"name": "exec", "desc": "Execute a .cfg file"}, …]
|
||||
}
|
||||
```
|
||||
|
||||
**Sourcing:** dump `cvarlist` / `cmdlist` from a freshly-started L4D2
|
||||
server with the project's common SourceMod plugins loaded. Hand-trim engine
|
||||
internals nobody touches. Descriptions come from the `cvarlist` output's
|
||||
trailing help text where present; otherwise omitted. Generated once,
|
||||
committed verbatim. Document the regeneration procedure in the top-of-file
|
||||
comment of `srccfg-vocab.json`.
|
||||
|
||||
**Loading:** lazy fetch on first `srccfg` editor mount; cached on
|
||||
`window.__srccfgVocab` so multiple editors on the same page share the load.
|
||||
|
||||
## Asset layout (new)
|
||||
|
||||
```
|
||||
l4d2web/l4d2web/static/
|
||||
vendor/
|
||||
prism.js # prismjs.com custom build: core + clike + bash, pinned
|
||||
prism.css
|
||||
codejar.js # github.com/antonmedv/codejar release, pinned
|
||||
README.md # source URLs + versions + SHA256 per file
|
||||
js/
|
||||
editor.js # widget: mount, popup, sync, language switch
|
||||
srccfg-grammar.js # Prism.languages.srccfg
|
||||
data/
|
||||
srccfg-vocab.json # curated cvars + commands
|
||||
```
|
||||
|
||||
All vendored files are self-hosted; the existing CSP
|
||||
(`l4d2web/l4d2web/app.py:99` — `default-src 'self'; script-src 'self'
|
||||
'nonce-…'`) requires it. Template `<script>` tags use `nonce="{{ g.csp_nonce }}"`
|
||||
per the established pattern (`app.py:86`).
|
||||
|
||||
## Files Touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `l4d2web/l4d2web/templates/blueprint_detail.html` | Add `data-editor-language="srccfg"` to line 52; include editor asset block |
|
||||
| `l4d2web/l4d2web/templates/overlay_detail.html` | Add `data-editor-language="bash"` to line 25; add `data-editor-language="auto"` + language `<select>` near line 178 filename field; include editor asset block |
|
||||
| `l4d2web/l4d2web/templates/_editor_assets.html` | New Jinja partial: the five script/link tags with nonces |
|
||||
| `l4d2web/l4d2web/static/vendor/prism.{js,css}` | New (vendored) |
|
||||
| `l4d2web/l4d2web/static/vendor/codejar.js` | New (vendored) |
|
||||
| `l4d2web/l4d2web/static/vendor/README.md` | New — versions + SHA256s |
|
||||
| `l4d2web/l4d2web/static/js/editor.js` | New — ~250 LOC widget |
|
||||
| `l4d2web/l4d2web/static/js/srccfg-grammar.js` | New — ~30 LOC Prism grammar |
|
||||
| `l4d2web/l4d2web/static/data/srccfg-vocab.json` | New — curated cvars/commands |
|
||||
| `l4d2web/l4d2web/static/css/editor.css` | New — dedicated stylesheet for `.editor-shell`, `.editor-code`, `.editor-popup`; reuses `tokens.css` color variables; preserves textarea visual to avoid layout shift. Loaded by the `_editor_assets.html` partial so it ships only on pages that mount an editor. |
|
||||
| `l4d2web/pyproject.toml` | Add `playwright` dev dep |
|
||||
| `l4d2web/tests/e2e/conftest.py` | New — boot Flask app under test, expose URL fixture |
|
||||
| `l4d2web/tests/e2e/test_editor.py` | New — Playwright test: type `sv_che`, assert popup, accept, assert textarea value |
|
||||
| `l4d2web/tests/test_blueprints.py` | Extend — assert editor attrs in GET, assert form contract on POST |
|
||||
|
||||
**Untouched by design:**
|
||||
`l4d2web/l4d2web/blueprint_routes.py`, `l4d2web/l4d2web/static/js/files-overlay.js`,
|
||||
`l4d2web/l4d2web/app.py` — the form contract and the JSON-fetch save path
|
||||
remain identical, which is the central reason this design uses
|
||||
"textarea-as-value-carrier" rather than replacing the textarea.
|
||||
|
||||
## Reusable patterns referenced
|
||||
|
||||
- `l4d2web/l4d2web/static/js/blueprint-overlay-picker.js:20-26,63` —
|
||||
progressive enhancement via DOM-manipulated form fields. This editor
|
||||
follows the same pattern (no submit interception, native form serialization).
|
||||
- `l4d2web/l4d2web/app.py:86` — `g.csp_nonce` accessor for template `<script>`
|
||||
tags.
|
||||
- `l4d2web/l4d2web/static/js/files-overlay.js:450-557` — JSON-fetch save
|
||||
path that continues to read `textarea.value` unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
Manual (Chrome MCP during development):
|
||||
|
||||
1. `/blueprints/<id>` — textarea replaced by editor; existing content preserved.
|
||||
2. Type `sv_che` → popup shows `sv_cheats`; Tab accepts; line highlights.
|
||||
3. Submit form; reload; saved value matches editor content.
|
||||
4. Script-type overlay → bash highlighting.
|
||||
5. Files-type overlay → create `test.cfg`, srccfg highlighting; dropdown to
|
||||
bash, re-highlights; save; persisted content matches.
|
||||
6. Disable JS, reload blueprint → textarea visible, form submits.
|
||||
7. View-source → no inline scripts, all assets nonce'd.
|
||||
|
||||
Automated:
|
||||
|
||||
- `cd l4d2web && uv run pytest tests/test_blueprints.py`
|
||||
- `cd l4d2web && uv run pytest -m e2e` (Playwright)
|
||||
|
||||
## Open / Closed
|
||||
|
||||
- **Closed in v1:** line numbers, search-in-editor, multi-cursor, bracket
|
||||
matching, theme switching, SourcePawn highlighting, bash buffer-context
|
||||
autocomplete, per-server live `cvarlist` capture.
|
||||
- **Open as additive future work:** line numbers (deferred per explicit
|
||||
scope decision; cheap to add later as a non-wrapping mode for `srccfg` +
|
||||
`bash`); SourcePawn grammar; per-server cvar augmentation.
|
||||
- **No backend changes.** The design is intentionally constrained to static
|
||||
assets + template attributes so the change is small to review and easy to
|
||||
revert by deleting the asset block.
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
/* 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. */
|
||||
.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;
|
||||
}
|
||||
|
|
@ -13,10 +13,6 @@
|
|||
--color-focus: #2563eb;
|
||||
--color-log-bg: #f8fafc;
|
||||
--color-log-text: #18181b;
|
||||
--color-string: #0a3069;
|
||||
--color-keyword: #cf222e;
|
||||
--color-number: #0550ae;
|
||||
--color-bg-popover-active: #e5e7eb;
|
||||
|
||||
--space-base: 0.25rem;
|
||||
--space-xs: var(--space-base);
|
||||
|
|
@ -55,10 +51,6 @@
|
|||
--color-focus: #bfdbfe;
|
||||
--color-log-bg: #111827;
|
||||
--color-log-text: #e5e7eb;
|
||||
--color-string: #a5d6ff;
|
||||
--color-keyword: #ff7b72;
|
||||
--color-number: #79c0ff;
|
||||
--color-bg-popover-active: #374151;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
{
|
||||
"_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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,403 +0,0 @@
|
|||
// 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",
|
||||
};
|
||||
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
// 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: " ", addClosing: false });
|
||||
|
||||
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);
|
||||
|
||||
// Warm the vocab cache so the first keystroke doesn't race a network
|
||||
// fetch. Fire-and-forget; loadVocab already caches the empty result
|
||||
// on failure so this is idempotent.
|
||||
if (language !== "plain") loadVocab(language);
|
||||
|
||||
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;
|
||||
}
|
||||
// Walk the selection focus backwards `fragment.length` characters
|
||||
// and replace the result with the chosen identifier.
|
||||
//
|
||||
// We can't use `range.setStart(endContainer, endOffset - fragment.length)`
|
||||
// because at end-of-line the caret often sits in a text node AFTER
|
||||
// Prism's <span class="token …"> (the trailing newline / empty text
|
||||
// node), so `endOffset` is small and the subtraction would go
|
||||
// negative or land in the wrong node. `Selection.modify` walks
|
||||
// across node boundaries the way the browser's own caret-movement
|
||||
// logic does, so it works regardless of which DOM node the caret
|
||||
// ended up in after the last Prism rehighlight.
|
||||
//
|
||||
// (Selection.modify is well-supported in all evergreen browsers
|
||||
// even though it's not standardised — same family of API as
|
||||
// contenteditable itself.)
|
||||
try {
|
||||
const sel = window.getSelection();
|
||||
for (let i = 0; i < ctx.fragment.length; i++) {
|
||||
sel.modify("extend", "backward", "character");
|
||||
}
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
const insertedNode = document.createTextNode(entry.name);
|
||||
range.insertNode(insertedNode);
|
||||
// Move the caret to inside the inserted text node (at its end),
|
||||
// NOT just "after" it via range.collapse(false). The latter
|
||||
// leaves anchorNode/focusNode pointing at <code> with an offset
|
||||
// — and CodeJar's save() has a special case (codejar.js:122-127)
|
||||
// that collapses any such selection to end-of-all-text, sending
|
||||
// restore() to the wrong place. Pointing the selection inside
|
||||
// the inserted text node keeps save() on its normal walk path.
|
||||
const caretRange = document.createRange();
|
||||
caretRange.setStart(insertedNode, insertedNode.length);
|
||||
caretRange.setEnd(insertedNode, insertedNode.length);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(caretRange);
|
||||
// Force CodeJar to re-highlight + emit onUpdate. updateCode does
|
||||
// `editor.textContent = code; highlight(editor)` — no caret
|
||||
// preservation — so we save the linear-text offset of the caret
|
||||
// (now positioned after the inserted identifier) before and
|
||||
// restore after. Use instance.jar (not bare `jar` closure) for
|
||||
// consistency with setLanguage's tear-down-and-remount: the jar
|
||||
// reference is swapped at runtime.
|
||||
const caretPos = instance.jar.save();
|
||||
instance.jar.updateCode(instance.jar.toString());
|
||||
instance.jar.restore(caretPos);
|
||||
} catch (err) {
|
||||
console.warn("[editor] acceptCompletion failed; dismissing popup", err);
|
||||
}
|
||||
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);
|
||||
});
|
||||
|
||||
// IMPORTANT: capture-phase listener.
|
||||
//
|
||||
// CodeJar registers its own keydown handler on this same element during
|
||||
// construction (window.CodeJar at line ~159 above), and it uses
|
||||
// preventDefault + insert-tab-spaces on Tab and preventDefault +
|
||||
// stopPropagation on Enter (with leading indent). Bubble-phase order =
|
||||
// registration order: CodeJar runs first and consumes those keys before
|
||||
// our popup handler ever sees them.
|
||||
//
|
||||
// Capture phase fires before bubble. By calling stopPropagation here,
|
||||
// we prevent CodeJar's bubble handler from running for the keys we own
|
||||
// while the popup is visible. Normal typing (popup hidden) still flows
|
||||
// through to CodeJar unchanged because we early-return without
|
||||
// stopPropagation in that case.
|
||||
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();
|
||||
e.stopPropagation();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
popupActive =
|
||||
(popupActive - 1 + Math.min(popupItems.length, 8)) %
|
||||
Math.min(popupItems.length, 8);
|
||||
renderPopup(popup, popupItems, popupActive);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
} else if (e.key === "Tab" || e.key === "Enter") {
|
||||
acceptCompletion(popupItems[popupActive]);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
} else if (e.key === "Escape") {
|
||||
hidePopup();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, true);
|
||||
|
||||
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: " ", addClosing: false });
|
||||
attachOnUpdate(instance.jar);
|
||||
},
|
||||
destroy: function () {
|
||||
instance.jar.destroy();
|
||||
if (popup) popup.remove();
|
||||
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 };
|
||||
})();
|
||||
|
|
@ -281,16 +281,6 @@
|
|||
saveBtn: editorDialog.querySelector(".files-editor-save"),
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function setEditorTitle(text) {
|
||||
editorEls.title.textContent = text;
|
||||
}
|
||||
|
|
@ -349,17 +339,10 @@
|
|||
editor.folder = folder;
|
||||
editor.queuedReplacement = null;
|
||||
|
||||
// Reset the language dropdown to "auto" on every modal open so the
|
||||
// displayed value matches what setEditorContent does internally
|
||||
// (which always calls setLanguage("auto")). Without this, a user
|
||||
// who picked a manual override on a previous open would see the
|
||||
// stale selection while the editor language follows the new file.
|
||||
if (languageSelect) languageSelect.value = "auto";
|
||||
|
||||
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
|
||||
editorEls.filename.value = "";
|
||||
editorEls.filename.disabled = false;
|
||||
setEditorContent("");
|
||||
editorEls.contentBox.value = "";
|
||||
editorEls.contentBox.disabled = false;
|
||||
editorEls.renameHint.hidden = true;
|
||||
editorEls.textPanel.hidden = false;
|
||||
|
|
@ -380,9 +363,6 @@
|
|||
editor.queuedReplacement = null;
|
||||
setQueuedReplacement(null);
|
||||
|
||||
// Reset the language dropdown — see openEditorTextNew for rationale.
|
||||
if (languageSelect) languageSelect.value = "auto";
|
||||
|
||||
editorEls.filename.value = basename(path);
|
||||
editorEls.filename.disabled = false;
|
||||
editorEls.renameHint.hidden = true;
|
||||
|
|
@ -395,14 +375,14 @@
|
|||
editor.mode = "text";
|
||||
editorEls.textPanel.hidden = false;
|
||||
editorEls.binaryPanel.hidden = true;
|
||||
setEditorContent("Loading…");
|
||||
editorEls.contentBox.value = "Loading…";
|
||||
editorEls.contentBox.disabled = true;
|
||||
|
||||
const r = await fetchJson(
|
||||
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
|
||||
);
|
||||
if (r.ok && r.body) {
|
||||
setEditorContent(r.body.content);
|
||||
editorEls.contentBox.value = r.body.content;
|
||||
editorEls.contentBox.disabled = false;
|
||||
updateByteCount();
|
||||
updateSaveEnabled();
|
||||
|
|
@ -427,21 +407,9 @@
|
|||
setTimeout(() => editorEls.filename.focus(), 0);
|
||||
}
|
||||
|
||||
const languageSelect = document.querySelector(".files-editor-language");
|
||||
if (languageSelect) {
|
||||
languageSelect.addEventListener("change", function () {
|
||||
const editor = editorEls.contentBox._codeEditor;
|
||||
if (editor) editor.setLanguage(languageSelect.value);
|
||||
});
|
||||
}
|
||||
|
||||
editorEls.filename.addEventListener("input", () => {
|
||||
updateRenameHint();
|
||||
updateSaveEnabled();
|
||||
const _editor = editorEls.contentBox._codeEditor;
|
||||
if (_editor && languageSelect && languageSelect.value === "auto") {
|
||||
_editor.setLanguage("auto");
|
||||
}
|
||||
});
|
||||
editorEls.contentBox.addEventListener("input", () => {
|
||||
updateByteCount();
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
// 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);
|
||||
28
l4d2web/l4d2web/static/vendor/README.md
vendored
28
l4d2web/l4d2web/static/vendor/README.md
vendored
|
|
@ -1,28 +0,0 @@
|
|||
# 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 | `636b6ce9db1eddd5b60992bb34e3fbc1c3364bce7c312f798f20a16011d7681c` |
|
||||
| `prism.css` | https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css | v1.29.0 | `928e23e6b9fcef82c5f1d1f05b6f7fc5a6e187c60195e59fbf16fc9d071ee057` |
|
||||
| `codejar.js` | https://cdn.jsdelivr.net/npm/codejar@4.0.0/dist/codejar.js + ESM-strip + attribution header + browser-global shim | v4.0.0 | `1d05dc0fdc6941c3ea0c865612843d098e271155b9258b4cbbc4095b07318d3b` |
|
||||
|
||||
CodeJar ships in source form (not minified) — chosen here over the
|
||||
minified variant because the strict CSP rules out runtime sourcemap
|
||||
loading from a CDN, and readable vendor source meaningfully helps
|
||||
debugging when something goes wrong in the editor widget.
|
||||
|
||||
## 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 (`dist/codejar.js`, not the bare `codejar.js` which does not
|
||||
exist in v4.x), strip ESM exports, prepend the in-file attribution
|
||||
header, re-append the `window.CodeJar` shim.
|
||||
|
||||
Bump the version + SHA columns in this table after any update.
|
||||
492
l4d2web/l4d2web/static/vendor/codejar.js
vendored
492
l4d2web/l4d2web/static/vendor/codejar.js
vendored
|
|
@ -1,492 +0,0 @@
|
|||
/* CodeJar v4.0.0 — https://cdn.jsdelivr.net/npm/codejar@4.0.0/dist/codejar.js
|
||||
* Local edits: leading `export` keyword stripped from `function CodeJar`;
|
||||
* `window.CodeJar = CodeJar;` shim appended at EOF for non-module <script> use. */
|
||||
const globalWindow = window;
|
||||
function CodeJar(editor, highlight, opt = {}) {
|
||||
const options = {
|
||||
tab: '\t',
|
||||
indentOn: /[({\[]$/,
|
||||
moveToNewLine: /^[)}\]]/,
|
||||
spellcheck: false,
|
||||
catchTab: true,
|
||||
preserveIdent: true,
|
||||
addClosing: true,
|
||||
history: true,
|
||||
window: globalWindow,
|
||||
...opt
|
||||
};
|
||||
const window = options.window;
|
||||
const document = window.document;
|
||||
let listeners = [];
|
||||
let history = [];
|
||||
let at = -1;
|
||||
let focus = false;
|
||||
let callback;
|
||||
let prev; // code content prior keydown event
|
||||
editor.setAttribute('contenteditable', 'plaintext-only');
|
||||
editor.setAttribute('spellcheck', options.spellcheck ? 'true' : 'false');
|
||||
editor.style.outline = 'none';
|
||||
editor.style.overflowWrap = 'break-word';
|
||||
editor.style.overflowY = 'auto';
|
||||
editor.style.whiteSpace = 'pre-wrap';
|
||||
let isLegacy = false; // true if plaintext-only is not supported
|
||||
highlight(editor);
|
||||
if (editor.contentEditable !== 'plaintext-only')
|
||||
isLegacy = true;
|
||||
if (isLegacy)
|
||||
editor.setAttribute('contenteditable', 'true');
|
||||
const debounceHighlight = debounce(() => {
|
||||
const pos = save();
|
||||
highlight(editor, pos);
|
||||
restore(pos);
|
||||
}, 30);
|
||||
let recording = false;
|
||||
const shouldRecord = (event) => {
|
||||
return !isUndo(event) && !isRedo(event)
|
||||
&& event.key !== 'Meta'
|
||||
&& event.key !== 'Control'
|
||||
&& event.key !== 'Alt'
|
||||
&& !event.key.startsWith('Arrow');
|
||||
};
|
||||
const debounceRecordHistory = debounce((event) => {
|
||||
if (shouldRecord(event)) {
|
||||
recordHistory();
|
||||
recording = false;
|
||||
}
|
||||
}, 300);
|
||||
const on = (type, fn) => {
|
||||
listeners.push([type, fn]);
|
||||
editor.addEventListener(type, fn);
|
||||
};
|
||||
on('keydown', event => {
|
||||
if (event.defaultPrevented)
|
||||
return;
|
||||
prev = toString();
|
||||
if (options.preserveIdent)
|
||||
handleNewLine(event);
|
||||
else
|
||||
legacyNewLineFix(event);
|
||||
if (options.catchTab)
|
||||
handleTabCharacters(event);
|
||||
if (options.addClosing)
|
||||
handleSelfClosingCharacters(event);
|
||||
if (options.history) {
|
||||
handleUndoRedo(event);
|
||||
if (shouldRecord(event) && !recording) {
|
||||
recordHistory();
|
||||
recording = true;
|
||||
}
|
||||
}
|
||||
if (isLegacy && !isCopy(event))
|
||||
restore(save());
|
||||
});
|
||||
on('keyup', event => {
|
||||
if (event.defaultPrevented)
|
||||
return;
|
||||
if (event.isComposing)
|
||||
return;
|
||||
if (prev !== toString())
|
||||
debounceHighlight();
|
||||
debounceRecordHistory(event);
|
||||
if (callback)
|
||||
callback(toString());
|
||||
});
|
||||
on('focus', _event => {
|
||||
focus = true;
|
||||
});
|
||||
on('blur', _event => {
|
||||
focus = false;
|
||||
});
|
||||
on('paste', event => {
|
||||
recordHistory();
|
||||
handlePaste(event);
|
||||
recordHistory();
|
||||
if (callback)
|
||||
callback(toString());
|
||||
});
|
||||
on('cut', event => {
|
||||
recordHistory();
|
||||
handleCut(event);
|
||||
recordHistory();
|
||||
if (callback)
|
||||
callback(toString());
|
||||
});
|
||||
function save() {
|
||||
const s = getSelection();
|
||||
const pos = { start: 0, end: 0, dir: undefined };
|
||||
let { anchorNode, anchorOffset, focusNode, focusOffset } = s;
|
||||
if (!anchorNode || !focusNode)
|
||||
throw 'error1';
|
||||
// If the anchor and focus are the editor element, return either a full
|
||||
// highlight or a start/end cursor position depending on the selection
|
||||
if (anchorNode === editor && focusNode === editor) {
|
||||
pos.start = (anchorOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
|
||||
pos.end = (focusOffset > 0 && editor.textContent) ? editor.textContent.length : 0;
|
||||
pos.dir = (focusOffset >= anchorOffset) ? '->' : '<-';
|
||||
return pos;
|
||||
}
|
||||
// Selection anchor and focus are expected to be text nodes,
|
||||
// so normalize them.
|
||||
if (anchorNode.nodeType === Node.ELEMENT_NODE) {
|
||||
const node = document.createTextNode('');
|
||||
anchorNode.insertBefore(node, anchorNode.childNodes[anchorOffset]);
|
||||
anchorNode = node;
|
||||
anchorOffset = 0;
|
||||
}
|
||||
if (focusNode.nodeType === Node.ELEMENT_NODE) {
|
||||
const node = document.createTextNode('');
|
||||
focusNode.insertBefore(node, focusNode.childNodes[focusOffset]);
|
||||
focusNode = node;
|
||||
focusOffset = 0;
|
||||
}
|
||||
visit(editor, el => {
|
||||
if (el === anchorNode && el === focusNode) {
|
||||
pos.start += anchorOffset;
|
||||
pos.end += focusOffset;
|
||||
pos.dir = anchorOffset <= focusOffset ? '->' : '<-';
|
||||
return 'stop';
|
||||
}
|
||||
if (el === anchorNode) {
|
||||
pos.start += anchorOffset;
|
||||
if (!pos.dir) {
|
||||
pos.dir = '->';
|
||||
}
|
||||
else {
|
||||
return 'stop';
|
||||
}
|
||||
}
|
||||
else if (el === focusNode) {
|
||||
pos.end += focusOffset;
|
||||
if (!pos.dir) {
|
||||
pos.dir = '<-';
|
||||
}
|
||||
else {
|
||||
return 'stop';
|
||||
}
|
||||
}
|
||||
if (el.nodeType === Node.TEXT_NODE) {
|
||||
if (pos.dir != '->')
|
||||
pos.start += el.nodeValue.length;
|
||||
if (pos.dir != '<-')
|
||||
pos.end += el.nodeValue.length;
|
||||
}
|
||||
});
|
||||
// collapse empty text nodes
|
||||
editor.normalize();
|
||||
return pos;
|
||||
}
|
||||
function restore(pos) {
|
||||
const s = getSelection();
|
||||
let startNode, startOffset = 0;
|
||||
let endNode, endOffset = 0;
|
||||
if (!pos.dir)
|
||||
pos.dir = '->';
|
||||
if (pos.start < 0)
|
||||
pos.start = 0;
|
||||
if (pos.end < 0)
|
||||
pos.end = 0;
|
||||
// Flip start and end if the direction reversed
|
||||
if (pos.dir == '<-') {
|
||||
const { start, end } = pos;
|
||||
pos.start = end;
|
||||
pos.end = start;
|
||||
}
|
||||
let current = 0;
|
||||
visit(editor, el => {
|
||||
if (el.nodeType !== Node.TEXT_NODE)
|
||||
return;
|
||||
const len = (el.nodeValue || '').length;
|
||||
if (current + len > pos.start) {
|
||||
if (!startNode) {
|
||||
startNode = el;
|
||||
startOffset = pos.start - current;
|
||||
}
|
||||
if (current + len > pos.end) {
|
||||
endNode = el;
|
||||
endOffset = pos.end - current;
|
||||
return 'stop';
|
||||
}
|
||||
}
|
||||
current += len;
|
||||
});
|
||||
if (!startNode)
|
||||
startNode = editor, startOffset = editor.childNodes.length;
|
||||
if (!endNode)
|
||||
endNode = editor, endOffset = editor.childNodes.length;
|
||||
// Flip back the selection
|
||||
if (pos.dir == '<-') {
|
||||
[startNode, startOffset, endNode, endOffset] = [endNode, endOffset, startNode, startOffset];
|
||||
}
|
||||
s.setBaseAndExtent(startNode, startOffset, endNode, endOffset);
|
||||
}
|
||||
function beforeCursor() {
|
||||
const s = getSelection();
|
||||
const r0 = s.getRangeAt(0);
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(editor);
|
||||
r.setEnd(r0.startContainer, r0.startOffset);
|
||||
return r.toString();
|
||||
}
|
||||
function afterCursor() {
|
||||
const s = getSelection();
|
||||
const r0 = s.getRangeAt(0);
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(editor);
|
||||
r.setStart(r0.endContainer, r0.endOffset);
|
||||
return r.toString();
|
||||
}
|
||||
function handleNewLine(event) {
|
||||
if (event.key === 'Enter') {
|
||||
const before = beforeCursor();
|
||||
const after = afterCursor();
|
||||
let [padding] = findPadding(before);
|
||||
let newLinePadding = padding;
|
||||
// If last symbol is "{" ident new line
|
||||
if (options.indentOn.test(before)) {
|
||||
newLinePadding += options.tab;
|
||||
}
|
||||
// Preserve padding
|
||||
if (newLinePadding.length > 0) {
|
||||
preventDefault(event);
|
||||
event.stopPropagation();
|
||||
insert('\n' + newLinePadding);
|
||||
}
|
||||
else {
|
||||
legacyNewLineFix(event);
|
||||
}
|
||||
// Place adjacent "}" on next line
|
||||
if (newLinePadding !== padding && options.moveToNewLine.test(after)) {
|
||||
const pos = save();
|
||||
insert('\n' + padding);
|
||||
restore(pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
function legacyNewLineFix(event) {
|
||||
// Firefox does not support plaintext-only mode
|
||||
// and puts <div><br></div> on Enter. Let's help.
|
||||
if (isLegacy && event.key === 'Enter') {
|
||||
preventDefault(event);
|
||||
event.stopPropagation();
|
||||
if (afterCursor() == '') {
|
||||
insert('\n ');
|
||||
const pos = save();
|
||||
pos.start = --pos.end;
|
||||
restore(pos);
|
||||
}
|
||||
else {
|
||||
insert('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
function handleSelfClosingCharacters(event) {
|
||||
const open = `([{'"`;
|
||||
const close = `)]}'"`;
|
||||
if (open.includes(event.key)) {
|
||||
preventDefault(event);
|
||||
const pos = save();
|
||||
const wrapText = pos.start == pos.end ? '' : getSelection().toString();
|
||||
const text = event.key + wrapText + close[open.indexOf(event.key)];
|
||||
insert(text);
|
||||
pos.start++;
|
||||
pos.end++;
|
||||
restore(pos);
|
||||
}
|
||||
}
|
||||
function handleTabCharacters(event) {
|
||||
if (event.key === 'Tab') {
|
||||
preventDefault(event);
|
||||
if (event.shiftKey) {
|
||||
const before = beforeCursor();
|
||||
let [padding, start,] = findPadding(before);
|
||||
if (padding.length > 0) {
|
||||
const pos = save();
|
||||
// Remove full length tab or just remaining padding
|
||||
const len = Math.min(options.tab.length, padding.length);
|
||||
restore({ start, end: start + len });
|
||||
document.execCommand('delete');
|
||||
pos.start -= len;
|
||||
pos.end -= len;
|
||||
restore(pos);
|
||||
}
|
||||
}
|
||||
else {
|
||||
insert(options.tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
function handleUndoRedo(event) {
|
||||
if (isUndo(event)) {
|
||||
preventDefault(event);
|
||||
at--;
|
||||
const record = history[at];
|
||||
if (record) {
|
||||
editor.innerHTML = record.html;
|
||||
restore(record.pos);
|
||||
}
|
||||
if (at < 0)
|
||||
at = 0;
|
||||
}
|
||||
if (isRedo(event)) {
|
||||
preventDefault(event);
|
||||
at++;
|
||||
const record = history[at];
|
||||
if (record) {
|
||||
editor.innerHTML = record.html;
|
||||
restore(record.pos);
|
||||
}
|
||||
if (at >= history.length)
|
||||
at--;
|
||||
}
|
||||
}
|
||||
function recordHistory() {
|
||||
if (!focus)
|
||||
return;
|
||||
const html = editor.innerHTML;
|
||||
const pos = save();
|
||||
const lastRecord = history[at];
|
||||
if (lastRecord) {
|
||||
if (lastRecord.html === html
|
||||
&& lastRecord.pos.start === pos.start
|
||||
&& lastRecord.pos.end === pos.end)
|
||||
return;
|
||||
}
|
||||
at++;
|
||||
history[at] = { html, pos };
|
||||
history.splice(at + 1);
|
||||
const maxHistory = 300;
|
||||
if (at > maxHistory) {
|
||||
at = maxHistory;
|
||||
history.splice(0, 1);
|
||||
}
|
||||
}
|
||||
function handlePaste(event) {
|
||||
preventDefault(event);
|
||||
const text = (event.originalEvent || event)
|
||||
.clipboardData
|
||||
.getData('text/plain')
|
||||
.replace(/\r\n?/g, '\n');
|
||||
const pos = save();
|
||||
insert(text);
|
||||
highlight(editor);
|
||||
restore({
|
||||
start: Math.min(pos.start, pos.end) + text.length,
|
||||
end: Math.min(pos.start, pos.end) + text.length,
|
||||
dir: '<-',
|
||||
});
|
||||
}
|
||||
function handleCut(event) {
|
||||
const pos = save();
|
||||
const selection = getSelection();
|
||||
const originalEvent = event.originalEvent ?? event;
|
||||
originalEvent.clipboardData.setData("text/plain", selection.toString());
|
||||
document.execCommand('delete');
|
||||
highlight(editor);
|
||||
restore({
|
||||
start: pos.start,
|
||||
end: pos.start,
|
||||
dir: '->',
|
||||
});
|
||||
preventDefault(event);
|
||||
}
|
||||
function visit(editor, visitor) {
|
||||
const queue = [];
|
||||
if (editor.firstChild)
|
||||
queue.push(editor.firstChild);
|
||||
let el = queue.pop();
|
||||
while (el) {
|
||||
if (visitor(el) === 'stop')
|
||||
break;
|
||||
if (el.nextSibling)
|
||||
queue.push(el.nextSibling);
|
||||
if (el.firstChild)
|
||||
queue.push(el.firstChild);
|
||||
el = queue.pop();
|
||||
}
|
||||
}
|
||||
function isCtrl(event) {
|
||||
return event.metaKey || event.ctrlKey;
|
||||
}
|
||||
function isUndo(event) {
|
||||
return isCtrl(event) && !event.shiftKey && getKeyCode(event) === 'Z';
|
||||
}
|
||||
function isRedo(event) {
|
||||
return isCtrl(event) && event.shiftKey && getKeyCode(event) === 'Z';
|
||||
}
|
||||
function isCopy(event) {
|
||||
return isCtrl(event) && getKeyCode(event) === 'C';
|
||||
}
|
||||
function getKeyCode(event) {
|
||||
let key = event.key || event.keyCode || event.which;
|
||||
if (!key)
|
||||
return undefined;
|
||||
return (typeof key === 'string' ? key : String.fromCharCode(key)).toUpperCase();
|
||||
}
|
||||
function insert(text) {
|
||||
text = text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
document.execCommand('insertHTML', false, text);
|
||||
}
|
||||
function debounce(cb, wait) {
|
||||
let timeout = 0;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = window.setTimeout(() => cb(...args), wait);
|
||||
};
|
||||
}
|
||||
function findPadding(text) {
|
||||
// Find beginning of previous line.
|
||||
let i = text.length - 1;
|
||||
while (i >= 0 && text[i] !== '\n')
|
||||
i--;
|
||||
i++;
|
||||
// Find padding of the line.
|
||||
let j = i;
|
||||
while (j < text.length && /[ \t]/.test(text[j]))
|
||||
j++;
|
||||
return [text.substring(i, j) || '', i, j];
|
||||
}
|
||||
function toString() {
|
||||
return editor.textContent || '';
|
||||
}
|
||||
function preventDefault(event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
function getSelection() {
|
||||
if (editor.parentNode?.nodeType == Node.DOCUMENT_FRAGMENT_NODE) {
|
||||
return editor.parentNode.getSelection();
|
||||
}
|
||||
return window.getSelection();
|
||||
}
|
||||
return {
|
||||
updateOptions(newOptions) {
|
||||
Object.assign(options, newOptions);
|
||||
},
|
||||
updateCode(code) {
|
||||
editor.textContent = code;
|
||||
highlight(editor);
|
||||
if (callback)
|
||||
callback(code);
|
||||
},
|
||||
onUpdate(cb) {
|
||||
callback = cb;
|
||||
},
|
||||
toString,
|
||||
save,
|
||||
restore,
|
||||
recordHistory,
|
||||
destroy() {
|
||||
for (let [type, fn] of listeners) {
|
||||
editor.removeEventListener(type, fn);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Browser global shim: surface CodeJar on window so non-module
|
||||
// <script> tags can call it.
|
||||
window.CodeJar = CodeJar;
|
||||
1
l4d2web/l4d2web/static/vendor/prism.css
vendored
1
l4d2web/l4d2web/static/vendor/prism.css
vendored
|
|
@ -1 +0,0 @@
|
|||
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
|
||||
4
l4d2web/l4d2web/static/vendor/prism.js
vendored
4
l4d2web/l4d2web/static/vendor/prism.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,16 +0,0 @@
|
|||
{# 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.
|
||||
|
||||
prism.css is intentionally NOT loaded — its base `code[class*=language-]`
|
||||
rule has specificity (0,1,1) which beats our `.editor-code` (0,1,0)
|
||||
and forces color:#000 + background:transparent, which renders black
|
||||
text on the dark-mode page background. We override every Prism token
|
||||
class we use in editor.css with --color-* tokens that follow
|
||||
prefers-color-scheme, so the default Prism theme adds nothing useful. #}
|
||||
<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>
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
<pre class="config-preview" aria-label="Auto-loaded overlay configs">{% for o in exposed %}exec {{ o.name }}.cfg
|
||||
{% endfor %}</pre>
|
||||
{% endif %}
|
||||
<textarea name="config" rows="8" spellcheck="false" data-editor-language="srccfg">{{ config_lines | join('\n') }}</textarea>
|
||||
<textarea name="config" rows="8" spellcheck="false">{{ config_lines | join('\n') }}</textarea>
|
||||
</div>
|
||||
</label>
|
||||
<button type="submit">Save blueprint</button>
|
||||
|
|
@ -92,5 +92,4 @@
|
|||
</div>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='js/blueprint-overlay-picker.js') }}" defer></script>
|
||||
{% include '_editor_assets.html' %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,9 @@
|
|||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Name <input name="name" required autofocus></label>
|
||||
{# Arguments, config, and overlay assignments are edited on the
|
||||
blueprint detail page where the srccfg editor + overlay picker
|
||||
live. Keeping the create modal name-only avoids the conflict
|
||||
where modal textareas can't host the editor cleanly. #}
|
||||
<label>Name <input name="name" required></label>
|
||||
<label>Arguments <textarea name="arguments" rows="8" spellcheck="false"></textarea></label>
|
||||
<label>Config <textarea name="config" rows="8" spellcheck="false"></textarea></label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="button-secondary" data-modal-close>Cancel</button>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
<form method="post" action="/overlays/{{ overlay.id }}/script" class="stack">
|
||||
<input type="hidden" name="csrf_token" value="{{ session.get('csrf_token', '') }}">
|
||||
<label>Bash script
|
||||
<textarea name="script" rows="20" spellcheck="false" data-editor-language="bash">{{ overlay.script or "" }}</textarea>
|
||||
<textarea name="script" rows="20" spellcheck="false">{{ overlay.script or "" }}</textarea>
|
||||
</label>
|
||||
<p class="muted">Runs sandboxed against the overlay directory mounted at <code>/overlay</code>.</p>
|
||||
{% if not latest_build_is_running %}
|
||||
|
|
@ -175,16 +175,7 @@
|
|||
<div class="files-editor-text">
|
||||
<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>
|
||||
<textarea class="files-editor-content" rows="14" spellcheck="false"></textarea>
|
||||
</label>
|
||||
<div class="files-editor-meta muted">
|
||||
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
|
||||
|
|
@ -282,11 +273,4 @@
|
|||
|
||||
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{# Only include the ~30 KB of editor assets on pages that actually mount
|
||||
an editor: script-type overlays (bash editor) and files-type overlays
|
||||
that the current user can edit (the files-editor modal). Workshop
|
||||
overlays and read-only pages skip the include entirely. #}
|
||||
{% if overlay.type == 'script' or files_can_edit %}
|
||||
{% include '_editor_assets.html' %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
"""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
|
||||
|
||||
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.py:57 sets SESSION_COOKIE_SECURE = not TESTING, which would
|
||||
# mark the session cookie Secure. The browser then drops it over
|
||||
# http://127.0.0.1 in e2e tests and the login flow silently fails
|
||||
# with a redirect back to /login. Force it off explicitly via the
|
||||
# env-var override (app.py:53-55) rather than flipping TESTING,
|
||||
# which would skip the SECRET_KEY guard and other production paths.
|
||||
monkeypatch.setenv("SESSION_COOKIE_SECURE", "0")
|
||||
app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"})
|
||||
# create_app() already calls init_db() inside an app context, which
|
||||
# binds tables to the in-app engine. The seed work below uses
|
||||
# session_scope() OUTSIDE any app context, which reads DATABASE_URL
|
||||
# from the environment and binds its own engine. This second init_db()
|
||||
# call creates the tables on that env-derived engine so the seed
|
||||
# inserts have somewhere to land.
|
||||
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)
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
"""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`, 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. Verifies Task 9's capture-phase keydown fix:
|
||||
# CodeJar would otherwise consume Tab and insert 2 spaces
|
||||
# before our popup handler ran.
|
||||
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()
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
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
|
||||
|
|
@ -253,62 +253,6 @@ def test_form_update_preserves_ordered_overlays_and_multiline_fields(user_client
|
|||
assert update.headers["Location"] == "/blueprints/1"
|
||||
|
||||
|
||||
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. prism.css intentionally not loaded
|
||||
# — its default theme's `code[class*=language-]` selector beats our
|
||||
# `.editor-code` background/color rules at higher specificity and
|
||||
# breaks dark mode. We override every Prism token class we use in
|
||||
# editor.css with theme-aware --color-* tokens instead.
|
||||
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
|
||||
assert "static/vendor/prism.css" not 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"]
|
||||
|
||||
|
||||
def test_blueprint_detail_renders_picker_list_and_select(user_client) -> None:
|
||||
with session_scope() as session:
|
||||
session.add(Overlay(name="o3", path="/opt/l4d2/overlays/o3"))
|
||||
|
|
|
|||
|
|
@ -277,25 +277,3 @@ def test_permissions_admin_can_edit_any(app, alice_id, admin_id) -> None:
|
|||
with session_scope() as s:
|
||||
overlay = s.query(Overlay).filter_by(id=overlay_id).one()
|
||||
assert overlay.script == "echo admin"
|
||||
|
||||
|
||||
def test_script_overlay_detail_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)
|
||||
# Editor opts the textarea in via a data-attribute.
|
||||
assert 'data-editor-language="bash"' in body
|
||||
# All editor assets are referenced. prism.css intentionally not loaded
|
||||
# — see _editor_assets.html for the rationale (specificity conflict
|
||||
# with our dark-mode tokens).
|
||||
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
|
||||
assert "static/vendor/prism.css" not in body
|
||||
# Scripts are nonce'd (CSP regression guard).
|
||||
assert 'nonce="' in body
|
||||
|
|
|
|||
|
|
@ -16,15 +16,8 @@ l4d2host = { workspace = true }
|
|||
l4d2web = { workspace = true }
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest",
|
||||
"playwright>=1.49.0",
|
||||
"pytest-playwright>=0.6.0",
|
||||
]
|
||||
dev = ["pytest"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["l4d2host/tests", "l4d2web/tests"]
|
||||
addopts = ["--import-mode=importlib", "-m", "not e2e"]
|
||||
markers = [
|
||||
"e2e: end-to-end browser tests (slow, require chromium)",
|
||||
]
|
||||
addopts = ["--import-mode=importlib"]
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Local dev server for inspecting the l4d2web UI without a real deploy.
|
||||
|
||||
Sets the same env vars the production systemd unit gets from
|
||||
/etc/left4me/*.env, runs alembic upgrade head, and starts Flask. First
|
||||
run also seeds an admin user + a tiny demo content set (one blueprint,
|
||||
one script overlay, one files overlay) so the editor is visible at all
|
||||
three call sites immediately.
|
||||
|
||||
Does NOT mock l4d2ctl, so server-deploy actions (initialize/start/stop)
|
||||
still fail — they need real systemd + steamcmd. Blueprint + overlay
|
||||
CRUD work fine.
|
||||
|
||||
Usage: scripts/dev-server.py [--port N] (default: 5051)
|
||||
Reset: rm -rf .tmp/dev-server (next run reseeds)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
DEV_ROOT = REPO_ROOT / ".tmp" / "dev-server"
|
||||
DB_FILE = DEV_ROOT / "l4d2web.db"
|
||||
|
||||
|
||||
def seed_demo_content():
|
||||
"""Insert one blueprint, one script overlay, one files overlay, one server."""
|
||||
overlay_root = DEV_ROOT / "overlays"
|
||||
conn = sqlite3.connect(DB_FILE)
|
||||
cur = conn.cursor()
|
||||
admin_id = cur.execute(
|
||||
"SELECT id FROM users WHERE admin = 1 LIMIT 1"
|
||||
).fetchone()[0]
|
||||
now = datetime.now(UTC).isoformat()
|
||||
blueprint_id = None # captured below; needed for the server row
|
||||
|
||||
config_lines = [
|
||||
"// L4D2 dev demo — try the srccfg highlighter + autocomplete",
|
||||
"sv_cheats 0",
|
||||
"mp_gamemode coop",
|
||||
"z_difficulty Normal",
|
||||
"// Type sv_ on a new line to see the cvar popup:",
|
||||
"",
|
||||
]
|
||||
cur.execute(
|
||||
"INSERT INTO blueprints "
|
||||
"(user_id, name, arguments, config, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(admin_id, "demo-srccfg", json.dumps([]),
|
||||
json.dumps(config_lines), now, now),
|
||||
)
|
||||
blueprint_id = cur.lastrowid
|
||||
|
||||
script_text = (
|
||||
"#!/usr/bin/env bash\n"
|
||||
"# L4D2 dev demo — try the bash highlighter\n"
|
||||
"set -euo pipefail\n"
|
||||
"\n"
|
||||
"for f in /overlay/*.cfg; do\n"
|
||||
' echo "processing $f"\n'
|
||||
"done\n"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO overlays (name, path, type, user_id, script, "
|
||||
"last_build_status, created_at, updated_at) "
|
||||
"VALUES (?, ?, 'script', NULL, ?, '', ?, ?)",
|
||||
("demo-bash", "_pending", script_text, now, now),
|
||||
)
|
||||
script_id = cur.lastrowid
|
||||
cur.execute("UPDATE overlays SET path = ? WHERE id = ?",
|
||||
(str(script_id), script_id))
|
||||
(overlay_root / str(script_id)).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cur.execute(
|
||||
"INSERT INTO overlays (name, path, type, user_id, script, "
|
||||
"last_build_status, created_at, updated_at) "
|
||||
"VALUES (?, ?, 'files', NULL, '', '', ?, ?)",
|
||||
("demo-files", "_pending", now, now),
|
||||
)
|
||||
files_id = cur.lastrowid
|
||||
cur.execute("UPDATE overlays SET path = ? WHERE id = ?",
|
||||
(str(files_id), files_id))
|
||||
files_dir = overlay_root / str(files_id)
|
||||
files_dir.mkdir(parents=True, exist_ok=True)
|
||||
(files_dir / "test.cfg").write_text(
|
||||
"// open this file in the editor modal — flip the language\n"
|
||||
"// dropdown to bash to see the runtime language switch.\n"
|
||||
"sv_cheats 0\n"
|
||||
)
|
||||
|
||||
# Server: links the blueprint above to a port. desired_state stays
|
||||
# "stopped" so the GUI shows it without trying to deploy via l4d2ctl.
|
||||
cur.execute(
|
||||
"INSERT INTO servers (user_id, blueprint_id, name, port, "
|
||||
"desired_state, actual_state, last_error, created_at, updated_at) "
|
||||
"VALUES (?, ?, 'demo-server', 27015, 'stopped', 'unknown', '', ?, ?)",
|
||||
(admin_id, blueprint_id, now, now),
|
||||
)
|
||||
server_id = cur.lastrowid
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f" blueprint id={blueprint_id} 'demo-srccfg' -> /blueprints/{blueprint_id}")
|
||||
print(f" overlay id={script_id} 'demo-bash' (script) -> /overlays/{script_id}")
|
||||
print(f" overlay id={files_id} 'demo-files' (files) -> /overlays/{files_id}")
|
||||
print(f" server id={server_id} 'demo-server' (:27015) -> /servers/{server_id}")
|
||||
|
||||
|
||||
def main():
|
||||
port = 5051
|
||||
if "--port" in sys.argv:
|
||||
port = int(sys.argv[sys.argv.index("--port") + 1])
|
||||
|
||||
DEV_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
(DEV_ROOT / "overlays").mkdir(exist_ok=True)
|
||||
(DEV_ROOT / "instances").mkdir(exist_ok=True)
|
||||
|
||||
# Production-equivalent env (systemd gets these from /etc/left4me/*.env).
|
||||
os.environ.update({
|
||||
"SECRET_KEY": "local-dev-only-not-a-secret",
|
||||
"DATABASE_URL": f"sqlite:///{DB_FILE}",
|
||||
"LEFT4ME_ROOT": str(DEV_ROOT),
|
||||
"SESSION_COOKIE_SECURE": "0", # so http://127.0.0.1 cookies stick
|
||||
"JOB_WORKER_ENABLED": "false", # don't fire (sudo-requiring) l4d2ctl
|
||||
})
|
||||
|
||||
print(f"==> Migrating {DB_FILE} to head", flush=True)
|
||||
subprocess.run(["uv", "run", "alembic", "upgrade", "head"],
|
||||
cwd=REPO_ROOT / "l4d2web", check=True, stdout=subprocess.DEVNULL)
|
||||
|
||||
user_count = sqlite3.connect(DB_FILE).execute(
|
||||
"SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
admin_user = os.environ.get("LEFT4ME_DEV_ADMIN_USERNAME", "dev")
|
||||
admin_pw = os.environ.get("LEFT4ME_DEV_ADMIN_PASSWORD", "devdevdev")
|
||||
if user_count == 0:
|
||||
print(f"==> Seeding admin user '{admin_user}'", flush=True)
|
||||
subprocess.run(
|
||||
["uv", "run", "flask", "--app", "l4d2web.app:create_app()",
|
||||
"create-user", admin_user, "--admin"],
|
||||
cwd=REPO_ROOT, check=True,
|
||||
env={**os.environ, "LEFT4ME_ADMIN_PASSWORD": admin_pw},
|
||||
)
|
||||
print("==> Seeding demo content", flush=True)
|
||||
seed_demo_content()
|
||||
|
||||
print(f"\n admin: {admin_user} / {admin_pw}", flush=True)
|
||||
print(f" db: {DB_FILE}", flush=True)
|
||||
print(f" root: {DEV_ROOT}", flush=True)
|
||||
print(f"\n Ctrl-C to stop. Reset all state: rm -rf .tmp/dev-server\n", flush=True)
|
||||
print(f"==> Starting Flask on http://127.0.0.1:{port}", flush=True)
|
||||
|
||||
# --debug enables: Python code reload on save, template auto-reload,
|
||||
# verbose error pages, the Werkzeug interactive debugger. Safe here
|
||||
# because we bind to 127.0.0.1 only.
|
||||
os.execvp("uv", ["uv", "run", "flask",
|
||||
"--app", "l4d2web.app:create_app()",
|
||||
"--debug", "run", "--port", str(port)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
94
uv.lock
94
uv.lock
|
|
@ -154,9 +154,7 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
|
||||
|
|
@ -164,9 +162,7 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
|
||||
|
|
@ -174,9 +170,7 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
|
||||
|
|
@ -284,9 +278,7 @@ dependencies = [
|
|||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "playwright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-playwright" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
|
|
@ -296,11 +288,7 @@ requires-dist = [
|
|||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "playwright", specifier = ">=1.49.0" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-playwright", specifier = ">=0.6.0" },
|
||||
]
|
||||
dev = [{ name = "pytest" }]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
|
|
@ -396,25 +384,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.59.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/48/abab23f40643b4de8f2665816f0a1bf0994eeecda39d6d62f0f292b2ad01/playwright-1.59.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bfc6940100b57423175c819ce2422ec5880d55fa2769987f62ab7a1f5fe6783e", size = 43156922, upload-time = "2026-04-29T08:11:08.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/71/5e4d98b2ce3641b4343623c6450ff33b9de1c979d12a957505e392338b07/playwright-1.59.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:af068143a0c045ec11608b67d6c42e58db7e9cf65a742dd21fddedc1a9802c47", size = 41947177, upload-time = "2026-04-29T08:11:12.867Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/91/fd219aa78ca03d37e93aaedaed4e224131e3090a9264f9bb773c8271d67e/playwright-1.59.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:4a4a2d4842b0e4120de3fa48636e4b69085a05b81d8a35ad4353f530ade72ed6", size = 43156922, upload-time = "2026-04-29T08:11:16.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/0c/1e513d37c5be07d12829ebce93dbfe7baee230084cb66966c423432799c4/playwright-1.59.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c5792aad9e22b91a09264b9edbc18553cf05ea5a39404d65dc19a012c6b2e51d", size = 47151793, upload-time = "2026-04-29T08:11:19.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/2d/15f72288cb65d690134e18fefb9483cc4976f7579b580648c45e494481a7/playwright-1.59.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c881a19377d2b900af855fb525b5f22a27bf3cfbecba6d1edb36766d56cb100", size = 46877615, upload-time = "2026-04-29T08:11:23.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/a1/717ac5bc99f387c0f60def91271ea4262125c0815d764a5d1776a272275c/playwright-1.59.0-py3-none-win32.whl", hash = "sha256:6989c476be2b9cd3e24a18cc9dcf202e266fb3d91e3e5395cd668c54ea54b119", size = 37713698, upload-time = "2026-04-29T08:11:27.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/a5/4e630ee05d8b46b840f943268e86d6063703e8dadb2d3eb405c7b9b2e48c/playwright-1.59.0-py3-none-win_amd64.whl", hash = "sha256:d5a5cc064b82ca92996080025710844e417f44df8fda9001102c28f44174171c", size = 37713704, upload-time = "2026-04-29T08:11:30.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/0c/3ece41761ba13c8321009aefcaec7a016eb42799c42eef5e03ace7f2de5b/playwright-1.59.0-py3-none-win_arm64.whl", hash = "sha256:93581ad515728cadc8af39b288a5633ba6d36e7d72048e79d890ce01ea2156f9", size = 33956745, upload-time = "2026-04-29T08:11:34.738Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
|
|
@ -424,18 +393,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
|
|
@ -461,46 +418,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-base-url"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/1a/b64ac368de6b993135cb70ca4e5d958a5c268094a3a2a4cac6f0021b6c4f/pytest_base_url-2.1.0.tar.gz", hash = "sha256:02748589a54f9e63fcbe62301d6b0496da0d10231b753e950c63e03aee745d45", size = 6702, upload-time = "2024-01-31T22:43:00.81Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/1c/b00940ab9eb8ede7897443b771987f2f4a76f06be02f1b3f01eb7567e24a/pytest_base_url-2.1.0-py3-none-any.whl", hash = "sha256:3ad15611778764d451927b2a53240c1a7a591b521ea44cebfe45849d2d2812e6", size = 5302, upload-time = "2024-01-31T22:42:58.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-playwright"
|
||||
version = "0.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "playwright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-base-url" },
|
||||
{ name = "python-slugify" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/6b/913e36aa421b35689ec95ed953ff7e8df3f2ee1c7b8ab2a3f1fd39d95faf/pytest_playwright-0.7.2.tar.gz", hash = "sha256:247b61123b28c7e8febb993a187a07e54f14a9aa04edc166f7a976d88f04c770", size = 16928, upload-time = "2025-11-24T03:43:22.53Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/76/61/4d333d8354ea2bea2c2f01bad0a4aa3c1262de20e1241f78e73360e9b620/pytest_playwright-0.7.2-py3-none-any.whl", hash = "sha256:8084e015b2b3ecff483c2160f1c8219b38b66c0d4578b23c0f700d1b0240ea38", size = 16881, upload-time = "2025-11-24T03:43:24.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-slugify"
|
||||
version = "8.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "text-unidecode" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
|
|
@ -613,15 +530,6 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "text-unidecode"
|
||||
version = "1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.25.1"
|
||||
|
|
|
|||
Loading…
Reference in a new issue