import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import tailwindcss from '@tailwindcss/vite'; import { viteSingleFile } from 'vite-plugin-singlefile'; import { defineConfig } from 'vite'; import { fileURLToPath } from 'node:url'; import { readFileSync } from 'node:fs'; /** Webfonts the shared theme declares, and where their bytes actually live. */ const FONTS = ['Inter', 'Fraunces']; /** * Inline the webfonts the shared theme CSS references by ABSOLUTE path. * * `src/tailwind-theme.css` is the design-token source of truth for both the live app and this * viewer, and it declares `src: url('/fonts/Inter.woff2')`. That is correct for the app, which * serves `static/fonts/` from the site root — but the keepsake is opened from `file://` off a USB * stick or a Downloads folder, where `/fonts/...` resolves to the root of the guest's DISK. Both * requests 404, silently: `font-display: swap` means the viewer renders in a fallback system font * with no error, so nothing on the server side can ever report it. The keepsake is the one artifact * the whole event exists to produce, and it was shipping without the typography it was designed in. * * Fixing it in the shared CSS would break the app (a data URI there would inline ~154 KB into every * page load for no reason), and shipping a `fonts/` folder beside `index.html` would give the guest * a directory they can break by moving one file. So the substitution belongs HERE, in the build that * knows its output has no origin: rewrite the emitted HTML only. * * Runs in `generateBundle` rather than `transformIndexHtml` because `viteSingleFile` inlines the * stylesheet during the latter — the `url()` we need to rewrite does not exist in the HTML until * after it has run. */ function inlineThemeFonts() { return { name: 'eventsnap:inline-theme-fonts', enforce: 'post', generateBundle(_options, bundle) { const html = bundle['index.html']; if (!html || typeof html.source !== 'string') return; for (const family of FONTS) { const bytes = readFileSync( fileURLToPath(new URL(`../static/fonts/${family}.woff2`, import.meta.url)) ); const uri = `data:font/woff2;base64,${bytes.toString('base64')}`; const before = html.source; html.source = html.source.replaceAll(`/fonts/${family}.woff2`, uri); // A silent no-op here is the exact failure this plugin exists to prevent, and it // would come back the moment the theme renames a font or switches to a CDN. Fail // the build instead of shipping another keepsake in Times New Roman. if (html.source === before) { this.error( `inline-theme-fonts: no reference to /fonts/${family}.woff2 in the built ` + `keepsake. The shared theme CSS changed how it loads webfonts — update ` + `FONTS in vite.standalone.config.js to match, or the offline viewer will ` + `render in a fallback font.` ); } } }, // The FONTS loop above only catches a RENAME. It cannot catch an ADDITION, and an addition // is the likelier accident by far: someone doing ordinary app work adds a display font or a // decorative background to the shared theme, has no reason to open a viewer build config, // and ships a keepsake that reaches for `/fonts/Playfair.woff2` on the guest's own disk. // `font-display: swap` hides it, so the artifact looks correct to everyone who happens to // have the file locally, and renders in Times New Roman for the couple. // // So assert the invariant itself rather than a list: nothing in the emitted keepsake may // reference an external URL. Self-maintaining — it covers renames, additions, fonts, // images and stylesheets alike, and nobody has to remember it exists. // // In `writeBundle`, NOT `generateBundle`: the latter runs more than once, and on the // earlier pass the stylesheet has not been inlined yet, so asserting there fails a // perfectly good build. This hook sees only what was actually written. writeBundle(_options, bundle) { const html = bundle['index.html']; if (!html || typeof html.source !== 'string') return; const external = [...html.source.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)] .map((m) => m[2].trim()) .filter((u) => !u.startsWith('data:')); if (external.length) { this.error( `inline-theme-fonts: the built keepsake still references ${external.length} ` + `external asset(s): ${[...new Set(external)].join(', ')}. The viewer is opened ` + `from file:// with no network and no origin, so every one of these resolves to ` + `the root of the guest's disk and 404s silently. Inline them (see FONTS above) ` + `or remove them from the theme the viewer imports.` ); } } }; } // Builds the keepsake viewer as ONE self-contained index.html (all JS + CSS // inlined) so it renders when opened via file://. Uses the plain Svelte plugin // (not SvelteKit) because SvelteKit emits multiple module entry points, which // cannot be inlined into a single file. export default defineConfig({ plugins: [ tailwindcss(), svelte({ configFile: false, preprocess: vitePreprocess(), compilerOptions: { runes: true } }), viteSingleFile(), inlineThemeFonts() ], resolve: { alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) } }, build: { outDir: fileURLToPath(new URL('../../backend/static/export-viewer', import.meta.url)), // NOT `true`. Vite empties outDir BEFORE generating, so a build that fails late — which is // now a real possibility, since `inlineThemeFonts` calls `this.error` on a keepsake that is // not self-contained — left the directory EMPTY. `include_dir!` over an empty directory // compiles perfectly happily, and `write_viewer_with_data` iterates zero files and returns // Ok, so the next `cargo build` produced a binary whose Memories.zip has the photos and no // viewer at all. Before the guard existed the build could not fail, so neither could this. // // The only output is a single `index.html`, overwritten on every successful build, so // there is nothing to accumulate. // // This protects ONE of the two failure paths, not both. `inlineThemeFonts` errors from // `generateBundle`, before anything is written, so the previous artifact survives intact. // The external-asset assertion errors from `writeBundle` — which runs AFTER Vite has // written `index.html` — so that failure does overwrite the good viewer with the broken // one. It cannot ship: the build exits non-zero, and `the_keepsake_viewer_is_compiled_into_ // this_binary` plus the `!html.contains("url(/")` assertion both fail the Rust test suite // before any image is built. But if a `writeBundle` failure is what you are looking at, // restore the artifact with `git checkout backend/static/export-viewer/` rather than // assuming the working copy is still the last good one. emptyOutDir: false, target: 'es2020' } });