From c7a59f0232b27f330784219abdf13831b61869d7 Mon Sep 17 00:00:00 2001 From: Paule Herman Date: Wed, 22 Oct 2025 10:14:27 +0200 Subject: [PATCH 1/8] Add(tailwind): support for twig components --- packages/backend/src/tailwind/tailwindMain.ts | 68 +++++++++++++++++++ .../plugin-ui/src/codegenPreferenceOptions.ts | 1 + packages/types/src/types.ts | 2 +- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index b668ef1a..3a10c479 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -172,6 +172,11 @@ const tailwindFrame = async ( node: FrameNode | InstanceNode | ComponentNode | ComponentSetNode, settings: TailwindSettings, ): Promise => { + // Check if this is an instance and should be rendered as a Twig component + if (node.type === "INSTANCE" && settings.tailwindGenerationMode === "twig") { + return tailwindComponentInstance(node, settings); + } + const childrenStr = await tailwindWidgetGenerator(node.children, settings); const clipsContentClass = @@ -192,6 +197,69 @@ const tailwindFrame = async ( return tailwindContainer(node, childrenStr, combinedProps, settings); }; + +// Helper function to generate Twig component syntax for component instances +const tailwindComponentInstance = async ( + node: InstanceNode, + settings: TailwindSettings, +): Promise => { + // Extract component name from the instance + const componentName = extractComponentName(node); + + // Get component properties if needed + const builder = new TailwindDefaultBuilder(node, settings) + // .commonPositionStyles() + // .commonShapeStyles() + ; + + const attrs: string[] = ['']; + + for (const prop in node.componentProperties) { + const cleanName = prop + .split("#")[0] + .replace(/\s+/g, "-") + .toLowerCase() + const attr = `${cleanName}="${node.componentProperties[prop]?.value}"`; + attrs.push(attr); + } + + + const attributes = builder.build(); + + // If we have children, process them + let childrenStr = ""; + if (node.children && node.children.length > 0) { + childrenStr = await tailwindWidgetGenerator(node.children.filter((n) => n.type === "INSTANCE"), settings); + return `\n${indentString(childrenStr)}\n`; + } else { + // Self-closing tag if no children + return `\n`; + } +}; + +// Helper function to extract component name from an instance +const extractComponentName = (node: InstanceNode): string => { + // Try to get name from mainComponent if available + if (node.mainComponent) { + const name = node.mainComponent.name; + // Convert component name to PascalCase for Twig component naming convention + return toPascalCase(name); + } + + // Fallback to node name if mainComponent is not available + return toPascalCase(node.name); +}; + +// Helper function to convert string to PascalCase +const toPascalCase = (str: string): string => { + // Remove any non-alphanumeric characters and split by spaces, underscores, or dashes + return str + .replace(/[^\w\s-]/g, '') + .split(/[\s_-]+/) + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(''); +}; + export const tailwindContainer = ( node: SceneNode & SceneNodeMixin & diff --git a/packages/plugin-ui/src/codegenPreferenceOptions.ts b/packages/plugin-ui/src/codegenPreferenceOptions.ts index 4b6a98a9..8db85935 100644 --- a/packages/plugin-ui/src/codegenPreferenceOptions.ts +++ b/packages/plugin-ui/src/codegenPreferenceOptions.ts @@ -83,6 +83,7 @@ export const selectPreferenceOptions: SelectPreferenceOptions[] = [ options: [ { label: "HTML", value: "html" }, { label: "React (JSX)", value: "jsx" }, + { label: "Twig", value: "twig" }, ], includedLanguages: ["Tailwind"], }, diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index eb76dbd7..3f1bb0c6 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -9,7 +9,7 @@ export interface HTMLSettings { htmlGenerationMode: "html" | "jsx" | "styled-components" | "svelte"; } export interface TailwindSettings extends HTMLSettings { - tailwindGenerationMode: "html" | "jsx"; + tailwindGenerationMode: "html" | "jsx" | "twig"; roundTailwindValues: boolean; roundTailwindColors: boolean; useColorVariables: boolean; From df91fbdaf51d576868883af1f82ad4c1b68319b9 Mon Sep 17 00:00:00 2001 From: Paule Herman Date: Wed, 22 Oct 2025 11:31:51 +0200 Subject: [PATCH 2/8] Support twig component content & embbed --- packages/backend/src/tailwind/tailwindMain.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index 3a10c479..51bb15e6 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -174,7 +174,7 @@ const tailwindFrame = async ( ): Promise => { // Check if this is an instance and should be rendered as a Twig component if (node.type === "INSTANCE" && settings.tailwindGenerationMode === "twig") { - return tailwindComponentInstance(node, settings); + return tailwindTwigComponentInstance(node, settings); } const childrenStr = await tailwindWidgetGenerator(node.children, settings); @@ -199,7 +199,7 @@ const tailwindFrame = async ( // Helper function to generate Twig component syntax for component instances -const tailwindComponentInstance = async ( +const tailwindTwigComponentInstance = async ( node: InstanceNode, settings: TailwindSettings, ): Promise => { @@ -212,7 +212,7 @@ const tailwindComponentInstance = async ( // .commonShapeStyles() ; - const attrs: string[] = ['']; + const twigComponentProperties: string[] = ['']; for (const prop in node.componentProperties) { const cleanName = prop @@ -220,23 +220,35 @@ const tailwindComponentInstance = async ( .replace(/\s+/g, "-") .toLowerCase() const attr = `${cleanName}="${node.componentProperties[prop]?.value}"`; - attrs.push(attr); + twigComponentProperties.push(attr); } - const attributes = builder.build(); + const attributes = builder.build() + twigComponentProperties.join(' '); // If we have children, process them let childrenStr = ""; - if (node.children && node.children.length > 0) { - childrenStr = await tailwindWidgetGenerator(node.children.filter((n) => n.type === "INSTANCE"), settings); - return `\n${indentString(childrenStr)}\n`; + + const embeddableChildren = node.children ? node.children.filter((n) => isVisibleComponent(n) || isTwigContentFrame(n)) : []; + + if (embeddableChildren.length > 0) { + // We keep embedded components and Frame named "TwigContent" + childrenStr = await tailwindWidgetGenerator(embeddableChildren, settings); + return `\n${indentString(childrenStr)}\n`; } else { // Self-closing tag if no children - return `\n`; + return `\n`; } }; +const isVisibleComponent = (node: SceneNode): boolean => { + return node.type === "INSTANCE" && (node.name[0] !== '.' && node.name[0] !== '_'); +} + +const isTwigContentFrame = (node: SceneNode): boolean => { + return node.type === "FRAME" && node.name === "TwigContent"; +} + // Helper function to extract component name from an instance const extractComponentName = (node: InstanceNode): string => { // Try to get name from mainComponent if available From 94e9aaeef93eb04cd8d14da2c0b945846a956c9a Mon Sep 17 00:00:00 2001 From: Paule Herman Date: Thu, 23 Oct 2025 10:16:21 +0200 Subject: [PATCH 3/8] Add(twig): handle content in component --- packages/backend/src/tailwind/tailwindMain.ts | 6 +++--- packages/plugin-ui/src/codegenPreferenceOptions.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index 51bb15e6..3490e5ef 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -173,7 +173,7 @@ const tailwindFrame = async ( settings: TailwindSettings, ): Promise => { // Check if this is an instance and should be rendered as a Twig component - if (node.type === "INSTANCE" && settings.tailwindGenerationMode === "twig") { + if (node.type === "INSTANCE" && settings.tailwindGenerationMode === "twig" && !node.name.startsWith("TwigContent")) { return tailwindTwigComponentInstance(node, settings); } @@ -229,7 +229,7 @@ const tailwindTwigComponentInstance = async ( // If we have children, process them let childrenStr = ""; - const embeddableChildren = node.children ? node.children.filter((n) => isVisibleComponent(n) || isTwigContentFrame(n)) : []; + const embeddableChildren = node.children ? node.children.filter((n) => isTwigContentFrame(n)) : []; if (embeddableChildren.length > 0) { // We keep embedded components and Frame named "TwigContent" @@ -246,7 +246,7 @@ const isVisibleComponent = (node: SceneNode): boolean => { } const isTwigContentFrame = (node: SceneNode): boolean => { - return node.type === "FRAME" && node.name === "TwigContent"; + return node.type === "INSTANCE" && node.name.startsWith("TwigContent"); } // Helper function to extract component name from an instance diff --git a/packages/plugin-ui/src/codegenPreferenceOptions.ts b/packages/plugin-ui/src/codegenPreferenceOptions.ts index 8db85935..c7c27d31 100644 --- a/packages/plugin-ui/src/codegenPreferenceOptions.ts +++ b/packages/plugin-ui/src/codegenPreferenceOptions.ts @@ -83,7 +83,7 @@ export const selectPreferenceOptions: SelectPreferenceOptions[] = [ options: [ { label: "HTML", value: "html" }, { label: "React (JSX)", value: "jsx" }, - { label: "Twig", value: "twig" }, + { label: "Twig (Experimental)", value: "twig" }, ], includedLanguages: ["Tailwind"], }, From 3feb628f66be8b9ebbf47b90f1cd1670057ffe78 Mon Sep 17 00:00:00 2001 From: Paule Herman Date: Thu, 23 Oct 2025 11:04:26 +0200 Subject: [PATCH 4/8] Twig : simplify component name --- packages/backend/src/tailwind/tailwindMain.ts | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index 3490e5ef..3c05b100 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -253,23 +253,11 @@ const isTwigContentFrame = (node: SceneNode): boolean => { const extractComponentName = (node: InstanceNode): string => { // Try to get name from mainComponent if available if (node.mainComponent) { - const name = node.mainComponent.name; - // Convert component name to PascalCase for Twig component naming convention - return toPascalCase(name); + return node.mainComponent.name; } // Fallback to node name if mainComponent is not available - return toPascalCase(node.name); -}; - -// Helper function to convert string to PascalCase -const toPascalCase = (str: string): string => { - // Remove any non-alphanumeric characters and split by spaces, underscores, or dashes - return str - .replace(/[^\w\s-]/g, '') - .split(/[\s_-]+/) - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) - .join(''); + return node.name; }; export const tailwindContainer = ( From 5e1f79c294e141cc48a11636eef930715f2d3858 Mon Sep 17 00:00:00 2001 From: Paule Herman Date: Thu, 23 Oct 2025 11:16:28 +0200 Subject: [PATCH 5/8] Improvement(twig): handle twig attributes --- .../src/common/commonFormatAttributes.ts | 3 +++ .../src/tailwind/tailwindDefaultBuilder.ts | 11 +++++++-- packages/backend/src/tailwind/tailwindMain.ts | 24 ++++--------------- 3 files changed, 16 insertions(+), 22 deletions(-) diff --git a/packages/backend/src/common/commonFormatAttributes.ts b/packages/backend/src/common/commonFormatAttributes.ts index e71fd588..e13a8b2d 100644 --- a/packages/backend/src/common/commonFormatAttributes.ts +++ b/packages/backend/src/common/commonFormatAttributes.ts @@ -20,6 +20,9 @@ export const formatStyleAttribute = ( export const formatDataAttribute = (label: string, value?: string) => ` data-${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`; +export const formatTwigAttribute = (label: string, value?: string) => + ` ${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`; + export const formatClassAttribute = ( classes: string[], isJSX: boolean, diff --git a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts index 8c5ef14e..f8561821 100644 --- a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts +++ b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts @@ -27,6 +27,7 @@ import { import { pxToBlur } from "./conversionTables"; import { formatDataAttribute, + formatTwigAttribute, getClassLabel, } from "../common/commonFormatAttributes"; import { TailwindColorType, TailwindSettings } from "types"; @@ -53,6 +54,10 @@ export class TailwindDefaultBuilder { return this.settings.tailwindGenerationMode === "jsx"; } + get isTwig() { + return this.settings.tailwindGenerationMode === "twig" + } + constructor(node: SceneNode, settings: TailwindSettings) { this.node = node; this.settings = settings; @@ -272,13 +277,15 @@ export class TailwindDefaultBuilder { if ("componentProperties" in this.node && this.node.componentProperties) { Object.entries(this.node.componentProperties) ?.map((prop) => { - if (prop[1].type === "VARIANT" || prop[1].type === "BOOLEAN") { + if (prop[1].type === "VARIANT" || prop[1].type === "BOOLEAN" || (this.isTwig && prop[1].type === "TEXT")) { const cleanName = prop[0] .split("#")[0] .replace(/\s+/g, "-") .toLowerCase(); - return formatDataAttribute(cleanName, String(prop[1].value)); + return this.isTwig + ? formatTwigAttribute(cleanName, String(prop[1].value)) + : formatDataAttribute(cleanName, String(prop[1].value)); } return ""; }) diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index 3c05b100..b1f3773f 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -173,7 +173,7 @@ const tailwindFrame = async ( settings: TailwindSettings, ): Promise => { // Check if this is an instance and should be rendered as a Twig component - if (node.type === "INSTANCE" && settings.tailwindGenerationMode === "twig" && !node.name.startsWith("TwigContent")) { + if (settings.tailwindGenerationMode === "twig" && node.type === "INSTANCE" && !isTwigContentNode(node)) { return tailwindTwigComponentInstance(node, settings); } @@ -212,24 +212,12 @@ const tailwindTwigComponentInstance = async ( // .commonShapeStyles() ; - const twigComponentProperties: string[] = ['']; - - for (const prop in node.componentProperties) { - const cleanName = prop - .split("#")[0] - .replace(/\s+/g, "-") - .toLowerCase() - const attr = `${cleanName}="${node.componentProperties[prop]?.value}"`; - twigComponentProperties.push(attr); - } - - - const attributes = builder.build() + twigComponentProperties.join(' '); + const attributes = builder.build(); // If we have children, process them let childrenStr = ""; - const embeddableChildren = node.children ? node.children.filter((n) => isTwigContentFrame(n)) : []; + const embeddableChildren = node.children ? node.children.filter((n) => isTwigContentNode(n)) : []; if (embeddableChildren.length > 0) { // We keep embedded components and Frame named "TwigContent" @@ -241,11 +229,7 @@ const tailwindTwigComponentInstance = async ( } }; -const isVisibleComponent = (node: SceneNode): boolean => { - return node.type === "INSTANCE" && (node.name[0] !== '.' && node.name[0] !== '_'); -} - -const isTwigContentFrame = (node: SceneNode): boolean => { +const isTwigContentNode = (node: SceneNode): boolean => { return node.type === "INSTANCE" && node.name.startsWith("TwigContent"); } From e117a1238c8b3b310ada3dddbf8e2185b2b19399 Mon Sep 17 00:00:00 2001 From: Paule Herman Date: Thu, 23 Oct 2025 11:38:25 +0200 Subject: [PATCH 6/8] Limit twig components attr to instances --- packages/backend/src/tailwind/tailwindDefaultBuilder.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts index f8561821..25fc7d40 100644 --- a/packages/backend/src/tailwind/tailwindDefaultBuilder.ts +++ b/packages/backend/src/tailwind/tailwindDefaultBuilder.ts @@ -54,8 +54,8 @@ export class TailwindDefaultBuilder { return this.settings.tailwindGenerationMode === "jsx"; } - get isTwig() { - return this.settings.tailwindGenerationMode === "twig" + get isTwigComponent() { + return this.settings.tailwindGenerationMode === "twig" && this.node.type === "INSTANCE" } constructor(node: SceneNode, settings: TailwindSettings) { @@ -277,13 +277,13 @@ export class TailwindDefaultBuilder { if ("componentProperties" in this.node && this.node.componentProperties) { Object.entries(this.node.componentProperties) ?.map((prop) => { - if (prop[1].type === "VARIANT" || prop[1].type === "BOOLEAN" || (this.isTwig && prop[1].type === "TEXT")) { + if (prop[1].type === "VARIANT" || prop[1].type === "BOOLEAN" || (this.isTwigComponent && prop[1].type === "TEXT")) { const cleanName = prop[0] .split("#")[0] .replace(/\s+/g, "-") .toLowerCase(); - return this.isTwig + return this.isTwigComponent ? formatTwigAttribute(cleanName, String(prop[1].value)) : formatDataAttribute(cleanName, String(prop[1].value)); } From f14d647b7bad6852e85b74af65cb2c7a86fe9564 Mon Sep 17 00:00:00 2001 From: Paule Herman Date: Fri, 31 Oct 2025 15:25:20 +0100 Subject: [PATCH 7/8] Hide component properties starting with . or _ --- packages/backend/src/common/commonFormatAttributes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/common/commonFormatAttributes.ts b/packages/backend/src/common/commonFormatAttributes.ts index e13a8b2d..3f858262 100644 --- a/packages/backend/src/common/commonFormatAttributes.ts +++ b/packages/backend/src/common/commonFormatAttributes.ts @@ -21,7 +21,7 @@ export const formatDataAttribute = (label: string, value?: string) => ` data-${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`; export const formatTwigAttribute = (label: string, value?: string) => - ` ${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`; + ['.', '_'].includes(label.charAt(0)) ? '' : (` ${lowercaseFirstLetter(label).replace(" ", "-")}${value === undefined ? `` : `="${value}"`}`); export const formatClassAttribute = ( classes: string[], From 75e8a710e49f84f3f21458c6ba4f8742571e6a72 Mon Sep 17 00:00:00 2001 From: Paule Herman Date: Fri, 31 Oct 2025 17:29:46 +0100 Subject: [PATCH 8/8] Handle non twig components --- packages/backend/src/tailwind/tailwindMain.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/tailwind/tailwindMain.ts b/packages/backend/src/tailwind/tailwindMain.ts index b1f3773f..e4580acc 100644 --- a/packages/backend/src/tailwind/tailwindMain.ts +++ b/packages/backend/src/tailwind/tailwindMain.ts @@ -173,7 +173,7 @@ const tailwindFrame = async ( settings: TailwindSettings, ): Promise => { // Check if this is an instance and should be rendered as a Twig component - if (settings.tailwindGenerationMode === "twig" && node.type === "INSTANCE" && !isTwigContentNode(node)) { + if (node.type === "INSTANCE" && isTwigComponentNode(node)) { return tailwindTwigComponentInstance(node, settings); } @@ -229,6 +229,10 @@ const tailwindTwigComponentInstance = async ( } }; +const isTwigComponentNode = (node: SceneNode): boolean => { + return localTailwindSettings.tailwindGenerationMode === "twig" && node.type === "INSTANCE" && !extractComponentName(node).startsWith("HTML:") && !isTwigContentNode(node); +} + const isTwigContentNode = (node: SceneNode): boolean => { return node.type === "INSTANCE" && node.name.startsWith("TwigContent"); }