Files
Oleg Isonen dde30ffb54 feat: Improve MCP authoring, compact output, and agent onboarding (#5883)
## Summary

This improves how agents discover, author, inspect, and verify Webstudio
projects while reducing unnecessary MCP context usage.

## What changed

- Adds an **Agents** section to Project Settings with ready-to-run setup
commands and client configuration for Claude Code, Codex, Cursor, and VS
Code.
- Makes large list and schema responses compact and paginated by
default, with **verbose** available when complete records or diagnostics
are needed.
- Applies consistent compact output to assets, instances, styles, design
tokens, CLI schemas, and project-session metadata.
- Returns actionable expression warnings for props, resources, and
Collection bindings, including unknown variables and risky render-time
mutation resources.
- Improves Collection expression guidance so agents can bind array items
with the correct scoped variables.
- Reports rendered audit outcomes explicitly as complete, partial,
confirmation-required, or failed.
- Expands runtime output schemas so MCP clients can validate responses
reliably.
- Strengthens the packaged CLI smoke workflow with a realistic
seven-page stdio MCP scenario.

## Architecture

- Consolidates `project-build` from 65 file-oriented entrypoints to 11
architecture-owned surfaces.
- Separates the core model, contracts, runtime authoring, transfer,
state, project sessions, MCP, visual tooling, persistence codecs, and
authenticated server services.
- Isolates Node-only JSX evaluation from browser-safe transfer utilities
and authenticated server modules.
- Keeps persistence codecs independent from authenticated database
services, preventing unrelated module initialization.
- Defines shared instance-path types once and reuses them across runtime
modules.
- Preserves Immer initialization through explicit package side-effect
metadata.
- Adds package tests that prevent a return to source-file entrypoints or
generic exposure of Node-only surfaces.

## Validation

- Full repository checks, tests, typechecks, generated-file checks, and
fixtures
- All 163 Builder test files and 2,141 tests
- All 71 project-build test files and 1,794 tests
- Lint and package-boundary validation
- Full packed CLI release smoke test, including install, preview, agent
setup, seven-page stdio MCP workflow, rendered audit, and structured
commands
2026-07-15 18:11:28 +02:00

904 lines
28 KiB
TypeScript

import { createGenerator } from "@unocss/core";
import { presetWind4 } from "@unocss/preset-wind4";
import {
camelCaseProperty,
extractCssCustomProperties,
parseCss,
parseMediaQuery,
type ParsedStyleDecl,
} from "@webstudio-is/css-data";
import type { StyleProperty } from "@webstudio-is/css-engine";
import {
getStyleDeclKey,
type Breakpoint,
type Instance,
type Prop,
type WebstudioFragment,
} from "@webstudio-is/sdk";
import { isBaseBreakpoint } from "@webstudio-is/project-build/runtime";
import { preflight } from "./__generated__/preflight";
// breakpoints used to map tailwind classes to webstudio breakpoints
// includes both min-width (desktop-first) and max-width (mobile-first) breakpoints
const tailwindBreakpoints: Breakpoint[] = [
{ id: "1536", label: "1536", minWidth: 1536 },
{ id: "1280", label: "1280", minWidth: 1280 },
{ id: "base", label: "" },
{ id: "1023", label: "1023", maxWidth: 1023 },
{ id: "767", label: "767", maxWidth: 767 },
{ id: "639", label: "639", maxWidth: 639 },
];
type StyleDecl = Omit<ParsedStyleDecl, "selector">;
type StyleBreakpoint = {
styleDecl: StyleDecl;
minWidth?: number;
maxWidth?: number;
};
type Range = {
styleDecl: StyleDecl;
start: number;
end: number;
};
const serializeStyleBreakpoint = (breakpoint: StyleBreakpoint) => {
if (breakpoint?.minWidth !== undefined) {
return `(min-width: ${breakpoint.minWidth}px)`;
}
if (breakpoint?.maxWidth !== undefined) {
return `(max-width: ${breakpoint.maxWidth}px)`;
}
};
const UPPER_BOUND = Number.MAX_SAFE_INTEGER;
const breakpointsToRanges = (breakpoints: StyleBreakpoint[]) => {
// collect lower bounds and ids
const values = new Set<number>([0]);
const styles = new Map<undefined | number, StyleDecl>();
for (const breakpoint of breakpoints) {
if (breakpoint.minWidth !== undefined) {
values.add(breakpoint.minWidth);
styles.set(breakpoint.minWidth, breakpoint.styleDecl);
} else if (breakpoint.maxWidth !== undefined) {
values.add(breakpoint.maxWidth + 1);
styles.set(breakpoint.maxWidth, breakpoint.styleDecl);
} else {
// base breakpoint
styles.set(undefined, breakpoint.styleDecl);
}
}
const sortedValues = Array.from(values).sort((left, right) => left - right);
const ranges: Range[] = [];
for (let index = 0; index < sortedValues.length; index += 1) {
const start = sortedValues[index];
let end;
if (index === sortedValues.length - 1) {
end = UPPER_BOUND;
} else {
end = sortedValues[index + 1] - 1;
}
const styleDecl =
styles.get(start) ?? styles.get(end) ?? styles.get(undefined);
if (styleDecl) {
ranges.push({ styleDecl, start, end });
continue;
}
// when declaration is missing add new one with unset value
// to fill the hole in breakpoints
// for example
// "sm:opacity-20" has a hole at the start
// "max-sm:opacity-10 md:opacity-20" has a whole in the middle
const example = Array.from(styles.values())[0];
if (example) {
const newStyleDecl: StyleDecl = {
...example,
value: { type: "keyword", value: "unset" },
};
ranges.push({ styleDecl: newStyleDecl, start, end });
}
}
return ranges;
};
const rangesToBreakpoints = (
ranges: Range[],
userBreakpoints: Breakpoint[]
) => {
const breakpoints: StyleBreakpoint[] = [];
for (const { styleDecl, start, end } of ranges) {
let matchedBreakpoint;
for (const breakpoint of userBreakpoints) {
if (breakpoint.minWidth === start) {
matchedBreakpoint = { styleDecl, minWidth: start };
}
if (breakpoint.maxWidth === end) {
matchedBreakpoint = { styleDecl, maxWidth: end };
}
if (
breakpoint.minWidth === undefined &&
breakpoint.maxWidth === undefined
) {
matchedBreakpoint ??= { styleDecl };
}
}
if (matchedBreakpoint) {
styleDecl.breakpoint = serializeStyleBreakpoint(matchedBreakpoint);
breakpoints.push(matchedBreakpoint);
}
}
return breakpoints;
};
const normalizeMediaQueryWidth = (value: number, direction: "min" | "max") => {
return direction === "min" ? Math.ceil(value) : Math.floor(value);
};
const adaptBreakpoints = (
parsedStyles: StyleDecl[],
userBreakpoints: Breakpoint[]
) => {
const breakpointGroups = new Map<string, StyleBreakpoint[]>();
for (const styleDecl of parsedStyles) {
const mediaQuery = styleDecl.breakpoint
? parseMediaQuery(styleDecl.breakpoint)
: undefined;
// Skip condition-only breakpoints (e.g. dark mode prefers-color-scheme)
// and combined min+max width breakpoints (e.g. md:max-xl:)
// @todo support composite breakpoints by splitting into range-based breakpoints
if (mediaQuery?.condition !== undefined) {
continue;
}
if (
mediaQuery?.minWidth !== undefined &&
mediaQuery?.maxWidth !== undefined
) {
continue;
}
if (mediaQuery?.minWidth !== undefined) {
mediaQuery.minWidth = normalizeMediaQueryWidth(
mediaQuery.minWidth,
"min"
);
}
if (mediaQuery?.maxWidth !== undefined) {
mediaQuery.maxWidth = normalizeMediaQueryWidth(
mediaQuery.maxWidth,
"max"
);
}
const groupKey = `${styleDecl.property}:${styleDecl.state ?? ""}`;
let group = breakpointGroups.get(groupKey);
if (group === undefined) {
group = [];
breakpointGroups.set(groupKey, group);
}
group.push({ styleDecl, ...mediaQuery });
}
const newStyles: typeof parsedStyles = [];
for (const group of breakpointGroups.values()) {
const ranges = breakpointsToRanges(group);
const newGroup = rangesToBreakpoints(ranges, userBreakpoints);
for (const { styleDecl } of newGroup) {
newStyles.push(styleDecl);
}
}
return newStyles;
};
const createUnoGenerator = async () => {
return await createGenerator({
presets: [
presetWind4({
// css variables are defined on the same element as styleDecl
preflights: { theme: "on-demand", reset: false, property: false },
// dark mode will be ignored by parser
dark: "media",
}),
],
});
};
const percentToNumber = (value: string) => {
const trimmed = value.trim();
if (trimmed.endsWith("%") === false) {
return trimmed;
}
const parsed = Number.parseFloat(trimmed.slice(0, -1));
if (Number.isNaN(parsed)) {
return trimmed;
}
return (parsed / 100).toString();
};
const appendAlphaToOklch = (oklch: string, alpha: string) => {
const match = oklch.match(/^oklch\((.*)\)$/i);
if (match === null) {
return oklch;
}
return `oklch(${match[1]} / ${alpha})`;
};
const hexToRgb = (hex: string) => {
const normalized =
hex.length === 4
? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`
: hex;
const r = Number.parseInt(normalized.slice(1, 3), 16);
const g = Number.parseInt(normalized.slice(3, 5), 16);
const b = Number.parseInt(normalized.slice(5, 7), 16);
return `${r} ${g} ${b}`;
};
const extractPropertyInitialValues = (css: string) => {
const values = new Map<string, string>();
for (const match of css.matchAll(/@property\s+(--[\w-]+)\s*\{([^{}]*)\}/g)) {
const initialValue = match[2].match(/initial-value\s*:\s*([^;]+)\s*;?/);
if (initialValue) {
values.set(match[1], initialValue[1].trim());
}
}
return values;
};
const findMatchingParen = (text: string, openIndex: number) => {
let depth = 0;
for (let index = openIndex; index < text.length; index += 1) {
const char = text[index];
if (char === "(") {
depth += 1;
} else if (char === ")") {
depth -= 1;
if (depth === 0) {
return index;
}
}
}
};
const splitCssVarArguments = (args: string) => {
let depth = 0;
for (let index = 0; index < args.length; index += 1) {
const char = args[index];
if (char === "(") {
depth += 1;
} else if (char === ")") {
depth -= 1;
} else if (char === "," && depth === 0) {
return [args.slice(0, index).trim(), args.slice(index + 1).trim()];
}
}
return [args.trim()];
};
const resolveCssVars = (
value: string,
vars: Map<string, string>,
seen = new Set<string>()
): string => {
let result = "";
let index = 0;
while (index < value.length) {
const varIndex = value.indexOf("var(", index);
if (varIndex === -1) {
result += value.slice(index);
break;
}
result += value.slice(index, varIndex);
const openIndex = varIndex + "var".length;
const closeIndex = findMatchingParen(value, openIndex);
if (closeIndex === undefined) {
result += value.slice(varIndex);
break;
}
const args = value.slice(openIndex + 1, closeIndex);
const [name, fallback] = splitCssVarArguments(args);
const replacement = vars.get(name);
if (replacement !== undefined && seen.has(name) === false) {
seen.add(name);
result += resolveCssVars(replacement, vars, seen);
seen.delete(name);
} else if (fallback !== undefined) {
result += resolveCssVars(fallback, vars, seen);
} else {
result += value.slice(varIndex, closeIndex + 1);
}
index = closeIndex + 1;
}
return result;
};
const normalizeUnoCssValues = (css: string, finalVars: Map<string, string>) => {
// Wind4 emits rem media queries (e.g. 40rem). Convert to px so existing
// breakpoint mapping code can keep working unchanged.
let normalized = css.replace(
/(min|max)-width:\s*(-?\d*\.?\d+)rem/g,
(_match, range, rem) => {
const px = Number.parseFloat(rem) * 16;
return `${range}-width: ${px}px`;
}
);
normalized = normalized.replace(
/(min|max)-width:\s*calc\((-?\d*\.?\d+)rem\s*-\s*0\.1px\)/g,
(_match, range, rem) => {
const px = Number.parseFloat(rem) * 16 - 0.1;
return `${range}-width: ${px}px`;
}
);
// Inline tracked theme and utility variables so parseCss can resolve computed
// values like calc(var(--spacing) * 2), gradients, and shadow fallbacks.
normalized = resolveCssVars(normalized, finalVars);
// Wind4 uses a leading utility var fallback for typography.
normalized = normalized.replace(
/var\(--tw-leading,\s*([^\)]+)\)/g,
(_match, fallback) => fallback.trim()
);
normalized = normalized.replaceAll("calc(infinity * 1px)", "9999px");
// Resolve wind4's color-mix based opacity pipeline into concrete colors that
// parseCss can read as typed color values.
normalized = normalized.replace(
/color-mix\(in\s+(?:srgb|oklab),\s*var\((--colors-[\w-]+)\)\s+([^,]+),\s*transparent\)/g,
(_match, colorVar: string, opacityExpr: string) => {
const color = finalVars.get(colorVar);
if (color === undefined) {
return _match;
}
const trimmedOpacityExpr = opacityExpr.trim();
let alpha = trimmedOpacityExpr;
const varMatch = trimmedOpacityExpr.match(/^var\((--[\w-]+)\)$/);
if (varMatch) {
alpha = finalVars.get(varMatch[1]) ?? "1";
}
alpha = percentToNumber(alpha);
if (alpha === "1") {
return color;
}
if (color.startsWith("oklch(")) {
return appendAlphaToOklch(color, alpha);
}
if (color.startsWith("#")) {
return `rgb(from ${color} r g b / ${alpha})`;
}
return _match;
}
);
// After variable inlining, remaining color-mix declarations may already have
// concrete colors as the first argument (e.g. #fff or oklch(...)).
normalized = normalized.replace(
/color-mix\(in\s+(?:srgb|oklab),\s*(oklch\([^\)]+\)|#[0-9a-fA-F]{3,8})\s+([^,]+),\s*transparent\)/g,
(_match, color: string, opacityExpr: string) => {
const alpha = percentToNumber(opacityExpr.trim());
if (alpha === "1") {
return color;
}
if (color.startsWith("oklch(")) {
return appendAlphaToOklch(color, alpha);
}
if (color.startsWith("#")) {
return `rgb(from ${color} r g b / ${alpha})`;
}
return _match;
}
);
normalized = normalized.replace(
/rgb\(from\s+(#[0-9a-fA-F]{3,8})\s+r\s+g\s+b\s*\/\s*([^\)]+)\)/g,
(_match, hex: string, alpha: string) => {
const rgb = hexToRgb(hex);
return `rgb(${rgb} / ${alpha.trim()})`;
}
);
// Tailwind v4 emits gradients like `linear-gradient(to bottom right in oklab, ...)`.
// Keep imported gradients broadly renderable and parseable by dropping the
// interpolation color space from the direction argument.
normalized = normalized.replace(
/linear-gradient\((to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?|[-+]?\d*\.?\d+(?:deg|rad|grad|turn))\s+in\s+(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|lab|oklab|lch|oklch|xyz(?:-d50|-d65)?),/gi,
"linear-gradient($1,"
);
return normalized;
};
const normalizeUnoCssForWebstudio = (generatedCss: string) => {
// UnoCSS uses the --un-* namespace. Keep generated CSS in Tailwind's
// namespace so custom properties match familiar Tailwind output and existing
// Webstudio styles.
const css = generatedCss.replaceAll("--un-", "--tw-");
// Normalize CSS custom property values: when the same var is declared in
// multiple utility-class rules, replace every occurrence with the value from
// the LAST declaration (the final cascaded value). This allows per-rule
// two-pass pre-collection to see the correct final value regardless of which
// rule a shorthand (e.g. border-color) lives in.
const finalVars = new Map([
...extractPropertyInitialValues(css),
...extractCssCustomProperties(css),
]);
let normalizedCss = normalizeUnoCssValues(css, finalVars);
if (finalVars.size > 0) {
normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => {
const colonIdx = match.indexOf(":");
const propName = match.slice(0, colonIdx).trim();
const finalValue = finalVars.get(propName);
return finalValue !== undefined
? `${propName}: ${resolveCssVars(finalValue, finalVars)}`
: match;
});
}
return { css: normalizedCss, vars: finalVars };
};
export const __testing__ = {
normalizeUnoCssForWebstudio,
};
const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => {
if (
styleDecl.property.startsWith("border-") === false ||
styleDecl.property.endsWith("-color") === false
) {
return false;
}
const value = styleDecl.value as {
type?: string;
alpha?: number;
components?: number[];
};
if (
value?.type !== "color" ||
value.alpha !== 1 ||
Array.isArray(value.components) === false ||
value.components.length < 3
) {
return false;
}
const [r, g, b] = value.components;
return (
Math.abs(r - 229 / 255) < 0.001 &&
Math.abs(g - 231 / 255) < 0.001 &&
Math.abs(b - 235 / 255) < 0.001
);
};
const stylePropertyGroups: Record<string, Set<StyleProperty>> = {
margin: new Set([
"marginTop",
"marginRight",
"marginBottom",
"marginLeft",
"marginBlockStart",
"marginBlockEnd",
"marginInlineStart",
"marginInlineEnd",
]),
padding: new Set([
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"paddingBlockStart",
"paddingBlockEnd",
"paddingInlineStart",
"paddingInlineEnd",
]),
};
const getStylePropertyGroup = (property: StyleProperty) => {
for (const [groupName, properties] of Object.entries(stylePropertyGroups)) {
if (properties.has(property)) {
return groupName;
}
}
};
const parseTailwindClasses = async (
classes: string,
userBreakpoints: Breakpoint[],
fragmentBreakpoints: Breakpoint[]
) => {
// avoid caching uno generator instance
// to prevent bloating css with preflights from previous calls
const generator = await createUnoGenerator();
let hasColumnGaps = false;
let hasRowGaps = false;
let hasFlexOrGrid = false;
let hasContainer = false;
classes = classes
.split(" ")
.map((item) => {
// Tailwind v4 css variable shorthand: border-(--x)
// UnoCSS doesn't parse this alias directly, so normalize it to
// an explicit arbitrary property utility it understands.
// TODO: remove workaround once fixed https://github.com/unocss/unocss/issues/5188
if (/^border-\(--[\w-]+\)$/.test(item)) {
const varName = item.slice("border-(".length, -1);
return `[border-color:var(${varName})]`;
}
// Tailwind v4 typed arbitrary border color: border-[color:value]
// UnoCSS wind4 incorrectly maps this to border-width. Rewrite to the
// explicit arbitrary property form so it resolves to border-color.
// TODO: remove workaround once fixed https://github.com/unocss/unocss/issues/5188
const borderColorMatch = item.match(/^border-\[color:(.+)\]$/);
if (borderColorMatch) {
return `[border-color:${borderColorMatch[1]}]`;
}
// styles data cannot express space-x and space-y selectors
// with lobotomized owl so replace with gaps
if (item.includes("space-x-")) {
hasColumnGaps = true;
return item.replace("space-x-", "gap-x-");
}
if (item.includes("space-y-")) {
hasRowGaps = true;
return item.replace("space-y-", "gap-y-");
}
hasFlexOrGrid ||= item.endsWith("flex") || item.endsWith("grid");
hasContainer ||= item === "container";
return item;
})
.join(" ");
const generated = await generator.generate(classes);
const { css: normalizedCss, vars: finalVars } = normalizeUnoCssForWebstudio(
generated.css
);
let parsedStyles: StyleDecl[] = [];
// @todo probably builtin in v4
if (normalizedCss.includes("border")) {
// Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
// [UnoCSS]: allow to override the default border color with css var `--un-default-border-color`
const reset = `.styles {
border-style: solid;
border-color: var(--tw-default-border-color, #e5e7eb);
border-width: 0;
}`;
parsedStyles.push(...parseCss(reset, new Map()).styles);
}
parsedStyles.push(...parseCss(normalizedCss, finalVars).styles);
parsedStyles = parsedStyles.map((styleDecl) => {
const value = styleDecl.value as {
type?: string;
unit?: string;
value?: number | string;
fallback?: { type?: string; value?: string };
};
const isOpacityProperty =
styleDecl.property === "opacity" ||
styleDecl.property.endsWith("opacity");
if (
isOpacityProperty &&
value.type === "unit" &&
value.unit === "%" &&
typeof value.value === "number"
) {
return {
...styleDecl,
value: {
type: "unit" as const,
unit: "number" as const,
value: value.value / 100,
},
};
}
if (value.type === "unparsed" && typeof value.value === "string") {
const calcMatch = value.value.match(
/^calc\((-?\d*\.?\d+)rem\*(-?\d*\.?\d+)\)$/
);
if (calcMatch) {
return {
...styleDecl,
value: {
type: "unit",
unit: "rem",
value:
Number.parseFloat(calcMatch[1]) * Number.parseFloat(calcMatch[2]),
},
};
}
}
if (
value.type === "var" &&
value.value === "tw-leading" &&
value.fallback?.type === "unparsed" &&
typeof value.fallback.value === "string"
) {
const fallbackMatch = value.fallback.value.match(/^(-?\d*\.?\d+)rem$/);
if (fallbackMatch) {
return {
...styleDecl,
value: {
type: "unit",
unit: "rem",
value: Number.parseFloat(fallbackMatch[1]),
},
};
}
}
return styleDecl;
});
// skip preflights with ::before, ::after and ::backdrop
parsedStyles = parsedStyles.filter(
(styleDecl) =>
!styleDecl.state?.startsWith("::") &&
// Wind4 emits design-token variables in the theme layer. We inline them
// into declarations above, so they don't need to be persisted as styles.
(styleDecl.property.startsWith("--") === false ||
styleDecl.property.startsWith("--tw-"))
);
// setup base breakpoint for container class to avoid hole in ranges
if (hasContainer) {
parsedStyles.unshift({
property: "max-width",
value: { type: "keyword", value: "none" },
});
}
// gaps work only with flex and grid
// so try to use one or another for different axes
if (hasColumnGaps && !hasFlexOrGrid) {
parsedStyles.unshift({
property: "display",
value: { type: "keyword", value: "flex" },
});
}
if (hasRowGaps && !hasFlexOrGrid) {
parsedStyles.unshift(
{
property: "display",
value: { type: "keyword", value: "flex" },
},
{
property: "flex-direction",
value: { type: "keyword", value: "column" },
}
);
}
parsedStyles = adaptBreakpoints(parsedStyles, userBreakpoints);
// container class generates max-width styles for all Tailwind breakpoints
// filter out min-width ones only if user doesn't have min-width breakpoints
const hasUserMinWidthBreakpoints = fragmentBreakpoints.some(
(bp) => bp.minWidth !== undefined
);
if (hasContainer && !hasUserMinWidthBreakpoints) {
parsedStyles = parsedStyles.filter(
(styleDecl) =>
styleDecl.property !== "max-width" ||
!styleDecl.breakpoint?.includes("min-width")
);
}
const newClasses = classes
.split(" ")
.filter((item) => !generated.matched.has(item))
.join(" ");
return { newClasses, parsedStyles };
};
const getUniqueIdForList = <List extends Array<{ id: string }>>(list: List) => {
const existingIds = list.map((item) => item.id);
let index = 0;
while (existingIds.includes(index.toString())) {
index += 1;
}
return index.toString();
};
export const generateFragmentFromTailwind = async (
fragment: WebstudioFragment
): Promise<WebstudioFragment> => {
// lazily create breakpoint
let breakpoints = fragment.breakpoints;
const getBreakpointId = (mediaQuery: undefined | string) => {
if (mediaQuery === undefined) {
let baseBreakpoint = breakpoints.find(isBaseBreakpoint);
if (baseBreakpoint === undefined) {
baseBreakpoint = { id: "base", label: "" };
breakpoints = [...breakpoints];
breakpoints.push(baseBreakpoint);
}
return baseBreakpoint.id;
}
const parsedMediaQuery = parseMediaQuery(mediaQuery);
// unknown breakpoint
if (parsedMediaQuery === undefined) {
return;
}
let breakpoint = breakpoints.find(
(item) =>
item.minWidth === parsedMediaQuery.minWidth &&
item.maxWidth === parsedMediaQuery.maxWidth
);
if (breakpoint === undefined) {
const label = `${parsedMediaQuery.minWidth ?? parsedMediaQuery.maxWidth}`;
breakpoint = {
// make sure new breakpoint id is not conflicted with already defined by fragment
id: getUniqueIdForList(breakpoints),
label,
...parsedMediaQuery,
};
breakpoints = [...breakpoints];
breakpoints.push(breakpoint);
}
return breakpoint.id;
};
const styleSourceSelections = new Map(
fragment.styleSourceSelections.map((item) => [item.instanceId, item])
);
const styleSources = new Map(
fragment.styleSources.map((item) => [item.id, item])
);
const styles = new Map(
fragment.styles.map((item) => [getStyleDeclKey(item), item])
);
const preflightStyleDeclKeys = new Set<string>();
const getLocalStyleSource = (instanceId: Instance["id"]) => {
const styleSourceSelection = styleSourceSelections.get(instanceId);
const lastStyleSourceId = styleSourceSelection?.values.at(-1);
const lastStyleSource = styleSources.get(lastStyleSourceId ?? "");
return lastStyleSource?.type === "local" ? lastStyleSource : undefined;
};
const createLocalStyleSource = (instanceId: Instance["id"]) => {
const localStyleSource = {
type: "local" as const,
id: `${instanceId}:ws:style`,
};
let styleSourceSelection = structuredClone(
styleSourceSelections.get(instanceId)
);
if (styleSourceSelection === undefined) {
styleSourceSelection = { instanceId, values: [] };
styleSourceSelections.set(instanceId, styleSourceSelection);
}
styleSources.set(localStyleSource.id, localStyleSource);
styleSourceSelection.values.push(localStyleSource.id);
return localStyleSource;
};
const createOrMergeLocalStyles = (
instanceId: Instance["id"],
newStyles: StyleDecl[],
{ skipExisting = false }: { skipExisting?: boolean } = {}
) => {
const localStyleSource =
getLocalStyleSource(instanceId) ?? createLocalStyleSource(instanceId);
const clearedPropertyGroups = new Set<string>();
for (const parsedStyleDecl of newStyles) {
const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint);
// ignore unknown breakpoints
if (breakpointId === undefined) {
continue;
}
const styleDecl = {
breakpointId,
styleSourceId: localStyleSource.id,
state: parsedStyleDecl.state,
property: camelCaseProperty(parsedStyleDecl.property),
value: parsedStyleDecl.value,
};
const styleDeclKey = getStyleDeclKey(styleDecl);
// preflight is a browser reset baseline and must not overwrite
// explicit styles already set from inline style attributes
if (skipExisting && styles.has(styleDeclKey)) {
continue;
}
if (skipExisting === false) {
const propertyGroup = getStylePropertyGroup(styleDecl.property);
const groupProperties =
propertyGroup === undefined
? undefined
: stylePropertyGroups[propertyGroup];
const groupKey =
propertyGroup === undefined
? undefined
: `${styleDecl.styleSourceId}:${styleDecl.breakpointId}:${
styleDecl.state ?? ""
}:${propertyGroup}`;
if (
groupProperties !== undefined &&
groupKey !== undefined &&
clearedPropertyGroups.has(groupKey) === false
) {
clearedPropertyGroups.add(groupKey);
for (const property of groupProperties) {
const groupStyleDeclKey = getStyleDeclKey({
...styleDecl,
property,
});
if (preflightStyleDeclKeys.has(groupStyleDeclKey)) {
styles.delete(groupStyleDeclKey);
preflightStyleDeclKeys.delete(groupStyleDeclKey);
}
}
}
}
styles.delete(styleDeclKey);
styles.set(styleDeclKey, styleDecl);
if (skipExisting) {
preflightStyleDeclKeys.add(styleDeclKey);
}
}
};
for (const instance of fragment.instances) {
const tag = instance.tag;
if (tag && preflight[tag]) {
createOrMergeLocalStyles(instance.id, preflight[tag], {
skipExisting: true,
});
}
}
const props: Prop[] = [];
await Promise.all(
fragment.props.map(async (prop) => {
if (prop.name === "class" && prop.type === "string") {
// always use tailwindBreakpoints for parsing to support all Tailwind classes
// new breakpoints will be created as needed via getBreakpointId
const { newClasses, parsedStyles } = await parseTailwindClasses(
prop.value,
tailwindBreakpoints,
fragment.breakpoints
);
if (parsedStyles.length > 0) {
const localStyleSource = getLocalStyleSource(prop.instanceId);
const filteredStyles = parsedStyles.filter((parsedStyleDecl) => {
if (
localStyleSource === undefined ||
isTailwindDefaultBorderColorStyle(parsedStyleDecl) === false
) {
return true;
}
const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint);
if (breakpointId === undefined) {
return true;
}
const existingStyleKey = getStyleDeclKey({
breakpointId,
styleSourceId: localStyleSource.id,
state: parsedStyleDecl.state,
property: camelCaseProperty(parsedStyleDecl.property),
});
// Keep authored inline border colors if present.
return styles.has(existingStyleKey) === false;
});
createOrMergeLocalStyles(prop.instanceId, filteredStyles);
if (newClasses.length > 0) {
props.push({ ...prop, value: newClasses });
}
}
return;
}
props.push(prop);
})
);
return {
...fragment,
props,
breakpoints,
styleSources: Array.from(styleSources.values()),
styleSourceSelections: Array.from(styleSourceSelections.values()),
styles: Array.from(styles.values()),
};
};