tower / files / vite.config.ts
vite.config.ts
| 1 | import { existsSync, readFileSync, writeFileSync } from 'node:fs' |
| 2 | import path from 'node:path' |
| 3 | import { type Plugin, defineConfig } from 'vite' |
| 4 | import { qrcode } from 'vite-plugin-qrcode' |
| 5 | import { parsePhoto } from './src/world/photos.js' |
| 6 | import { parseWorld, toPublicWorld } from './src/world/schema.js' |
| 7 | |
| 8 | /** |
| 9 | * `content/world.json` is the world, and the only copy of it (D-072). Readers get it |
| 10 | * less its notes and its unpublished features, and that file is made from it here — |
| 11 | * served at `world.json` in dev, emitted into the build — rather than committed beside |
| 12 | * it, so the two can never disagree. A change to the file reloads an open dev page. |
| 13 | */ |
| 14 | const WORLD = path.resolve('content/world.json') |
| 15 | const publicWorld = (): string => |
| 16 | JSON.stringify(toPublicWorld(parseWorld(JSON.parse(readFileSync(WORLD, 'utf8')))), null, 2) |
| 17 | |
| 18 | function world(): Plugin { |
| 19 | return { |
| 20 | name: 'tower-world', |
| 21 | configureServer(server) { |
| 22 | server.watcher.add(WORLD) |
| 23 | server.watcher.on('change', (file) => { |
| 24 | if (path.resolve(file) === WORLD) server.ws.send({ type: 'full-reload' }) |
| 25 | }) |
| 26 | server.middlewares.use((req, res, next) => { |
| 27 | if (req.url?.split('?')[0] !== '/world.json') return next() |
| 28 | res.setHeader('Content-Type', 'application/json') |
| 29 | res.setHeader('Cache-Control', 'no-store') |
| 30 | res.end(publicWorld()) |
| 31 | }) |
| 32 | }, |
| 33 | generateBundle() { |
| 34 | this.emitFile({ type: 'asset', fileName: 'world.json', source: publicWorld() }) |
| 35 | }, |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | /** |
| 40 | * `content/photos.json` is the list of saved shots (src/world/photos.ts, D-099), and the dev |
| 41 | * server is what keeps it: the app posts a shot here when the author presses P, and reads |
| 42 | * the list back to go to one. Dev only — there is no such thing in the build — and only |
| 43 | * from this machine, because `npm run dev` is open to the whole network and this writes a |
| 44 | * file. Every field is checked first. |
| 45 | */ |
| 46 | const PHOTOS = path.resolve('content/photos.json') |
| 47 | |
| 48 | function photos(): Plugin { |
| 49 | return { |
| 50 | name: 'tower-photos', |
| 51 | configureServer(server) { |
| 52 | server.middlewares.use((req, res, next) => { |
| 53 | if (req.url?.split('?')[0] !== '/__photos') return next() |
| 54 | const say = (status: number, text: string): void => { |
| 55 | res.statusCode = status |
| 56 | res.end(text) |
| 57 | } |
| 58 | const from = req.socket.remoteAddress ?? '' |
| 59 | if (!['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(from)) return say(403, 'shots are saved from the machine the dev server runs on') |
| 60 | if (req.method === 'GET') { |
| 61 | res.setHeader('Content-Type', 'application/json') |
| 62 | res.setHeader('Cache-Control', 'no-store') |
| 63 | return say(200, existsSync(PHOTOS) ? readFileSync(PHOTOS, 'utf8') : '[]') |
| 64 | } |
| 65 | if (req.method !== 'POST') return say(405, 'GET the list, or POST a shot') |
| 66 | let body = '' |
| 67 | req.on('data', (chunk: Buffer) => { body += chunk.toString() }) |
| 68 | req.on('end', () => { |
| 69 | try { |
| 70 | const { photo, replace } = JSON.parse(body) as { photo: unknown; replace?: boolean } |
| 71 | const shot = parsePhoto(photo) |
| 72 | const list = existsSync(PHOTOS) ? (JSON.parse(readFileSync(PHOTOS, 'utf8')) as { id?: string; note?: string }[]) : [] |
| 73 | const at = list.findIndex((p) => p.id === shot.id) |
| 74 | if (at >= 0 && !replace) return say(409, `there is already a shot called ${shot.id}`) |
| 75 | // A re-framed shot keeps its place in the list, and whatever note was written on it. |
| 76 | if (at >= 0) list[at] = { ...shot, ...(list[at]!.note === undefined ? {} : { note: list[at]!.note }) } |
| 77 | else list.push(shot) |
| 78 | writeFileSync(PHOTOS, `${JSON.stringify(list, null, 2)}\n`) |
| 79 | say(200, shot.id) |
| 80 | } catch (e) { |
| 81 | say(400, e instanceof Error ? e.message : String(e)) |
| 82 | } |
| 83 | }) |
| 84 | }) |
| 85 | }, |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | /** |
| 90 | * Relative asset paths by default, so the same build works wherever Pages puts it. |
| 91 | * |
| 92 | * GitLab serves project pages either at https://<user>.gitlab.io/<project>/ or, if |
| 93 | * unique domains are on, at the root of a generated domain. A hard-coded `/tower/` |
| 94 | * base 404s every asset in the second case, and there is no way to know which one |
| 95 | * applies until the first deploy. Relative paths are correct for both. |
| 96 | * |
| 97 | * Set BASE to override — an absolute base is needed if this ever gains client-side |
| 98 | * routing, since relative URLs resolve against the current path rather than the app |
| 99 | * root. `??` would not catch BASE="", so the check is explicit. |
| 100 | */ |
| 101 | const base = process.env.BASE || './' |
| 102 | |
| 103 | export default defineConfig({ |
| 104 | base, |
| 105 | // `npm run dev` binds to every interface (--host) so a phone on the same Wi-Fi |
| 106 | // can open the app; this prints a scannable QR of that network URL alongside it. |
| 107 | plugins: [qrcode(), world(), photos()], |
| 108 | // Vite's static directory is `static/`, not the default `public/`, because |
| 109 | // GitLab Pages requires its artifact to be a directory named `public` — and |
| 110 | // `mv dist public` does not rename when `public/` already exists, it moves dist |
| 111 | // *inside* it. The collision is silent and produces a 404 at the site root. |
| 112 | publicDir: 'static', |
| 113 | build: { |
| 114 | target: 'es2022', |
| 115 | // Three.js is ~515 kB and is deliberately split into its own chunk, loaded |
| 116 | // only when the exterior view is opened. Raise the threshold past it so the |
| 117 | // warning stays meaningful for chunks that are large by accident. |
| 118 | chunkSizeWarningLimit: 600, |
| 119 | }, |
| 120 | }) |