diff --git a/apps/builder/app/builder/features/settings-panel/shared.tsx b/apps/builder/app/builder/features/settings-panel/shared.tsx index dacf43ea637d..9e6f9b2f6c15 100644 --- a/apps/builder/app/builder/features/settings-panel/shared.tsx +++ b/apps/builder/app/builder/features/settings-panel/shared.tsx @@ -11,7 +11,11 @@ import { type ComponentProps, } from "react"; import equal from "fast-deep-equal"; -import { ariaAttributes, attributesByTag } from "@webstudio-is/html-data"; +import { + ariaAttributes, + attributesByTag, + elementsByTag, +} from "@webstudio-is/html-data"; import { reactPropsToStandardAttributes, showAttribute, @@ -465,13 +469,16 @@ export const $selectedInstancePropsMetas = computed( const propsMetas = new Map(); // add html attributes only when instance has tag if (tag) { - for (const attribute of [...ariaAttributes].reverse()) { - propsMetas.set(attribute.name, attributeToMeta(attribute)); - } - if (attributesByTag["*"]) { - for (const attribute of [...attributesByTag["*"]].reverse()) { + if (elementsByTag[tag].categories.includes("html-element")) { + for (const attribute of [...ariaAttributes].reverse()) { propsMetas.set(attribute.name, attributeToMeta(attribute)); } + // include global attributes only for html elements + if (attributesByTag["*"]) { + for (const attribute of [...attributesByTag["*"]].reverse()) { + propsMetas.set(attribute.name, attributeToMeta(attribute)); + } + } } if (attributesByTag[tag]) { for (const attribute of [...attributesByTag[tag]].reverse()) { diff --git a/packages/html-data/bin/attributes.ts b/packages/html-data/bin/attributes.ts index 735f26a04993..5a7a3edfb940 100644 --- a/packages/html-data/bin/attributes.ts +++ b/packages/html-data/bin/attributes.ts @@ -1,4 +1,5 @@ import { mkdir, writeFile } from "node:fs/promises"; +import hash from "@emotion/hash"; import { coreMetas, createScope, @@ -10,13 +11,16 @@ import { } from "@webstudio-is/sdk"; import { generateWebstudioComponent } from "@webstudio-is/react-sdk"; import { + findByClasses, findByTags, getAttr, getTextContent, loadHtmlIndices, + loadSvgSinglePage, parseHtml, } from "./crawler"; import { possibleStandardNames } from "./possible-standard-names"; +import { ignoredTags } from "./overrides"; const validHtmlAttributes = new Set(); @@ -28,14 +32,7 @@ type Attribute = { options?: string[]; }; -const overrides: Record< - string, - false | Record> -> = { - template: false, - link: false, - script: false, - style: false, +const overrides: Record>> = { "*": { // react has own opinions about it style: false, @@ -215,8 +212,7 @@ for (const row of rows) { if (/custom elements/i.test(tag)) { continue; } - const tagOverride = overrides[tag]; - if (tagOverride === false) { + if (ignoredTags.includes(tag)) { continue; } if (!attributesByTag[tag]) { @@ -224,7 +220,7 @@ for (const row of rows) { } const attributes = attributesByTag[tag]; if (!attributes.some((item) => item.name === attribute)) { - const override = tagOverride?.[attribute]; + const override = overrides[tag]?.[attribute]; if (override !== false) { attributes.push({ name: attribute, @@ -238,18 +234,105 @@ for (const row of rows) { } } +{ + const svg = await loadSvgSinglePage(); + const document = parseHtml(svg); + const attributeOptions = new Map(); + // find all property definition and extract there keywords + for (const propdef of findByClasses(document, "propdef")) { + let options: undefined | string[]; + for (const row of findByTags(propdef, "tr")) { + const [nameNode, valueNode] = row.childNodes; + const name = getTextContent(nameNode); + const list = getTextContent(valueNode) + .trim() + .split(/\s+\|\s+/); + if ( + name.toLowerCase().includes("value") && + list.every((item) => item.match(/^[a-zA-Z-]+$/)) + ) { + options = list; + } + } + for (const propNameNode of findByClasses(propdef, "propdef-title")) { + const propName = getTextContent(propNameNode).slice(1, -1); + if (options) { + attributeOptions.set(propName, options); + } + } + } + + for (const summary of findByClasses(document, "element-summary")) { + const [tag] = findByClasses(summary, "element-summary-name").map((item) => + getTextContent(item).slice(1, -1) + ); + // ignore existing + if (attributesByTag[tag] || ignoredTags.includes(tag)) { + continue; + } + const attributes = new Set(); + const [dl] = findByTags(summary, "dl"); + for (let index = 0; index < dl.childNodes.length; index += 1) { + const child = dl.childNodes[index]; + if (getTextContent(child).toLowerCase().includes("attributes")) { + const dd = dl.childNodes[index + 1]; + for (const attrNameNode of findByClasses(dd, "attr-name")) { + const attrName = getTextContent(attrNameNode).slice(1, -1); + // skip events + if (attrName.startsWith("on") || attrName === "style") { + continue; + } + validHtmlAttributes.add(attrName); + attributes.add(attrName); + } + } + } + attributesByTag[tag] = Array.from(attributes) + .sort() + .map((name) => { + let options = attributeOptions.get(name); + if (name === "externalResourcesRequired") { + options = ["true", "false"]; + } + if (name === "accumulate") { + options = ["none", "sum"]; + } + if (name === "additive") { + options = ["replace", "sum"]; + } + if (name === "preserveAlpha") { + options = ["true", "false"]; + } + if (options) { + return { name, description: "", type: "select", options }; + } + return { name, description: "", type: "string" }; + }); + } +} + // sort tags and attributes const tags = Object.keys(attributesByTag).sort(); +const attributesByHash = new Map(); +const reusableAttributesByHash = new Map(); for (const tag of tags) { const attributes = attributesByTag[tag]; delete attributesByTag[tag]; - attributes.sort(); + attributes.sort((left, right) => left.name.localeCompare(right.name)); if (attributes.length > 0) { + for (const attribute of attributes) { + const attributeHash = hash(JSON.stringify(attribute)); + if (attributesByHash.has(attributeHash)) { + reusableAttributesByHash.set(attributeHash, attribute); + } else { + attributesByHash.set(attributeHash, attribute); + } + } attributesByTag[tag] = attributes; } } -const attributesContent = `type Attribute = { +let attributesContent = `type Attribute = { name: string, description: string, required?: boolean, @@ -257,9 +340,44 @@ const attributesContent = `type Attribute = { options?: string[] } -export const attributesByTag: Record = ${JSON.stringify(attributesByTag, null, 2)}; `; +const attributeVariableByHash = new Map(); +for (const [key, attribute] of reusableAttributesByHash) { + const normalizedName = attribute.name + .replaceAll("-", "_") + .replaceAll(":", "_"); + const variableName = `attribute_${normalizedName}_${key}`; + attributeVariableByHash.set(key, variableName); + attributesContent += `const ${variableName}: Attribute = ${JSON.stringify(attribute, null, 2)};\n\n`; +} + +const serializableAttributesByTag: Record< + string, + Array +> = {}; +for (const tag of tags) { + const attributes = attributesByTag[tag]; + serializableAttributesByTag[tag] = attributes.map((attribute) => { + const key = hash(JSON.stringify(attribute)); + const variableName = attributeVariableByHash.get(key); + if (variableName) { + return variableName; + } + return attribute; + }); +} + +attributesContent += ` +export const attributesByTag: Record = ${JSON.stringify(serializableAttributesByTag, null, 2)}; +`; +for (const variableName of attributeVariableByHash.values()) { + attributesContent = attributesContent.replaceAll( + `"${variableName}"`, + variableName + ); +} + await mkdir("./src/__generated__", { recursive: true }); await writeFile("./src/__generated__/attributes.ts", attributesContent); diff --git a/packages/html-data/bin/crawler.ts b/packages/html-data/bin/crawler.ts index 9dd3f6ed914c..19fa2153c20c 100644 --- a/packages/html-data/bin/crawler.ts +++ b/packages/html-data/bin/crawler.ts @@ -38,7 +38,8 @@ export const findByClasses = ( if ( "tagName" in node && node.attrs.some( - (item) => item.name === "class" && item.value === className + (item) => + item.name === "class" && item.value.split(/\s+/).includes(className) ) ) { result.push(node); diff --git a/packages/html-data/bin/elements.ts b/packages/html-data/bin/elements.ts index 2499edd04b73..65749dc39452 100644 --- a/packages/html-data/bin/elements.ts +++ b/packages/html-data/bin/elements.ts @@ -8,6 +8,7 @@ import { loadSvgSinglePage, parseHtml, } from "./crawler"; +import { ignoredTags } from "./overrides"; // Crawl WHATWG HTML. @@ -59,6 +60,9 @@ const elementsByTag: Record = {}; categories.unshift("html-element"); let children = parseList(getTextContent(row.childNodes[4])); for (const tag of elements) { + if (ignoredTags.includes(tag)) { + continue; + } // 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 if (tag === "textarea") { @@ -86,9 +90,12 @@ const elementsByTag: Record = {}; const document = parseHtml(svg); const summaries = findByClasses(document, "element-summary"); for (const summary of summaries) { - const [name] = findByClasses(summary, "element-summary-name").map((item) => + const [tag] = findByClasses(summary, "element-summary-name").map((item) => getTextContent(item).slice(1, -1) ); + if (ignoredTags.includes(tag)) { + continue; + } const children: string[] = []; const [dl] = findByTags(summary, "dl"); for (let index = 0; index < dl.childNodes.length; index += 1) { @@ -100,13 +107,13 @@ const elementsByTag: Record = {}; } } } - if (elementsByTag[name]) { - console.info(`${name} element from SVG specification is skipped`); + if (elementsByTag[tag]) { + console.info(`${tag} element from SVG specification is skipped`); continue; } - const categories = name === "svg" ? ["flow", "phrasing"] : ["none"]; - categories.unshift("svg-element"); - elementsByTag[name] = { + const categories = tag === "svg" ? ["flow", "phrasing"] : ["none"]; + categories.unshift(tag === "svg" ? "html-element" : "svg-element"); + elementsByTag[tag] = { description: "", categories, children, @@ -127,10 +134,7 @@ await mkdir(dirname(contentModelFile), { recursive: true }); await writeFile(contentModelFile, contentModel); const tags: string[] = []; -for (const [tag, element] of Object.entries(elementsByTag)) { - if (element.categories.includes("metadata")) { - continue; - } +for (const tag of Object.keys(elementsByTag)) { tags.push(tag); } const getTagScore = (tag: string) => { diff --git a/packages/html-data/bin/overrides.ts b/packages/html-data/bin/overrides.ts new file mode 100644 index 000000000000..b9df33bac408 --- /dev/null +++ b/packages/html-data/bin/overrides.ts @@ -0,0 +1,28 @@ +export const ignoredTags: string[] = [ + "base", + "template", + "meta", + "noscript", + "link", + "script", + "style", + "title", + "glyph", + "glyphRef", + "altGlyph", + "altGlyphDef", + "altGlyphItem", + "animateColor", + "color-profile", + "missing-glyph", + "vkern", + "hkern", + "cursor", + "tref", + "font", + "font-face", + "font-face-format", + "font-face-name", + "font-face-src", + "font-face-uri", +]; diff --git a/packages/html-data/package.json b/packages/html-data/package.json index aa2d6c957cab..2df83e9fbc73 100644 --- a/packages/html-data/package.json +++ b/packages/html-data/package.json @@ -12,6 +12,7 @@ "build:aria": "tsx --conditions=webstudio ./bin/aria.ts && prettier --write ./src/__generated__" }, "devDependencies": { + "@emotion/hash": "^0.9.2", "@types/aria-query": "^5.0.4", "@webstudio-is/react-sdk": "workspace:*", "@webstudio-is/sdk": "workspace:*", diff --git a/packages/html-data/src/__generated__/attributes-jsx-test.tsx b/packages/html-data/src/__generated__/attributes-jsx-test.tsx index d9d17dc1b351..0f26054bfb40 100644 --- a/packages/html-data/src/__generated__/attributes-jsx-test.tsx +++ b/packages/html-data/src/__generated__/attributes-jsx-test.tsx @@ -38,6 +38,178 @@ const Page = () => { type={""} /> + + + {""} { preload={"none"} src={""} /> -