mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
bdd64660e7
commit
fc2c372ffc
4 files changed
+95
-8
No files matched your search
@@ -5,6 +5,8 @@
|
||||
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
|
||||
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
|
||||
usable translation or short name. Empty or malformed names no longer appear as
|
||||
|
||||
@@ -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
|
||||
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
|
||||
writes `public/icons/`. Output is committed, so building needs no image
|
||||
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
|
||||
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.
|
||||
It also builds the install links under `base: '/example/'` and asserts every
|
||||
emitted link and manifest URL stays inside that base.
|
||||
|
||||
## Local build preview
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
"name": "God's Eye View",
|
||||
"short_name": "God's Eye",
|
||||
"description": "Live spatial intelligence in the browser.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"start_url": ".",
|
||||
"scope": ".",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#0a0a0f",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "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-maskable.png",
|
||||
"src": "icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { rm, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
import { build } from 'vite';
|
||||
import { makeFixtureRoot } from './fixtureRoot.mjs';
|
||||
|
||||
const REPO_ROOT = path.resolve(
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
@@ -12,7 +15,7 @@ const REPO_ROOT = path.resolve(
|
||||
const PUBLIC_ROOT = path.join(REPO_ROOT, 'public');
|
||||
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) {
|
||||
assert.ok(href.startsWith('/'), `${href} must be a root-relative URL`);
|
||||
return path.join(PUBLIC_ROOT, href.slice(1));
|
||||
@@ -22,6 +25,18 @@ const manifestHref = INDEX_HTML.match(
|
||||
/<link[^>]+rel="manifest"[^>]+href="([^"]+)"/,
|
||||
)?.[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', () => {
|
||||
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', () => {
|
||||
const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
|
||||
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'])
|
||||
assert.ok(manifest[field], `manifest is missing ${field}`);
|
||||
// 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 () => {
|
||||
const manifest = JSON.parse(readFileSync(publicFile(manifestHref), 'utf8'));
|
||||
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);
|
||||
assert.equal(meta.format, 'png', `${icon.src} is not a PNG`);
|
||||
// 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`);
|
||||
}
|
||||
});
|
||||
|
||||
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))),
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user