Public: anyone can read this; the crew can change it.
forked from trevato/cs
JavaScript off is a sentence, not a dead Play button: one noscript line in the shell, on all three games #249
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/161-javascript-off-is-a-sentence-not-a-dead → main 1 commit fork
10 files
changed +171
−9
README.md +10 −2
| @@ -953,7 +953,7 @@ never checked itself. | ||
| 953 | 953 | refusal — carry `style-src 'self'` and accept no inline style at all. |
| 954 | 954 | - `src/pages/shell.ts` — the document every game is served inside, and nothing |
| 955 | 955 | about any one game: `page({ title, description, heading, nav, env, pull, |
| 956 | commit, script, ssh, terminalLead, stageHtml, sectionsHtml, footer, | |
| 956 | commit, script, ssh, terminalLead, noscript, stageHtml, sectionsHtml, footer, | |
| 957 | 957 | extraStyle })` |
| 958 | 958 | gives you the doctype, the head, the one `<style>` block, the icon, the header |
| 959 | 959 | with its nav of the games, its environment pill and short commit, `<main>`, the |
| @@ -973,7 +973,15 @@ never checked itself. | ||
| 973 | 973 | free** — and no page ever imports another page. What one page says about |
| 974 | 974 | another comes from there too (`othersHtml(here)`): the sentence about a game |
| 975 | 975 | is that game's own registry entry, never prose hand-written next door, which |
| 976 | is how it goes stale. | |
| 976 | is how it goes stale. **So is what a game says to a browser with no | |
| 977 | JavaScript** (`noscriptFor(here)`, out of the registry's `noscript` field): | |
| 978 | one sentence at the top of `<main>`, directly above the stage, saying what | |
| 979 | that page needs a script for — its Play or Start button is the one dead | |
| 980 | control — and what on it is server-rendered and still worth reading, which on | |
| 981 | these pages is the leaderboard, the recent matches, the prose and the terminal | |
| 982 | line. It is a `<noscript>` element, so a browser that runs scripts renders | |
| 983 | none of it: no box, no height, no shift and no second request. The 404 and the | |
| 984 | 500 hand in none and get no element, the same bargain `script` is. | |
| 977 | 985 | A rule only one game needs goes in `extraStyle`, never in the shared block, |
| 978 | 986 | and CSS handed in there cannot close the block. It has to be a constant the |
| 979 | 987 | page exports, because `src/server.ts` hashes the shared block plus that page's |
scripts/train.test.ts +10 −1
| @@ -11,14 +11,23 @@ | ||
| 11 | 11 | * hand, and that a retrain writes a file shaped exactly like the one it |
| 12 | 12 | * replaces. `src/client/weights.test.ts` is the other half: it holds the file |
| 13 | 13 | * that is actually in the tree to the numbers it claims. |
| 14 | * | |
| 15 | * `../src/client/packed.ts` is imported before `./train.ts` on purpose, and | |
| 16 | * the order is load-bearing until somebody breaks the cycle it works around: | |
| 17 | * `packed.ts` reads `OBS_SIZE` out of `observe.ts` while it is initialising, | |
| 18 | * and `observe.ts` → `world.ts` → `bots.ts` → `packed.ts` closes the ring, so | |
| 19 | * a graph entered at the `observe.ts` end — which is what `./train.ts` is, one | |
| 20 | * import further on — runs `packed.ts`'s body before that constant exists and | |
| 21 | * throws `Cannot access 'OBS_SIZE' before initialization`. Entered here, this | |
| 22 | * file loads on its own as well as in a suite. Not this pull's to fix. | |
| 14 | 23 | */ |
| 15 | 24 | |
| 16 | 25 | import { expect, test } from "bun:test"; |
| 17 | 26 | import { mkdtempSync, writeFileSync } from "node:fs"; |
| 18 | 27 | import { tmpdir } from "node:os"; |
| 19 | 28 | import { join, resolve } from "node:path"; |
| 20 | import { cardModule, DEFAULTS, parseOptions, policyDocument, POLICY, USAGE, WEIGHTS, type Options } from "./train.ts"; | |
| 21 | 29 | import { parsePacked } from "../src/client/packed.ts"; |
| 30 | import { cardModule, DEFAULTS, parseOptions, policyDocument, POLICY, USAGE, WEIGHTS, type Options } from "./train.ts"; | |
| 22 | 31 | import { PACKED } from "../src/client/shipped.ts"; |
| 23 | 32 | import { CARD } from "../src/client/weights.ts"; |
| 24 | 33 |
src/games.ts +15
| @@ -55,6 +55,15 @@ export type Game = { | ||
| 55 | 55 | assets: readonly Asset[]; |
| 56 | 56 | /** The one sentence the *other* pages say about this game. Text, escaped where it is rendered. Prose about a game belongs to that game, or it drifts. */ |
| 57 | 57 | blurb: string; |
| 58 | /** | |
| 59 | * The one sentence this game's own page says to a browser with no JavaScript: | |
| 60 | * what it needs a script for, which control is dead without one, and what on | |
| 61 | * the page is still worth reading. Text, escaped where it is rendered, and | |
| 62 | * one sentence — a page that apologises in a paragraph is worse than the dead | |
| 63 | * button it is apologising for. It lives here for the reason `blurb` does: it | |
| 64 | * is prose about a game, and a game owns its own. | |
| 65 | */ | |
| 66 | noscript: string; | |
| 58 | 67 | }; |
| 59 | 68 | |
| 60 | 69 | /** |
| @@ -457,6 +466,8 @@ export const GAMES: readonly Game[] = [ | ||
| 457 | 466 | ], |
| 458 | 467 | blurb: |
| 459 | 468 | "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.", |
| 469 | noscript: | |
| 470 | "Playing cs needs JavaScript — the match runs in your browser, so with scripts off Play stays disabled — but the leaderboard, the recent matches and everything written below are server-rendered and still here.", | |
| 460 | 471 | }, |
| 461 | 472 | { |
| 462 | 473 | name: "range", |
| @@ -467,6 +478,8 @@ export const GAMES: readonly Game[] = [ | ||
| 467 | 478 | assets: [], |
| 468 | 479 | blurb: |
| 469 | 480 | "thirty seconds on the clock and two drills down one lane — plates that pop up, and plates that slide across it — a shot traced in three dimensions and a score, every line of it out of the engine and none of it out of the shooter.", |
| 481 | noscript: | |
| 482 | "The range needs JavaScript — the lane, the plates and the clock are played in your browser, so with scripts off Capture the mouse does nothing — but everything written below about the drills, the movement and the sounds is server-rendered and still here.", | |
| 470 | 483 | }, |
| 471 | 484 | { |
| 472 | 485 | name: "example", |
| @@ -477,5 +490,7 @@ export const GAMES: readonly Game[] = [ | ||
| 477 | 490 | assets: [], |
| 478 | 491 | blurb: |
| 479 | 492 | "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in WebGL2 through the engine's own renderer, with the plan from above for a browser that has none — the price tag on a third game.", |
| 493 | noscript: | |
| 494 | "The example needs JavaScript — the guide's game runs in your browser, so with scripts off Start does nothing — but the measured cost of a third game, which is the point of this page, is server-rendered below and still here.", | |
| 480 | 495 | }, |
| 481 | 496 | ]; |
src/pages/example.ts +2 −1
| @@ -13,7 +13,7 @@ | ||
| 13 | 13 | import { bytes, kB } from "../bytes.ts"; |
| 14 | 14 | import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts"; |
| 15 | 15 | import { type Cost, GZIP_LEVEL } from "../cost.ts"; |
| 16 | import { navFor, othersHtml } from "./games.ts"; | |
| 16 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; | |
| 17 | 17 | import { page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 18 | 18 | |
| 19 | 19 | /** |
| @@ -86,6 +86,7 @@ export function examplePage(options: PageOptions): string { | ||
| 86 | 86 | "The guide's own game, served: one room, one plate, thirty seconds and a score, drawn in WebGL2 through the engine's own renderer, with the plan from above as its fallback.", |
| 87 | 87 | heading: "example", |
| 88 | 88 | nav: navFor("/example"), |
| 89 | noscript: noscriptFor("/example"), | |
| 89 | 90 | terminalLead: "The shooter's leaderboard in a terminal:", |
| 90 | 91 | footer: "no database, no cookie: this page keeps nothing", |
| 91 | 92 | stageHtml: stage({ |
src/pages/game.ts +2 −1
| @@ -15,7 +15,7 @@ import { NOTHING_POSTED } from "../client/pending.ts"; | ||
| 15 | 15 | import { CARD, type Card } from "../client/weights.ts"; |
| 16 | 16 | import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; |
| 17 | 17 | import type { Who } from "../who.ts"; |
| 18 | import { navFor, othersHtml } from "./games.ts"; | |
| 18 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; | |
| 19 | 19 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 20 | 20 | |
| 21 | 21 | export { escape }; |
| @@ -406,6 +406,7 @@ export function gamePage( | ||
| 406 | 406 | "A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.", |
| 407 | 407 | heading: "cs", |
| 408 | 408 | nav: navFor("/"), |
| 409 | noscript: noscriptFor("/"), | |
| 409 | 410 | terminalLead: "The same leaderboard in a terminal:", |
| 410 | 411 | footer: "state lives in this environment's own database", |
| 411 | 412 | stageHtml: stage({ |
src/pages/games.ts +9
| @@ -27,6 +27,15 @@ export const GAMES: readonly Game[] = REGISTRY.map(({ href, label }) => ({ href, | ||
| 27 | 27 | export const navFor = (here: string): NavEntry[] => |
| 28 | 28 | GAMES.map((game) => (game.href === here ? { ...game, current: true } : { ...game })); |
| 29 | 29 | |
| 30 | /** | |
| 31 | * The sentence the game at `here` says to a browser with no script, handed | |
| 32 | * straight to the shell. A path that is no game's — the 404, the 500 — gets | |
| 33 | * the empty string, which the shell renders as no element at all: a page that | |
| 34 | * needs no script has nothing to explain. | |
| 35 | */ | |
| 36 | export const noscriptFor = (here: string): string => | |
| 37 | REGISTRY.find((game) => game.href === here)?.noscript ?? ""; | |
| 38 | ||
| 30 | 39 | /** A link to one game, and the one sentence its own entry says about it. Text in, markup out: both are escaped here. */ |
| 31 | 40 | const entryHtml = ({ href, label, blurb }: Registered): string => |
| 32 | 41 | `<a href="${escape(href)}">${escape(label)}</a> — ${escape(blurb)}`; |
src/pages/range.ts +2 −1
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | * terminal stay Counter-Strike's. |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | import { navFor, othersHtml } from "./games.ts"; | |
| 13 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; | |
| 14 | 14 | import { page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 15 | 15 | |
| 16 | 16 | /** What the shell needs that this page cannot know: the environment it runs in. */ |
| @@ -52,6 +52,7 @@ export function rangePage(options: PageOptions): string { | ||
| 52 | 52 | "A shooting range built only from this app's engine: seeded pop-up targets, a traced shot, a thirty second clock and a score.", |
| 53 | 53 | heading: "range", |
| 54 | 54 | nav: navFor("/range"), |
| 55 | noscript: noscriptFor("/range"), | |
| 55 | 56 | terminalLead: "The shooter's leaderboard in a terminal:", |
| 56 | 57 | footer: "no database, no cookie: this page keeps nothing", |
| 57 | 58 | stageHtml: stage({ |
src/pages/shell.test.ts +52 −2
| @@ -6,8 +6,9 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | import { expect, test } from "bun:test"; |
| 9 | import { escape, nav, page, stage, styleFor, terminal, where } from "./shell.ts"; | |
| 10 | import { GAMES, navFor } from "./games.ts"; | |
| 9 | import { escape, nav, noscript, page, stage, styleFor, terminal, where } from "./shell.ts"; | |
| 10 | import { GAMES as REGISTRY } from "../games.ts"; | |
| 11 | import { GAMES, navFor, noscriptFor } from "./games.ts"; | |
| 11 | 12 | |
| 12 | 13 | const XSS = `</script><script>alert(1)</script>`; |
| 13 | 14 | |
| @@ -204,6 +205,55 @@ test("the list of games lives once, and a page marks its own", () => { | ||
| 204 | 205 | expect(navFor("/nope")).toHaveLength(GAMES.length); |
| 205 | 206 | }); |
| 206 | 207 | |
| 208 | test("a page that needs a script says so, above the stage, in one sentence", () => { | |
| 209 | const line = "cs needs JavaScript — Play stays disabled without one — but the leaderboard is here."; | |
| 210 | const html = page({ ...options, noscript: line, stageHtml: `<div id="stage"></div>` }); | |
| 211 | expect(html).toContain(` <noscript><p class="noscript">${escape(line)}</p></noscript>\n`); | |
| 212 | // With the stage, not under the footer: inside <main>, before the stage, and | |
| 213 | // nowhere near the end of the document. | |
| 214 | expect(html.indexOf("<noscript")).toBeGreaterThan(html.indexOf("<main>")); | |
| 215 | expect(html.indexOf("<noscript")).toBeLessThan(html.indexOf(`id="stage"`)); | |
| 216 | expect(html.indexOf("<noscript")).toBeLessThan(html.indexOf("<footer>")); | |
| 217 | // Styled as part of the page, in the one style block the policy hashes: no | |
| 218 | // second <style>, no style attribute, no second request. | |
| 219 | expect(html).toContain("p.noscript {"); | |
| 220 | expect(html.match(/<style/g)?.length).toBe(1); | |
| 221 | expect(html).not.toContain(" style="); | |
| 222 | // And a page that runs nothing — the 404, the 500 — gets no element at all. | |
| 223 | expect(page(options)).not.toContain("<noscript"); | |
| 224 | expect(page({ ...options, noscript: "" })).not.toContain("<noscript"); | |
| 225 | expect(noscript(undefined)).toBe(""); | |
| 226 | expect(noscript("")).toBe(""); | |
| 227 | }); | |
| 228 | ||
| 229 | test("the noscript line is text, wherever it enters", () => { | |
| 230 | const html = page({ ...options, noscript: XSS }); | |
| 231 | expect(html).toContain(`<noscript><p class="noscript"></script><script>alert(1)</script></p></noscript>`); | |
| 232 | expect(html).not.toContain("</script><script>"); | |
| 233 | // Still one script element: the bundle's, and nothing the sentence opened. | |
| 234 | expect(html.match(/<script/g)?.length).toBe(1); | |
| 235 | }); | |
| 236 | ||
| 237 | test("every game's noscript line is that game's own, and it is one sentence", () => { | |
| 238 | // Driven from the registry like the nav and the blurbs: a fourth game is held | |
| 239 | // to this the moment it is written down. | |
| 240 | for (const game of REGISTRY) { | |
| 241 | const line = game.noscript; | |
| 242 | expect(noscriptFor(game.href)).toBe(line); | |
| 243 | // One sentence, ending in one full stop and containing no other. | |
| 244 | expect(line.endsWith(".")).toBe(true); | |
| 245 | expect(line.split(".").length - 1).toBe(1); | |
| 246 | // It names this game rather than a generic page, and never another's. | |
| 247 | expect(line).toContain(game.label); | |
| 248 | for (const other of REGISTRY) if (other.name !== game.name) expect(line).not.toContain(other.label); | |
| 249 | // It says what the script is for and what survives without one. | |
| 250 | expect(line).toContain("JavaScript"); | |
| 251 | } | |
| 252 | // A path that is no game's — the 404 and the 500 go through the same shell. | |
| 253 | expect(noscriptFor("")).toBe(""); | |
| 254 | expect(noscriptFor("/nope")).toBe(""); | |
| 255 | }); | |
| 256 | ||
| 207 | 257 | test("the stage keeps the ids the client finds it by", () => { |
| 208 | 258 | const html = stage({ |
| 209 | 259 | touch: [[{ action: "duck", label: "crouch" }], [{ action: "fire", label: "fire" }]], |
src/pages/shell.ts +35 −1
| @@ -15,6 +15,14 @@ | ||
| 15 | 15 | * in none and gets no script element at all. Everything else in the document is |
| 16 | 16 | * the same for both, which is the point of the shell. |
| 17 | 17 | * |
| 18 | * The `<noscript>` line is the same bargain as the script: a game hands in the | |
| 19 | * one sentence it wants a scriptless browser to read and gets it at the top of | |
| 20 | * `<main>`, above the stage; a page that runs nothing hands in none and gets no | |
| 21 | * element. It is a line rather than a rewritten page because these pages are | |
| 22 | * genuinely useful without a script — the leaderboard, the recent matches, the | |
| 23 | * prose and the terminal line are all server-rendered — so what is owed is an | |
| 24 | * explanation of the one dead control, not an apology for the document. | |
| 25 | * | |
| 18 | 26 | * A game's assets are the same bargain one step further: a game hands in |
| 19 | 27 | * `{ id, href }` for each file it downloads at boot and gets a `<link |
| 20 | 28 | * rel="preload">` in the head, high up, so the browser starts fetching it |
| @@ -100,6 +108,13 @@ button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; } | ||
| 100 | 108 | #touch[hidden] { display:none; } |
| 101 | 109 | #touch .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; } |
| 102 | 110 | #touch button { opacity:.72; background:#0f131acc; } |
| 111 | /* The sentence a browser with no script reads, and the only thing on the page | |
| 112 | that is styled for a reader who will never see the game: at the top of main, | |
| 113 | directly over the stage whose one live control is dead without a script. It | |
| 114 | costs nothing when there is one — a browser that runs scripts renders none of | |
| 115 | that element's content, so there is no box, no height and nothing to shift. */ | |
| 116 | p.noscript { margin:1rem 0 0; padding:.7rem .9rem; font-size:.92rem; background:#1d1810; | |
| 117 | border:1px solid var(--line); border-left:.25rem solid var(--t); border-radius:.45rem; } | |
| 103 | 118 | main section { margin:1.5rem 0; } |
| 104 | 119 | table { width:100%; border-collapse:collapse; font-size:.9rem; } |
| 105 | 120 | th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--line); } |
| @@ -219,6 +234,19 @@ export function terminal(ssh: string | undefined, lead: string): string { | ||
| 219 | 234 | return ssh === undefined ? "The app's terminal is off." : `${escape(lead)} <code>${escape(ssh)}</code>`; |
| 220 | 235 | } |
| 221 | 236 | |
| 237 | /** | |
| 238 | * What a browser with no script is told, which is one sentence and never two: | |
| 239 | * what this page needs a script for, which control is dead without one, and | |
| 240 | * what on it is still worth reading. The text is the game's own — it lives in | |
| 241 | * `../games.ts` beside the blurb — and it is escaped here like every other | |
| 242 | * parameter that carries text. | |
| 243 | * | |
| 244 | * A page that hands in nothing gets no element at all, which is the 404 and | |
| 245 | * the 500: they run no script, so they have nothing to explain the absence of. | |
| 246 | */ | |
| 247 | export const noscript = (line: string | undefined): string => | |
| 248 | line === undefined || line === "" ? "" : ` <noscript><p class="noscript">${escape(line)}</p></noscript>\n`; | |
| 249 | ||
| 222 | 250 | /** Which deployment this is, in words a visitor can act on. */ |
| 223 | 251 | export const where = (env: string, pull: string | undefined): string => |
| 224 | 252 | env === "preview" ? `preview of pull #${pull ?? "?"}` : env; |
| @@ -275,6 +303,12 @@ export type PageOptions = { | ||
| 275 | 303 | ssh: string | undefined; |
| 276 | 304 | /** The sentence in front of the ssh command, when there is one. */ |
| 277 | 305 | terminalLead: string; |
| 306 | /** | |
| 307 | * Text. The one sentence a browser with no script reads, rendered at the top | |
| 308 | * of `<main>` immediately above the stage — where the control it is about is. | |
| 309 | * Absent is a page that needs no script and says nothing about one. | |
| 310 | */ | |
| 311 | noscript?: string; | |
| 278 | 312 | /** Markup, already rendered — usually `stage(...)`. */ |
| 279 | 313 | stageHtml: string; |
| 280 | 314 | /** Markup, already rendered: the `<section>`s under the stage. */ |
| @@ -305,7 +339,7 @@ ${preloads(options.assets)}${endpoints(options.endpoints)}<title>${escape(option | ||
| 305 | 339 | ${options.commit ? `<small class="commit">@ ${escape(options.commit)}</small>` : ""} |
| 306 | 340 | </header> |
| 307 | 341 | <main> |
| 308 | ${options.stageHtml} | |
| 342 | ${noscript(options.noscript)}${options.stageHtml} | |
| 309 | 343 | |
| 310 | 344 | ${options.sectionsHtml} |
| 311 | 345 | <p class="terminal">${terminal(options.ssh, options.terminalLead)}</p> |
src/server.test.ts +34
| @@ -400,6 +400,40 @@ test("what one page says about another is that game's own sentence", async () => | ||
| 400 | 400 | } |
| 401 | 401 | }); |
| 402 | 402 | |
| 403 | test("with no script every game says so once, in its own words, beside the control that is dead", async () => { | |
| 404 | // Server-rendered, so a browser that never runs a line of this app's | |
| 405 | // JavaScript still gets the sentence: it is in the first paint, in <main>, | |
| 406 | // above the stage the dead control sits in — not under the footer, and not | |
| 407 | // fetched. | |
| 408 | for (const game of GAMES) { | |
| 409 | const body = await (await fetch(base + game.href)).text(); | |
| 410 | expect(body).toContain(`<noscript><p class="noscript">${escape(game.noscript)}</p></noscript>`); | |
| 411 | expect(body.match(/<noscript/g)?.length).toBe(1); | |
| 412 | expect(body.indexOf("<noscript")).toBeGreaterThan(body.indexOf("<main>")); | |
| 413 | expect(body.indexOf("<noscript")).toBeLessThan(body.indexOf(`<div id="stage">`)); | |
| 414 | expect(body.indexOf("<noscript")).toBeLessThan(body.indexOf("<footer>")); | |
| 415 | // Its own sentence and no other game's, and it names this game. | |
| 416 | for (const other of GAMES) if (other.name !== game.name) expect(body).not.toContain(escape(other.noscript)); | |
| 417 | expect(game.noscript).toContain(game.label); | |
| 418 | // One sentence, and styled by the one block the policy already hashes: | |
| 419 | // nothing inline, no second style element, no second request. | |
| 420 | expect(game.noscript.split(".").length - 1).toBe(1); | |
| 421 | expect(body).toContain("p.noscript {"); | |
| 422 | expect(body.match(/<style/g)?.length).toBe(1); | |
| 423 | expect(body.match(/<script/g)?.length).toBe(1); | |
| 424 | } | |
| 425 | // The pages that run nothing explain nothing: they go through the same shell | |
| 426 | // and hand it no sentence, because they have no dead control to apologise for. | |
| 427 | const miss = await fetch(base + "/nope"); | |
| 428 | expect(miss.status).toBe(404); | |
| 429 | expect(await miss.text()).not.toContain("<noscript"); | |
| 430 | const console_error = console.error; | |
| 431 | console.error = () => {}; | |
| 432 | const bug = onError(new Error("boom")); | |
| 433 | console.error = console_error; | |
| 434 | expect(await bug.text()).not.toContain("<noscript"); | |
| 435 | }); | |
| 436 | ||
| 403 | 437 | test("the 404 names every game in the registry and what each one is", async () => { |
| 404 | 438 | const body = await (await fetch(base + "/nope")).text(); |
| 405 | 439 | for (const game of GAMES) { |
new above
offline