Files
webstudio/packages/html-data/bin/attributes.ts
T
Bogdan Chadkin 4dfdf50380 feat: migrate forms components to html elements (#5230)
Ref https://github.com/webstudio-is/webstudio/issues/3632

This is big one. So I switched to html elements whole forms section in
components. Though here I introduced some deviation from specification

**Textarea**

Initial value is represented as text content
```html
<textarea>content</textarea>
````

Though it is trickier to manage text content as state and all frameworks
rely on value property of textarea dom interface.

**Select**

Similar story. There is no value attribute and state is represented by
selected boolean attribute on options

```html
<select>
  <option value="value1" selected></option>
  <option value="value2"></option>
</select>
```

We replace selected attribute on option with value attribute on selected
when paste.

So basically all form elements are using "value" and "checked"
attributes which can be easily mapped to html and all frameworks.
2025-05-24 15:11:49 +03:00

382 lines
9.7 KiB
TypeScript

import { mkdir, writeFile } from "node:fs/promises";
import {
coreMetas,
createScope,
elementComponent,
type Instance,
type Instances,
type Prop,
type Props,
} from "@webstudio-is/sdk";
import { generateWebstudioComponent } from "@webstudio-is/react-sdk";
import {
findTags,
getAttr,
getTextContent,
loadHtmlIndices,
parseHtml,
} from "./crawler";
import { possibleStandardNames } from "./possible-standard-names";
const validHtmlAttributes = new Set<string>();
type Attribute = {
name: string;
description: string;
required?: boolean;
type: "string" | "boolean" | "number" | "select" | "url";
options?: string[];
};
const overrides: Record<
string,
false | Record<string, false | Partial<Attribute>>
> = {
template: false,
link: false,
script: false,
style: false,
"*": {
// react has own opinions about it
style: false,
// specific to input in react types
enterkeyhint: false,
inert: false,
popover: false,
writingsuggestions: false,
hidden: {
// "until-found"; "hidden"; the empty string
type: "boolean",
options: undefined,
},
},
a: {
href: { type: "url", required: true },
target: { required: true },
download: { type: "boolean", required: true },
},
blockquote: {
cite: { required: true },
},
form: {
action: { required: true },
method: { required: true },
enctype: { required: true },
},
area: {
ping: false,
},
button: {
type: { required: true },
command: false,
commandfor: false,
popovertarget: false,
popovertargetaction: false,
},
label: {
for: { required: true },
},
dialog: {
closedby: false,
},
img: {
ismap: false,
},
input: {
name: { required: true },
value: { required: true },
checked: { required: true },
type: { required: true },
placeholder: { required: true },
required: { required: true },
autofocus: { required: true },
alpha: false,
colorspace: false,
// react types have it only in textarea
dirname: false,
popovertarget: false,
popovertargetaction: false,
},
textarea: {
name: { required: true },
placeholder: { required: true },
required: { required: true },
autofocus: { required: true },
},
select: {
name: { required: true },
required: { required: true },
autofocus: { required: true },
// mutltiple mode is not considered accessible
// and we cannot express it in builder so easier to remove
multiple: false,
},
option: {
label: { required: true },
value: { required: true },
disabled: { required: true },
// enforce fake value attribute on select element
selected: false,
},
};
// Crawl WHATWG HTML.
const html = await loadHtmlIndices();
const document = parseHtml(html);
const table = findTags(document, "table").find(
(table) => getAttr(table, "id")?.value === "attributes-1"
);
const [tbody] = findTags(table, "tbody");
const rows = findTags(tbody, "tr");
const attributesByTag: Record<string, Attribute[]> = {};
// textarea does not have value attribute and text content is used as initial value
// introduce fake value attribute to manage initial state similar to input
attributesByTag.textarea = [
{
name: "value",
description: "Value of the form control",
type: "string",
required: true,
},
];
// select does not have value attribute and selected options are used as initial value
// introduce fake value attribute to manage initial state similar to input
attributesByTag.select = [
{
name: "value",
description: "Value of the form control",
type: "string",
required: true,
},
];
for (const row of rows) {
const attribute = getTextContent(row.childNodes[0]).trim();
const elements = getTextContent(row.childNodes[1]).trim();
const description = getTextContent(row.childNodes[2]).trim();
const tags = /HTML elements/.test(elements)
? ["*"]
: elements.split(/;/g).map((d) => d.replace(/\([^)]+\)/g, "").trim());
let value = getTextContent(row.childNodes[3]).trim().toLowerCase();
if (value.endsWith(";")) {
value = value.slice(0, -1);
}
let possibleOptions = value
.split(/\s*;\s*/)
.filter((item) => item.startsWith('"') && item.endsWith('"'))
.map((item) => item.slice(1, -1));
if (value.includes("valid navigable target name or keyword")) {
possibleOptions = ["_blank", "_self", "_parent", "_top"];
}
if (value.includes("input type keyword")) {
possibleOptions = [
"hidden",
"text",
"search",
"tel",
"url",
"email",
"password",
"date",
"month",
"week",
"time",
"datetime-local",
"number",
"range",
"color",
"checkbox",
"radio",
"file",
"submit",
"image",
"reset",
"button",
];
}
let type: "string" | "boolean" | "number" | "select" = "string";
let options: undefined | string[];
if (possibleOptions.length > 0) {
type = "select";
options = possibleOptions;
} else if (value.includes("boolean attribute")) {
type = "boolean";
} else if (
(value.includes("number") || value.includes("integer")) &&
!value.includes("list") &&
!value.includes("string")
) {
type = "number";
}
validHtmlAttributes.add(attribute);
for (let tag of tags) {
tag = tag.toLowerCase().trim();
if (/custom elements/i.test(tag)) {
continue;
}
const tagOverride = overrides[tag];
if (tagOverride === false) {
continue;
}
if (!attributesByTag[tag]) {
attributesByTag[tag] = [];
}
const attributes = attributesByTag[tag];
if (!attributes.some((item) => item.name === attribute)) {
const override = tagOverride?.[attribute];
if (override !== false) {
attributes.push({
name: attribute,
description,
type,
options,
...override,
});
}
}
}
}
// sort tags and attributes
const tags = Object.keys(attributesByTag).sort();
for (const tag of tags) {
const attributes = attributesByTag[tag];
delete attributesByTag[tag];
attributes.sort();
if (attributes.length > 0) {
attributesByTag[tag] = attributes;
}
}
const attributesContent = `type Attribute = {
name: string,
description: string,
required?: boolean,
type: 'string' | 'boolean' | 'number' | 'select' | 'url',
options?: string[]
}
export const attributesByTag: Record<string, undefined | Attribute[]> = ${JSON.stringify(attributesByTag, null, 2)};
`;
await mkdir("./src/__generated__", { recursive: true });
await writeFile("./src/__generated__/attributes.ts", attributesContent);
// generate jsx for testing react types
const instances: Instances = new Map();
const props: Props = new Map();
let id = 0;
const getId = () => {
id += 1;
return id.toString();
};
const body: Instance = {
type: "instance",
id: getId(),
component: elementComponent,
tag: "body",
children: [],
};
instances.set(body.id, body);
for (const entry of Object.entries(attributesByTag)) {
let [tag] = entry;
const [_tag, attributes] = entry;
if (tag === "*") {
tag = "div";
}
const instance: Instance = {
type: "instance",
id: getId(),
component: elementComponent,
tag,
children: [],
};
body.children.push({ type: "id", value: instance.id });
instances.set(instance.id, instance);
for (const { name, type, options } of attributes) {
const id = getId();
const instanceId = instance.id;
if (type === "string" || type === "url") {
const prop: Prop = { id, instanceId, type: "string", name, value: "" };
props.set(prop.id, prop);
continue;
}
if (type === "boolean") {
const prop: Prop = { id, instanceId, type, name, value: true };
props.set(prop.id, prop);
continue;
}
if (type === "select") {
const prop: Prop = {
id,
instanceId,
type: "string",
name,
value: options?.[0] ?? "",
};
props.set(prop.id, prop);
continue;
}
if (type === "number") {
const prop: Prop = {
id,
instanceId,
type: "number",
name,
value: 0,
};
props.set(prop.id, prop);
continue;
}
(type) satisfies never;
throw Error(`Unknown attribute ${name} with type ${type}`);
}
}
await mkdir("./src/__generated__", { recursive: true });
await writeFile(
"./src/__generated__/attributes-jsx-test.tsx",
generateWebstudioComponent({
name: "Page",
scope: createScope(),
metas: new Map(Object.entries(coreMetas)),
instances,
props,
dataSources: new Map(),
rootInstanceId: body.id,
classesMap: new Map(),
parameters: [],
}) + "export { Page }"
);
// react does not have this one
possibleStandardNames["dirname"] = "dirName";
const standardAttributesToReactProps: Record<string, string> = {};
const reactPropsToStandardAttributes: Record<string, string> = {};
for (const [htmlAttribute, reactProperty] of Object.entries(
possibleStandardNames
)) {
if (
validHtmlAttributes.has(htmlAttribute) &&
htmlAttribute !== reactProperty
) {
standardAttributesToReactProps[htmlAttribute] = reactProperty;
reactPropsToStandardAttributes[reactProperty] = htmlAttribute;
}
}
let standardAttributesContent = "";
standardAttributesContent += `export const standardAttributesToReactProps: Record<string, string> = ${JSON.stringify(standardAttributesToReactProps, null, 2)};\n\n`;
standardAttributesContent += `export const reactPropsToStandardAttributes: Record<string, string> = ${JSON.stringify(reactPropsToStandardAttributes, null, 2)};\n`;
await mkdir("../react-sdk/src/__generated__", { recursive: true });
await writeFile(
"../react-sdk/src/__generated__/standard-attributes.ts",
standardAttributesContent
);