Adds prerendered, JS-less, self-contained error pages for nginx error_page use — served directly from /var/www/errors/ when the SvelteKit upstream is unreachable or any nginx-originated 4xx/5xx fires (including the catch-all default_server for unknown hosts). - /errors/[status] (DE default) + /errors/en/[status] (EN), each with a header language toggle linking absolute to bocken.org so the switch works even on unknown-host fallbacks. - httpStatus param matcher restricts entries to 401/403/404/500/ 502/503/504; entries() drives prerender output. - generate-error-quotes.ts looks up curated bilingual references in the existing allioli/drb TSV bibles at prebuild time and writes src/lib/data/errorQuotes.json. - build-error-page.ts (postbuild) inlines all CSS, strips module preloads/scripts, rewrites the home-link to canonical https URL, and emits .html + .gz + .br per status under build/client/errors. - deploy.sh syncs build/client/errors → /var/www/errors with http:http ownership for nginx access.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Postbuild: turn each prerendered /errors/<status> route into a self-contained
|
||||
* HTML file at build/client/errors/<status>.html for nginx error_page use.
|
||||
*
|
||||
* - Inlines every <link rel="stylesheet"> by replacing it with <style>.
|
||||
* - Strips <script type="module"> and <link rel="modulepreload"> (csr=false,
|
||||
* so JS is dead weight and a missing-asset risk if upstream is dead).
|
||||
* - Leaves font/image URLs alone — nginx serves them from the same root.
|
||||
* - Emits matching .gz + .br for nginx gzip_static / brotli_static.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/build-error-page.ts
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { dirname, resolve, join, posix } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzipSync, brotliCompressSync, constants as zlib } from 'node:zlib';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const PRERENDER_DIR = join(ROOT, 'build/prerendered/errors');
|
||||
const CLIENT = join(ROOT, 'build/client');
|
||||
const OUT_DIR = join(CLIENT, 'errors');
|
||||
|
||||
// Error pages may be served from arbitrary domains via nginx's default_server
|
||||
// catchall. Rewrite the home-link to an absolute canonical URL so clicking
|
||||
// the logo always lands on the real site.
|
||||
const CANONICAL_HOME = 'https://bocken.org/';
|
||||
|
||||
if (!existsSync(PRERENDER_DIR)) {
|
||||
console.error(`[error-page] missing prerender dir: ${PRERENDER_DIR}`);
|
||||
console.error('[error-page] is /errors/[status=httpStatus]/+page.ts setting `prerender = true` with `entries()`?');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
// Recursively collect every prerendered html under build/prerendered/errors,
|
||||
// so we pick up nested language variants (errors/en/<status>.html).
|
||||
function walk(dir: string, prefix = ''): { rel: string; abs: string }[] {
|
||||
const out: { rel: string; abs: string }[] = [];
|
||||
for (const ent of readdirSync(dir, { withFileTypes: true })) {
|
||||
const abs = join(dir, ent.name);
|
||||
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
|
||||
if (ent.isDirectory()) out.push(...walk(abs, rel));
|
||||
else if (ent.isFile() && ent.name.endsWith('.html')) out.push({ rel, abs });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const sources = walk(PRERENDER_DIR);
|
||||
if (sources.length === 0) {
|
||||
console.error(`[error-page] no .html files under ${PRERENDER_DIR}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve a possibly-relative href (../foo, ./foo, /foo) against the page's
|
||||
// path (e.g. /errors/503.html) into a path inside CLIENT.
|
||||
function resolveAsset(href: string, pagePath: string): string {
|
||||
const abs = posix.resolve(posix.dirname(pagePath), href); // e.g. /_app/immutable/assets/x.css
|
||||
return join(CLIENT, abs.replace(/^\//, ''));
|
||||
}
|
||||
|
||||
function inline(html: string, pagePath: string): string {
|
||||
// Inline <link rel="stylesheet"> regardless of attribute order.
|
||||
html = html.replace(/<link\b[^>]*>/g, (tag) => {
|
||||
if (!/\brel=["']stylesheet["']/.test(tag)) return tag;
|
||||
const m = tag.match(/\bhref=["']([^"']+)["']/);
|
||||
if (!m) return tag;
|
||||
const cssPath = resolveAsset(m[1], pagePath);
|
||||
if (!existsSync(cssPath)) {
|
||||
console.warn(`[error-page] stylesheet not found, leaving link tag: ${m[1]}`);
|
||||
return tag;
|
||||
}
|
||||
return `<style>${readFileSync(cssPath, 'utf8')}</style>`;
|
||||
});
|
||||
// Drop module preloads and module scripts — nothing should hydrate.
|
||||
html = html.replace(/<link[^>]*\brel=["']modulepreload["'][^>]*>\s*/g, '');
|
||||
html = html.replace(/<script[^>]*\btype=["']module["'][^>]*>[\s\S]*?<\/script>\s*/g, '');
|
||||
|
||||
// Point the brand/home link at the canonical site (the page may be served
|
||||
// from any domain when used as nginx's default_server fallback).
|
||||
html = html.replace(/<a\b[^>]*\bclass="[^"]*\bhome-link\b[^"]*"[^>]*>/g, (tag) =>
|
||||
tag.replace(/\bhref="[^"]*"/, `href="${CANONICAL_HOME}"`)
|
||||
);
|
||||
return html;
|
||||
}
|
||||
|
||||
for (const { rel, abs } of sources) {
|
||||
const dst = join(OUT_DIR, rel);
|
||||
mkdirSync(dirname(dst), { recursive: true });
|
||||
const html = inline(readFileSync(abs, 'utf8'), `/errors/${rel}`);
|
||||
const buf = Buffer.from(html, 'utf8');
|
||||
writeFileSync(dst, buf);
|
||||
writeFileSync(`${dst}.gz`, gzipSync(buf, { level: 9 }));
|
||||
writeFileSync(`${dst}.br`, brotliCompressSync(buf, {
|
||||
params: { [zlib.BROTLI_PARAM_QUALITY]: 11 }
|
||||
}));
|
||||
console.log(`[error-page] wrote errors/${rel} (${(buf.length / 1024).toFixed(1)} kB) + .gz + .br`);
|
||||
}
|
||||
+13
-1
@@ -15,6 +15,8 @@ REMOTE="${REMOTE:-root@bocken.org}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
|
||||
REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
|
||||
SERVICE="${SERVICE:-homepage.service}"
|
||||
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
|
||||
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
|
||||
|
||||
DRY=""
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
@@ -62,13 +64,23 @@ echo ":: Syncing package.json + pnpm-lock.yaml"
|
||||
rsync -az $DRY \
|
||||
package.json pnpm-lock.yaml "$REMOTE:$REMOTE_DIR/"
|
||||
|
||||
if [[ ! -d build/client/errors ]]; then
|
||||
echo "!! build/client/errors not produced — postbuild error-page step did not run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ":: Syncing error pages → $REMOTE:$ERROR_PAGES_DIR/"
|
||||
ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR"
|
||||
rsync -az --delete $DRY --info=progress2 \
|
||||
build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/"
|
||||
|
||||
if [[ -n "$DRY" ]]; then
|
||||
echo ":: Dry run complete — no service restart"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ":: Fixing ownership on server"
|
||||
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml"
|
||||
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR"
|
||||
|
||||
echo ":: Restarting $SERVICE"
|
||||
ssh "$REMOTE" "systemctl restart $SERVICE"
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Build-time generation of bilingual Bible quotes per HTTP error status.
|
||||
*
|
||||
* Looks up curated references in static/allioli.tsv (DE) + static/drb.tsv (EN)
|
||||
* via the existing bible reference parser, then writes the resolved verses to
|
||||
* src/lib/data/errorQuotes.json for the prerendered /errors/[status] pages.
|
||||
*
|
||||
* - Add or change a status by editing REFS below.
|
||||
* - Refs use the abbreviations defined in the TSVs (e.g. Mt 7,7 / Mt 7:7).
|
||||
* - Fails the build if any reference cannot be resolved.
|
||||
*
|
||||
* Run: pnpm exec vite-node scripts/generate-error-quotes.ts
|
||||
*/
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { lookupReference } from '../src/lib/server/bible';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
const ALLIOLI = join(ROOT, 'static/allioli.tsv');
|
||||
const DRB = join(ROOT, 'static/drb.tsv');
|
||||
const OUT = join(ROOT, 'src/lib/data/errorQuotes.json');
|
||||
|
||||
// Curated refs. Abbreviations must match the TSV's `abbreviation` column.
|
||||
const REFS: Record<number, { de: string; en: string }> = {
|
||||
401: { de: 'Mt 7,7', en: 'Mt 7:7' },
|
||||
403: { de: 'Mt 7,14', en: 'Mt 7:14' },
|
||||
404: { de: 'Mt 7,8', en: 'Mt 7:8' },
|
||||
500: { de: '2Kor 4,7', en: '2Cor 4:7' },
|
||||
502: { de: '1Mo 11,9', en: 'Gn 11:9' },
|
||||
503: { de: 'Ps 37,7', en: 'Ps 37:7' },
|
||||
504: { de: 'Jes 40,31', en: 'Is 40:31' }
|
||||
};
|
||||
|
||||
type ResolvedQuote = { text: string; reference: string };
|
||||
|
||||
function resolveOne(ref: string, tsv: string): ResolvedQuote {
|
||||
const result = lookupReference(ref, tsv);
|
||||
if (!result || result.verses.length === 0) {
|
||||
throw new Error(`could not resolve reference "${ref}" in ${tsv}`);
|
||||
}
|
||||
// Range refs join verses with a space. Display reference reuses the
|
||||
// original input so the UI keeps the canonical "Mt 7,7" / "Mt 7:7" form.
|
||||
const text = result.verses.map((v) => v.text).join(' ');
|
||||
return { text, reference: ref };
|
||||
}
|
||||
|
||||
const out: Record<string, { de: ResolvedQuote; en: ResolvedQuote }> = {};
|
||||
for (const [status, refs] of Object.entries(REFS)) {
|
||||
out[status] = {
|
||||
de: resolveOne(refs.de, ALLIOLI),
|
||||
en: resolveOne(refs.en, DRB)
|
||||
};
|
||||
console.log(`[error-quotes] ${status}: ${refs.de} / ${refs.en}`);
|
||||
}
|
||||
|
||||
mkdirSync(dirname(OUT), { recursive: true });
|
||||
writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
|
||||
console.log(`[error-quotes] wrote ${OUT.replace(ROOT + '/', '')} (${Object.keys(out).length} statuses)`);
|
||||
Reference in New Issue
Block a user