import { type DefaultTreeAdapterMap, defaultTreeAdapter, parseFragment, } from "parse5"; import { type WebstudioFragment, type Instance, elementComponent, Prop, tags, StyleDecl, Breakpoint, StyleSource, StyleSourceSelection, } from "@webstudio-is/sdk"; import { ariaAttributes, attributesByTag } from "@webstudio-is/html-data"; import { camelCaseProperty, parseCss, extractCssCustomProperties, parseClassBasedSelector, parseMediaQuery, type ParsedStyleDecl, type ParsedClassSelector, } from "@webstudio-is/css-data"; import { richTextContentTags } from "./content-model"; import { setIsSubsetOf } from "./shim"; import { isAttributeNameSafe } from "@webstudio-is/react-sdk"; import * as csstree from "css-tree"; import { titleCase } from "title-case"; type ElementNode = DefaultTreeAdapterMap["element"]; const spaceRegex = /^\s*$/; const getAttributeType = ( attribute: (typeof ariaAttributes)[number] ): "string" | "boolean" | "number" => { if ( attribute.type === "string" || attribute.type === "select" || attribute.type === "url" ) { return "string"; } if (attribute.type === "number" || attribute.type === "boolean") { return attribute.type; } attribute.type satisfies never; throw Error("Unknown type"); }; const getAttributeTypes = () => { const attributeTypes = new Map(); for (const attribute of ariaAttributes) { attributeTypes.set(attribute.name, getAttributeType(attribute)); } for (const attribute of attributesByTag["*"] ?? []) { attributeTypes.set(attribute.name, getAttributeType(attribute)); } for (const [tag, attributes] of Object.entries(attributesByTag)) { if (attributes) { for (const attribute of attributes) { attributeTypes.set( `${tag}:${attribute.name}`, getAttributeType(attribute) ); } } } return attributeTypes; }; const findContentTags = (element: ElementNode, tags = new Set()) => { for (const childNode of element.childNodes) { if (defaultTreeAdapter.isElementNode(childNode)) { tags.add(childNode.tagName); findContentTags(childNode, tags); } } return tags; }; /** * Collect all text content from `, }); return { type: "id" as const, value: instance.id }; } // action.type === "skip" — all rules were class rules (or empty) return undefined; } if (!tags.includes(node.tagName)) { return; } const instance: Instance = { type: "instance", id: getNewId(), component: elementComponent, tag: node.tagName, children: [], }; // users expect to get optimized images by default // though still able to create raw img element if (node.tagName === "img") { instance.component = "Image"; delete instance.tag; } instances.set(instance.id, instance); for (const attr of node.attrs) { // skip attributes which cannot be rendered in jsx if (!isAttributeNameSafe(attr.name)) { continue; } const id = `${instance.id}:${attr.name}`; const instanceId = instance.id; const name = attr.name; // cast props to types extracted from html and aria specs const type = attributeTypes.get(`${node.tagName}:${name}`) ?? attributeTypes.get(name) ?? "string"; // ignore style attribute to not conflict with react if (attr.name === "style") { createLocalStyles(instanceId, attr.value); continue; } // resolve class attribute against class rules from style tags if ( attr.name === "class" && (classRules.size > 0 || nestedClassRules.size > 0) ) { const classNames = attr.value.split(/\s+/).filter(Boolean); const resolvedTokenNames: string[] = []; const unresolvedClasses: string[] = []; // First: match individual class names as simple tokens for (const className of classNames) { if (classRules.has(className)) { resolvedTokenNames.push(className); usedClassNames.add(className); } else { unresolvedClasses.push(className); } } // Second: match compound class combinations (e.g., class="card active" // matches token "card.active" from .card.active {} selector) if (classNames.length >= 2) { const classSet = new Set(classNames); for (const tokenName of classRules.keys()) { if (!tokenName.includes(".")) { continue; } const parts = tokenName.split("."); if (parts.every((part) => classSet.has(part))) { resolvedTokenNames.push(tokenName); usedClassNames.add(tokenName); } } } // Third: match nested class rules (e.g., .card .title) if (nestedClassRules.size > 0) { const classSet = new Set(classNames); for (const [tokenName, { parsed }] of nestedClassRules) { const targetRequired = new Set(parsed.classNames); if (!setIsSubsetOf(targetRequired, classSet)) { continue; } if ( parsed.ancestors && !elementMatchesAncestors(node, parsed.ancestors) ) { continue; } resolvedTokenNames.push(tokenName); usedNestedTokenNames.add(tokenName); // Remove matched target class names from unresolved for (const cn of parsed.classNames) { const idx = unresolvedClasses.indexOf(cn); if (idx >= 0) { unresolvedClasses.splice(idx, 1); } } } } // Token style source selections will be created after all instances if (resolvedTokenNames.length > 0) { // Store the resolved token names for post-processing instanceTokenClasses.set(instanceId, resolvedTokenNames); } // Keep unresolved class names as class prop if (unresolvedClasses.length > 0) { props.push({ id, instanceId, name: "class", type: "string", value: unresolvedClasses.join(" "), }); } continue; } // selected option is represented as fake value attribute on select element if (node.tagName === "option" && attr.name === "selected") { continue; } if (type === "string") { props.push({ id, instanceId, name, type, value: attr.value }); continue; } if (type === "number") { props.push({ id, instanceId, name, type, value: Number(attr.value) }); continue; } if (type === "boolean") { props.push({ id, instanceId, name, type, value: true }); continue; } (type) satisfies never; } const contentTags = findContentTags(node); const hasNonRichTextContent = !setIsSubsetOf( contentTags, richTextContentTags ); if (node.tagName === "select") { for (const childNode of node.childNodes) { if (defaultTreeAdapter.isElementNode(childNode)) { if ( childNode.tagName === "option" && childNode.attrs.find((attr) => attr.name === "selected") ) { const valueAttr = childNode.attrs.find( (attr) => attr.name === "value" ); // if value attribute is omitted, the value is taken from the text content of the option element const childText = childNode.childNodes.find((childNode) => defaultTreeAdapter.isTextNode(childNode) ); // selected option is represented as fake value attribute on select element props.push({ id: `${instance.id}:value`, instanceId: instance.id, name: "value", type: "string", value: valueAttr?.value ?? childText?.value.trim() ?? "", }); } } } } for (let index = 0; index < node.childNodes.length; index += 1) { const childNode = node.childNodes[index]; if (defaultTreeAdapter.isElementNode(childNode)) { const child = convertElementToInstance(childNode); if (child) { instance.children.push(child); } } if (defaultTreeAdapter.isTextNode(childNode)) { // trim spaces around rich text // do not for code if (spaceRegex.test(childNode.value) && node.tagName !== "code") { if (index === 0 || index === node.childNodes.length - 1) { continue; } } let child: Instance["children"][number] = { type: "text", value: childNode.value, }; if (node.tagName !== "code") { // collapse spacing characters inside of text to avoid preserved newlines child.value = child.value.replaceAll(/\s+/g, " "); // remove unnecessary spacing in nodes if (index === 0) { child.value = child.value.trimStart(); } if (index === node.childNodes.length - 1) { child.value = child.value.trimEnd(); } } // textarea content is initial value // and represented with fake value attribute if (node.tagName === "textarea") { props.push({ id: `${instance.id}:value`, instanceId: instance.id, name: "value", type: "string", value: child.value.trim(), }); continue; } // when element has content elements other than supported by rich text // wrap its text children with span, for example //
// text //
//
// is converted into //
// text //
//
if (hasNonRichTextContent) { // remove spaces between elements outside of rich text if (spaceRegex.test(childNode.value)) { continue; } const span: Instance = { type: "instance", id: getNewId(), component: elementComponent, tag: "span", children: [child], }; instances.set(span.id, span); child = { type: "id", value: span.id }; } instance.children.push(child); } } return { type: "id" as const, value: instance.id }; }; const children: Instance["children"] = []; for (const childNode of documentFragment.childNodes) { if (defaultTreeAdapter.isElementNode(childNode)) { const child = convertElementToInstance(childNode); if (child) { children.push(child); } } } // ---- Post-processing: create token style sources and styles ---- // Now that all instances have been created and IDs assigned, // create token style sources (using the shared getNewId counter) // in the order that matches template rendering (instances first, then tokens) // Ensure all simple class rules become tokens, even when no elements reference them // (e.g. pasting just a