mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-02 18:18:35 +09:00
## 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
904 lines
28 KiB
TypeScript
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()),
|
|
};
|
|
};
|