442 lines
18 KiB
TypeScript
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();
|
|
}
|
|
}
|