fix: keep install assets inside a non-root base

Manifest start_url, scope and icon src resolve against the manifest URL,
so root-relative values sent an app served under a path prefix to the
domain root. Make them relative, and add a build test under
base: '/example/' that asserts every emitted link and manifest URL stays
inside the base. index.html is unchanged: Vite already rewrites its
links under base.
This commit is contained in:
Cliff Flood committed 2026-10-13 18:54:59 -04:00
1 parent bdd64660e7
commit fc2c372ffc
4 files changed
+95 -8

No files matched your search

+2
View File
@@ -5,6 +5,8 @@
instead of a blank tile. The icons are generated from `public/logo.svg` by instead of a blank tile. The icons are generated from `public/logo.svg` by
`node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs `node scripts/generate-icons.mjs` and committed, so a plain `vite build` needs
no image toolchain. The existing SVG favicon still serves the browser tab. no image toolchain. The existing SVG favicon still serves the browser tab.
Manifest paths are relative to the manifest, so a build served under a path
prefix installs and launches inside that prefix.
- GBFS v3 station names use English translations when available, then the first - GBFS v3 station names use English translations when available, then the first
usable translation or short name. Empty or malformed names no longer appear as usable translation or short name. Empty or malformed names no longer appear as
+6
View File
@@ -1140,12 +1140,18 @@ favicon; the existing SVG favicon still serves the browser tab. Safari ignores a
SVG apple-touch-icon and substitutes a page screenshot, so the raster is what SVG apple-touch-icon and substitutes a page screenshot, so the raster is what
makes an installed icon appear at all. makes an installed icon appear at all.
Manifest `start_url`, `scope` and icon paths are relative to the manifest, and
Vite rewrites the `index.html` links under `base`, so a build served from a path
prefix installs and launches inside that prefix.
`node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and `node scripts/generate-icons.mjs` renders every raster from `public/logo.svg` and
writes `public/icons/`. Output is committed, so building needs no image writes `public/icons/`. Output is committed, so building needs no image
toolchain; re-run the script after changing the logo. Ordinary icons inset the toolchain; re-run the script after changing the logo. Ordinary icons inset the
mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the mark to 78% of the canvas and the maskable variant to 54%, keeping it inside the
circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links, circle Android may crop to. `src/tooling/webAppIcons.test.mjs` checks the links,
the manifest fields and that each committed PNG is square at its declared size. the manifest fields and that each committed PNG is square at its declared size.
It also builds the install links under `base: '/example/'` and asserts every
emitted link and manifest URL stays inside that base.
## Local build preview ## Local build preview
+5 -5
View File
@@ -2,17 +2,17 @@
"name": "God's Eye View", "name": "God's Eye View",
"short_name": "God's Eye", "short_name": "God's Eye",
"description": "Live spatial intelligence in the browser.", "description": "Live spatial intelligence in the browser.",
"start_url": "/", "start_url": ".",
"scope": "/", "scope": ".",
"display": "standalone", "display": "standalone",
"orientation": "any", "orientation": "any",
"background_color": "#0a0a0f", "background_color": "#0a0a0f",
"theme_color": "#0a0a0f", "theme_color": "#0a0a0f",
"icons": [ "icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ {
"src": "/icons/icon-512-maskable.png", "src": "icons/icon-512-maskable.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
+82 -3
View File
@@ -1,9 +1,12 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { rm, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import sharp from 'sharp'; import sharp from 'sharp';
import { build } from 'vite';
import { makeFixtureRoot } from './fixtureRoot.mjs';
const REPO_ROOT = path.resolve( const REPO_ROOT = path.resolve(
fileURLToPath(new URL('.', import.meta.url)), fileURLToPath(new URL('.', import.meta.url)),
@@ -12,7 +15,7 @@ const REPO_ROOT = path.resolve(
const PUBLIC_ROOT = path.join(REPO_ROOT, 'public'); const PUBLIC_ROOT = path.join(REPO_ROOT, 'public');
const INDEX_HTML = readFileSync(path.join(REPO_ROOT, 'index.html'), 'utf8'); const INDEX_HTML = readFileSync(path.join(REPO_ROOT, 'index.html'), 'utf8');
/** `public/` is served at the site root, so a manifest href maps to a file by dropping the leading slash. */ /** `public/` is served at the site root, so an index.html href maps to a file by dropping the leading slash. */
function publicFile(href) { function publicFile(href) {
assert.ok(href.startsWith('/'), `${href} must be a root-relative URL`); assert.ok(href.startsWith('/'), `${href} must be a root-relative URL`);
return path.join(PUBLIC_ROOT, href.slice(1)); return path.join(PUBLIC_ROOT, href.slice(1));
@@ -22,6 +25,18 @@ const manifestHref = INDEX_HTML.match(
/<link[^>]+rel="manifest"[^>]+href="([^"]+)"/, /<link[^>]+rel="manifest"[^>]+href="([^"]+)"/,
)?.[1]; )?.[1];
/**
* Manifest URLs resolve against the manifest, not the page. Keeping them
* relative is what lets a build served under a path prefix install inside it.
*/
function manifestFile(src) {
assert.ok(
!src.startsWith('/') && !/^[a-z][a-z\d+.-]*:/i.test(src),
`${src} must be relative to the manifest`,
);
return path.join(path.dirname(publicFile(manifestHref)), src);
}
test('index.html links a web app manifest', () => { test('index.html links a web app manifest', () => {
assert.ok(manifestHref, 'no <link rel="manifest"> in index.html'); assert.ok(manifestHref, 'no <link rel="manifest"> in index.html');
}); });
@@ -41,7 +56,8 @@ test('iOS gets a raster home-screen icon', () => {
test('the manifest declares a standalone installable app', () => { test('the manifest declares a standalone installable app', () => {
const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8')); const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
assert.equal(manifest.display, 'standalone'); assert.equal(manifest.display, 'standalone');
assert.equal(manifest.start_url, '/'); assert.equal(manifest.start_url, '.');
assert.equal(manifest.scope, '.');
for (const field of ['name', 'short_name', 'background_color', 'theme_color']) for (const field of ['name', 'short_name', 'background_color', 'theme_color'])
assert.ok(manifest[field], `manifest is missing ${field}`); assert.ok(manifest[field], `manifest is missing ${field}`);
// Android's install prompt needs both, and uses the 512 for the splash screen. // Android's install prompt needs both, and uses the 512 for the splash screen.
@@ -59,7 +75,7 @@ test('the manifest declares a standalone installable app', () => {
test('every declared icon is a square PNG at its declared size', async () => { test('every declared icon is a square PNG at its declared size', async () => {
const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8')); const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
for (const icon of manifest.icons) { for (const icon of manifest.icons) {
const meta = await sharp(publicFile(icon.src)).metadata(); const meta = await sharp(manifestFile(icon.src)).metadata();
const [width, height] = icon.sizes.split('x').map(Number); const [width, height] = icon.sizes.split('x').map(Number);
assert.equal(meta.format, 'png', `${icon.src} is not a PNG`); assert.equal(meta.format, 'png', `${icon.src} is not a PNG`);
// A size the manifest overstates is worse than a missing one: the platform // A size the manifest overstates is worse than a missing one: the platform
@@ -68,3 +84,66 @@ test('every declared icon is a square PNG at its declared size', async () => {
assert.equal(meta.height, height, `${icon.src} height`); assert.equal(meta.height, height, `${icon.src} height`);
} }
}); });
test('install assets stay inside a non-root Vite base', async (t) => {
// The real index.html pulls the whole Cesium app into a build, so only its
// install links are copied, verbatim, into a fixture page.
const tags = INDEX_HTML.match(
/<link[^>]+rel="(?:icon|apple-touch-icon|manifest)"[^>]*>/g,
);
assert.ok(tags?.length >= 3, 'index.html lost its install links');
const root = await makeFixtureRoot('gev-webapp-icons-');
t.after(() => rm(root, { recursive: true, force: true }));
await writeFile(
path.join(root, 'index.html'),
`<!doctype html><html><head>${tags.join('')}</head><body></body></html>`,
);
const base = '/example/';
const origin = 'https://host.test';
const result = await build({
root,
configFile: false,
envFile: false,
logLevel: 'silent',
base,
publicDir: PUBLIC_ROOT,
build: { write: false },
});
const html = result.output.find(
(item) => item.fileName === 'index.html',
).source;
const hrefs = [...html.matchAll(/<link[^>]+href="([^"]+)"/g)].map(
(match) => match[1],
);
assert.equal(hrefs.length, tags.length);
for (const href of hrefs) {
assert.ok(href.startsWith(base), `${href} escapes ${base}`);
assert.doesNotThrow(() =>
readFileSync(path.join(PUBLIC_ROOT, href.slice(base.length))),
);
}
// Vite copies public/ only when it writes, and copies it verbatim, so the
// deployed manifest is the committed one, served at the emitted href.
const manifestUrl = new URL(
html.match(/<link[^>]+rel="manifest"[^>]+href="([^"]+)"/)[1],
origin,
);
const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
const inBase = (value) => {
const url = new URL(value, manifestUrl);
assert.ok(
url.href.startsWith(origin + base),
`${value} resolves to ${url.href}, outside ${base}`,
);
return url;
};
assert.equal(inBase(manifest.start_url).href, origin + base);
assert.equal(inBase(manifest.scope).href, origin + base);
for (const icon of manifest.icons) {
const url = inBase(icon.src);
assert.doesNotThrow(() =>
readFileSync(path.join(PUBLIC_ROOT, url.pathname.slice(base.length))),
);
}
});