Files
kandev/apps/web/lib/preview-inspect-bridge.ts
T
2026-08-15 23:46:38 +01:00

232 lines
6.2 KiB
TypeScript

export const INSPECTOR_SOURCE = "kandev-inspector" as const;
export interface CapturedElement {
tag: string;
id?: string;
classes?: string;
role?: string;
ariaLabel?: string;
text?: string;
selector?: string;
}
export interface AnnotationRect {
x: number;
y: number;
w: number;
h: number;
}
export type AnnotationKind = "pin" | "area";
export interface Annotation {
id: string;
number: number;
kind: AnnotationKind;
pagePath: string;
comment: string;
rect?: AnnotationRect;
element?: CapturedElement | null;
elements?: CapturedElement[] | null;
}
/** Wire payload — number is assigned by the parent on receipt. */
export type AnnotationWirePayload = Omit<Annotation, "number">;
interface InspectorToggleCommand {
source: typeof INSPECTOR_SOURCE;
type: "toggle-inspect";
payload: { active: boolean };
}
interface InspectorClearCommand {
source: typeof INSPECTOR_SOURCE;
type: "clear-annotations";
payload: Record<string, never>;
}
interface InspectorRemoveMarkerCommand {
source: typeof INSPECTOR_SOURCE;
type: "remove-marker";
payload: { number: number };
}
interface AnnotationAddedMessage {
source: typeof INSPECTOR_SOURCE;
type: "annotation-added";
payload: AnnotationWirePayload;
}
interface InspectExitedMessage {
source: typeof INSPECTOR_SOURCE;
type: "inspect-exited";
payload: Record<string, never>;
}
interface InspectorReadyMessage {
source: typeof INSPECTOR_SOURCE;
type: "inspector-ready";
payload: Record<string, never>;
}
interface PreviewConsoleReadyMessage {
source: typeof INSPECTOR_SOURCE;
type: "console-ready";
payload: Record<string, never>;
}
export type PreviewConsoleLevel = "log" | "warn" | "error" | "info" | "debug";
const PREVIEW_CONSOLE_LEVELS: ReadonlySet<string> = new Set([
"log",
"warn",
"error",
"info",
"debug",
]);
interface PreviewConsoleMessage {
source: typeof INSPECTOR_SOURCE;
type: "console";
payload: { level: PreviewConsoleLevel; args: unknown[] };
}
export type InspectorMessage =
| AnnotationAddedMessage
| InspectExitedMessage
| InspectorReadyMessage
| PreviewConsoleReadyMessage
| PreviewConsoleMessage;
// Narrows past the union — and validates that `level` is one we handle and
// `args` is an array. Without the Array.isArray check, a malformed payload
// with non-iterable `args` would throw inside the consumer's `...args` spread
// and kill the message listener for the rest of the session.
export function isPreviewConsoleMessage(msg: InspectorMessage): msg is PreviewConsoleMessage {
if (msg.type !== "console") return false;
const p = msg.payload as { level?: unknown; args?: unknown };
return (
typeof p.level === "string" && PREVIEW_CONSOLE_LEVELS.has(p.level) && Array.isArray(p.args)
);
}
export function isPreviewConsoleReadyMessage(
msg: InspectorMessage,
): msg is PreviewConsoleReadyMessage {
return msg.type === "console-ready";
}
export function isInspectorMessage(data: unknown): data is InspectorMessage {
if (typeof data !== "object" || data === null) return false;
const d = data as { source?: unknown; type?: unknown; payload?: unknown };
return (
d.source === INSPECTOR_SOURCE &&
typeof d.type === "string" &&
typeof d.payload === "object" &&
d.payload !== null
);
}
export function sendToggleInspect(iframe: HTMLIFrameElement, active: boolean): void {
iframe.contentWindow?.postMessage(
{
source: INSPECTOR_SOURCE,
type: "toggle-inspect",
payload: { active },
} satisfies InspectorToggleCommand,
"*",
);
}
export function sendClearAnnotations(iframe: HTMLIFrameElement): void {
iframe.contentWindow?.postMessage(
{
source: INSPECTOR_SOURCE,
type: "clear-annotations",
payload: {},
} satisfies InspectorClearCommand,
"*",
);
}
export function sendRemoveMarker(iframe: HTMLIFrameElement, number: number): void {
iframe.contentWindow?.postMessage(
{
source: INSPECTOR_SOURCE,
type: "remove-marker",
payload: { number },
} satisfies InspectorRemoveMarkerCommand,
"*",
);
}
export function sendConsoleBind(iframe: HTMLIFrameElement, targetOrigin: string): void {
iframe.contentWindow?.postMessage(
{
source: INSPECTOR_SOURCE,
type: "console-bind",
payload: {},
},
targetOrigin,
);
}
function formatElement(el: CapturedElement): string {
let idPart = "";
if (el.id) idPart = `#${el.id}`;
else if (el.classes) idPart = `.${el.classes.trim().split(/\s+/)[0]}`;
return `\`${el.tag}${idPart}\``;
}
function formatPin(a: Annotation): string[] {
const lines: string[] = [];
const el = a.element;
const role = el?.role ? ` [role="${el.role}"]` : "";
let label = "";
if (el?.ariaLabel) label = ` "${el.ariaLabel}"`;
else if (el?.text) label = ` "${el.text}"`;
const elPart = el ? ` ${formatElement(el)}` : "";
lines.push(`${a.number}. [Pin]${elPart}${role}${label}`);
if (el?.selector) lines.push(` Selector: \`${el.selector}\``);
if (a.comment) lines.push(` Comment: ${a.comment}`);
return lines;
}
function formatArea(a: Annotation): string[] {
const lines: string[] = [];
const r = a.rect;
const header = r
? `${a.number}. [Area ${Math.round(r.w)}x${Math.round(r.h)} at (${Math.round(r.x)},${Math.round(r.y)})]`
: `${a.number}. [Area]`;
lines.push(header);
if (a.elements && a.elements.length > 0) {
const names = a.elements.map(formatElement).join(", ");
lines.push(` Contains: ${names}`);
}
if (a.comment) lines.push(` Comment: ${a.comment}`);
return lines;
}
export function formatAnnotations(annotations: Annotation[]): string {
if (annotations.length === 0) return "";
const byPath = new Map<string, Annotation[]>();
for (const a of annotations) {
const list = byPath.get(a.pagePath) ?? [];
list.push(a);
byPath.set(a.pagePath, list);
}
const sections: string[] = [];
for (const [pagePath, list] of byPath) {
const lines: string[] = [`> Preview annotations on \`${pagePath}\``, ">"];
for (const a of list) {
const body = a.kind === "pin" ? formatPin(a) : formatArea(a);
for (const line of body) lines.push("> " + line);
lines.push(">");
}
while (lines[lines.length - 1] === ">") lines.pop();
sections.push(lines.join("\n"));
}
return sections.join("\n\n");
}