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[](...)`. */ relate?: Record; /** Scoped mirrors of the element itself, requested through `$self[]`. */ self?: Record; } 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; self?: Record; }; 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(); 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(); 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(); 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 = {}; 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 = {}; 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[]` 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(); 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 = { 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[] { 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 * ``), 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(); } }