diff --git a/.gitignore b/.gitignore index 9d4e091..738d3ad 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ public/props/ public/kits/ public/offices/ docs/ + +# Intermediate art for the share cards. Regenerated by +# `scripts/brand-assets/capture.mjs`; the finished cards in `public/` are what +# ships, and these are 2.8 MB of screenshot nobody needs in history. +scripts/brand-assets/art-*.png diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet index 195d2ff..4de1fa7 100644 --- a/deploy/Caddyfile.snippet +++ b/deploy/Caddyfile.snippet @@ -18,6 +18,33 @@ # The API and the static build are deliberately the same origin. Nothing here # needs CORS, which is why TERA_CORS_ORIGIN defaults to empty — set it only for # a Vite dev server on another port. +# +# +# ## Two doors out of one root +# +# `office.` and `tera.` are one build, and the app reads its own hostname to +# decide which one it is. A crawler cannot do that — it reads the HTML and +# nothing else — so `npm run build` emits a second shell, `office.html`, which is +# byte-identical below `` and carries the office's title, description and +# share card. Point the office door's fallback at it and the two unfurl as the +# two places they are: +# +# office.lumbridgecorp.com { +# import tera_api +# root * /srv/tera/dist # the SAME root as tera., not a copy +# try_files {path} {path}/index.html /office.html +# file_server +# } +# +# The root is shared rather than copied on purpose — a deploy cannot then leave +# the two doors on different builds — and `office.html` comes out of the same +# `npm run build` as `index.html`, so the shells cannot drift from each other +# either. The only line that differs between the two site blocks is the +# `try_files` fallback. +# +# A deployment that skips this is not broken: the office door keeps working and +# simply unfurls with the city's card, which is what it did before there was a +# second shell at all. (tera_api) { handle /api/v1/* { diff --git a/index.html b/index.html index 1a6f50f..b012477 100644 --- a/index.html +++ b/index.html @@ -20,7 +20,57 @@ and logs the 404 to the console, which buries the errors that matter. --> - Lumbridge Simulate — San Francisco + + + Tera — cities from above, and an office you can walk into + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+

+

+

+
+

lumbridgecorp.com

+

Apache-2.0

+
+ + + + diff --git a/vite.config.ts b/vite.config.ts index 3c75592..38f4ac6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,107 @@ -import { defineConfig } from "vite"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { defineConfig, type Plugin } from "vite"; + +/** + * The office's copy of the page, emitted from the city's. + * + * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind + * two names, and the app works out which door it is at runtime by reading its + * own hostname. A crawler cannot: it reads the HTML and nothing else. So a + * single `index.html` means both doors unfurl with the same title, the same + * sentence and the same picture — and the office door's whole reason to exist is + * that it is a different place. + * + * The obvious fix, a second hand-written `office.html`, is the one the Caddy + * config already argues against for the static root: "sharing the root rather + * than copying it means a deploy cannot leave the two doors on different + * builds". Two 900-line files each carrying the whole inline stylesheet would + * drift on the first CSS change, and nothing would notice, because the drift is + * invisible until somebody opens the other door. + * + * So there is one source and the build emits both shells. Everything outside the + * `ogc:` markers in `index.html` is copied byte for byte — the stylesheet, the + * DOM, the script tag Vite has already rewritten to the hashed bundle — and only + * the block between them is replaced. A change to the interface reaches both + * doors by construction, and the only thing that can differ is the thing that is + * supposed to. + * + * It runs in `writeBundle` rather than `transformIndexHtml` because it needs the + * *finished* document, after Vite has substituted the asset URLs. Transforming + * earlier would emit a page pointing at unhashed source paths. + */ +function twoDoors(): Plugin { + const MARKER_END = ""; + + const OFFICE = ` + Spaces — an office you can walk around + + + + + + + + + + + + + + + + + + `; + + return { + name: "lumbridge:two-doors", + apply: "build", + async writeBundle(options) { + const dir = options.dir ?? "dist"; + const html = await readFile(join(dir, "index.html"), "utf8"); + + const open = html.indexOf("ogc:start"); + const end = html.indexOf(MARKER_END); + // Loudly, rather than by silently shipping two identical shells. A card + // that is quietly the wrong one is the failure this plugin exists to + // prevent, so losing the markers must not be a no-op. + if (open === -1 || end === -1) { + this.error("index.html has no ogc:start/ogc:end markers — cannot emit office.html"); + return; + } + // The opening marker sits inside a larger comment, so cut from the start + // of that comment: otherwise the explanation ships on both doors while + // being true of only one. + const from = html.lastIndexOf("" + + OFFICE + + html.slice(end + MARKER_END.length); + + await writeFile(join(dir, "office.html"), office, "utf8"); + }, + }; +} export default defineConfig({ // Mounted under tera.lumbridgecorp.com in production; the trailing // slash matters, since every asset URL is resolved against it. base: process.env.TERA_BASE ?? "/", + plugins: [twoDoors()], build: { outDir: "dist", target: "es2022", @@ -11,6 +109,10 @@ export default defineConfig({ // verbatim — so `import.meta.env` was never substituted there and the page // could not be told which identity provider to sign in against. It is a real // entry now; the built URL (`/login.html`) is unchanged. + // + // `office.html` is deliberately not a third entry: it is the same document + // as `index.html` with a different head, so making it one would give it its + // own copy of the bundle graph. It is emitted after the build instead. rollupOptions: { input: { index: "index.html", login: "login.html" } }, }, });