Files
houseplan-card/src/labs.ts
T
2026-08-28 14:40:06 +03:00

228 lines
7.4 KiB
TypeScript

/** Reusable, presentation-only Labs flags. No flag may gate data or requests. */
export interface LabsFlag {
id: string;
issue: number;
since: string;
expires: string;
summary: string;
}
export const LABS_STORAGE_KEY = 'houseplan_card_labs_v1';
export const LABS_FLAGS: readonly LabsFlag[] = Object.freeze([
Object.freeze({
id: 'iso',
issue: 89,
since: '1.62.0',
expires: '1.65.0',
summary: 'Volumetric plan renderer',
}),
]);
export interface LabsLocation {
search: string;
hash: string;
}
export interface LabsResolution {
active: readonly string[];
space: string;
persist: string | undefined;
knownUrlOperation: boolean;
}
export interface LabsSnapshot {
active: readonly string[];
space: string;
}
type VersionCore = readonly [number, number, number];
export function parseVersionCore(value: string): VersionCore | null {
const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim());
if (!match) return null;
const core = match.slice(1).map(Number) as unknown as VersionCore;
return core.every((part) => Number.isSafeInteger(part)) ? core : null;
}
function compareVersion(a: VersionCore, b: VersionCore): number {
for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
return 0;
}
export function validLabsFlag(flag: LabsFlag): boolean {
if (!/^[a-z][a-z0-9-]*$/.test(flag.id) || !Number.isInteger(flag.issue) || flag.issue <= 0
|| !flag.summary.trim()) return false;
const since = parseVersionCore(flag.since);
const expires = parseVersionCore(flag.expires);
return !!since && !!expires && compareVersion(since, expires) < 0;
}
/** A malformed or ambiguous registry disables the whole mechanism. */
export function validLabsRegistry(registry: readonly LabsFlag[]): boolean {
const ids = new Set<string>();
for (const flag of registry) {
if (!validLabsFlag(flag) || ids.has(flag.id)) return false;
ids.add(flag.id);
}
return true;
}
export function liveLabsFlags(
version: string, registry: readonly LabsFlag[] = LABS_FLAGS,
): ReadonlyMap<string, LabsFlag> {
const current = parseVersionCore(version);
const live = new Map<string, LabsFlag>();
if (!current || !validLabsRegistry(registry)) return live;
for (const flag of registry) {
const since = parseVersionCore(flag.since)!;
const expires = parseVersionCore(flag.expires)!;
if (compareVersion(current, since) >= 0 && compareVersion(current, expires) < 0) {
live.set(flag.id, flag);
}
}
return live;
}
export function hashParams(hash: string): URLSearchParams {
const raw = String(hash || '').replace(/^#/, '');
return new URLSearchParams(raw);
}
export function hashSpace(hash: string): string {
return hashParams(hash).get('space') || '';
}
function storedFlags(raw: string | null | undefined): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
} catch {
return [];
}
}
function operations(params: URLSearchParams): string[] {
return params.getAll('hp-labs').flatMap((value) => value.split(','))
.map((value) => value.trim()).filter(Boolean);
}
/** Pure URL/storage resolver. Query operations run before stronger hash operations. */
export function resolveLabs(
location: LabsLocation,
storageValue: string | null | undefined,
version: string,
registry: readonly LabsFlag[] = LABS_FLAGS,
): LabsResolution {
const live = liveLabsFlags(version, registry);
const known = new Set(validLabsRegistry(registry) ? registry.map((flag) => flag.id) : []);
const active = new Set(storedFlags(storageValue).filter((id) => live.has(id)));
let knownUrlOperation = false;
const apply = (token: string) => {
if (token === 'off') {
active.clear();
knownUrlOperation = true;
return;
}
const remove = token.startsWith('-');
const id = remove ? token.slice(1) : token;
if (!known.has(id)) return;
knownUrlOperation = true;
if (remove || !live.has(id)) active.delete(id);
else active.add(id);
};
for (const token of operations(new URLSearchParams(String(location.search || '').replace(/^\?/, '')))) apply(token);
for (const token of operations(hashParams(location.hash))) apply(token);
const sorted = Object.freeze([...active].sort());
return {
active: sorted,
space: hashSpace(location.hash),
persist: knownUrlOperation ? JSON.stringify(sorted) : undefined,
knownUrlOperation,
};
}
declare global {
interface Window {
__hpLabs?: readonly string[];
__hpLabsListenerCleanup?: () => void;
}
}
let snapshot: LabsSnapshot = { active: Object.freeze([]), space: '' };
let configuredVersion = '';
let listening = false;
let loggedSignature = '';
const subscribers = new Set<(value: LabsSnapshot) => void>();
function browserStorageRead(): string | null {
try { return window.localStorage.getItem(LABS_STORAGE_KEY); } catch { return null; }
}
function browserStorageWrite(value: string): void {
try { window.localStorage.setItem(LABS_STORAGE_KEY, value); } catch { /* private mode/quota */ }
}
function publishBrowserLabs(): LabsSnapshot {
if (typeof window === 'undefined') return snapshot;
const resolved = resolveLabs(window.location, browserStorageRead(), configuredVersion);
if (resolved.persist !== undefined) browserStorageWrite(resolved.persist);
snapshot = { active: resolved.active, space: resolved.space };
window.__hpLabs = resolved.active;
for (const subscriber of subscribers) subscriber(snapshot);
return snapshot;
}
function onLocationChange(): void { publishBrowserLabs(); }
function ensureBrowserLabs(version: string): LabsSnapshot {
configuredVersion = version;
if (typeof window === 'undefined') return snapshot;
if (!listening) {
listening = true;
window.__hpLabsListenerCleanup?.();
window.addEventListener('hashchange', onLocationChange);
window.addEventListener('popstate', onLocationChange);
const cleanup = (): void => {
window.removeEventListener('hashchange', onLocationChange);
window.removeEventListener('popstate', onLocationChange);
if (window.__hpLabsListenerCleanup === cleanup) {
delete window.__hpLabsListenerCleanup;
}
};
window.__hpLabsListenerCleanup = cleanup;
}
return publishBrowserLabs();
}
/** One module-level browser listener; card instances only subscribe to its snapshot. */
export function subscribeLabs(
version: string, subscriber: (value: LabsSnapshot) => void,
): () => void {
subscribers.add(subscriber);
const current = ensureBrowserLabs(version);
if (typeof window === 'undefined') subscriber(current);
return () => { subscribers.delete(subscriber); };
}
export function currentLabs(version: string): LabsSnapshot {
return ensureBrowserLabs(version);
}
/** Diagnostics are emitted only when an active Labs frame is actually rendered. */
export function noteLabsRender(version: string): void {
if (!configuredVersion) ensureBrowserLabs(version);
const current = snapshot;
const signature = current.active.join(',');
if (!signature || signature === loggedSignature) return;
loggedSignature = signature;
const registry = new Map(LABS_FLAGS.map((flag) => [flag.id, flag]));
const summary = current.active.map((id) => {
const flag = registry.get(id)!;
return `${id} (#${flag.issue}, expires ${flag.expires})`;
}).join(', ');
console.info(`HOUSEPLAN LABS: ${summary}`);
}