Build: landing/learn shed eager Monaco+Vega; post-build gate + immutable asset caching

This commit is contained in:
2026-07-02 22:34:24 +03:00
parent fb4b42a1a7
commit 552900dac5
11 changed files with 170 additions and 77 deletions
+3 -3
View File
@@ -44,10 +44,10 @@ export default tseslint.config(
languageOptions: { globals: { ...globals.node } }, languageOptions: { globals: { ...globals.node } },
}, },
// Plain JS config files (this file, etc.) are not part of the TS project — // Plain JS config files and Node scripts (this file, scripts/*.mjs, etc.) are
// run them through the untyped ruleset only. // not part of the TS project — run them through the untyped ruleset only.
{ {
files: ['**/*.js'], files: ['**/*.{js,mjs}'],
extends: [tseslint.configs.disableTypeChecked], extends: [tseslint.configs.disableTypeChecked],
languageOptions: { globals: { ...globals.node } }, languageOptions: { globals: { ...globals.node } },
}, },
+1
View File
@@ -40,6 +40,7 @@
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"ajv": "^8.20.0",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
+2 -1
View File
@@ -6,7 +6,7 @@
"description": "A browser-based snippet manager for Vega-Lite visualizations.", "description": "A browser-based snippet manager for Vega-Lite visualizations.",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build && node scripts/check-light-entries.mjs",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
@@ -56,6 +56,7 @@
"@types/react": "^19.2.16", "@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0", "@vitejs/plugin-react": "^5.2.0",
"ajv": "^8.20.0",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
+10
View File
@@ -0,0 +1,10 @@
# Cloudflare Pages header rules (copied into dist/ by Vite's public/ passthrough).
#
# Everything under /assets/ is content-hashed by the build (JS, CSS, and the
# font files), so it is safe to cache forever — a changed file gets a new URL.
# Without this rule CF Pages serves its default `max-age=14400, must-revalidate`,
# making every returning visitor revalidate multi-MB vendor chunks every 4 hours.
# HTML keeps the platform default (max-age=0, must-revalidate) so deploys
# propagate instantly; icons live at the root un-hashed and keep the default too.
/assets/*
Cache-Control: public, max-age=31536000, immutable
+47
View File
@@ -0,0 +1,47 @@
// Post-build gate: the marketing entries stay light. The landing (/) and the
// learning section (/learn/) must never gain a static edge into the heavy
// vendor chunks — this shipped once (Vite's preload helper emitted inside the
// monaco chunk chained every lazy import() to it; a vega-scale import in
// theme-controls was reachable from Landing). Vite lists an entry's full
// static graph as modulepreload links / module scripts in its HTML, so
// grepping the emitted HTML catches any regression regardless of cause.
//
// Runs as part of `npm run build` (after `vite build`). Exit 1 on violation.
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
const pages = ['dist/index.html', 'dist/learn/index.html'];
if (existsSync('dist/learn')) {
for (const entry of readdirSync('dist/learn', { withFileTypes: true })) {
if (entry.isDirectory()) pages.push(join('dist/learn', entry.name, 'index.html'));
}
}
const HEAVY = /vendor-(?:monaco|vega)-[^"']*\.js/;
const missing = pages.filter((p) => !existsSync(p));
if (missing.length > 0) {
console.error(
`check-light-entries: expected pages missing from dist/:\n ${missing.join('\n ')}`,
);
process.exit(1);
}
// The gate bans chunks by name, so it must not fail open: if the names minted in
// vite.config.ts (manualChunks) ever change, this makes the rename update the gate
// instead of silently disarming it.
const assets = readdirSync('dist/assets');
for (const chunk of ['vendor-monaco', 'vendor-vega']) {
if (!assets.some((f) => f.startsWith(`${chunk}-`) && f.endsWith('.js'))) {
console.error(
`check-light-entries: no ${chunk}-*.js in dist/assets — chunk naming changed; update vite.config.ts manualChunks and this gate together.`,
);
process.exit(1);
}
}
const offenders = pages.filter((p) => HEAVY.test(readFileSync(p, 'utf8')));
if (offenders.length > 0) {
console.error(
`check-light-entries: light entries reference heavy vendor chunks:\n ${offenders.join('\n ')}`,
);
process.exit(1);
}
console.log(`check-light-entries: OK (${pages.length} pages clean of vendor-monaco/vendor-vega)`);
+2 -7
View File
@@ -20,13 +20,8 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { JsonObject } from '@core/spec-config'; import type { JsonObject } from '@core/spec-config';
import { import { type ConfigPath, countSet, getConfigValue, schemesByKind } from '@core/theme-controls';
type ConfigPath, import { schemeColors } from '@core/scheme-colors';
countSet,
getConfigValue,
schemeColors,
schemesByKind,
} from '@core/theme-controls';
import { Button } from './Button'; import { Button } from './Button';
import { ColorField } from './ColorField'; import { ColorField } from './ColorField';
import { Icon } from './Icon'; import { Icon } from './Icon';
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { schemeColors } from './scheme-colors';
describe('schemeColors', () => {
it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
const colors = schemeColors('tableau10', 3);
expect(colors).toHaveLength(10);
expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
});
it('samples a continuous scheme at count stops, normalized to hex', () => {
const colors = schemeColors('viridis', 5);
expect(colors).toHaveLength(5);
expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true);
});
it('samples the midpoint for a single continuous stop', () => {
expect(schemeColors('viridis', 1)).toHaveLength(1);
});
it('returns [] for an unknown scheme', () => {
expect(schemeColors('not-a-scheme')).toEqual([]);
});
});
+40
View File
@@ -0,0 +1,40 @@
/**
* Named-scheme resolution to hex swatches (docs/exploration/chart-theming-scope.md §5).
*
* Split from theme-controls.ts because of the `vega-scale` import below:
* theme-controls is reachable from the landing/learn entry graphs (via
* `vega-themes`), and a static vega-scale edge there drags the vega vendor
* chunk into the marketing pages' eager JS (`scripts/check-light-entries.mjs`
* guards this after every build). Only the app's color controls resolve
* schemes to swatches, so the edge lives here, app-side of the split.
*/
import { scheme } from 'vega-scale';
/**
* Resolve a named Vega scheme to hex swatches. A categorical scheme returns its
* fixed palette in full; a continuous scheme (sequential/diverging) is sampled
* at `count` evenly-spaced stops (`count` applies to continuous schemes only).
* Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown
* name returns `[]` — the picker shows that scheme without a preview rather than
* throwing. Used for the swatch/gradient preview and "materialize to swatches".
*/
export function schemeColors(name: string, count = 9): string[] {
const resolved: unknown = scheme(name);
if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c)));
if (typeof resolved === 'function' && count > 0) {
const interp = resolved as (t: number) => string;
if (count === 1) return [toHex(interp(0.5))];
return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1))));
}
return [];
}
/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */
function toHex(color: string): string {
if (color.startsWith('#')) return color;
const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
if (!m) return color;
const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0');
return `#${h(m[1])}${h(m[2])}${h(m[3])}`;
}
+1 -23
View File
@@ -8,10 +8,10 @@ import {
enumValue, enumValue,
getConfigValue, getConfigValue,
normalizeRangeSchemes, normalizeRangeSchemes,
schemeColors,
schemesByKind, schemesByKind,
setConfigValue, setConfigValue,
} from './theme-controls'; } from './theme-controls';
import { schemeColors } from './scheme-colors';
describe('getConfigValue', () => { describe('getConfigValue', () => {
const config = { range: { category: ['#111', '#222'] }, mark: { color: '#333' } }; const config = { range: { category: ['#111', '#222'] }, mark: { color: '#333' } };
@@ -163,28 +163,6 @@ describe('leaf coercion', () => {
}); });
}); });
describe('schemeColors', () => {
it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
const colors = schemeColors('tableau10', 3);
expect(colors).toHaveLength(10);
expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
});
it('samples a continuous scheme at count stops, normalized to hex', () => {
const colors = schemeColors('viridis', 5);
expect(colors).toHaveLength(5);
expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true);
});
it('samples the midpoint for a single continuous stop', () => {
expect(schemeColors('viridis', 1)).toHaveLength(1);
});
it('returns [] for an unknown scheme', () => {
expect(schemeColors('not-a-scheme')).toEqual([]);
});
});
describe('enumValue', () => { describe('enumValue', () => {
const options = [{ value: '' }, { value: 'start' }, { value: 'end' }] as const; const options = [{ value: '' }, { value: 'start' }, { value: 'end' }] as const;
+8 -40
View File
@@ -1,9 +1,8 @@
/** /**
* Theme Builder structured-control primitives (docs/chart-theming-scope.md §5). * Theme Builder structured-control primitives (docs/exploration/chart-theming-scope.md §5).
* *
* Pure core: the read/write transforms the builder's structured controls run on * Pure core: the read/write transforms the builder's structured controls run on
* a draft config, plus the named-color-scheme catalog and its resolution. Three * a draft config, plus the named-color-scheme catalog. Two concerns:
* concerns:
* *
* - **Path get/set** — read a value at a nested config path, and set one back * - **Path get/set** — read a value at a nested config path, and set one back
* immutably while preserving every sibling key at each level. That guarantee * immutably while preserving every sibling key at each level. That guarantee
@@ -15,16 +14,15 @@
* writes a minimal diff, never a default dump. * writes a minimal diff, never a default dump.
* - **Scheme catalog** — the named Vega color schemes the color controls offer, * - **Scheme catalog** — the named Vega color schemes the color controls offer,
* grouped by kind (categorical / sequential / diverging). * grouped by kind (categorical / sequential / diverging).
* - **Scheme resolution** (`schemeColors`) — a scheme name to hex swatches, for
* the picker preview and the "materialize to an editable array" action.
* *
* `vega-scale` (a focused vega sub-package, like core's `vega-expression`) owns * Scheme *resolution* (name → hex swatches) lives in `scheme-colors.ts`: it
* the scheme registry; importing it here keeps the umbrella `vega` out of core. * imports the `vega-scale` registry, and this module is reachable from the
* The one control transform that predates this module, `applyFontToConfig`, * landing/learn entry graphs (via `vega-themes`), which must stay free of
* stays in custom-theme.ts with the record entity. * static edges into the vega vendor chunk. The one control transform that
* predates this module, `applyFontToConfig`, stays in custom-theme.ts with the
* record entity.
*/ */
import { scheme } from 'vega-scale';
import { isJsonObject, type JsonObject } from './spec-config'; import { isJsonObject, type JsonObject } from './spec-config';
// ── Path get/set ────────────────────────────────────────────────────────── // ── Path get/set ──────────────────────────────────────────────────────────
@@ -213,33 +211,3 @@ export const THEME_SCHEMES: ReadonlyArray<ThemeScheme> = [
export function schemesByKind(kind: SchemeKind): ThemeScheme[] { export function schemesByKind(kind: SchemeKind): ThemeScheme[] {
return THEME_SCHEMES.filter((s) => s.kind === kind); return THEME_SCHEMES.filter((s) => s.kind === kind);
} }
// ── Scheme resolution ───────────────────────────────────────────────────────
/**
* Resolve a named Vega scheme to hex swatches. A categorical scheme returns its
* fixed palette in full; a continuous scheme (sequential/diverging) is sampled
* at `count` evenly-spaced stops (`count` applies to continuous schemes only).
* Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown
* name returns `[]` — the picker shows that scheme without a preview rather than
* throwing. Used for the swatch/gradient preview and "materialize to swatches".
*/
export function schemeColors(name: string, count = 9): string[] {
const resolved: unknown = scheme(name);
if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c)));
if (typeof resolved === 'function' && count > 0) {
const interp = resolved as (t: number) => string;
if (count === 1) return [toHex(interp(0.5))];
return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1))));
}
return [];
}
/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */
function toHex(color: string): string {
if (color.startsWith('#')) return color;
const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
if (!m) return color;
const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0');
return `#${h(m[1])}${h(m[2])}${h(m[3])}`;
}
+32 -3
View File
@@ -44,9 +44,38 @@ export default defineConfig({
// Split the heavy vendors so the chunk graph stays legible and the PWA // Split the heavy vendors so the chunk graph stays legible and the PWA
// can precache/update them independently of app code. Monaco and the // can precache/update them independently of app code. Monaco and the
// Vega stack are each multi-MB; isolating them keeps app rebuilds small. // Vega stack are each multi-MB; isolating them keeps app rebuilds small.
manualChunks: { //
monaco: ['monaco-editor'], // Function form with explicit homes for every module the light and
vega: ['vega', 'vega-lite', 'vega-embed'], // 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;
}, },
}, },
}, },