morphit/apps/web/scripts/og-fallback-meta-smoke.ts

128 lines
5.2 KiB
TypeScript

#!/usr/bin/env tsx
/**
* apps/web/scripts/og-fallback-meta-smoke.ts
*
* v1.8.0 (t.txt) — link-preview fallback for non-prerendered / bare URLs.
*
* The site ships with `fallback: 'index.html'` (svelte.config.js). Any URL
* that isn't a prerendered `/<lang>/…` route — a bare `/chat`, a dynamic
* `/<lang>/chat/<peer>`, or a link pasted WITHOUT the locale prefix — is
* served the SPA shell, whose `%sveltekit.head%` is empty until JS hydrates.
* A link-preview scraper (Element/Matrix, Slack, Discord, …) never runs that
* JS, so with no OG tags it scraped the visible <noscript> <h1> ("Morphit
* works without JavaScript — partly.") as the title and the favicon as the
* image — the exact ugly preview Ken reported.
*
* The fix is a block of STATIC default og:/twitter: tags in app.html, before
* `%sveltekit.head%`, so every fallback URL has a correct default card.
* Head.svelte re-emits route-specific tags below these on prerendered routes;
* scalar OG properties are last-wins, so the route-specific values still win
* there. This smoke pins the invariant so the fallback can't silently regress.
*
* Scenarios:
* 1. app.html has a static og:title, and it is NOT the noscript heading
* 2. app.html has og:image → the canonical PNG (absolute morphit.io URL)
* 3. app.html declares og:image dimensions + type (large-card eligibility)
* 4. app.html sets twitter:card = summary_large_image
* 5. the static OG block appears BEFORE %sveltekit.head% (so route tags win)
* 6. app.html has NO static <title> (browsers honour the FIRST <title>, so a
* static one would clobber every prerendered route's tab title)
* 7. the noscript heading still exists (kept for real no-JS users) but is no
* longer the only title signal a scraper can find
*/
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '..', '..', '..');
const APP_HTML = join(REPO, 'apps/web/src/app.html');
let failed = 0;
let passed = 0;
function check(name: string, ok: boolean): void {
if (ok) {
console.log(`${name}`);
passed++;
} else {
console.log(`${name}`);
failed++;
}
}
if (!existsSync(APP_HTML)) {
console.error(`og-fallback-meta-smoke: app.html not found at ${APP_HTML}`);
process.exit(1);
}
const raw = readFileSync(APP_HTML, 'utf-8');
// GUARD (v1.8.1 regression): an HTML comment must NEVER contain a %sveltekit.*%
// token. SvelteKit string-replaces those tokens everywhere in app.html — inside
// comments too — and the injected head's Svelte hydration markers terminate the
// comment early, spilling its prose onto the visible top of the page. The
// explanatory block above therefore describes the placeholder in PROSE.
const commentBlocks = raw.match(/<!--[\s\S]*?-->/g) ?? [];
check(
'no app.html comment embeds a %sveltekit.*% token (it would be replaced and break the comment)',
commentBlocks.every((c) => !/%sveltekit\.[a-z]+%/.test(c))
);
// Analyse the real markup only: strip HTML comments so the explanatory comment
// block (which mentions the words "static <title>") can't create false positives.
const html = raw.replace(/<!--[\s\S]*?-->/g, '');
const headIdx = html.indexOf('%sveltekit.head%');
const ogTitleMatch = html.match(/<meta\s+property="og:title"\s+content="([^"]*)"/);
const ogTitleIdx = ogTitleMatch ? html.indexOf(ogTitleMatch[0]) : -1;
// 1 — og:title present and not the noscript "works without JavaScript" line.
check(
'app.html has a static og:title that is not the noscript heading',
!!ogTitleMatch &&
/Morphit/.test(ogTitleMatch[1]!) &&
!/without JavaScript/i.test(ogTitleMatch[1]!)
);
// 2 — og:image is the canonical absolute PNG.
check(
'app.html og:image points at the absolute morphit.io/og-image.png',
/<meta\s+property="og:image"\s+content="https:\/\/morphit\.io\/og-image\.png"/.test(html)
);
// 3 — dimensions + type so scrapers render the large card, not a thumbnail.
check(
'app.html declares og:image width/height/type',
/<meta\s+property="og:image:width"\s+content="1200"/.test(html) &&
/<meta\s+property="og:image:height"\s+content="630"/.test(html) &&
/<meta\s+property="og:image:type"\s+content="image\/png"/.test(html)
);
// 4 — twitter card is the large-image variant.
check(
'app.html sets twitter:card = summary_large_image',
/<meta\s+name="twitter:card"\s+content="summary_large_image"/.test(html)
);
// 5 — static OG block is BEFORE %sveltekit.head% so route-specific tags (which
// SvelteKit injects there) win via last-wins on prerendered routes.
check(
'static og:title appears before %sveltekit.head%',
ogTitleIdx !== -1 && headIdx !== -1 && ogTitleIdx < headIdx
);
// 6 — NO static <title> in app.html (first-<title>-wins would clobber routes).
check('app.html has no static <title> element', !/<title[\s>]/i.test(html));
// 7 — the noscript block is still there for genuine no-JS users.
check(
'noscript no-JS explainer is still present',
/<noscript>/.test(html) && /without JavaScript/i.test(html)
);
if (failed === 0) {
console.log(`\n✓ all ${passed} og-fallback-meta scenarios passed`);
} else {
console.log(`\n✗ ${failed}/${passed + failed} og-fallback-meta scenarios failed`);
process.exit(1);
}