mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-27 23:27:09 +09:00
Closes #6584 ## Change A `flex` shorthand with a nested `calc()` and CSS variable could lose its basis during expansion. Keep the complete basis when it appears alone, before the numeric flex factors, or after one or two factors. The parser handles these forms without a function-name list. Also update `mdn-data` from 2.33.0 to 2.37.2, as requested for this PR. Regenerated CSS data adds `scroll-axis-lock` and `window-drag`, marks `zoom` animatable, and refreshes MDN links. The two new property article links currently lead to missing pages, so the generator uses its existing MDN search fallback for them. The flex grammar in MDN data did not change. The flex parser changes add 20 net non-test lines. The dependency update adds 3 net handwritten lines in the generator; the rest is package metadata and generated output. ## Verification - The reported shorthand regression and a `parseCss` comparison against explicit longhands failed before the parser fix and pass afterward. - CSS-data tests: 4,657 passed, 1 skipped. CSS-engine tests: 173 passed. Targeted Builder Chromium tests: 27 passed. - CSS-data, CSS-engine, and Builder typechecks; root lint; formatting; and `git diff --check` passed. - Frozen lockfile install and the full fixture link/sync/build pipeline passed. The only fixture output drift came from unrelated synced sitemap content and was excluded. - Both CSS generators produced stable output on a second run. Both fallback MDN search links returned HTTP 200. - Reviewed Builder `docs/`: no documentation change is needed. Existing guidance covers property status and animation choices. ## Known limitation `calc-size()` is preserved during shorthand expansion, but the separate longhand validator still marks it invalid in non-browser builds. Numeric and basis functions can also be ambiguous until CSS variables resolve; this fallback handles the reproduced basis forms, not every possible calculation. ## Checklist - [x] Reproduce the reported nested `calc()` value and cover shorthand and saved-style parsing. - [x] Preserve the full basis without changing simple flex expansion. - [x] Simplify the fallback parsing, remove the function-name list, and test another valid basis function. - [x] Update `mdn-data` to 2.37.2 and regenerate CSS data and variable fixtures. - [x] Use working MDN search links for the two new properties whose articles return 404. - [x] Review generated CSS, type, and fixture changes. - [x] Run CSS tests, typechecks, lint, formatting, and fixtures. - [x] Review Builder `docs/` for both changes; no documentation update is needed. - [x] Review the diff and commit only related files. - [x] Resolve review feedback for calculated flex bases with optional factors and basis-first order. - [x] Verify required PR CI checks after the final update.
553 lines
15 KiB
TypeScript
Executable File
553 lines
15 KiB
TypeScript
Executable File
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join, dirname } from "node:path";
|
|
import { parse, definitionSyntax, lexer, type CssNode } from "css-tree";
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore @todo add the missing DSNode type definition
|
|
import type { DSNode } from "css-tree";
|
|
import properties from "mdn-data/css/properties.json";
|
|
import syntaxes from "mdn-data/css/syntaxes.json";
|
|
import selectors from "mdn-data/css/selectors.json";
|
|
import data from "css-tree/dist/data";
|
|
import type {
|
|
KeywordValue,
|
|
StyleValue,
|
|
Unit,
|
|
UnitValue,
|
|
UnparsedValue,
|
|
FontFamilyValue,
|
|
CssProperty,
|
|
} from "@webstudio-is/css-engine";
|
|
import * as customData from "../src/custom-data";
|
|
import { camelCaseProperty } from "../src/parse-css";
|
|
|
|
const units: Record<string, Array<string>> = {
|
|
number: [],
|
|
// consider % as unit
|
|
percentage: ["%"],
|
|
...data.units,
|
|
};
|
|
|
|
type Property = keyof typeof properties;
|
|
type Value = (typeof properties)[Property];
|
|
|
|
const autoValue = {
|
|
type: "keyword",
|
|
value: "auto",
|
|
} as const;
|
|
|
|
// MDN data includes article URLs for these properties before the articles exist.
|
|
const undocumentedProperties = new Set(["scroll-axis-lock", "window-drag"]);
|
|
|
|
const getMdnUrl = (property: string, config: Value): string =>
|
|
"mdn_url" in config && undocumentedProperties.has(property) === false
|
|
? config.mdn_url
|
|
: `https://developer.mozilla.org/en-US/search?q=${encodeURIComponent(property)}`;
|
|
|
|
// Normalize browser dependant properties.
|
|
const normalizedValues = {
|
|
// dependsOnUserAgent
|
|
"font-family": {
|
|
type: "fontFamily",
|
|
value: ["serif"],
|
|
} satisfies FontFamilyValue,
|
|
// startOrNamelessValueIfLTRRightIfRTL
|
|
"text-align": { type: "keyword", value: "start" },
|
|
// canvastext
|
|
color: { type: "keyword", value: "black" },
|
|
"column-gap": {
|
|
type: "unit",
|
|
value: 0,
|
|
unit: "px",
|
|
},
|
|
"row-gap": {
|
|
type: "unit",
|
|
value: 0,
|
|
unit: "px",
|
|
},
|
|
"background-size": autoValue,
|
|
"text-size-adjust": autoValue,
|
|
} as const;
|
|
|
|
const beautifyKeyword = (_property: string, keyword: string) => {
|
|
/*
|
|
* The default value is `invert` or `currentcolor` for some css properties.
|
|
* But that isn't supported in all browsers, example outline-color.
|
|
* So, going with currentColor for consistency.
|
|
* https://developer.mozilla.org/en-US/docs/Web/CSS/outline-color#formal_definition
|
|
*/
|
|
if (keyword === "currentcolor" || keyword === "invertOrCurrentColor") {
|
|
return "currentColor";
|
|
}
|
|
return keyword;
|
|
};
|
|
|
|
const convertToStyleValue = (
|
|
node: CssNode,
|
|
property: string,
|
|
unitGroups: Set<string>
|
|
): undefined | UnitValue | KeywordValue | UnparsedValue => {
|
|
if (node?.type === "Identifier") {
|
|
return {
|
|
type: "keyword",
|
|
value: beautifyKeyword(property, node.name),
|
|
};
|
|
}
|
|
if (node?.type === "Number") {
|
|
let unit: Unit = "number";
|
|
// set explicit unit when 0 initial is specified without unit
|
|
if (unitGroups.has("number") === false) {
|
|
if (unitGroups.has("length")) {
|
|
unit = "px";
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
return {
|
|
type: "unit",
|
|
unit,
|
|
value: Number(node.value),
|
|
};
|
|
}
|
|
if (node?.type === "Percentage") {
|
|
return {
|
|
type: "unit",
|
|
unit: "%",
|
|
value: Number(node.value),
|
|
};
|
|
}
|
|
if (node?.type === "Dimension") {
|
|
return {
|
|
type: "unit",
|
|
unit: node.unit as Unit,
|
|
value: Number(node.value),
|
|
};
|
|
}
|
|
};
|
|
|
|
const parseInitialValue = (
|
|
property: string,
|
|
value: string,
|
|
unitGroups: Set<string>
|
|
): StyleValue => {
|
|
// Our default values hardcoded because no single standard
|
|
if (property in normalizedValues) {
|
|
return normalizedValues[property as keyof typeof normalizedValues];
|
|
}
|
|
const ast = parse(value, { context: "value" });
|
|
if (ast.type !== "Value") {
|
|
throw Error(`Unknown parsed type ${ast.type}`);
|
|
}
|
|
|
|
// more than 2 values consider as keyword
|
|
if (ast.children.first !== ast.children.last) {
|
|
const values: Array<
|
|
Exclude<ReturnType<typeof convertToStyleValue>, undefined>
|
|
> = [];
|
|
for (const node of ast.children) {
|
|
const styleValue = convertToStyleValue(node, property, unitGroups);
|
|
if (styleValue === undefined) {
|
|
return { type: "unparsed", value };
|
|
}
|
|
values.push(styleValue);
|
|
}
|
|
return {
|
|
type: "tuple",
|
|
value: values,
|
|
};
|
|
}
|
|
|
|
const node = ast.children.first;
|
|
let styleValue: undefined | StyleValue;
|
|
if (node) {
|
|
styleValue = convertToStyleValue(node, property, unitGroups);
|
|
}
|
|
if (styleValue !== undefined) {
|
|
return styleValue;
|
|
}
|
|
|
|
return { type: "unparsed", value };
|
|
};
|
|
|
|
const walkSyntax = (
|
|
syntax: string,
|
|
enter: (node: DSNode) => void,
|
|
parsedSyntaxes = new Set<string>()
|
|
) => {
|
|
// fix cyclic syntaxes
|
|
if (parsedSyntaxes.has(syntax)) {
|
|
return;
|
|
}
|
|
parsedSyntaxes.add(syntax);
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore @todo add missing type defitions for definitionSyntax
|
|
const parsed = definitionSyntax.parse(syntax);
|
|
|
|
const walk = (node: DSNode) => {
|
|
if (node.type === "Group") {
|
|
for (const term of node.terms) {
|
|
// skip functions and their content as complex values
|
|
if (term.type === "Function") {
|
|
break;
|
|
}
|
|
walk(term);
|
|
}
|
|
return;
|
|
}
|
|
if (node.type === "Multiplier") {
|
|
walk(node.term);
|
|
return;
|
|
}
|
|
if (node.type === "Type") {
|
|
if (node.name === "deprecated-system-color") {
|
|
return;
|
|
}
|
|
const nestedSyntax = syntaxes[node.name as keyof typeof syntaxes]?.syntax;
|
|
if (nestedSyntax === undefined) {
|
|
enter(node);
|
|
} else {
|
|
// resolve nested syntaxes
|
|
walkSyntax(nestedSyntax, enter, parsedSyntaxes);
|
|
}
|
|
return;
|
|
}
|
|
if (node.type === "Property") {
|
|
// resolve other properties references
|
|
if (node.name in properties) {
|
|
walkSyntax(
|
|
properties[node.name as Property].syntax,
|
|
enter,
|
|
parsedSyntaxes
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
enter(node);
|
|
};
|
|
|
|
walk(parsed);
|
|
};
|
|
|
|
const autogeneratedHint = "// This file was generated by pnpm mdn-data\n";
|
|
|
|
const writeToFile = (fileName: string, constant: string, data: unknown) => {
|
|
const content =
|
|
autogeneratedHint +
|
|
`export const ${constant} = ` +
|
|
JSON.stringify(data, null, 2) +
|
|
" as const;";
|
|
|
|
writeFileSync(join(targetDir, fileName), content, "utf8");
|
|
};
|
|
|
|
// Most shorthands are identifiable in MDN data because their `initial` value
|
|
// is an array of subproperties. These additional shorthands still expand into
|
|
// multiple longhands, but MDN models them with a scalar initial value like
|
|
// `normal`, so we need to include them explicitly.
|
|
const additionalShorthandProperties = [
|
|
"font-synthesis",
|
|
"font-variant",
|
|
"text-wrap",
|
|
"white-space",
|
|
];
|
|
|
|
// Properties we don't support in this form.
|
|
const unsupportedProperties = [
|
|
"--*",
|
|
// shorthand properties
|
|
"all",
|
|
"overflow",
|
|
"background-position",
|
|
"border-block-style",
|
|
"border-block-width",
|
|
"border-block-color",
|
|
"border-inline-style",
|
|
"border-inline-width",
|
|
"border-inline-color",
|
|
];
|
|
|
|
type FilteredProperties = { [property: string]: Value };
|
|
|
|
const filterData = () => {
|
|
let property: Property;
|
|
const allLonghands = {} as FilteredProperties;
|
|
const allShorthands = {} as FilteredProperties;
|
|
const animatableLonghands = {} as FilteredProperties;
|
|
const animatableShorthands = {} as FilteredProperties;
|
|
|
|
for (property in properties) {
|
|
if (unsupportedProperties.includes(property)) {
|
|
continue;
|
|
}
|
|
|
|
const config = properties[property];
|
|
|
|
const isAnimatableProperty =
|
|
property.startsWith("-") === false &&
|
|
config.animationType !== "discrete" &&
|
|
config.animationType !== "notAnimatable";
|
|
|
|
const isShorthandProperty =
|
|
Array.isArray(config.initial) ||
|
|
additionalShorthandProperties.includes(property);
|
|
|
|
if (isShorthandProperty) {
|
|
allShorthands[property] = config;
|
|
if (isAnimatableProperty) {
|
|
animatableShorthands[property] = config;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
allLonghands[property] = config;
|
|
if (isAnimatableProperty) {
|
|
animatableLonghands[property] = config;
|
|
}
|
|
}
|
|
|
|
return {
|
|
allLonghands,
|
|
allShorthands,
|
|
animatableLonghands,
|
|
animatableShorthands,
|
|
};
|
|
};
|
|
|
|
const getPropertiesData = (
|
|
customPropertiesData: typeof customData.propertiesData,
|
|
filteredProperties: FilteredProperties
|
|
) => {
|
|
const propertiesData = { ...customPropertiesData };
|
|
|
|
let property: string;
|
|
for (property in filteredProperties) {
|
|
const config = filteredProperties[property];
|
|
const unitGroups = new Set<string>();
|
|
walkSyntax(config.syntax, (node) => {
|
|
if (node.type === "Type") {
|
|
if (node.name === "number-token" || node.name === "number") {
|
|
unitGroups.add("number");
|
|
return;
|
|
}
|
|
|
|
// type names match unit groups
|
|
if (node.name in units) {
|
|
unitGroups.add(node.name);
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (Array.isArray(config.initial)) {
|
|
throw new Error(
|
|
`Property ${property} contains non string initial value ${config.initial.join(
|
|
", "
|
|
)}`
|
|
);
|
|
}
|
|
|
|
propertiesData[property] = {
|
|
unitGroups: Array.from(unitGroups),
|
|
inherited: config.inherited,
|
|
initial: parseInitialValue(property, config.initial, unitGroups),
|
|
mdnUrl: getMdnUrl(property, config),
|
|
};
|
|
}
|
|
|
|
return propertiesData;
|
|
};
|
|
|
|
const pseudoElements = Object.keys(selectors)
|
|
.filter((selector) => {
|
|
return selector.startsWith("::");
|
|
})
|
|
.map((selector) => selector.slice(2));
|
|
|
|
const pseudoClasses = Object.keys(selectors)
|
|
.filter((selector) => {
|
|
return selector.startsWith(":") && !selector.startsWith("::");
|
|
})
|
|
.map((selector) => selector.slice(1));
|
|
|
|
const getKeywordValues = (filteredProperties: FilteredProperties) => {
|
|
const result = { ...customData.keywordValues };
|
|
// https://www.w3.org/TR/css-values/#common-keywords
|
|
const commonKeywords = ["initial", "inherit", "unset"];
|
|
|
|
for (const property in filteredProperties) {
|
|
// prevent merging with custom keywords
|
|
if (result[property]) {
|
|
continue;
|
|
}
|
|
const keywords = new Set<string>();
|
|
walkSyntax(filteredProperties[property].syntax, (node) => {
|
|
if (node.type === "Keyword") {
|
|
keywords.add(beautifyKeyword(property, node.name));
|
|
}
|
|
});
|
|
|
|
for (const commonKeyword of commonKeywords) {
|
|
// Delete to add commonKeyword at the end of the set
|
|
keywords.delete(commonKeyword);
|
|
keywords.add(commonKeyword);
|
|
}
|
|
|
|
result[property] = [...(result[property] ?? []), ...keywords];
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const getTypes = (propertiesData: typeof customData.propertiesData) => {
|
|
let types = "";
|
|
|
|
const camelCasedProperties = Object.keys(propertiesData).map((property) =>
|
|
JSON.stringify(camelCaseProperty(property as CssProperty))
|
|
);
|
|
types += `export type CamelCasedProperty = ${camelCasedProperties.join(" | ")};\n\n`;
|
|
const hyphenatedProperties = Object.keys(propertiesData).map((property) =>
|
|
JSON.stringify(property)
|
|
);
|
|
types += `export type HyphenatedProperty = ${hyphenatedProperties.join(" | ")};\n\n`;
|
|
|
|
const unitLiterals = Object.values(units)
|
|
.flat()
|
|
.map((unit) => JSON.stringify(unit));
|
|
types += `export type Unit = ${unitLiterals.join(" | ")};\n`;
|
|
|
|
return types;
|
|
};
|
|
|
|
const filteredData = filterData();
|
|
|
|
const getLonghandInitials = (
|
|
property: Property,
|
|
seen = new Set<Property>()
|
|
): Array<[property: string, initial: string]> => {
|
|
if (seen.has(property)) {
|
|
return [];
|
|
}
|
|
const config = properties[property];
|
|
if (Array.isArray(config.initial) === false) {
|
|
return [[property, config.initial]];
|
|
}
|
|
const nextSeen = new Set(seen).add(property);
|
|
return config.initial.flatMap((longhand) =>
|
|
getLonghandInitials(longhand as Property, nextSeen)
|
|
);
|
|
};
|
|
|
|
const getSyntaxShorthandInitials = (property: Property) => {
|
|
const initials = getLonghandInitials(property);
|
|
const propertySyntax = lexer.getProperty(property);
|
|
if (propertySyntax === null) {
|
|
return [];
|
|
}
|
|
|
|
const references = new Set<string>();
|
|
definitionSyntax.walk(propertySyntax.syntax, (node) => {
|
|
if (node.type === "Property" && "name" in node) {
|
|
references.add(node.name);
|
|
}
|
|
});
|
|
if (
|
|
references.size !== initials.length ||
|
|
initials.some(([longhand]) => references.has(longhand) === false)
|
|
) {
|
|
return [];
|
|
}
|
|
return initials;
|
|
};
|
|
|
|
const longhandPropertiesData = getPropertiesData(
|
|
customData.propertiesData,
|
|
filteredData.allLonghands
|
|
);
|
|
|
|
const targetDir = join(process.cwd(), process.argv.slice(2).pop() as string);
|
|
mkdirSync(targetDir, { recursive: true });
|
|
writeToFile("units.ts", "units", units);
|
|
|
|
const propertiesData = Object.fromEntries(
|
|
Object.entries(longhandPropertiesData).map(([property, data]) => [
|
|
property,
|
|
data,
|
|
])
|
|
);
|
|
const propertiesContent = `${autogeneratedHint}
|
|
import type { __HyphenatedProperty, StyleValue } from "@webstudio-is/css-engine"
|
|
type UnitGroup = ${Object.keys(units)
|
|
.map((item) => JSON.stringify(item))
|
|
.join(" | ")}
|
|
type PropertyData = {
|
|
unitGroups: UnitGroup[],
|
|
inherited: boolean,
|
|
initial: StyleValue,
|
|
mdnUrl?: string
|
|
}
|
|
type Properties =
|
|
Record<__HyphenatedProperty, PropertyData> &
|
|
Record<\`--\${string}\`, undefined>
|
|
export const properties: Properties = ${JSON.stringify(propertiesData, null, 2)}
|
|
`;
|
|
writeFileSync(join(targetDir, "properties.ts"), propertiesContent);
|
|
|
|
const keywordValues = Object.fromEntries(
|
|
Object.entries(getKeywordValues(filteredData.allLonghands)).map(
|
|
([property, data]) => [property, data]
|
|
)
|
|
);
|
|
const keywordValuesContent = `${autogeneratedHint}
|
|
import type { __HyphenatedProperty } from "@webstudio-is/css-engine"
|
|
type KeywordValues =
|
|
Record<__HyphenatedProperty, string[]> &
|
|
Record<\`--\${string}\`, undefined>
|
|
export const keywordValues: KeywordValues = ${JSON.stringify(keywordValues, null, 2)}
|
|
`;
|
|
writeFileSync(join(targetDir, "keyword-values.ts"), keywordValuesContent);
|
|
|
|
writeToFile(
|
|
"shorthand-properties.ts",
|
|
"shorthandProperties",
|
|
Object.keys(filteredData.allShorthands)
|
|
);
|
|
writeToFile(
|
|
"shorthand-expansions.ts",
|
|
"shorthandExpansions",
|
|
Object.fromEntries(
|
|
Object.entries(filteredData.allShorthands).flatMap(([property, config]) => {
|
|
if (Array.isArray(config.initial) === false) {
|
|
return [];
|
|
}
|
|
const initials = getSyntaxShorthandInitials(property as Property);
|
|
if (initials.length === 0) {
|
|
return [];
|
|
}
|
|
return [[property, Object.fromEntries(initials)]];
|
|
})
|
|
)
|
|
);
|
|
writeToFile(
|
|
"animatable-properties.ts",
|
|
"animatableProperties",
|
|
Object.keys(filteredData.animatableLonghands)
|
|
);
|
|
writeToFile(
|
|
"property-statuses.ts",
|
|
"propertyStatuses",
|
|
Object.fromEntries(
|
|
Object.entries({
|
|
...filteredData.allLonghands,
|
|
...filteredData.allShorthands,
|
|
}).map(([property, config]) => [property, config.status])
|
|
)
|
|
);
|
|
writeToFile("pseudo-elements.ts", "pseudoElements", pseudoElements);
|
|
writeToFile("pseudo-classes.ts", "pseudoClasses", pseudoClasses);
|
|
|
|
const typesFile = join(
|
|
process.cwd(),
|
|
"../css-engine/src/__generated__/types.ts"
|
|
);
|
|
mkdirSync(dirname(typesFile), { recursive: true });
|
|
writeFileSync(typesFile, autogeneratedHint + getTypes(longhandPropertiesData));
|