muddle/adapters/prediction.ts
2026-10-23 08:53:07 -04:00

442 lines
18 KiB
TypeScript

export type BreakpointAcc = "XL" | "LG" | "MD" | "SM";
export interface LayoutPredictionOptions {
selectors?: string[];
viewport?: { width: number; height: number };
strict?: boolean;
mobileViewport?: { width: number; height: number };
/** Media query receiving the mobile relation values. Defaults to the page's widest max-width query. */
mobileMediaQuery?: string;
/** Named responsive layers to calculate ahead of time. Multiple values produce cascading relation layers. */
breakpointAcc?: BreakpointAcc | BreakpointAcc[];
/** Maximum number of relation propagation passes before a static build fails. */
maxPasses?: number;
/** Emit discovered dotted-anchor group metadata for reflective selectors. Enabled by default. */
reflective?: boolean;
}
export interface LayoutPrediction {
selector: string;
index: number;
width: number;
height: number;
x: number;
y: number;
mobile?: boolean;
anchor?: string | null;
backgroundColor?: string;
color?: string;
transition?: string;
/** Extra computed properties requested through `$refer[<prop>](...)`. */
relate?: Record<string, string>;
/** Scoped mirrors of the element itself, requested through `$self[<prop>]`. */
self?: Record<string, string>;
}
export interface ReflectiveAnchorGroup {
name: string;
anchors: string[];
}
/**
* Measures a rendered page inside headless chromium.
*
* The script discovers which `$refer` and `$self` properties the shipped
* stylesheets request, labels every measure target and mirrors the requested
* properties for every anchor element. Geometry properties (`scroll-height`,
* `client-width`, ...) are read as numbers off the element, every other name
* is read from its computed style.
*
* The pass runs twice: once for the desktop viewport and, with `remeasure`,
* again on the mobile viewport where only the previously labelled targets are
* visited.
*/
const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => {
const { remeasure, selectors = [] } = input;
const document = (globalThis as unknown as { document: any }).document;
const getComputedStyle = (globalThis as unknown as { getComputedStyle: (element: any) => any }).getComputedStyle;
type Entry = {
selector: string;
index: number;
width: number;
height: number;
x: number;
y: number;
anchor: string | null;
backgroundColor: string;
color: string;
transition: string;
relate?: Record<string, string>;
self?: Record<string, string>;
};
const GEOMETRY = new Set([
"scroll-height",
"scroll-width",
"client-height",
"client-width",
"offset-height",
"offset-width",
]);
const STANDARDS = new Set([
"x",
"y",
"width",
"height",
"background-color",
"color",
"transition",
]);
const propertyValue = (element: any, style: any, property: string): string => {
switch (property) {
case "scroll-height": return `${element.scrollHeight}px`;
case "scroll-width": return `${element.scrollWidth}px`;
case "client-height": return `${element.clientHeight}px`;
case "client-width": return `${element.clientWidth}px`;
case "offset-height": return `${element.offsetHeight}px`;
case "offset-width": return `${element.offsetWidth}px`;
default: return style.getPropertyValue(property) ?? "";
}
};
// Which `$refer` and `$self` properties do the shipped stylesheets request?
const styles: string[] = [];
for (const sheet of [...document.styleSheets]) {
try {
for (const rule of [...sheet.cssRules]) styles.push(rule.cssText);
} catch {
// cross-origin stylesheets are unreadable, keep going
}
}
const selfProps: string[] = [];
const seenSelf = new Set<string>();
for (const text of styles) {
for (const match of text.matchAll(/--muddle-self-([a-zA-Z][\w-]*)/g)) {
if (!seenSelf.has(match[1]!)) {
seenSelf.add(match[1]!);
selfProps.push(match[1]!);
}
}
}
const anchors = [...document.querySelectorAll("[data-muddle-anchor]")] as any[];
if (!remeasure) {
for (const element of anchors) {
const name = element.getAttribute("data-muddle-anchor") as string | null;
if (!name?.includes(".")) continue;
const parts = name.split(".");
const groups = parts.slice(0, -1).map((_part, index) => parts.slice(0, index + 1).join("."));
element.setAttribute("data-muddle-group", groups.join(" "));
}
}
const requests = new Map<string, string[]>();
for (const element of anchors) {
const name = element.getAttribute("data-muddle-anchor");
if (!name) continue;
const key = name.replace(/[^a-zA-Z0-9_-]/g, "_");
for (const text of styles) {
const needle = `--muddle-relate-${key}-`;
let index = text.indexOf(needle);
while (index !== -1) {
const property = /^[a-zA-Z][\w-]*/.exec(text.slice(index + needle.length))?.[0];
if (property && !requests.get(key)?.includes(property)) {
requests.set(key, [...(requests.get(key) ?? []), property]);
}
index = text.indexOf(needle, index + needle.length + (property?.length ?? 0));
}
}
}
// Label the measure targets once, they keep their id across viewports.
if (!remeasure) {
const selected = new Set<any>();
for (const selector of selectors) {
for (const element of document.querySelectorAll(selector)) selected.add(element);
}
for (const [index, element] of [...selected].entries()) {
element.setAttribute("data-muddle-predicted", `predict-${index}`);
}
}
const entries: Entry[] = [];
for (const [index, element] of ([...document.querySelectorAll("[data-muddle-predicted]")] as any[]).entries()) {
const box = element.getBoundingClientRect();
const style = getComputedStyle(element);
const entry: Entry = {
selector: `[data-muddle-predicted="${element.getAttribute("data-muddle-predicted")}"]`,
index,
width: box.width,
height: box.height,
x: box.x,
y: box.y,
anchor: element.getAttribute("data-muddle-anchor"),
backgroundColor: style.backgroundColor,
color: style.color,
transition: style.transition,
};
const name = element.getAttribute("data-muddle-anchor");
if (name) {
const key = name.replace(/[^a-zA-Z0-9_-]/g, "_");
const relate: Record<string, string> = {};
for (const property of requests.get(key) ?? []) {
if (STANDARDS.has(property)) continue;
const value = propertyValue(element, style, property);
if (value) relate[property] = value;
}
if (Object.keys(relate).length > 0) entry.relate = relate;
const self: Record<string, string> = {};
for (const property of selfProps) {
const value = propertyValue(element, style, property);
if (value) self[property] = value;
}
if (Object.keys(self).length > 0) entry.self = self;
}
entries.push(entry);
}
return entries;
};
function predictionVariables(prediction: LayoutPrediction, variant: string): string {
return [
`${prediction.selector} {`,
`--muddle-predicted-${variant}-width: ${prediction.width}px;`,
`--muddle-predicted-${variant}-height: ${prediction.height}px;`,
`--muddle-predicted-${variant}-x: ${prediction.x}px;`,
`--muddle-predicted-${variant}-y: ${prediction.y}px;`,
`--muddle-predicted-width: var(--muddle-predicted-${variant}-width);`,
`--muddle-predicted-height: var(--muddle-predicted-${variant}-height);`,
`--muddle-predicted-x: var(--muddle-predicted-${variant}-x);`,
`--muddle-predicted-y: var(--muddle-predicted-${variant}-y);`,
"}",
].join(" ");
}
/**
* Relation variables of one anchor.
*
* The suffixed variables hold the raw measurement of the variant, the plain
* alias resolves to whichever variant applies at the current viewport.
*/
function relationVariables(prediction: LayoutPrediction, variant: string): string {
if (!prediction.anchor) return "";
const key = prediction.anchor.replace(/[^a-zA-Z0-9_-]/g, "_");
const prefix = `--muddle-relate-${key}-${variant}`;
const values = [
`${prefix}-x: ${prediction.x}px`,
`${prefix}-y: ${prediction.y}px`,
`${prefix}-width: ${prediction.width}px`,
`${prefix}-height: ${prediction.height}px`,
prediction.backgroundColor ? `${prefix}-background-color: ${prediction.backgroundColor}` : "",
prediction.color ? `${prefix}-color: ${prediction.color}` : "",
prediction.transition ? `${prefix}-transition: ${prediction.transition}` : "",
`--muddle-relate-${key}-x: var(${prefix}-x)`,
`--muddle-relate-${key}-y: var(${prefix}-y)`,
`--muddle-relate-${key}-width: var(${prefix}-width)`,
`--muddle-relate-${key}-height: var(${prefix}-height)`,
prediction.backgroundColor ? `--muddle-relate-${key}-background-color: var(${prefix}-background-color)` : "",
prediction.color ? `--muddle-relate-${key}-color: var(${prefix}-color)` : "",
prediction.transition ? `--muddle-relate-${key}-transition: var(${prefix}-transition)` : "",
]
.concat(Object.entries(prediction.relate ?? {}).flatMap(([property, value]) => [
`${prefix}-${property}: ${value}`,
`--muddle-relate-${key}-${property}: var(${prefix}-${property})`,
]))
.filter(Boolean);
return `:root {\n\t${values.join(";\n")};\n}`;
}
/**
* Scoped `$self` mirrors of one anchor element.
*
* `$self[<property>]` resolves through the element itself, so the mirrored
* value cannot leak between elements that show the same anchor name once.
*/
function selfVariables(prediction: LayoutPrediction): string {
if (!prediction.anchor) return "";
const entries = Object.entries(prediction.self ?? {});
if (entries.length === 0) return "";
const values = entries.map(([property, value]) => `--muddle-self-${property}: ${value}`);
return `[data-muddle-anchor="${prediction.anchor}"] {\n\t${values.join(";\n")};\n}`;
}
interface PredictionLayer {
name: string;
media: string;
viewport: { width: number; height: number };
predictions: LayoutPrediction[];
}
function reflectiveAnchorGroups(predictions: LayoutPrediction[]): ReflectiveAnchorGroup[] {
const groups = new Map<string, string[]>();
for (const prediction of predictions) {
const anchor = prediction.anchor;
if (!anchor?.includes(".")) continue;
const parts = anchor.split(".");
for (let index = 1; index < parts.length; index += 1) {
const name = parts.slice(0, index).join(".");
const entries = groups.get(name) ?? [];
if (!entries.includes(anchor)) entries.push(anchor);
groups.set(name, entries);
}
}
return [...groups].map(([name, anchors]) => ({ name, anchors }));
}
function reflectionCss(groups: ReflectiveAnchorGroup[]): string[] {
return groups.flatMap((group) => {
const key = group.name.replace(/[^a-zA-Z0-9_-]/g, "_");
return [
`[data-muddle-group~="${group.name}"] { --muddle-reflect-${key}-count: ${group.anchors.length}; }`,
...group.anchors.map((anchor, index) => `[data-muddle-anchor="${anchor}"] { --muddle-reflect-${key}-index: ${index}; }`),
];
});
}
function predictionCss(desktop: LayoutPrediction[], layers: PredictionLayer[], groups: ReflectiveAnchorGroup[]): string {
const rules = (predictions: LayoutPrediction[], variant: string) => predictions.flatMap((entry) => [
predictionVariables(entry, variant),
relationVariables(entry, variant),
selfVariables(entry),
]);
return [
...reflectionCss(groups),
"@media (min-width: 0) {",
...rules(desktop, "desktop"),
"}",
...layers.flatMap((layer) => [`@media ${layer.media} {`, ...rules(layer.predictions, layer.name), "}"]),
]
.filter((line) => line.trim().length > 0)
.join("\n");
}
function mobileMediaQuery(html: string, explicit?: string): string {
if (explicit) return explicit.trim().startsWith("(") ? explicit.trim() : `(${explicit.trim()})`;
const candidates = [...html.matchAll(/@media\s*\(\s*max-width\s*:\s*([^)]+)\)/gi)]
.map((match) => match[1]!.trim())
.sort((left, right) => mediaWidth(right) - mediaWidth(left));
return candidates[0] ? `(max-width: ${candidates[0]})` : "(max-width: 48rem)";
}
function mediaWidth(value: string): number {
const match = /^(\d+(?:\.\d+)?)\s*(px|rem|em)?$/i.exec(value);
if (!match) return 0;
const amount = Number(match[1]);
return amount * (match[2]?.toLowerCase() === "px" ? 1 : 16);
}
function viewportForMobileMedia(query: string, fallback: { width: number; height: number }): { width: number; height: number } {
const match = /max-width\s*:\s*([^)\s]+)/i.exec(query);
const width = match ? mediaWidth(match[1]!) : 0;
return width > 0 ? { ...fallback, width: Math.min(fallback.width, Math.max(1, Math.floor(width - 1))) } : fallback;
}
const BREAKPOINTS: Record<BreakpointAcc, { width: number; media: string }> = {
XL: { width: 1280, media: "(max-width: 80rem)" },
LG: { width: 1024, media: "(max-width: 64rem)" },
MD: { width: 768, media: "(max-width: 48rem)" },
SM: { width: 640, media: "(max-width: 40rem)" },
};
function predictionLayers(options: LayoutPredictionOptions, html: string): Omit<PredictionLayer, "predictions">[] {
const requested = options.breakpointAcc
? [...new Set(Array.isArray(options.breakpointAcc) ? options.breakpointAcc : [options.breakpointAcc])]
: [];
if (requested.length) {
return requested
.sort((left, right) => BREAKPOINTS[right].width - BREAKPOINTS[left].width)
.map((accuracy) => ({
name: accuracy.toLowerCase(),
media: BREAKPOINTS[accuracy].media,
viewport: { width: BREAKPOINTS[accuracy].width - 1, height: 844 },
}));
}
const media = mobileMediaQuery(html, options.mobileMediaQuery);
return [{ name: "mobile", media, viewport: options.mobileViewport ?? viewportForMobileMedia(media, { width: 390, height: 844 }) }];
}
/** Reads the `@measure` targets emitted into stylesheets by the STCSS compiler. */
export function extractPredictSelectors(html: string): string[] {
return [...html.matchAll(/\/\*\s*muddle-predict\s*:([\s\S]*?)\*\//g)].flatMap((match) => (match[1]
? match[1].split(/[|,]/).map((selector) => selector.trim()).filter(Boolean)
: []));
}
/**
* Bakes prediction and relation variables into prerendered html.
*
* A headless chromium renders the page at the desktop viewport, measures it,
* then repeats at the mobile viewport. The results are inlined as a single
* `<style>` block in the `<head>` of the returned page, so the shipped bundle
* contains no scripting at all.
*/
export async function predictLayout(html: string, options: LayoutPredictionOptions): Promise<{ html: string; predictions: LayoutPrediction[]; groups: ReflectiveAnchorGroup[] }> {
let browser: import("playwright").Browser | undefined;
try {
const { chromium } = await import("playwright");
browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: options.viewport ?? { width: 1440, height: 900 } });
await page.setContent(html, { waitUntil: "networkidle" });
const layers = predictionLayers(options, html);
// Disclosure bodies are measured in their open state.
const openStates = await page.locator("details").evaluateAll((elements) => elements.map((element) => (element as unknown as { hasAttribute(name: string): boolean }).hasAttribute("open")));
await page.locator("details").evaluateAll((elements) => {
for (const element of elements) (element as unknown as { setAttribute(name: string, value: string): void }).setAttribute("open", "");
});
const selected = [...new Set([...(options.selectors ?? []), ...extractPredictSelectors(html), "[data-muddle-anchor]"])];
// First collect the independent anchor geometry. Relation-driven targets
// cannot have a meaningful size until those values exist in the page.
const initialDesktop = await page.evaluate(measureScript, { remeasure: false, selectors: selected }) as LayoutPrediction[];
const groups = options.reflective === false ? [] : reflectiveAnchorGroups(initialDesktop);
const initialLayers: PredictionLayer[] = [];
for (const layer of layers) {
await page.setViewportSize(layer.viewport);
initialLayers.push({ ...layer, predictions: await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[] });
}
// Propagate the measurements until the emitted relation stylesheet stops
// changing. A relation can itself size an anchor used by another relation,
// so a single replay only settles one edge of that graph.
let desktop = initialDesktop;
let measuredLayers = initialLayers;
let css = predictionCss(desktop, measuredLayers, groups);
let settled = false;
for (let pass = 0; pass < (options.maxPasses ?? 8); pass++) {
const provisional = await page.addStyleTag({ content: css });
await page.setViewportSize(options.viewport ?? { width: 1440, height: 900 });
const nextDesktop = await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[];
const nextLayers: PredictionLayer[] = [];
for (const layer of measuredLayers) {
await page.setViewportSize(layer.viewport);
nextLayers.push({ ...layer, predictions: await page.evaluate(measureScript, { remeasure: true }) as LayoutPrediction[] });
}
await provisional.evaluate((element) => element.remove());
const nextCss = predictionCss(nextDesktop, nextLayers, groups);
desktop = nextDesktop;
measuredLayers = nextLayers;
if (nextCss === css) {
settled = true;
break;
}
css = nextCss;
}
if (!settled) throw new Error(`relation prediction did not settle within ${options.maxPasses ?? 8} passes`);
css = predictionCss(desktop, measuredLayers, groups);
await page.locator("details").evaluateAll((elements, states) => {
elements.forEach((element, index) => {
if (!states[index]) (element as unknown as { removeAttribute(name: string): void }).removeAttribute("open");
});
}, openStates);
const rendered = await page.content();
return { html: rendered.replace("</head>", `<style>${css}</style></head>`), predictions: [...desktop, ...measuredLayers.flatMap((layer) => layer.predictions)], groups };
} catch (error) {
if (options.strict) throw error;
console.warn(`muddle prediction skipped: ${(error as Error).message.split("\n")[0]}`);
return { html, predictions: [], groups: [] };
} finally {
await browser?.close();
}
}