fix: Parse font shorthand with variable families (#6551)

## Summary

- Parse `font-size / line-height` structurally so a custom-property font
family does not swallow the line-height.
- Add parser, shorthand-expansion, and fragment-insertion regression
coverage.
- Document the semantic conversion sequence for composite media,
preserving wrappers while deleting obsolete provider parts.

Closes #6547
Closes #6544

## Verification

- `pnpm --filter @webstudio-is/css-data test -- src/shorthands.test.ts
src/parse-css.test.ts` — 4,326 passed, 1 skipped
- `pnpm --filter @webstudio-is/project-build test --
src/runtime/components.test.ts src/runtime/instances.test.ts` — 181
passed
- CSS data, project-build, and CLI typechecks
- CLI docs generation
- GitHub Actions: all workflows passed (including all six Builder E2E
shards)

## Checklist

- [x] Regression tests added for the reported behavior.
- [x] Docs impact reviewed; MCP manuals and generated docs updated.
- [x] Diff and generated documentation verified.
This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-10-17 02:21:05 +02:00
1 parent 37429b2aad
commit 2c20db0d01
8 files changed
+142 -7

No files matched your search

+2
View File
@@ -523,6 +523,8 @@ Use `node packages/cli/local.js mcp` from the Webstudio monorepo root for local
Conversion retains instance ids, children (including editable copy), styles, and most props; it removes a legacy `tag` prop and normalizes React prop aliases when converting to `ws:element`. Review component-specific props and behavior afterward: replacing Tabs with HTML does not recreate tab selection, keyboard behavior, or accessibility semantics. If a project requires the whole tree to change in one commit, use `snapshot` to read the current build version and exact records, then a single versioned `apply-patch` transaction for the existing instance component/tag fields. Preserve all existing ids and child references, include any needed prop changes in that same transaction, and verify the final tree.
For composite media such as Vimeo, inspect the player subtree and surrounding content first. A native `video` cannot retain provider-only parts such as `VimeoPlayButton`, `VimeoSpinner`, or `VimeoPreviewImage`. Use `delete-instance` with `instanceIds` containing the exact obsolete part ids (deleting a part also cleans its descendants and owned data), then `convert-instance` on the player root with `component: "ws:element"`, `tag: "video"`, and its current `instanceSelector`. Keep surrounding wrappers, captions, and other content outside the deletion set; move any content that must survive out of an obsolete part before deleting it. The player keeps its id and styles. Remove the provider-specific props with `delete-props`, using `{"deletions":[{"instanceId":"<player-id>","name":"url"}]}` for the old Vimeo URL prop and adding one entry for each other obsolete prop found during inspection. Then use `update-props` to set the native video source and playback props, and `update-styles` for any styling changes. A native video source must be a playable media URL or asset, not a Vimeo watch-page URL. These are separate semantic mutations; no raw instance patch is needed for this sequence.
## Vision Verification Loop
Vision-capable AI can use MCP to see what it is building:
+1 -1
View File
@@ -8,7 +8,7 @@ export const cliDocs = {
"manual-llm":
'# Webstudio CLI Manual for LLMs\n\nUse this order. Stop only when a command returns ok:false.\n\nIf you are inside the Webstudio monorepo, the first command discovery should use\nthe local CLI exactly as `node packages/cli/local.js ...` from the repo root. Do\nnot use `packages/cli/bin.js` for local source-tree work; it is the packaged\nbuild entry and may use stale built output. Do not use `pnpm exec webstudio`,\n`pnpm --filter webstudio exec webstudio`, or a global `webstudio`: they can\nresolve an older binary.\n\nFor delegated design-system or “use every component” tasks, skip the generic warm-up sequence and start with exactly one MCP command: `webstudio workflow.next \'{"goal":"design-system-page"}\'`. Report that returned checkpoint to the parent/user and stop until continued.\n\n## Use MCP locally or optionally connect a client\n\nDo not install, register, or connect an MCP server merely because the user asks\nyou to edit a Webstudio project. If Webstudio MCP tools are already available,\nuse them. If you have shell access, use the local CLI shortcuts such as\n`webstudio meta.index` and `webstudio list-pages`; they expose the same project\noperations without changing client configuration or restarting the app.\n\nOnly when the user explicitly asks for persistent native MCP integration, run\nthe command for their client:\n\n- Claude Code: `webstudio connect claude`\n- Codex: `webstudio connect codex`\n- Cursor: `webstudio connect cursor`\n- VS Code or GitHub Copilot: `webstudio connect vscode`\n\nRun project operations from the linked project root. If the folder is not\nlinked, ask for an editable Builder share link and run\n`webstudio init --link <share-link> --json`. You can then use local CLI\nshortcuts immediately. Do not run `webstudio sync`, `webstudio connect`, or\nrestart the app unless the user specifically wants native MCP registration:\nMCP reads and edits the latest editable Builder build directly, including for\nprojects that have never been published. Treat the share link as a credential\nand do not include it in committed files, logs, screenshots, or issue reports.\n\nThe optional `connect` command verifies project access before changing client configuration. For\nClaude Code, Cursor, and VS Code it safely merges the `webstudio` server into\nthe client\'s project configuration. For Codex it runs both `codex mcp add` and\n`codex mcp get webstudio`; do not repeat those commands separately. Follow the\nreload, restart, or approval instruction printed by `connect`, then verify the\nloaded MCP connection by asking the client to use Webstudio MCP and list the\nproject pages. Use `--print` only to inspect the generated setup without\nchanging configuration or requiring project access.\n\n## Always\n\n1. webstudio permissions --json\n2. For bounded shell workflows, call MCP tools directly through the CLI shortcut, for example `webstudio meta.index` or `webstudio insert-fragment \'<json>\' --dry-run`. The explicit form `webstudio mcp single-op-call <tool> \'<json>\'` is equivalent and useful when you need to make the MCP boundary obvious. Use `webstudio mcp run \'[{"tool":"components.find","input":{"brief":"button"}}]\'` for multiple calls in one shared CLI session. Use a normal JSON file path for large batches. Use long-running `webstudio mcp` only when your environment is a real MCP client. Do not manually send raw JSON-RPC to `webstudio mcp` from a shell or PTY.\n3. Read MCP `meta.index`, for example `webstudio meta.index`.\n4. Use focused MCP calls with concrete JSON: `webstudio meta.guide \'{"brief":"Create a design system page using every component"}\'`, `webstudio meta.get-more-tools \'{"tools":["insert-fragment"]}\'`, `webstudio components.list \'{"source":"all"}\'`, `webstudio components.coverage-plan`, `webstudio components.search \'{"brief":"radix select"}\'`, `webstudio components.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'`, `webstudio templates.list`, and `webstudio templates.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'`.\n5. Read overview resources `webstudio://project/tools-overview` or `webstudio://project/components-overview` when useful. Read full resources `webstudio://project/tools` or `webstudio://project/components` only when focused tools are insufficient.\n6. Pick focused MCP read tool.\n7. Pick semantic MCP write tool.\n\nUse `webstudio schema mcp` for a compact MCP tool overview. Add `--verbose` only when exact input schemas for all tools are truly needed; otherwise prefer focused `meta.get-more-tools` and `components.*` calls.\n\nRun these commands from the linked project root. Use the MCP startup status line\'s absolute root for local files; write temporary scripts and artifacts under `<project root>/.temp`, not under a parent workspace.\n\nMonorepo quick path for a simple styled section:\n\n```sh\nnode packages/cli/local.js mcp single-op-call meta.index\nnode packages/cli/local.js mcp single-op-call m Line truncated
"manual-mcp":
'# Webstudio MCP Manual\n\n`webstudio mcp` starts a stdio MCP server for real MCP clients. Shell users can call MCP tools with the shortcut form `webstudio <tool> \'<json>\'`, for example `webstudio meta.index` or `webstudio insert-fragment \'<json>\' --dry-run`. `webstudio mcp single-op-call` is the explicit equivalent and prints the structured JSON result. `webstudio mcp run` runs multiple MCP tool calls from inline JSON or a normal JSON file in one shared CLI session. Do not manually type or pipe raw JSON-RPC frames into `webstudio mcp` from an interactive shell or PTY.\n\n## Startup\n\nIf you are already working with a shell-capable agent, it can use the local CLI\ndirectly. Native MCP client registration is optional. Give the editable Builder\nshare link only when the trusted agent asks for it. Treat the share link as a\ncredential: do not include it in committed files, screenshots, logs, or issue\nreports.\n\n1. Configure a project with `webstudio init --link <api-share-link> --json`.\n2. Check capabilities with `webstudio permissions --json`.\n3. Use shortcut calls such as `webstudio meta.index` and `webstudio insert-fragment \'<json>\' --dry-run` for individual MCP tool calls. Use the explicit equivalent `webstudio mcp single-op-call <tool> \'<json>\'` when you need to force the MCP path, or `webstudio mcp run \'[{"tool":"components.find","input":{"brief":"button"}}]\'` for bounded multi-call workflows. Use `webstudio mcp run .temp/mcp-calls.json` for large batches.\n4. Start discovery with `meta.index`, then call focused tools with concrete JSON, for example `webstudio mcp single-op-call meta.guide \'{"brief":"Create a design system page using every component"}\'`.\n\nDo not run `webstudio sync`, install an MCP server, change client configuration,\nor restart the app for this local CLI workflow.\n\nWhen the user explicitly wants persistent native MCP integration, run\n`webstudio connect claude`, `webstudio connect codex`, `webstudio connect\ncursor`, or `webstudio connect vscode`. This optional command changes client\nconfiguration, so follow its client-specific reload or restart instruction.\nUse `--print` to inspect the generated setup without changing configuration or\nrequiring project access. For Codex, `connect` registers and verifies the server\nthrough the Codex CLI. Before changing client configuration, `connect` verifies\nthat the saved project endpoint is reachable and its credential is accepted.\n\nStart MCP from the linked Webstudio project root. The lifecycle status line prints that absolute root; create local scripts, screenshots, and temporary artifacts under that root, for example `<project root>/.temp/script.mjs`. If the shell starts in a parent workspace, `cd` into the project root first or use absolute paths.\n\nWhen developing inside the Webstudio monorepo, start the local CLI exactly as `node packages/cli/local.js mcp` from the repo root. Do not use `pnpm exec webstudio`, `pnpm --filter webstudio exec webstudio`, or a global `webstudio`: they can resolve an older binary.\n\nWhile the server is running, stdout is reserved for MCP JSON-RPC messages. Do not print human text from the server process. The server advertises MCP `logging` capability and emits sparse `notifications/message` logs for ready state and tool lifecycle checkpoints such as `tool preview.start started`, `tool preview.start still running after 10000ms`, and `tool preview.start succeeded in 1234ms`; stderr also mirrors these sparse lifecycle fallback lines prefixed with `[webstudio mcp]`.\n\n## One-Shot Tool Calls\n\n### Server-side Form simulation\n\n`forms.simulate-submission` evaluates a saved Form using synthetic string fields\nand the same trusted Builder server Form handler used by Builder Preview. Supply\nthe saved Form instance ID and page path. The server uses this project\'s\npublisher origin. It can trigger the Form\'s configured HTTP and Email Resources, so verify\nevery destination and recipient first. For dynamic checkbox or multiple-select fields,\npass `arrayNames` to preserve an empty or single-value array when the saved\nfield props do not identify the control. The result reports destination statuses\nand errors without Resource bodies. This server simulation does not exercise\nclient validation, browser event handlers, navigation, file bytes, or actual\nbrowser information. The Builder server loads the saved draft and holds the\nEmail service credentials; the CLI does not receive those credentials.\n\n```json\n{\n "path": "/contact",\n "formId": "saved-form-instance-id",\n "values": [{ "name": "email", "value": "synthetic@example.com" }]\n}\n```\n\nUse the shortcut `webstudio <tool> \'<json>\'` when you are operating from a shell and need one MCP tool result. The explicit form `webstudio mcp single-op-call <tool> \'<json>\'` is equivalent and avoids writing temporary Node.js stdio client scripts.\n\nExamples:\n\n```sh\nwebstudio mcp single-op-call meta.index\nwebstudio mcp single-op-caLine truncated
'# Webstudio MCP Manual\n\n`webstudio mcp` starts a stdio MCP server for real MCP clients. Shell users can call MCP tools with the shortcut form `webstudio <tool> \'<json>\'`, for example `webstudio meta.index` or `webstudio insert-fragment \'<json>\' --dry-run`. `webstudio mcp single-op-call` is the explicit equivalent and prints the structured JSON result. `webstudio mcp run` runs multiple MCP tool calls from inline JSON or a normal JSON file in one shared CLI session. Do not manually type or pipe raw JSON-RPC frames into `webstudio mcp` from an interactive shell or PTY.\n\n## Startup\n\nIf you are already working with a shell-capable agent, it can use the local CLI\ndirectly. Native MCP client registration is optional. Give the editable Builder\nshare link only when the trusted agent asks for it. Treat the share link as a\ncredential: do not include it in committed files, screenshots, logs, or issue\nreports.\n\n1. Configure a project with `webstudio init --link <api-share-link> --json`.\n2. Check capabilities with `webstudio permissions --json`.\n3. Use shortcut calls such as `webstudio meta.index` and `webstudio insert-fragment \'<json>\' --dry-run` for individual MCP tool calls. Use the explicit equivalent `webstudio mcp single-op-call <tool> \'<json>\'` when you need to force the MCP path, or `webstudio mcp run \'[{"tool":"components.find","input":{"brief":"button"}}]\'` for bounded multi-call workflows. Use `webstudio mcp run .temp/mcp-calls.json` for large batches.\n4. Start discovery with `meta.index`, then call focused tools with concrete JSON, for example `webstudio mcp single-op-call meta.guide \'{"brief":"Create a design system page using every component"}\'`.\n\nDo not run `webstudio sync`, install an MCP server, change client configuration,\nor restart the app for this local CLI workflow.\n\nWhen the user explicitly wants persistent native MCP integration, run\n`webstudio connect claude`, `webstudio connect codex`, `webstudio connect\ncursor`, or `webstudio connect vscode`. This optional command changes client\nconfiguration, so follow its client-specific reload or restart instruction.\nUse `--print` to inspect the generated setup without changing configuration or\nrequiring project access. For Codex, `connect` registers and verifies the server\nthrough the Codex CLI. Before changing client configuration, `connect` verifies\nthat the saved project endpoint is reachable and its credential is accepted.\n\nStart MCP from the linked Webstudio project root. The lifecycle status line prints that absolute root; create local scripts, screenshots, and temporary artifacts under that root, for example `<project root>/.temp/script.mjs`. If the shell starts in a parent workspace, `cd` into the project root first or use absolute paths.\n\nWhen developing inside the Webstudio monorepo, start the local CLI exactly as `node packages/cli/local.js mcp` from the repo root. Do not use `pnpm exec webstudio`, `pnpm --filter webstudio exec webstudio`, or a global `webstudio`: they can resolve an older binary.\n\nWhile the server is running, stdout is reserved for MCP JSON-RPC messages. Do not print human text from the server process. The server advertises MCP `logging` capability and emits sparse `notifications/message` logs for ready state and tool lifecycle checkpoints such as `tool preview.start started`, `tool preview.start still running after 10000ms`, and `tool preview.start succeeded in 1234ms`; stderr also mirrors these sparse lifecycle fallback lines prefixed with `[webstudio mcp]`.\n\n## One-Shot Tool Calls\n\n### Server-side Form simulation\n\n`forms.simulate-submission` evaluates a saved Form using synthetic string fields\nand the same trusted Builder server Form handler used by Builder Preview. Supply\nthe saved Form instance ID and page path. The server uses this project\'s\npublisher origin. It can trigger the Form\'s configured HTTP and Email Resources, so verify\nevery destination and recipient first. For dynamic checkbox or multiple-select fields,\npass `arrayNames` to preserve an empty or single-value array when the saved\nfield props do not identify the control. The result reports destination statuses\nand errors without Resource bodies. This server simulation does not exercise\nclient validation, browser event handlers, navigation, file bytes, or actual\nbrowser information. The Builder server loads the saved draft and holds the\nEmail service credentials; the CLI does not receive those credentials.\n\n```json\n{\n "path": "/contact",\n "formId": "saved-form-instance-id",\n "values": [{ "name": "email", "value": "synthetic@example.com" }]\n}\n```\n\nUse the shortcut `webstudio <tool> \'<json>\'` when you are operating from a shell and need one MCP tool result. The explicit form `webstudio mcp single-op-call <tool> \'<json>\'` is equivalent and avoids writing temporary Node.js stdio client scripts.\n\nExamples:\n\n```sh\nwebstudio mcp single-op-call meta.index\nwebstudio mcp single-op-caLine truncated
"mcp-startup-epilogue":
'If you are inside the Webstudio monorepo, use the local CLI exactly as `node packages/cli/local.js ...` as your first command path. Do not use `packages/cli/bin.js` for local source-tree work; it is the packaged build entry and may use stale built output.\n\nPlain `webstudio mcp` starts the stdio MCP server for real MCP clients. Do not manually type or pipe raw JSON-RPC frames into it from an interactive shell or PTY. From a shell, use shortcut calls such as `webstudio meta.index` and `webstudio insert-fragment \'<json>\' --dry-run` for one bounded tool call. The explicit equivalent is `webstudio mcp single-op-call <tool> \'<json>\'`. Use `webstudio mcp run \'[{"tool":"components.find","input":{"brief":"button"}}]\'` for small multi-call batches in one shared CLI session. For large batches, pass a normal JSON file path such as `.temp/mcp-calls.json`. Do not use shell process substitution like `<(...)`; use inline JSON or a real file.\n\nPut each local CLI call in its own shell command; do not chain helper commands with `&&`, `;`, command substitution, or shell wrappers around the CLI when reporting a CLI step. Do not use `pnpm exec webstudio`, `pnpm --filter webstudio exec webstudio`, or a global `webstudio`; those can resolve an older binary. For example:\n\n```sh\nnode packages/cli/local.js mcp single-op-call meta.index\nnode packages/cli/local.js mcp single-op-call meta.get-more-tools \'{"tools":["insert-fragment"]}\'\nnode packages/cli/local.js mcp single-op-call insert-fragment --input-file .temp/insert-fragment.json --dry-run\n```\n\nSave the input as `.temp/insert-fragment.json`; use single quotes for JSX attributes so the JSON needs no backslash escaping:\n\n```json\n{\n "parentInstanceId": "parent-id",\n "fragment": "<section ws:style={css`padding: 32px; display: grid; gap: 12px;`}><h2>Launch Kit</h2><p>A focused section created with Webstudio JSX.</p><button>Get started</button></section>"\n}\n```\n\nThe shorter local shortcut form is preferred for simple shell steps:\n\n```sh\nnode packages/cli/local.js meta.index\nnode packages/cli/local.js meta.get-more-tools \'{"tools":["insert-fragment"]}\'\nnode packages/cli/local.js insert-fragment --input-file .temp/insert-fragment.json --dry-run\n```\n\nFor simple authored/styled sections, run the three commands above. Do not grep source files, dump full MCP resources, or write parser scripts first. Use `list-pages`, `get-page-by-path`, or `list-instances` only when you still need the target `parentInstanceId`.\n\nWhen building for a Content-mode editor, use a Content Block (`ws:block`) around every region that editor must change. Content-mode text and supported props are editable only in Content Block descendants; content outside is read-only. Keep reusable insertable source options in exactly one direct `ws:block-template` child. A missing or second template container makes the block invalid. Templates themselves are protected; an editor\'s inserted copy becomes editable Content Block body content. Verify this structure before handoff.\n\nFor an MDX-backed Content Block, use the dedicated content-source tools instead of generic prop mutations: connect, switch, inspect, edit, update frontmatter, reload, or disconnect the source. Confirm before replacing existing Body content. Prefer Markdown for standard document content; it automatically uses unique matching semantic templates. Use lowercase JSX such as `<section>` or `<svg>` for HTML or SVG that Markdown cannot express. Capitalized JSX resolves a unique stable template **Name** or a built-in MDX adapter. Add custom components to this Content Block\'s Templates before referencing them in MDX. The display label is independent. JSX attributes accept quoted values and bare booleans; expressions such as `{false}` are unsupported. Legacy `ws.element` and `ws:name` forms are compatibility input only. Component namespaces such as `$.*`, `radix.*`, and `animation.*` are unsupported; use direct component identifiers. Matching explicit children overlay designed descendants and keep template styles. A mismatched child structure replaces defaults, an explicit empty pair clears defaults, and a self-closing reference keeps them. Editing inherited default content writes it back as explicit JSX children. Template resolution is live when matching templates are added later. Preserve invalid source and unresolved names, inspect all diagnostics, and reload before retrying a revision conflict. A frontmatter update replaces the complete property map; preserve fields the user did not ask to remove. Store frontmatter images as exact `$ref` objects; bind editable Image sources to `.src` with explicit `binding.mode:"readwrite"` and alt properties to the Asset `.description` with `mode:"read"`. Omitting the source mode leaves the image visible but not replaceable in Content mode. Only exact direct frontmatter bindings may use `mode:"readwrite"`. After a template rename or deletion, use `migrate-content-block-template-refe Line truncated
"mcp-vision":
+2
View File
@@ -498,6 +498,8 @@ Use `node packages/cli/local.js mcp` from the Webstudio monorepo root for local
Conversion retains instance ids, children (including editable copy), styles, and most props; it removes a legacy `tag` prop and normalizes React prop aliases when converting to `ws:element`. Review component-specific props and behavior afterward: replacing Tabs with HTML does not recreate tab selection, keyboard behavior, or accessibility semantics. If a project requires the whole tree to change in one commit, use `snapshot` to read the current build version and exact records, then a single versioned `apply-patch` transaction for the existing instance component/tag fields. Preserve all existing ids and child references, include any needed prop changes in that same transaction, and verify the final tree.
For composite media such as Vimeo, inspect the player subtree and surrounding content first. A native `video` cannot retain provider-only parts such as `VimeoPlayButton`, `VimeoSpinner`, or `VimeoPreviewImage`. Use `delete-instance` with `instanceIds` containing the exact obsolete part ids (deleting a part also cleans its descendants and owned data), then `convert-instance` on the player root with `component: "ws:element"`, `tag: "video"`, and its current `instanceSelector`. Keep surrounding wrappers, captions, and other content outside the deletion set; move any content that must survive out of an obsolete part before deleting it. The player keeps its id and styles. Remove the provider-specific props with `delete-props`, using `{"deletions":[{"instanceId":"<player-id>","name":"url"}]}` for the old Vimeo URL prop and adding one entry for each other obsolete prop found during inspection. Then use `update-props` to set the native video source and playback props, and `update-styles` for any styling changes. A native video source must be a playable media URL or asset, not a Vimeo watch-page URL. These are separate semantic mutations; no raw instance patch is needed for this sequence.
## Vision Verification Loop
Vision-capable AI can use MCP to see what it is building:
+28
View File
@@ -9,6 +9,34 @@ import {
import { propertyVarTestFixtures } from "./__generated__/property-var-test-fixtures";
describe("Parse CSS", () => {
test.each([
["16px/1.5", "16px", "1.5", "var(--font-body)"],
["16px / 24px", "16px", "24px", "var(--font-body)"],
["16px/var(--leading)", "16px", "var(--leading)", "var(--font-body)"],
["16px", "16px", "initial", "var(--font-body)"],
])(
"font shorthand %s %s %s %s matches explicit longhands",
(sizeAndHeight, size, height, family) => {
const shorthand = parseCss(
`.test { font: 600 ${sizeAndHeight} ${family} }`,
new Map()
);
const longhands = parseCss(
`.test {
font-style: initial;
font-variant-caps: initial;
font-weight: 600;
font-stretch: initial;
font-size: ${size};
line-height: ${height};
font-family: ${family};
}`,
new Map()
);
expect(shorthand.styles).toEqual(longhands.styles);
}
);
test("detects rules unsupported by template CSS", () => {
expect(hasUnsupportedCssTemplateRules("color: red")).toBe(false);
expect(
+14
View File
@@ -524,6 +524,20 @@ test("expand font with css-wide keywords", () => {
]);
});
test("expand font with variable family and fallback", () => {
expect(
expandShorthands([["font", "600 16px/1.5 var(--font-body, sans-serif)"]])
).toEqual([
["font-style", "initial"],
["font-variant-caps", "initial"],
["font-weight", "600"],
["font-stretch", "initial"],
["font-size", "16px"],
["line-height", "1.5"],
["font-family", "var(--font-body, sans-serif)"],
]);
});
test("expand font-synthesis", () => {
expect(expandShorthands([["font-synthesis", `none`]])).toEqual([
["font-synthesis-weight", "none"],
+6 -6
View File
@@ -395,17 +395,17 @@ const expandFont = function* (value: CssNode) {
let lineHeight: CssNode = createInitialNode();
let fontFamily: CssNode = createInitialNode();
if (config) {
if (
lexer.match("<'font-size'> / <'line-height'> <'font-family'>#", config)
.matched
) {
const nodes = getValueList(config);
// Match the separator structurally: the lexer cannot match a grammar
// containing var(), even when size, line-height, and family are unambiguous.
if (nodes[1]?.type === "Operator" && nodes[1].value === "/") {
const [fontSizeNode, _slashNode, lineHeightNode, ...fontFamilyNodes] =
getValueList(config);
nodes;
fontSize = fontSizeNode;
lineHeight = lineHeightNode;
fontFamily = createValueNode(fontFamilyNodes);
} else {
const [fontSizeNode, ...fontFamilyNodes] = getValueList(config);
const [fontSizeNode, ...fontFamilyNodes] = nodes;
fontSize = fontSizeNode;
fontFamily = createValueNode(fontFamilyNodes);
}
@@ -1687,6 +1687,30 @@ test("explains unmatched jsx without implying session state", async () => {
);
});
test("inserts font shorthand with separate line-height and variable family", async () => {
const parent = createParent();
const fragment = await parseWebstudioJsxFragment(
'<ws.element ws:tag="p" ws:style={css`font: 600 16px/1.5 var(--font-body);`} />'
);
const mutation = insertFragment(
createState(parent),
{ parentInstanceId: parent.id, fragment },
{ createId: createIdFactory(), projectId: "project-id" }
);
expect(getAddedValues<StyleDecl>(mutation, "styles")).toEqual(
expect.arrayContaining([
expect.objectContaining({
property: "lineHeight",
value: { type: "unit", unit: "number", value: 1.5 },
}),
expect.objectContaining({
property: "fontFamily",
value: { type: "var", value: "font-body" },
}),
])
);
});
test("inserts bare template-backed components from webstudio jsx fragments", async () => {
const parent = createParent();
const fragment = await parseWebstudioJsxFragment(`<Switch />`);
@@ -1234,6 +1234,71 @@ describe("canConvertInstance", () => {
});
describe("convertInstance", () => {
test("converts composite media after semantically deleting obsolete provider parts", () => {
const instances = new Map([
[
"body",
createInstance("body", "Body", [{ type: "id", value: "wrapper" }]),
],
[
"wrapper",
createInstance("wrapper", "Box", [
{ type: "id", value: "player" },
{ type: "id", value: "caption" },
]),
],
[
"player",
createInstance("player", "Vimeo", [{ type: "id", value: "play" }]),
],
["play", createInstance("play", "VimeoPlayButton", [])],
[
"caption",
createInstance("caption", "Text", [{ type: "text", value: "Caption" }]),
],
]);
const state = {
instances,
props: new Map<string, Prop>(),
dataSources: new Map<string, DataSource>(),
styleSources: new Map<string, StyleSource>(),
styleSourceSelections: new Map<string, StyleSourceSelection>(),
styles: new Map<string, StyleDecl>(),
};
const input = {
instanceSelector: ["player", "wrapper", "body"],
component: elementComponent,
tag: "video",
};
expect(() => convertInstance(state, input, runtimeContext)).toThrow(
"Converted tree violates content model"
);
const removal = deleteInstances(state, { instanceIds: ["play"] });
applyBuilderPatchPayloadMutable(
(namespace) => state[namespace as keyof typeof state],
removal.payload
);
const conversion = convertInstance(state, input, runtimeContext);
applyBuilderPatchPayloadMutable(
(namespace) => state[namespace as keyof typeof state],
conversion.payload
);
expect(instances.get("player")).toMatchObject({
id: "player",
component: elementComponent,
tag: "video",
children: [],
});
expect(instances.has("play")).toBe(false);
expect(instances.get("wrapper")?.children).toEqual([
{ type: "id", value: "player" },
{ type: "id", value: "caption" },
]);
expect(instances.get("caption")?.children).toEqual([
{ type: "text", value: "Caption" },
]);
});
test("converts a Tabs tree leaf first without losing content or props", () => {
const radix = "@webstudio-is/sdk-components-react-radix:";
const instances = new Map([