Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions apps/builder/app/builder/features/settings-panel/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -465,13 +469,16 @@ export const $selectedInstancePropsMetas = computed(
const propsMetas = new Map<Prop["name"], PropMeta>();
// 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()) {
Expand Down
146 changes: 132 additions & 14 deletions packages/html-data/bin/attributes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mkdir, writeFile } from "node:fs/promises";
import hash from "@emotion/hash";
import {
coreMetas,
createScope,
Expand All @@ -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<string>();

Expand All @@ -28,14 +32,7 @@ type Attribute = {
options?: string[];
};

const overrides: Record<
string,
false | Record<string, false | Partial<Attribute>>
> = {
template: false,
link: false,
script: false,
style: false,
const overrides: Record<string, Record<string, false | Partial<Attribute>>> = {
"*": {
// react has own opinions about it
style: false,
Expand Down Expand Up @@ -215,16 +212,15 @@ 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]) {
attributesByTag[tag] = [];
}
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,
Expand All @@ -238,28 +234,150 @@ for (const row of rows) {
}
}

{
const svg = await loadSvgSinglePage();
const document = parseHtml(svg);
const attributeOptions = new Map<string, string[]>();
// 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<string>();
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<string, Attribute>();
const reusableAttributesByHash = new Map<string, Attribute>();
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,
type: 'string' | 'boolean' | 'number' | 'select' | 'url',
options?: string[]
}

export const attributesByTag: Record<string, undefined | Attribute[]> = ${JSON.stringify(attributesByTag, null, 2)};
`;

const attributeVariableByHash = new Map<string, string>();
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<string | Attribute>
> = {};
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<string, undefined | Attribute[]> = ${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);

Expand Down
3 changes: 2 additions & 1 deletion packages/html-data/bin/crawler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 14 additions & 10 deletions packages/html-data/bin/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
loadSvgSinglePage,
parseHtml,
} from "./crawler";
import { ignoredTags } from "./overrides";

// Crawl WHATWG HTML.

Expand Down Expand Up @@ -59,6 +60,9 @@ const elementsByTag: Record<string, Element> = {};
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") {
Expand Down Expand Up @@ -86,9 +90,12 @@ const elementsByTag: Record<string, Element> = {};
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) {
Expand All @@ -100,13 +107,13 @@ const elementsByTag: Record<string, Element> = {};
}
}
}
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,
Expand All @@ -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) => {
Expand Down
28 changes: 28 additions & 0 deletions packages/html-data/bin/overrides.ts
Original file line number Diff line number Diff line change
@@ -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",
];
1 change: 1 addition & 0 deletions packages/html-data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
Loading