Three ways the compiled-in viewer could be wrong, none of which anything would have reported. Found by mutation-testing the guard added below — it failed when it should have passed, and the reason was the second bullet. * `include_dir!` registers NO rebuild dependency. Run `npm run build` in frontend/export-viewer, then `cargo build`, and cargo sees no source change and reuses the cached binary — carrying the PREVIOUS index.html. The file on disk and the file in the binary disagree, git is clean, every check passes, and Memories.zip ships a stale viewer. Confirmed empirically: after replacing the artifact the compiled-in copy did not change until a source file was touched. A build.rs now declares `rerun-if-changed` for `static/export-viewer` AND `migrations` — sqlx::migrate!() embeds its directory the same way, and there the stale snapshot is worse still: the binary boots against a database that already ran a newer migration and crash-loops with VersionMissing. * `emptyOutDir: true` deleted the committed artifact BEFORE generating. That was safe while the build could not fail; it no longer is, because `inlineThemeFonts` now calls `this.error` on a keepsake that is not self-contained. A failed build left the directory empty — and include_dir! over an empty directory compiles fine, while `write_viewer_with_data` iterates zero files and returns Ok. The result is a valid archive with every photo and no viewer. The output is one overwritten file, so nothing accumulates without the wipe. * Nothing asserted the viewer was there at all. Now asserted at the point of use (bail rather than write a viewer-less keepsake) and in a test that checks presence, plausible size, and that no `url(/...)` survived inlining — the three ways it can be present but useless. The Dockerfile copies build.rs with the sources rather than with Cargo.toml, so the dependency-cache layer stays byte-identical and the dummy build does not run it.
124 lines
6.1 KiB
JavaScript
124 lines
6.1 KiB
JavaScript
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 — and a failed build now leaves the last good artifact in
|
|
// place instead of deleting it.
|
|
emptyOutDir: false,
|
|
target: 'es2020'
|
|
}
|
|
});
|