Files
Oleg Isonen 2752bcc786 feat: Support script and style tag when pasting HTML (#5447)
## Description

1. What is this PR about (link the issue and add a short description)

## Steps for reproduction

1. click button
2. expect xyz

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
0000)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
2025-11-11 14:26:55 +01:00

346 lines
10 KiB
TypeScript

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 } from "@webstudio-is/css-data";
import { richTextContentTags } from "./content-model";
import { setIsSubsetOf } from "./shim";
import { isAttributeNameSafe } from "@webstudio-is/react-sdk";
import { capitalCase } from "change-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<string, "string" | "number" | "boolean">();
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<string>()) => {
for (const childNode of element.childNodes) {
if (defaultTreeAdapter.isElementNode(childNode)) {
tags.add(childNode.tagName);
findContentTags(childNode, tags);
}
}
return tags;
};
export const generateFragmentFromHtml = (html: string): WebstudioFragment => {
const attributeTypes = getAttributeTypes();
const instances = new Map<Instance["id"], Instance>();
const styleSourceSelections: StyleSourceSelection[] = [];
const styleSources: StyleSource[] = [];
const styles: StyleDecl[] = [];
const breakpoints: Breakpoint[] = [];
const props: Prop[] = [];
let lastId = -1;
const getNewId = () => {
lastId += 1;
return lastId.toString();
};
let baseBreakpoint: undefined | Breakpoint;
const getBaseBreakpointId = () => {
if (baseBreakpoint) {
return baseBreakpoint.id;
}
baseBreakpoint = { id: "base", label: "" };
breakpoints.push(baseBreakpoint);
return baseBreakpoint.id;
};
const createLocalStyles = (instanceId: string, css: string) => {
const localStyleSource: StyleSource = {
type: "local",
id: `${instanceId}:ws:style`,
};
styleSources.push(localStyleSource);
styleSourceSelections.push({ instanceId, values: [localStyleSource.id] });
for (const { property, value } of parseCss(`.styles{${css}}`)) {
styles.push({
styleSourceId: localStyleSource.id,
breakpointId: getBaseBreakpointId(),
property: camelCaseProperty(property),
value,
});
}
};
const convertElementToInstance = (node: ElementNode) => {
if (
(node.tagName === "script" || node.tagName === "style") &&
node.sourceCodeLocation
) {
const { startCol, startOffset, endOffset } = node.sourceCodeLocation;
const indent = startCol - 1;
const htmlFragment = html
.slice(startOffset, endOffset)
// try to preserve indentation
.split("\n")
.map((line, index) => {
if (index > 0 && /^\s+$/.test(line.slice(0, indent))) {
return line.slice(indent);
}
return line;
})
.join("\n");
const instance: Instance = {
type: "instance",
id: getNewId(),
component: "HtmlEmbed",
label: capitalCase(node.tagName),
children: [],
};
instances.set(instance.id, instance);
if (node.tagName === "script") {
props.push({
id: `${instance.id}:clientOnly`,
instanceId: instance.id,
name: "clientOnly",
type: "boolean",
value: true,
});
}
props.push({
id: `${instance.id}:code`,
instanceId: instance.id,
name: "code",
type: "string",
value: htmlFragment,
});
return { type: "id" as const, value: instance.id };
}
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;
}
// 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
// <div>
// text
// <article></article>
// </div>
// is converted into
// <div>
// <span>text</span>
// <article></article>
// </div>
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 documentFragment = parseFragment(html, {
scriptingEnabled: false,
sourceCodeLocationInfo: true,
});
const children: Instance["children"] = [];
for (const childNode of documentFragment.childNodes) {
if (defaultTreeAdapter.isElementNode(childNode)) {
const child = convertElementToInstance(childNode);
if (child) {
children.push(child);
}
}
}
return {
children,
instances: Array.from(instances.values()),
props,
dataSources: [],
resources: [],
styleSourceSelections,
styleSources,
styles,
breakpoints,
assets: [],
};
};