69607fbfe9
engine.ts used mulberry32 and engine.py used random.Random(seed). Same seed, different word — so every ?seed= permalink on the site would have shown a different puzzle than the recorded run it claimed to be replaying, and nobody would have noticed until someone checked one by hand. Both now derive the index from FNV-1a 32-bit over the decimal seed. A hash rather than a PRNG because there is no honest one-line JavaScript equivalent of Mersenne Twister, and this way there is nothing to keep in step: both sides compute the same integer from the same string. Math.imul on the JS side is load-bearing — a plain multiply overflows into a double and diverges after the first few bytes. Twelve seeds are pinned as a vector in both test suites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019mt6sHQHEnEYrJZvoMCJSB
113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
/**
|
|
* Per-route document head.
|
|
*
|
|
* `scripts/prerender.mjs` bakes these tags into the static HTML at build time,
|
|
* which is what crawlers and link unfurlers actually read. This hook exists for
|
|
* the other half: a visitor who lands on `/` and clicks through to a demo never
|
|
* fetches a new document, so without it the tab title and the canonical link
|
|
* would still say "home" three pages later.
|
|
*
|
|
* Deliberately no cleanup. Restoring the previous head on unmount would mean
|
|
* every navigation flickers back to the old title before the next route sets
|
|
* its own; the next route always sets one, so the last writer simply wins.
|
|
*/
|
|
|
|
import { useEffect } from 'react';
|
|
|
|
export const SITE_ORIGIN = 'https://demo.primeintellectgrowth.com';
|
|
export const SITE_NAME = 'PIG Demo';
|
|
|
|
/** `Wordle-five — PIG Demo`. One place, so every tab reads the same shape. */
|
|
export function pageTitle(name?: string): string {
|
|
return name && name.trim() !== '' ? `${name} — ${SITE_NAME}` : SITE_NAME;
|
|
}
|
|
|
|
/**
|
|
* Absolutise a site-root path. Open Graph consumers do not resolve relative
|
|
* URLs — a relative `og:image` is simply no image, silently, and you only find
|
|
* out when someone pastes the link into Slack.
|
|
*/
|
|
export function absoluteUrl(pathOrUrl: string): string {
|
|
if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
|
|
return `${SITE_ORIGIN}${pathOrUrl.startsWith('/') ? '' : '/'}${pathOrUrl}`;
|
|
}
|
|
|
|
export interface SeoInput {
|
|
/** Used verbatim as `document.title`. Wrap with `pageTitle()` if you want the suffix. */
|
|
title: string;
|
|
description?: string;
|
|
/** Site-root path or absolute URL. */
|
|
canonical?: string;
|
|
/** Site-root path or absolute URL. */
|
|
ogImage?: string;
|
|
}
|
|
|
|
type MetaKey = { name: string } | { property: string };
|
|
|
|
function upsertMeta(key: MetaKey, content: string): void {
|
|
const selector =
|
|
'name' in key ? `meta[name="${key.name}"]` : `meta[property="${key.property}"]`;
|
|
let tag = document.head.querySelector<HTMLMetaElement>(selector);
|
|
if (!tag) {
|
|
tag = document.createElement('meta');
|
|
if ('name' in key) tag.setAttribute('name', key.name);
|
|
else tag.setAttribute('property', key.property);
|
|
document.head.appendChild(tag);
|
|
}
|
|
tag.setAttribute('content', content);
|
|
}
|
|
|
|
function upsertCanonical(href: string): void {
|
|
let link = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
|
|
if (!link) {
|
|
link = document.createElement('link');
|
|
link.setAttribute('rel', 'canonical');
|
|
document.head.appendChild(link);
|
|
}
|
|
link.setAttribute('href', href);
|
|
}
|
|
|
|
export function applySeo(input: SeoInput): void {
|
|
if (typeof document === 'undefined') return;
|
|
|
|
document.title = input.title;
|
|
upsertMeta({ property: 'og:title' }, input.title);
|
|
upsertMeta({ name: 'twitter:title' }, input.title);
|
|
|
|
if (input.description) {
|
|
upsertMeta({ name: 'description' }, input.description);
|
|
upsertMeta({ property: 'og:description' }, input.description);
|
|
upsertMeta({ name: 'twitter:description' }, input.description);
|
|
}
|
|
|
|
if (input.canonical) {
|
|
const href = absoluteUrl(input.canonical);
|
|
upsertCanonical(href);
|
|
upsertMeta({ property: 'og:url' }, href);
|
|
}
|
|
|
|
if (input.ogImage) {
|
|
const href = absoluteUrl(input.ogImage);
|
|
upsertMeta({ property: 'og:image' }, href);
|
|
upsertMeta({ name: 'twitter:image' }, href);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set the head for this route.
|
|
*
|
|
* Deps are the individual strings rather than the object, so a caller can pass
|
|
* an inline literal without re-running this on every render.
|
|
*/
|
|
export function useSeo(input: SeoInput): void {
|
|
const { title, description, canonical, ogImage } = input;
|
|
useEffect(() => {
|
|
applySeo({
|
|
title,
|
|
...(description === undefined ? {} : { description }),
|
|
...(canonical === undefined ? {} : { canonical }),
|
|
...(ogImage === undefined ? {} : { ogImage }),
|
|
});
|
|
}, [title, description, canonical, ogImage]);
|
|
}
|