effa077cc7
The two office stills were captioned for a building that no longer exists: "thirty-four metres by eighteen, one storey" against a plate that is now forty-eight by eighteen over two storeys, and a night note promising the office "keeps its own light after the sun has gone" — which was aspirational when it was written and is only now true. Two new frames. `office-commons` is the double-height wing, which is the one view that shows why the storeys are held fourteen metres apart. `office-hangar` is Frontier Valley, and it needed a new capability: the harness presses the office picker before choosing a chapter, because switching buildings disposes the scene and rebuilds it, so a chapter click issued first would land against a legend that no longer exists. Selected by visible label and asserted, for the same reason `chapter()` asserts its `shortLabel` — a reordered `OFFICES` table would otherwise photograph one building and caption it with the other's name. The office share card said "Lumbridge HQ, one floor: the rooms, the desks and who is at them". It is the first thing a stranger sees and it was wrong in three ways. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
201 lines
9.4 KiB
TypeScript
201 lines
9.4 KiB
TypeScript
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 = "<!-- ogc:end -->";
|
|
|
|
const OFFICE = `
|
|
<title>Spaces — an office you can walk around</title>
|
|
<meta
|
|
name="description"
|
|
content="Two buildings you can walk around: a tower floor above San Francisco and a hangar at Alameda Point. Real sun, real hours, the lights on at night. Rendered in the browser from a data file anybody can copy. Apache-2.0, self-hostable."
|
|
/>
|
|
<link rel="canonical" href="https://office.lumbridgecorp.com/" />
|
|
|
|
<meta property="og:type" content="website" />
|
|
<meta property="og:site_name" content="Lumbridge Spaces" />
|
|
<meta property="og:title" content="Spaces — an office you can walk around" />
|
|
<meta
|
|
property="og:description"
|
|
content="The rooms, the desks and who is at them — floor plans rendered from a data file anybody can copy, under the sun of the day you are reading this."
|
|
/>
|
|
<meta property="og:url" content="https://office.lumbridgecorp.com/" />
|
|
<meta property="og:image" content="https://office.lumbridgecorp.com/og-office.png" />
|
|
<meta property="og:image:type" content="image/png" />
|
|
<meta property="og:image:width" content="1200" />
|
|
<meta property="og:image:height" content="630" />
|
|
<meta
|
|
property="og:image:alt"
|
|
content="An office seen from above as a cutaway model: two storeys held apart around a double-height commons, desks and meeting rooms visible, people at their seats."
|
|
/>
|
|
|
|
<meta name="twitter:card" content="summary_large_image" />
|
|
<meta name="twitter:title" content="Spaces — an office you can walk around" />
|
|
<meta
|
|
name="twitter:description"
|
|
content="The rooms, the desks and who is at them — floor plans rendered from a data file anybody can copy, under the sun of the day you are reading this."
|
|
/>
|
|
<meta name="twitter:image" content="https://office.lumbridgecorp.com/og-office.png" />`;
|
|
|
|
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("<!--", open);
|
|
const office =
|
|
html.slice(0, from) +
|
|
"<!-- The office door. Generated from index.html by `twoDoors` in vite.config.ts. -->" +
|
|
OFFICE +
|
|
html.slice(end + MARKER_END.length);
|
|
|
|
await writeFile(join(dir, "office.html"), office, "utf8");
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Cut `satellite.js`'s WASM runtime out of the bundle.
|
|
*
|
|
* `satellite.js`'s entry point ends with `export * from './wasm/index.js'`, and
|
|
* that subtree is an Emscripten build: a megabyte of generated glue that reaches
|
|
* for `node:module` and `node:worker_threads`, wraps itself in a top-level
|
|
* `await`, and cannot be tree-shaken away because a star re-export of a module
|
|
* with side effects is not something a bundler may drop on its own.
|
|
*
|
|
* Measured, before this existed: **308 kB** of minified WASM loader shipped to
|
|
* every visitor, in a chunk nothing ever called. `engine/satellites.ts` uses the
|
|
* pure-JS `propagate` and says in its own header why it does not want the
|
|
* `BulkPropagator` — a binary loaded at runtime plus a fallback path for when
|
|
* that fails, to buy back two milliseconds a frame that are already budgeted.
|
|
* Paying 308 kB to *not* use it was the worst of both.
|
|
*
|
|
* The stub is empty because nothing imports a name from it. If a future version
|
|
* of this repo does want the bulk propagator, the fix is to delete this plugin
|
|
* and pay the kilobytes deliberately — not to widen the stub.
|
|
*
|
|
* Matching is on the **importer** as well as the specifier, so this cannot
|
|
* silently swallow some other package's `./wasm/index.js`.
|
|
*/
|
|
function noWasmPropagator(): Plugin {
|
|
const STUB = "\0lumbridge:satellite-wasm-stub";
|
|
return {
|
|
name: "lumbridge:no-wasm-propagator",
|
|
enforce: "pre",
|
|
resolveId(source, importer) {
|
|
if (source !== "./wasm/index.js") return null;
|
|
if (importer === undefined || !importer.includes("satellite.js")) return null;
|
|
return STUB;
|
|
},
|
|
load(id) {
|
|
return id === STUB ? "export {};" : null;
|
|
},
|
|
};
|
|
}
|
|
|
|
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: [noWasmPropagator(), twoDoors()],
|
|
/**
|
|
* The heightfield worker is constructed as `{ type: "module" }` in
|
|
* `world.ts`, and this is the build setting that agrees with it.
|
|
*
|
|
* Vite's default worker format is `iife`, which was survivable while nothing
|
|
* in the graph needed anything an IIFE cannot express — and stopped being
|
|
* survivable the moment it did. `satellite.js` reaches a WASM runtime with a
|
|
* top-level `await` in it, and rollup's answer to a top-level await in an
|
|
* `iife` chunk is to fail the whole build, with an error naming a file no
|
|
* worker imports.
|
|
*
|
|
* So this is agreement rather than a workaround: the module the browser is
|
|
* told to load as an ES module is now built as one.
|
|
*/
|
|
worker: { format: "es" },
|
|
build: {
|
|
outDir: "dist",
|
|
target: "es2022",
|
|
// Two entries. `login.html` used to sit in `public/`, which Vite copies
|
|
// 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" },
|
|
output: {
|
|
/**
|
|
* three.js and the SGP4 propagator get a chunk of their own.
|
|
*
|
|
* Not to make the download smaller — it is the same bytes either way —
|
|
* but to stop them being *re-downloaded*. They were inside the entry
|
|
* chunk, so every deploy that changed a line of app code invalidated
|
|
* three quarters of a megabyte of dependency that had not changed since
|
|
* the last release. Split out, a returning visitor pays for the app and
|
|
* keeps the vendor chunk it already has.
|
|
*
|
|
* They are one chunk rather than two because they are always wanted
|
|
* together: `engine/satellites.ts` imports both, and it is reached from
|
|
* the entry on every board.
|
|
*
|
|
* Measured: the entry chunk goes from 758 kB to 208 kB and the vendor
|
|
* chunk is 550 kB. Rollup's 500 kB warning therefore still fires — and
|
|
* it should. It now points at three.js, where it is a true statement
|
|
* about a dependency nobody here can shrink, instead of at our own code,
|
|
* where it was pointing at three.js all along and reading as if it were
|
|
* about us. Raising `chunkSizeWarningLimit` would have hidden both.
|
|
*/
|
|
manualChunks: {
|
|
vendor: ["three", "satellite.js"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|