Files
astrolabe/vite.config.ts
T

178 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { generateLearnPages, lessonInputs } from './scripts/learn-pages';
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf-8')) as {
version: string;
};
// Write a static `learn/<slug>/index.html` shell per lesson (from the lesson `.md`
// frontmatter) before the inputs below reference them. Regenerated whenever Vite
// (re)loads its config — every dev start and build — so a newly added lesson needs a
// dev restart to get its page; the generated dirs are git-ignored.
generateLearnPages();
export default defineConfig({
server: {
// Hetzner dev box: fronted by tailscale serve (HTTPS on the tailnet), which
// forwards the ts.net Host header — allow it past DNS-rebinding protection.
allowedHosts: ['.ts.net'],
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@core': fileURLToPath(new URL('./src/core', import.meta.url)),
},
},
define: {
__APP_VERSION__: JSON.stringify(pkg.version),
},
build: {
rollupOptions: {
// Multi-page: the marketing landing is served at `/` (index.html → the
// lightweight `src/landing` entry); the app at `/app/` (app/index.html →
// src/main.tsx). The app is hash-routed, so it runs unchanged under /app/.
// `/learn/` is the deep-dive learning section — another light marketing
// entry (src/learn), reusing core + the landing's chart embed only. Its index
// is `learn/index.html`; each lesson is its own page (`learn/<slug>/`) from a
// generated shell, added to the inputs here via `lessonInputs()`.
input: {
main: fileURLToPath(new URL('./index.html', import.meta.url)),
app: fileURLToPath(new URL('./app/index.html', import.meta.url)),
learn: fileURLToPath(new URL('./learn/index.html', import.meta.url)),
...lessonInputs(),
},
output: {
// Split the heavy vendors so the chunk graph stays legible and the PWA
// can precache/update them independently of app code. Monaco and the
// Vega stack are each multi-MB; isolating them keeps app rebuilds small.
//
// Function form with explicit homes for every module the light and
// heavy graphs *share* — Rollup merges small shared modules into their
// biggest importer, which is how the landing's eager JS twice gained a
// static edge into a multi-MB vendor chunk:
// - Vite's dynamic-import preload helper (a virtual module used by
// every chunk that calls `import()`, monaco included) was absorbed
// into the monaco chunk, chaining the landing's lazy chart embed to
// all of Monaco. It gets its own micro-chunk.
// - vega-themes (preset config data), vega-expression (+ its dep
// vega-util), and json-stringify-pretty-compact (used by core's
// json-format) are imported both by core modules the landing/learn
// entries reach and by vega/vega-embed internals; unassigned, they
// were absorbed into the vega chunk. They get a small shared
// 'vendor-light' chunk — the heavy chunk importing the light one is
// fine, the reverse is not. vega-scale stays unassigned: after the
// scheme-colors.ts split, only app code and vega internals reach it.
// `scripts/check-light-entries.mjs` asserts the invariant (no
// vendor-monaco/vendor-vega reference in the landing/learn HTML) after
// every build.
manualChunks: (id: string) => {
if (id.includes('vite/preload-helper')) return 'preload-helper';
if (!id.includes('node_modules')) return undefined;
if (id.includes('node_modules/monaco-editor/')) return 'vendor-monaco';
if (
/node_modules\/(vega-themes|vega-expression|vega-util|json-stringify-pretty-compact)\//.test(
id,
)
)
return 'vendor-light';
if (/node_modules\/(vega|vega-lite|vega-embed)\//.test(id)) return 'vendor-vega';
return undefined;
},
},
},
},
plugins: [
react(),
VitePWA({
registerType: 'prompt',
// The app lives at /app/; scope the service worker there so it never
// controls the marketing landing at / (which stays uncontrolled and
// always-fresh). A /sw.js narrowing its scope to /app/ needs no
// Service-Worker-Allowed header — narrowing is always permitted.
scope: '/app/',
includeAssets: ['favicon.svg', 'icon-maskable.svg', 'icon-mono.svg', 'apple-touch-icon.png'],
manifest: {
id: '/app/',
name: 'Astrolabe',
short_name: 'Astrolabe',
scope: '/app/',
start_url: '/app/',
description: 'A browser-based snippet manager for Vega-Lite visualizations.',
// theme_color tints the OS/browser chrome; background_color is the splash
// behind the icon. theme_color is the app accent (`--accent`, light theme);
// background_color matches the light `--bg` the app boots into.
theme_color: '#0e7490',
background_color: '#ffffff',
display: 'standalone',
// SVG marks scale to any size — Chrome/Edge/desktop install accept SVG
// icons. maskable = Android adaptive; monochrome = tinted mark. Safari
// ignores SVG and the manifest both, so the 180×180 PNG covers the one
// touch surface we target — iPad add-to-home-screen (Astrolabe is a
// desktop/tablet tool, not a phone app). It's linked as
// <link rel="apple-touch-icon"> in index.html, where Safari reads it; the
// PNG is the favicon art rasterized full-bleed (no self-rounding — iOS
// masks its own corners).
icons: [
{ src: 'favicon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
{ src: 'icon-maskable.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'maskable' },
{ src: 'icon-mono.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'monochrome' },
{ src: 'apple-touch-icon.png', sizes: '180x180', type: 'image/png', purpose: 'any' },
],
},
workbox: {
// Client-side navigations under /app/ fall back to the app shell when
// offline; the SW's /app/ scope means / (the landing) is never matched.
navigateFallback: '/app/index.html',
// Precache the app, plus the font woff2 the offline-first UI and the
// common chart path need:
// - the bare `latin` subset of every family (`*-latin-[0-9]*` — the
// weight digit excludes `latin-ext`), the common chart case;
// - every subset of the UI fonts (IBM Plex Sans + Mono) — these are
// capability: the chrome must render any script (incl. latin-ext,
// Cyrillic, Greek) offline. The Sans brace-list names the subsets so
// it matches the UI Sans but not `ibm-plex-sans-condensed-*`, a roster
// chart font that stays latin-only like the rest.
// Every other subset (the roster's latin-ext and non-latin scripts) is
// decoration with per-glyph fallback: left out of the precache and
// runtime-cached on first use below, so the install stays small while the
// roster still works offline once a script has been seen. (Default
// globPatterns omit woff2 entirely — without this the first offline load
// silently falls back to system fonts.)
globPatterns: [
'**/*.{js,css,html,ico,png,svg}',
'**/*-latin-[0-9]*.woff2',
'**/ibm-plex-sans-{latin,latin-ext,cyrillic,cyrillic-ext,greek,greek-ext,vietnamese}-*.woff2',
'**/ibm-plex-mono-*.woff2',
],
// Monaco and Vega vendor chunks exceed Workbox's 2 MiB default; raise the
// ceiling so the whole app (offline is a core requirement) is precached.
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
runtimeCaching: [
{
// Chart-roster non-latin font subsets: not precached, so fetch on
// demand and keep them offline-after-first-use. Precached woff2
// (latin + Plex) are served from the precache and never reach here.
urlPattern: /\.woff2$/,
handler: 'CacheFirst',
options: {
cacheName: 'chart-font-subsets',
expiration: { maxEntries: 120, maxAgeSeconds: 60 * 60 * 24 * 365 },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
}),
],
// Vitest config (shares this file)
test: {
globals: true,
environment: 'happy-dom',
include: ['src/**/*.test.{ts,tsx}'],
},
} as Parameters<typeof defineConfig>[0]);