The font-inlining guard only caught a RENAME. It asked "did /fonts/<listed family>.woff2 disappear?", so an ADDITION walked straight past it — and an addition is the likelier accident: 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 right to everyone who happens to have the file locally and renders in Times New Roman for the couple. It now asserts the invariant instead of a list: nothing in the emitted keepsake may reference an external URL. Self-maintaining, and it covers fonts, images and stylesheets alike. In writeBundle rather than generateBundle — generateBundle runs more than once and the stylesheet is not inlined on the earlier pass, so asserting there fails a perfectly good build. viewer-no-broken-tiles gets a positive anchor. Its "nothing is broken" check filters img elements, so a viewer that rendered NOTHING yields [] and passes: the one spec whose whole subject is that the images resolve was the one that would have stayed green through a total viewer regression. Everything else it checks comes from the backend and the classic head script, neither of which needs the viewer bundle to have run. And a CI job, because neither of the above fires on its own: no workflow, Dockerfile or script built this viewer, so the guard could sit disarmed indefinitely, and the committed artifact — compiled into the binary with include_dir! — could drift from its source with nothing to say so.
114 lines
5.3 KiB
JavaScript
114 lines
5.3 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)),
|
|
emptyOutDir: true,
|
|
target: 'es2020'
|
|
}
|
|
});
|