From 79eaac893fa3159cdce6adcb052b817112588542 Mon Sep 17 00:00:00 2001 From: Jake Albaugh Date: Mon, 11 Mar 2024 16:48:13 -0500 Subject: [PATCH] first cut recursive params --- code.js | 383 ++++++++++++------- src/code.ts | 18 +- src/index.d.ts | 12 + src/params.ts | 97 ++++- src/snippets.ts | 421 ++++++++++++--------- src/test.ts | 421 ++++++++++++++++++++- test.js | 962 ++++++++++++++++++++++++++---------------------- 7 files changed, 1548 insertions(+), 766 deletions(-) diff --git a/code.js b/code.js index 0ebdb60..a2df36e 100644 --- a/code.js +++ b/code.js @@ -140,59 +140,107 @@ ); async function nodeSnippetTemplateDataArrayFromNode(node, codeSnippetParamsMap, globalTemplates, indent = "", recursionIndex = 0, parentCodegenResult) { const nodeSnippetTemplateDataArray = []; - const seenSnippetTemplates = {}; - async function processSnippetTemplatesForNode(snippetNode) { - const codegenResults = getCodegenResultsFromPluginData(snippetNode); - const matchingTemplates = (templates) => templates.filter( - ({ title, language }) => !parentCodegenResult || title === parentCodegenResult.title && language === parentCodegenResult.language - ); - const matchingCodegenResults = matchingTemplates(codegenResults); - const codegenResultTemplates = []; - if (matchingCodegenResults.length) { - const seenKey = JSON.stringify(matchingCodegenResults); - if (!seenSnippetTemplates[seenKey]) { - seenSnippetTemplates[seenKey] = 1; - codegenResultTemplates.push(...matchingCodegenResults); - } - } - if (globalTemplates.components) { - const componentTemplates = "key" in snippetNode ? globalTemplates.components[snippetNode.key] || [] : []; - codegenResultTemplates.push(...matchingTemplates(componentTemplates)); - } - if (!Object.keys(seenSnippetTemplates).length && !codegenResultTemplates.length && globalTemplates.types) { - const typeTemplates = globalTemplates.types[snippetNode.type] || []; - const seenKey = JSON.stringify(typeTemplates); - if (!seenSnippetTemplates[seenKey]) { - seenSnippetTemplates[seenKey] = 1; - const defaultTemplates = !typeTemplates.length && globalTemplates.types.DEFAULT ? globalTemplates.types.DEFAULT : []; - codegenResultTemplates.push(...matchingTemplates(typeTemplates)); - codegenResultTemplates.push(...matchingTemplates(defaultTemplates)); - } - } - const children = "children" in node ? node.children.filter((n) => n.visible) : []; + const templatesWithInheritanceNode = await snippetTemplatesWithInheritanceNode( + node, + globalTemplates, + parentCodegenResult + ); + for (let [ + inheritanceNode, + snippetTemplates + ] of templatesWithInheritanceNode) { const nodeSnippetTemplateData = await hydrateSnippets( - codegenResultTemplates, + snippetTemplates, codeSnippetParamsMap, - snippetNode.type, - children, + inheritanceNode.type, indent, recursionIndex, globalTemplates ); nodeSnippetTemplateDataArray.push(nodeSnippetTemplateData); } + return nodeSnippetTemplateDataArray; + } + async function snippetTemplatesWithInheritanceNode(node, globalTemplates, parentCodegenResult) { + const seenSnippetTemplates = {}; + const nodeAndTemplates = []; if (node.type === "COMPONENT" && node.parent && node.parent.type === "COMPONENT_SET") { - await processSnippetTemplatesForNode(node.parent); + const componentSetTemplates = await snippetTemplatesForNode( + node.parent, + seenSnippetTemplates, + globalTemplates, + parentCodegenResult + ); + if (componentSetTemplates.length) { + nodeAndTemplates.push([node.parent, componentSetTemplates]); + } } else if (node.type === "INSTANCE") { if (node.mainComponent) { if (node.mainComponent.parent && node.mainComponent.parent.type === "COMPONENT_SET") { - await processSnippetTemplatesForNode(node.mainComponent.parent); + const componentSetTemplates = await snippetTemplatesForNode( + node.mainComponent.parent, + seenSnippetTemplates, + globalTemplates, + parentCodegenResult + ); + if (componentSetTemplates.length) { + nodeAndTemplates.push([ + node.mainComponent.parent, + componentSetTemplates + ]); + } + } + const mainComponentTemplates = await snippetTemplatesForNode( + node.mainComponent, + seenSnippetTemplates, + globalTemplates, + parentCodegenResult + ); + if (mainComponentTemplates.length) { + nodeAndTemplates.push([node.mainComponent, mainComponentTemplates]); } - await processSnippetTemplatesForNode(node.mainComponent); } } - await processSnippetTemplatesForNode(node); - return nodeSnippetTemplateDataArray; + const nodeTemplates = await snippetTemplatesForNode( + node, + seenSnippetTemplates, + globalTemplates, + parentCodegenResult + ); + if (nodeTemplates.length) { + nodeAndTemplates.push([node, nodeTemplates]); + } + return nodeAndTemplates; + } + async function snippetTemplatesForNode(snippetNode, seenSnippetTemplates, globalTemplates, parentCodegenResult) { + const codegenResults = getCodegenResultsFromPluginData(snippetNode); + const matchingTemplates = (templates) => templates.filter( + ({ title, language }) => !parentCodegenResult || title === parentCodegenResult.title && language === parentCodegenResult.language + ); + const matchingCodegenResults = matchingTemplates(codegenResults); + const codegenResultTemplates = []; + if (matchingCodegenResults.length) { + const seenKey = JSON.stringify(matchingCodegenResults); + if (!seenSnippetTemplates[seenKey]) { + seenSnippetTemplates[seenKey] = 1; + codegenResultTemplates.push(...matchingCodegenResults); + } + } + if (globalTemplates.components) { + const componentTemplates = "key" in snippetNode ? globalTemplates.components[snippetNode.key] || [] : []; + codegenResultTemplates.push(...matchingTemplates(componentTemplates)); + } + if (!Object.keys(seenSnippetTemplates).length && !codegenResultTemplates.length && globalTemplates.types) { + const typeTemplates = globalTemplates.types[snippetNode.type] || []; + const seenKey = JSON.stringify(typeTemplates); + if (!seenSnippetTemplates[seenKey]) { + seenSnippetTemplates[seenKey] = 1; + const defaultTemplates = !typeTemplates.length && globalTemplates.types.DEFAULT ? globalTemplates.types.DEFAULT : []; + codegenResultTemplates.push(...matchingTemplates(typeTemplates)); + codegenResultTemplates.push(...matchingTemplates(defaultTemplates)); + } + } + return codegenResultTemplates; } function transformStringWithFilter(string, rawString, filter = "hyphen") { const splitString = string.split("-"); @@ -213,66 +261,21 @@ } return splitString.join(" "); } - async function hydrateSnippets(codegenResultTemplatesArray, codeSnippetParamsMap, nodeType, nodeChildren, indent, recursionIndex, globalTemplates) { - const { paramsRaw, params } = codeSnippetParamsMap; + async function hydrateSnippets(codegenResultTemplatesArray, codeSnippetParamsMap, nodeType, indent, recursionIndex, globalTemplates) { const codegenResultArray = []; const codegenResultRawTemplatesArray = []; const resultPromises = codegenResultTemplatesArray.map( async (codegenResult, index) => { - const lines = codegenResult.code.split("\n"); - const code = []; - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - const [matches, qualifies] = lineConditionalMatch(line, params); - matches.forEach((match) => { - line = line.replace(match[0], ""); - }); - const symbolMatches = [...line.matchAll(regexSymbols)]; - if (qualifies && symbolMatches.length) { - let succeeded = true; - for (let j = 0; j < symbolMatches.length; j++) { - const symbolMatch = symbolMatches[j]; - const [match, param, _, filter] = symbolMatch.map( - (a) => a ? a.trim() : a - ); - if (param in params) { - const value = transformStringWithFilter( - params[param], - paramsRaw[param], - filter - ); - line = line.replace(match, value); - } else if (param === "figma.children" && recursionIndex < MAX_RECURSION) { - const indentMatch = line.match(/^[ \t]+/); - const indent2 = indentMatch ? indentMatch[0] : ""; - const childrenValue = await findChildrenSnippets( - codegenResult, - nodeChildren, - indent2, - recursionIndex + 1, - globalTemplates - ); - if (childrenValue) { - line = line.replace(/^[ \t]+/, ""); - line = line.replace(match, childrenValue); - } else { - succeeded = false; - } - } else { - succeeded = false; - } - } - if (succeeded) { - line = unescapeBrackets(line); - code.push(line); - } - } else if (qualifies) { - line = unescapeBrackets(line); - code.push(line); - } - } - const codeString = code.join("\n").replace(/\\\\\n/g, "").replace(/\\\n\\/g, "").replace(/\\\n/g, " "); - const indentedCodeString = indent + codeString.replace(/\n/g, ` + const snippetId = snippetIdFromCodegenResult(codegenResult); + const code = await hydrateCodeStringWithParams( + codegenResult.code, + codeSnippetParamsMap, + snippetId, + indent, + recursionIndex, + globalTemplates + ); + const indentedCodeString = indent + code.replace(/\n/g, ` ${indent}`); codegenResultArray[index] = { title: codegenResult.title, @@ -293,32 +296,89 @@ ${indent}`); codegenResultArray }; } - async function findChildrenSnippets(codegenResult, nodeChildren, indent, recursionIndex, globalTemplates) { - const string = []; - const childPromises = nodeChildren.map(async (child, index) => { - const paramsMap = await paramsFromNode(child); - const snippets = await nodeSnippetTemplateDataArrayFromNode( - child, - paramsMap, - globalTemplates, - indent, - recursionIndex + 1, - codegenResult + async function hydrateCodeStringWithParams(codeString, codeSnippetParamsMap, snippetId, indent, recursionIndex, globalTemplates) { + const { paramsRaw, params, template } = codeSnippetParamsMap; + const lines = codeString.split("\n"); + const code = []; + const templateChildren = template[snippetId] ? template[snippetId].children : void 0; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + const [matches, qualifies] = lineConditionalMatch( + line, + params, + templateChildren ); - const snippet = snippets.map( - (s) => s.codegenResultArray.find( - (r) => r.title === codegenResult.title && r.language === codegenResult.language - ) - ).find(Boolean); - if (snippet) { - string[index] = snippet.code; + matches.forEach((match) => { + line = line.replace(match[0], ""); + }); + const symbolMatches = [...line.matchAll(regexSymbols)]; + if (qualifies && symbolMatches.length) { + let succeeded = true; + for (let j = 0; j < symbolMatches.length; j++) { + const symbolMatch = symbolMatches[j]; + const [match, param, _, filter] = symbolMatch.map( + (a) => a ? a.trim() : a + ); + if (param in params) { + const value = transformStringWithFilter( + params[param], + paramsRaw[param], + filter + ); + line = line.replace(match, value); + } else if (param === "figma.children" && recursionIndex < MAX_RECURSION && templateChildren) { + const indentMatch = line.match(/^[ \t]+/); + const indent2 = indentMatch ? indentMatch[0] : ""; + const childrenValue = await findChildrenSnippets( + templateChildren, + indent2, + recursionIndex + 1, + globalTemplates + ); + if (childrenValue) { + line = line.replace(/^[ \t]+/, ""); + line = line.replace(match, childrenValue); + } else { + succeeded = false; + } + } else { + succeeded = false; + } + } + if (succeeded) { + line = unescapeBrackets(line); + code.push(line); + } + } else if (qualifies) { + line = unescapeBrackets(line); + code.push(line); } - return; - }); - await Promise.all(childPromises); + } + const singleLineFormatted = code.join(` +`).replace(/\\\\\n/g, "").replace(/\\\n\\/g, "").replace(/\\\n/g, " "); + return indent + singleLineFormatted.split("\n").join(` +${indent}`); + } + async function findChildrenSnippets(childrenSnippetParams, indent, recursionIndex, globalTemplates) { + const string = []; + for (let childSnippetParams of childrenSnippetParams) { + const snippetId = Object.keys(childSnippetParams.template)[0]; + const template = childSnippetParams.template[snippetId]; + if (template) { + const hydrated = await hydrateCodeStringWithParams( + template.code, + childSnippetParams, + snippetId, + indent, + recursionIndex, + globalTemplates + ); + string.push(hydrated); + } + } return string.filter(Boolean).join("\n"); } - function lineConditionalMatch(line, params) { + function lineConditionalMatch(line, params, templateChildren) { const matches = [...line.matchAll(regexConditional)]; if (!matches.length) { return [[], true]; @@ -338,10 +398,17 @@ ${indent}`); const matches2 = match2.match(/([^=]+)(=([^\}]+))?/); if (matches2) { const [_2, symbol, equals, value] = matches2; - const symbolIsDefined = symbol in params; - const paramsMatch = params[symbol] === value; - const presenceOnly = !Boolean(equals); - return presenceOnly ? symbolIsDefined : paramsMatch; + if (symbol === "figma.children") { + if (!equals && templateChildren) { + return Boolean(templateChildren.length); + } + return false; + } else { + const symbolIsDefined = symbol in params; + const paramsMatch = params[symbol] === value; + const presenceOnly = !Boolean(equals); + return presenceOnly ? symbolIsDefined : paramsMatch; + } } else { return false; } @@ -390,12 +457,66 @@ ${indent}`); } } if (propertiesOnly) { - return { params, paramsRaw }; + return { params, paramsRaw, template: {} }; } const initial = await initialParamsFromNode(node); return { params: Object.assign(params, initial.params), - paramsRaw: Object.assign(paramsRaw, initial.paramsRaw) + paramsRaw: Object.assign(paramsRaw, initial.paramsRaw), + template: {} + }; + } + function snippetIdFromCodegenResult(codegenResult) { + return `${codegenResult.title}-${codegenResult.language}`; + } + async function recursiveParamsFromNode(node, globalTemplates, specificSnippetId) { + const children = "children" in node ? node.children.filter((n) => "visible" in n ? n.visible : true) : []; + const childrenTemplatesObject = {}; + for (let child of children) { + const templatesArray = await snippetTemplatesWithInheritanceNode( + child, + globalTemplates + ); + const templates = templatesArray.flatMap((result) => result[1]); + templates.forEach((template) => { + const snippetId = snippetIdFromCodegenResult(template); + if (!specificSnippetId || snippetId === specificSnippetId) { + childrenTemplatesObject[snippetId] = childrenTemplatesObject[snippetId] || []; + childrenTemplatesObject[snippetId].push(child); + } + }); + } + const nodeTemplatesArray = await snippetTemplatesWithInheritanceNode( + node, + globalTemplates + ); + const nodeTemplates = nodeTemplatesArray.flatMap((result) => result[1]); + const nodeTemplatesObject = {}; + for (let template of nodeTemplates) { + const snippetId = snippetIdFromCodegenResult(template); + if (!specificSnippetId || snippetId === specificSnippetId) { + nodeTemplatesObject[snippetId] = nodeTemplatesObject[snippetId] || { + code: template.code + }; + if (template.code.match(/\{\{ *figma.children *\}\}/)) { + if (childrenTemplatesObject[snippetId]) { + nodeTemplatesObject[snippetId].children = await Promise.all( + childrenTemplatesObject[snippetId].map( + async (child) => await recursiveParamsFromNode(child, globalTemplates, snippetId) + ) + ); + } + } + if (template.code.match(/\{\{ *figma.svg *\}\}/) && "exportAsync" in node) { + nodeTemplatesObject[snippetId].svg = (await node.exportAsync({ format: "SVG" })).toString(); + } + } + } + const { params, paramsRaw } = await paramsFromNode(node); + return { + params, + paramsRaw, + template: nodeTemplatesObject }; } async function componentPropertyDataFromNode(node) { @@ -533,7 +654,7 @@ ${indent}`); } }); } - return { params, paramsRaw }; + return { params, paramsRaw, template: {} }; } function isComponentPropertyDefinitionsObject(object) { return object[Object.keys(object)[0]] && "defaultValue" in object[Object.keys(object)[0]]; @@ -667,11 +788,14 @@ ${indent}`); const isDetailsMode = detailsMode === "on"; const hasDefaultMessage = defaultSnippet === "message"; const currentNode = handleCurrentSelection(); - const paramsMap = await paramsFromNode(currentNode); const templates = await getGlobalTemplatesFromClientStorage() || {}; + const recursiveParamsMap = await recursiveParamsFromNode( + currentNode, + templates + ); const nodeSnippetTemplateDataArray = await nodeSnippetTemplateDataArrayFromNode( currentNode, - paramsMap, + recursiveParamsMap, templates ); const snippets = codegenResultsFromNodeSnippetTemplateDataArray( @@ -680,13 +804,8 @@ ${indent}`); ); if (isDetailsMode) { snippets.push({ - title: "Node Params", - code: JSON.stringify(paramsMap.params, null, 2), - language: "JSON" - }); - snippets.push({ - title: "Node Params (Raw)", - code: JSON.stringify(paramsMap.paramsRaw, null, 2), + title: "Params", + code: JSON.stringify(recursiveParamsMap, null, 2), language: "JSON" }); } diff --git a/src/code.ts b/src/code.ts index ce64bd2..c0e5dfc 100644 --- a/src/code.ts +++ b/src/code.ts @@ -3,7 +3,7 @@ import { getCodegenResultsFromPluginData, setCodegenResultsInPluginData, } from "./pluginData"; -import { paramsFromNode } from "./params"; +import { recursiveParamsFromNode } from "./params"; import { nodeSnippetTemplateDataArrayFromNode } from "./snippets"; import { getGlobalTemplatesFromClientStorage, @@ -78,12 +78,15 @@ function initializeCodegenMode() { const hasDefaultMessage = defaultSnippet === "message"; const currentNode = handleCurrentSelection(); - const paramsMap = await paramsFromNode(currentNode); const templates = (await getGlobalTemplatesFromClientStorage()) || {}; + const recursiveParamsMap = await recursiveParamsFromNode( + currentNode, + templates + ); const nodeSnippetTemplateDataArray = await nodeSnippetTemplateDataArrayFromNode( currentNode, - paramsMap, + recursiveParamsMap, templates ); @@ -98,13 +101,8 @@ function initializeCodegenMode() { */ if (isDetailsMode) { snippets.push({ - title: "Node Params", - code: JSON.stringify(paramsMap.params, null, 2), - language: "JSON", - }); - snippets.push({ - title: "Node Params (Raw)", - code: JSON.stringify(paramsMap.paramsRaw, null, 2), + title: "Params", + code: JSON.stringify(recursiveParamsMap, null, 2), language: "JSON", }); } diff --git a/src/index.d.ts b/src/index.d.ts index 7b6d22c..3945246 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -13,12 +13,24 @@ type CodeSnippetGlobalTemplates = { */ type CodeSnippetParams = { [k: string]: string }; +/** + * An object of template specific params, only generated when template calls for it. + */ +type CodeSnippetParamsTemplateParams = { + code: string; + children?: CodeSnippetParamsMap[]; + svg?: string; +}; + /** * A map of raw and normalized code snippet params objects. Keys are implicitly the same, values are formatted differently. */ type CodeSnippetParamsMap = { paramsRaw: CodeSnippetParams; params: CodeSnippetParams; + template: { + [templateId: string]: CodeSnippetParamsTemplateParams; + }; }; /** diff --git a/src/params.ts b/src/params.ts index cf8a534..763cc41 100644 --- a/src/params.ts +++ b/src/params.ts @@ -1,4 +1,8 @@ -import { transformStringWithFilter } from "./snippets"; +import { getCodegenResultsFromPluginData } from "./pluginData"; +import { + snippetTemplatesWithInheritanceNode, + transformStringWithFilter, +} from "./snippets"; /** * Return the code snippet params for a node. @@ -8,7 +12,7 @@ import { transformStringWithFilter } from "./snippets"; * (only true when getting instance swap child properties) * @returns Promise that resolves a CodeSnippetParamsMap */ -export async function paramsFromNode( +async function paramsFromNode( node: BaseNode, propertiesOnly = false ): Promise { @@ -44,13 +48,98 @@ export async function paramsFromNode( } if (propertiesOnly) { - return { params, paramsRaw }; + return { params, paramsRaw, template: {} }; } const initial = await initialParamsFromNode(node); return { params: Object.assign(params, initial.params), paramsRaw: Object.assign(paramsRaw, initial.paramsRaw), + template: {}, + }; +} + +/** + * Generating a snippet template id string from a CodegenResult + * @param codegenResult + * @returns a string to identify the snippet + */ +export function snippetIdFromCodegenResult(codegenResult: CodegenResult) { + return `${codegenResult.title}-${codegenResult.language}`; +} + +/** + * Return the code snippet params for a node. + * https://github.com/figma/code-snippet-editor-plugin#params + * @param node the node we want params for + * @returns Promise that resolves a CodeSnippetParamsMap + */ +export async function recursiveParamsFromNode( + node: BaseNode, + globalTemplates: CodeSnippetGlobalTemplates, + specificSnippetId?: string +): Promise { + const children = + "children" in node + ? node.children.filter((n) => ("visible" in n ? n.visible : true)) + : []; + const childrenTemplatesObject: { + [snippetId: string]: BaseNode[]; + } = {}; + for (let child of children) { + const templatesArray = await snippetTemplatesWithInheritanceNode( + child, + globalTemplates + ); + const templates = templatesArray.flatMap((result) => result[1]); + templates.forEach((template) => { + const snippetId = snippetIdFromCodegenResult(template); + if (!specificSnippetId || snippetId === specificSnippetId) { + childrenTemplatesObject[snippetId] = + childrenTemplatesObject[snippetId] || []; + childrenTemplatesObject[snippetId].push(child); + } + }); + } + const nodeTemplatesArray = await snippetTemplatesWithInheritanceNode( + node, + globalTemplates + ); + const nodeTemplates = nodeTemplatesArray.flatMap((result) => result[1]); + const nodeTemplatesObject: { + [snippetId: string]: CodeSnippetParamsTemplateParams; + } = {}; + for (let template of nodeTemplates) { + const snippetId = snippetIdFromCodegenResult(template); + if (!specificSnippetId || snippetId === specificSnippetId) { + nodeTemplatesObject[snippetId] = nodeTemplatesObject[snippetId] || { + code: template.code, + }; + if (template.code.match(/\{\{ *figma.children *\}\}/)) { + if (childrenTemplatesObject[snippetId]) { + nodeTemplatesObject[snippetId].children = await Promise.all( + childrenTemplatesObject[snippetId].map( + async (child) => + await recursiveParamsFromNode(child, globalTemplates, snippetId) + ) + ); + } + } + if ( + template.code.match(/\{\{ *figma.svg *\}\}/) && + "exportAsync" in node + ) { + nodeTemplatesObject[snippetId].svg = ( + await node.exportAsync({ format: "SVG" }) + ).toString(); + } + } + } + const { params, paramsRaw } = await paramsFromNode(node); + return { + params, + paramsRaw, + template: nodeTemplatesObject, }; } @@ -221,7 +310,7 @@ async function initialParamsFromNode( } }); } - return { params, paramsRaw }; + return { params, paramsRaw, template: {} }; } /** diff --git a/src/snippets.ts b/src/snippets.ts index 689f44b..7d6c9b8 100644 --- a/src/snippets.ts +++ b/src/snippets.ts @@ -1,4 +1,4 @@ -import { paramsFromNode } from "./params"; +import { snippetIdFromCodegenResult } from "./params"; import { getCodegenResultsFromPluginData } from "./pluginData"; /** @@ -69,7 +69,7 @@ const regexConditional = new RegExp( * @returns NodeSnippetTemplateData array containing hydrated snippets for the current node. */ export async function nodeSnippetTemplateDataArrayFromNode( - node: SceneNode, + node: BaseNode, codeSnippetParamsMap: CodeSnippetParamsMap, globalTemplates: CodeSnippetGlobalTemplates, indent: string = "", @@ -77,65 +77,22 @@ export async function nodeSnippetTemplateDataArrayFromNode( parentCodegenResult?: CodegenResult ): Promise { const nodeSnippetTemplateDataArray: NodeSnippetTemplateData[] = []; - const seenSnippetTemplates: { [k: string]: number } = {}; - /** - * Process snippets for any node. Called multiple times up the lineage for component and instance nodes. - * Instances have the same pluginData as their mainComponent, unless they have overridden the pluginData. - * This tracks these duplicate cases in seenSnippetTemplates and filters them out. - * @param node the node to check for templates in plugin data - * @returns Promise will push into nodeSnippetTemplateDataArray. - */ - async function processSnippetTemplatesForNode(snippetNode: SceneNode) { - const codegenResults = getCodegenResultsFromPluginData(snippetNode); - const matchingTemplates = (templates: CodegenResult[]) => - templates.filter( - ({ title, language }) => - !parentCodegenResult || - (title === parentCodegenResult.title && - language === parentCodegenResult.language) - ); - const matchingCodegenResults = matchingTemplates(codegenResults); - const codegenResultTemplates: CodegenResult[] = []; - if (matchingCodegenResults.length) { - const seenKey = JSON.stringify(matchingCodegenResults); - if (!seenSnippetTemplates[seenKey]) { - seenSnippetTemplates[seenKey] = 1; - codegenResultTemplates.push(...matchingCodegenResults); - } - } - if (globalTemplates.components) { - const componentTemplates = - "key" in snippetNode - ? globalTemplates.components[snippetNode.key] || [] - : []; - codegenResultTemplates.push(...matchingTemplates(componentTemplates)); - } + const templatesWithInheritanceNode = + await snippetTemplatesWithInheritanceNode( + node, + globalTemplates, + parentCodegenResult + ); - if ( - !Object.keys(seenSnippetTemplates).length && - !codegenResultTemplates.length && - globalTemplates.types - ) { - const typeTemplates = globalTemplates.types[snippetNode.type] || []; - const seenKey = JSON.stringify(typeTemplates); - if (!seenSnippetTemplates[seenKey]) { - seenSnippetTemplates[seenKey] = 1; - const defaultTemplates = - !typeTemplates.length && globalTemplates.types.DEFAULT - ? globalTemplates.types.DEFAULT - : []; - codegenResultTemplates.push(...matchingTemplates(typeTemplates)); - codegenResultTemplates.push(...matchingTemplates(defaultTemplates)); - } - } - const children = - "children" in node ? node.children.filter((n) => n.visible) : []; + for (let [ + inheritanceNode, + snippetTemplates, + ] of templatesWithInheritanceNode) { const nodeSnippetTemplateData = await hydrateSnippets( - codegenResultTemplates, + snippetTemplates, codeSnippetParamsMap, - snippetNode.type, - children, + inheritanceNode.type, indent, recursionIndex, globalTemplates @@ -143,6 +100,16 @@ export async function nodeSnippetTemplateDataArrayFromNode( nodeSnippetTemplateDataArray.push(nodeSnippetTemplateData); } + return nodeSnippetTemplateDataArray; +} + +export async function snippetTemplatesWithInheritanceNode( + node: BaseNode, + globalTemplates: CodeSnippetGlobalTemplates, + parentCodegenResult?: CodegenResult +) { + const seenSnippetTemplates: { [k: string]: number } = {}; + const nodeAndTemplates: [BaseNode, CodegenResult[]][] = []; /** * Templates via inheritance from component lineage. * Starting at the top with component sets, then components, then instances. @@ -152,25 +119,121 @@ export async function nodeSnippetTemplateDataArrayFromNode( node.parent && node.parent.type === "COMPONENT_SET" ) { - await processSnippetTemplatesForNode(node.parent); + const componentSetTemplates = await snippetTemplatesForNode( + node.parent, + seenSnippetTemplates, + globalTemplates, + parentCodegenResult + ); + if (componentSetTemplates.length) { + nodeAndTemplates.push([node.parent, componentSetTemplates]); + } } else if (node.type === "INSTANCE") { if (node.mainComponent) { if ( node.mainComponent.parent && node.mainComponent.parent.type === "COMPONENT_SET" ) { - await processSnippetTemplatesForNode(node.mainComponent.parent); + const componentSetTemplates = await snippetTemplatesForNode( + node.mainComponent.parent, + seenSnippetTemplates, + globalTemplates, + parentCodegenResult + ); + if (componentSetTemplates.length) { + nodeAndTemplates.push([ + node.mainComponent.parent, + componentSetTemplates, + ]); + } + } + const mainComponentTemplates = await snippetTemplatesForNode( + node.mainComponent, + seenSnippetTemplates, + globalTemplates, + parentCodegenResult + ); + if (mainComponentTemplates.length) { + nodeAndTemplates.push([node.mainComponent, mainComponentTemplates]); } - await processSnippetTemplatesForNode(node.mainComponent); } } /** * Templates on the given node */ - await processSnippetTemplatesForNode(node); + const nodeTemplates = await snippetTemplatesForNode( + node, + seenSnippetTemplates, + globalTemplates, + parentCodegenResult + ); + if (nodeTemplates.length) { + nodeAndTemplates.push([node, nodeTemplates]); + } - return nodeSnippetTemplateDataArray; + return nodeAndTemplates; +} + +/** + * Process snippets for any node. Called multiple times up the lineage for component and instance nodes. + * Instances have the same pluginData as their mainComponent, unless they have overridden the pluginData. + * This tracks these duplicate cases in seenSnippetTemplates and filters them out. + * @param snippetNode the node to check for templates in plugin data + * @param seenSnippetTemplates a memo of seen snippet templates, so duplicates can be ignored + * @param globalTemplates CodeSnippetGlobalTemplates object + * @param parentCodegenResult If present, template language and title must match this. Used to filter out templates up front during recursion. + * @returns Promise will push into nodeSnippetTemplateDataArray. + */ +async function snippetTemplatesForNode( + snippetNode: BaseNode, + seenSnippetTemplates: { [k: string]: number }, + globalTemplates: CodeSnippetGlobalTemplates, + parentCodegenResult?: CodegenResult +) { + const codegenResults = getCodegenResultsFromPluginData(snippetNode); + const matchingTemplates = (templates: CodegenResult[]) => + templates.filter( + ({ title, language }) => + !parentCodegenResult || + (title === parentCodegenResult.title && + language === parentCodegenResult.language) + ); + const matchingCodegenResults = matchingTemplates(codegenResults); + const codegenResultTemplates: CodegenResult[] = []; + if (matchingCodegenResults.length) { + const seenKey = JSON.stringify(matchingCodegenResults); + if (!seenSnippetTemplates[seenKey]) { + seenSnippetTemplates[seenKey] = 1; + codegenResultTemplates.push(...matchingCodegenResults); + } + } + if (globalTemplates.components) { + const componentTemplates = + "key" in snippetNode + ? globalTemplates.components[snippetNode.key] || [] + : []; + codegenResultTemplates.push(...matchingTemplates(componentTemplates)); + } + + if ( + !Object.keys(seenSnippetTemplates).length && + !codegenResultTemplates.length && + globalTemplates.types + ) { + const typeTemplates = globalTemplates.types[snippetNode.type] || []; + const seenKey = JSON.stringify(typeTemplates); + if (!seenSnippetTemplates[seenKey]) { + seenSnippetTemplates[seenKey] = 1; + const defaultTemplates = + !typeTemplates.length && globalTemplates.types.DEFAULT + ? globalTemplates.types.DEFAULT + : []; + codegenResultTemplates.push(...matchingTemplates(typeTemplates)); + codegenResultTemplates.push(...matchingTemplates(defaultTemplates)); + } + } + return codegenResultTemplates; } /** @@ -212,7 +275,6 @@ export function transformStringWithFilter( * @param codegenResultTemplatesArray codegen result array of templates loaded from pluginData or global templates. * @param codeSnippetParamsMap the map of raw and sanitized params used to hydrate the template. * @param nodeType string for the type of node the template is coming from. used in the title in details mode. - * @param children an array of child nodes * @param indent the string to use for indent on children * @param recursionIndex how deep are we in recursion * @param globalTemplates the global templates object for component key or node type based templates @@ -222,90 +284,29 @@ export async function hydrateSnippets( codegenResultTemplatesArray: CodegenResult[], codeSnippetParamsMap: CodeSnippetParamsMap, nodeType: string, - nodeChildren: readonly SceneNode[], indent: string, recursionIndex: number, globalTemplates: CodeSnippetGlobalTemplates ): Promise { - const { paramsRaw, params } = codeSnippetParamsMap; const codegenResultArray: CodegenResult[] = []; const codegenResultRawTemplatesArray: CodegenResult[] = []; const resultPromises = codegenResultTemplatesArray.map( async (codegenResult, index) => { - const lines = codegenResult.code.split("\n"); - const code: string[] = []; - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - const [matches, qualifies] = lineConditionalMatch(line, params); - matches.forEach((match) => { - line = line.replace(match[0], ""); - }); - - const symbolMatches = [...line.matchAll(regexSymbols)]; - if (qualifies && symbolMatches.length) { - let succeeded = true; - for (let j = 0; j < symbolMatches.length; j++) { - const symbolMatch = symbolMatches[j]; - const [match, param, _, filter] = symbolMatch.map((a) => - a ? a.trim() : a - ) as [string, string, string, SnippetStringFilter]; - if (param in params) { - const value = transformStringWithFilter( - params[param], - paramsRaw[param], - filter - ); - line = line.replace(match, value); - } else if ( - param === "figma.children" && - recursionIndex < MAX_RECURSION - ) { - const indentMatch = line.match(/^[ \t]+/); - const indent = indentMatch ? indentMatch[0] : ""; - const childrenValue = await findChildrenSnippets( - codegenResult, - nodeChildren, - indent, - recursionIndex + 1, - globalTemplates - ); - if (childrenValue) { - line = line.replace(/^[ \t]+/, ""); - line = line.replace(match, childrenValue); - } else { - succeeded = false; - } - } else { - succeeded = false; - } - } - - if (succeeded) { - line = unescapeBrackets(line); - code.push(line); - } - } else if (qualifies) { - line = unescapeBrackets(line); - code.push(line); - } - } - - /** - * Single line syntax collapses "/" prefix and suffix into single line spaces - * https://github.com/figma/code-snippet-editor-plugin#single-line-syntax - */ - const codeString = code - .join("\n") - .replace(/\\\\\n/g, "") // collapse single line leading space - .replace(/\\\n\\/g, "") // collapse single line trailing space - .replace(/\\\n/g, " "); // collapse single line + const snippetId = snippetIdFromCodegenResult(codegenResult); + const code = await hydrateCodeStringWithParams( + codegenResult.code, + codeSnippetParamsMap, + snippetId, + indent, + recursionIndex, + globalTemplates + ); /** * Prepend indent to every line. */ - const indentedCodeString = - indent + codeString.replace(/\n/g, `\n${indent}`); + const indentedCodeString = indent + code.replace(/\n/g, `\n${indent}`); codegenResultArray[index] = { title: codegenResult.title, @@ -331,48 +332,124 @@ export async function hydrateSnippets( }; } +async function hydrateCodeStringWithParams( + codeString: string, + codeSnippetParamsMap: CodeSnippetParamsMap, + snippetId: string, + indent: string, + recursionIndex: number, + globalTemplates: CodeSnippetGlobalTemplates +) { + const { paramsRaw, params, template } = codeSnippetParamsMap; + const lines = codeString.split("\n"); + const code: string[] = []; + const templateChildren = template[snippetId] + ? template[snippetId].children + : undefined; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + const [matches, qualifies] = lineConditionalMatch( + line, + params, + templateChildren + ); + matches.forEach((match) => { + line = line.replace(match[0], ""); + }); + + const symbolMatches = [...line.matchAll(regexSymbols)]; + if (qualifies && symbolMatches.length) { + let succeeded = true; + for (let j = 0; j < symbolMatches.length; j++) { + const symbolMatch = symbolMatches[j]; + const [match, param, _, filter] = symbolMatch.map((a) => + a ? a.trim() : a + ) as [string, string, string, SnippetStringFilter]; + if (param in params) { + const value = transformStringWithFilter( + params[param], + paramsRaw[param], + filter + ); + line = line.replace(match, value); + } else if ( + param === "figma.children" && + recursionIndex < MAX_RECURSION && + templateChildren + ) { + const indentMatch = line.match(/^[ \t]+/); + const indent = indentMatch ? indentMatch[0] : ""; + const childrenValue = await findChildrenSnippets( + templateChildren, + indent, + recursionIndex + 1, + globalTemplates + ); + if (childrenValue) { + line = line.replace(/^[ \t]+/, ""); + line = line.replace(match, childrenValue); + } else { + succeeded = false; + } + } else { + succeeded = false; + } + } + + if (succeeded) { + line = unescapeBrackets(line); + code.push(line); + } + } else if (qualifies) { + line = unescapeBrackets(line); + code.push(line); + } + } + + /** + * Single line syntax collapses "/" prefix and suffix into single line spaces + * https://github.com/figma/code-snippet-editor-plugin#single-line-syntax + */ + const singleLineFormatted = code + .join(`\n`) + .replace(/\\\\\n/g, "") // collapse single line leading space + .replace(/\\\n\\/g, "") // collapse single line trailing space + .replace(/\\\n/g, " "); // collapse single line + + // Indenting each line + return indent + singleLineFormatted.split("\n").join(`\n${indent}`); +} + /** * - * @param codegenResult the template children snippets are being inserted inside of. title and language must map and children templates - * @param nodeChildren an array of the children to search for templates on - * @param indent the indentation string + * @param childrenSnippetParams an array of children snippet params map + * @param indent indentation string * @param recursionIndex tracking recursion to prevent infinite loops * @param globalTemplates the CodeSnippetGlobalTemplates to reference * @returns a Promise that resolves a string of all children snippets with the right indentation. */ async function findChildrenSnippets( - codegenResult: CodegenResult, - nodeChildren: readonly SceneNode[], + childrenSnippetParams: CodeSnippetParamsMap[], indent: string, recursionIndex: number, globalTemplates: CodeSnippetGlobalTemplates ): Promise { const string: string[] = []; - const childPromises = nodeChildren.map(async (child, index) => { - const paramsMap = await paramsFromNode(child); - const snippets = await nodeSnippetTemplateDataArrayFromNode( - child, - paramsMap, - globalTemplates, - indent, - recursionIndex + 1, - codegenResult - ); - const snippet = snippets - .map((s) => - s.codegenResultArray.find( - (r) => - r.title === codegenResult.title && - r.language === codegenResult.language - ) - ) - .find(Boolean); - if (snippet) { - string[index] = snippet.code; + for (let childSnippetParams of childrenSnippetParams) { + const snippetId = Object.keys(childSnippetParams.template)[0]; + const template = childSnippetParams.template[snippetId]; + if (template) { + const hydrated = await hydrateCodeStringWithParams( + template.code, + childSnippetParams, + snippetId, + indent, + recursionIndex, + globalTemplates + ); + string.push(hydrated); } - return; - }); - await Promise.all(childPromises); + } return string.filter(Boolean).join("\n"); } @@ -386,7 +463,8 @@ async function findChildrenSnippets( */ function lineConditionalMatch( line: string, - params: CodeSnippetParams + params: CodeSnippetParams, + templateChildren?: CodeSnippetParamsMap[] ): [RegExpMatchArray[], boolean] { /** * Line conditional statement matches. @@ -421,10 +499,17 @@ function lineConditionalMatch( const matches = match.match(/([^=]+)(=([^\}]+))?/); if (matches) { const [_, symbol, equals, value] = matches; - const symbolIsDefined = symbol in params; - const paramsMatch = params[symbol] === value; - const presenceOnly = !Boolean(equals); - return presenceOnly ? symbolIsDefined : paramsMatch; + if (symbol === "figma.children") { + if (!equals && templateChildren) { + return Boolean(templateChildren.length); + } + return false; + } else { + const symbolIsDefined = symbol in params; + const paramsMatch = params[symbol] === value; + const presenceOnly = !Boolean(equals); + return presenceOnly ? symbolIsDefined : paramsMatch; + } } else { return false; } diff --git a/src/test.ts b/src/test.ts index 1e42a0d..0dd4cf8 100644 --- a/src/test.ts +++ b/src/test.ts @@ -28,6 +28,7 @@ const SNIPPET_TEST_CODE_EXPECTATION = Object.values(SNIPPET_TESTS) async function test() { try { await testSnippets(); + await recursiveTest(); return "Tests succeed!"; } catch (e) { throw e; @@ -37,9 +38,12 @@ async function test() { async function testSnippets() { const result = await hydrateSnippets( [{ language: "PLAINTEXT", code: SNIPPET_TEST_CODE, title: "test" }], - { params: SNIPPET_TEST_PARAMS, paramsRaw: SNIPPET_TEST_PARAMS }, + { + params: SNIPPET_TEST_PARAMS, + paramsRaw: SNIPPET_TEST_PARAMS, + template: {}, + }, "INSTANCE", - [], "", 0, {} @@ -52,3 +56,416 @@ async function testSnippets() { } test().then(console.log).catch(console.error); + +async function recursiveTest() { + const params: CodeSnippetParamsMap = { + params: { + "node.name": "buttons-frame", + "node.type": "frame", + "node.children": "2", + "css.display": "flex", + "css.width": "400px", + "css.padding": + "var(--padding-spacious, 16px) var(--padding-comfortable, 12px)", + "css.flexDirection": "column", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-lg, 16px)", + "css.border": "2px solid #E0E0E0", + "css.background": "var(--color-bg-subtle, #F0F0F0)", + "variables.itemSpacing": "gap-lg", + "variables.paddingLeft": "padding-comfortable", + "variables.paddingTop": "padding-spacious", + "variables.paddingRight": "padding-comfortable", + "variables.paddingBottom": "padding-spacious", + "variables.fills": "color-bg-subtle", + "autolayout.layoutMode": "vertical", + "autolayout.paddingLeft": "12", + "autolayout.paddingRight": "12", + "autolayout.paddingTop": "16", + "autolayout.paddingBottom": "16", + "autolayout.itemSpacing": "16", + "autolayout.primaryAxisAlignItems": "center", + "autolayout.counterAxisAlignItems": "center", + }, + paramsRaw: { + "node.name": "Buttons Frame", + "node.type": "FRAME", + "node.children": "2", + "css.display": "flex", + "css.width": "400px", + "css.padding": + "var(--padding-spacious, 16px) var(--padding-comfortable, 12px)", + "css.flexDirection": "column", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-lg, 16px)", + "css.border": "2px solid #E0E0E0", + "css.background": "var(--color-bg-subtle, #F0F0F0)", + "variables.itemSpacing": "gap/lg", + "variables.paddingLeft": "padding/comfortable", + "variables.paddingTop": "padding/spacious", + "variables.paddingRight": "padding/comfortable", + "variables.paddingBottom": "padding/spacious", + "variables.fills": "color/bg-subtle", + "autolayout.layoutMode": "VERTICAL", + "autolayout.paddingLeft": "12", + "autolayout.paddingRight": "12", + "autolayout.paddingTop": "16", + "autolayout.paddingBottom": "16", + "autolayout.itemSpacing": "16", + "autolayout.primaryAxisAlignItems": "CENTER", + "autolayout.counterAxisAlignItems": "CENTER", + }, + template: { + "React-JAVASCRIPT": { + code: '\n{{?figma.children}}>\n {{figma.children}}\n{{?figma.children}}', + children: [ + { + params: { + "node.name": "heyo-look-at-this", + "node.type": "text", + "node.characters": "heyo-look-at-this", + "node.textStyle": "heading-02", + "css.color": "#000", + "css.fontFamily": "Inter", + "css.fontSize": "36px", + "css.fontStyle": "normal", + "css.fontWeight": "400", + "css.lineHeight": "normal", + }, + paramsRaw: { + "node.name": "Heyo look at this", + "node.type": "TEXT", + "node.characters": "Heyo look at this", + "node.textStyle": "Heading 02", + "css.color": "#000", + "css.fontFamily": "Inter", + "css.fontSize": "36px", + "css.fontStyle": "normal", + "css.fontWeight": "400", + "css.lineHeight": "normal", + }, + template: { + "React-JAVASCRIPT": { + code: '{{node.characters|raw}}', + }, + }, + }, + { + params: { + "node.name": "frame-2", + "node.type": "frame", + "node.children": "2", + "css.display": "flex", + "css.justifyContent": "flex-end", + "css.alignItems": "center", + "css.gap": "var(--gap-md, 12px)", + "variables.itemSpacing": "gap-md", + "autolayout.layoutMode": "horizontal", + "autolayout.paddingLeft": "0", + "autolayout.paddingRight": "0", + "autolayout.paddingTop": "0", + "autolayout.paddingBottom": "0", + "autolayout.itemSpacing": "12", + "autolayout.primaryAxisAlignItems": "max", + "autolayout.counterAxisAlignItems": "center", + }, + paramsRaw: { + "node.name": "Frame 2", + "node.type": "FRAME", + "node.children": "2", + "css.display": "flex", + "css.justifyContent": "flex-end", + "css.alignItems": "center", + "css.gap": "var(--gap-md, 12px)", + "variables.itemSpacing": "gap/md", + "autolayout.layoutMode": "HORIZONTAL", + "autolayout.paddingLeft": "0", + "autolayout.paddingRight": "0", + "autolayout.paddingTop": "0", + "autolayout.paddingBottom": "0", + "autolayout.itemSpacing": "12", + "autolayout.primaryAxisAlignItems": "MAX", + "autolayout.counterAxisAlignItems": "CENTER", + }, + template: { + "React-JAVASCRIPT": { + code: '\n{{?figma.children}}>\n {{figma.children}}\n{{?figma.children}}', + children: [ + { + params: { + "property.iconEnd.b": "false", + "property.iconEnd.i": "icon-refresh", + "property.iconStart.b": "false", + "property.iconStart.i": "icon-heart-solid", + "property.label": "cancel", + "property.variant": "inverse", + "property.state": "default", + "property.size": "small", + "node.name": "button", + "node.type": "instance", + "node.children": "1", + "component.key": + "7e95f3069ff381e6d1ea1e34d13d82045be8e249", + "component.type": "component-set", + "component.name": "button", + "css.display": "flex", + "css.padding": + "var(--padding-compact, 4px) var(--padding-spacious, 16px)", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-sm, 8px)", + "css.borderRadius": "var(--size-24, 24px)", + "css.background": "var(--color-bg-default, #FFF)", + "variables.itemSpacing": "gap-sm", + "variables.paddingLeft": "padding-spacious", + "variables.paddingTop": "padding-compact", + "variables.paddingRight": "padding-spacious", + "variables.paddingBottom": "padding-compact", + "variables.topLeftRadius": "size-24", + "variables.topRightRadius": "size-24", + "variables.bottomLeftRadius": "size-24", + "variables.bottomRightRadius": "size-24", + "variables.fills": "color-bg-default", + "autolayout.layoutMode": "horizontal", + "autolayout.paddingLeft": "16", + "autolayout.paddingRight": "16", + "autolayout.paddingTop": "4", + "autolayout.paddingBottom": "4", + "autolayout.itemSpacing": "8", + "autolayout.primaryAxisAlignItems": "center", + "autolayout.counterAxisAlignItems": "center", + }, + paramsRaw: { + "property.iconEnd.b": "false", + "property.iconEnd.i": "Icon Refresh", + "property.iconStart.b": "false", + "property.iconStart.i": "Icon Heart - Solid", + "property.label": "Cancel", + "property.variant": "Inverse", + "property.state": "Default", + "property.size": "Small", + "node.name": "Button", + "node.type": "INSTANCE", + "node.children": "1", + "component.key": + "7e95f3069ff381e6d1ea1e34d13d82045be8e249", + "component.type": "COMPONENT_SET", + "component.name": "Button", + "css.display": "flex", + "css.padding": + "var(--padding-compact, 4px) var(--padding-spacious, 16px)", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-sm, 8px)", + "css.borderRadius": "var(--size-24, 24px)", + "css.background": "var(--color-bg-default, #FFF)", + "variables.itemSpacing": "gap/sm", + "variables.paddingLeft": "padding/spacious", + "variables.paddingTop": "padding/compact", + "variables.paddingRight": "padding/spacious", + "variables.paddingBottom": "padding/compact", + "variables.topLeftRadius": "size-24", + "variables.topRightRadius": "size-24", + "variables.bottomLeftRadius": "size-24", + "variables.bottomRightRadius": "size-24", + "variables.fills": "color/bg-default", + "autolayout.layoutMode": "HORIZONTAL", + "autolayout.paddingLeft": "16", + "autolayout.paddingRight": "16", + "autolayout.paddingTop": "4", + "autolayout.paddingBottom": "4", + "autolayout.itemSpacing": "8", + "autolayout.primaryAxisAlignItems": "CENTER", + "autolayout.counterAxisAlignItems": "CENTER", + }, + template: { + "React-JAVASCRIPT": { + code: '}\n {{?property.iconEnd.b=true}}iconEnd={<{{property.iconEnd.i|pascal}} />}\n onClick={() => {}}\n>\n {{property.label|raw}}\n', + }, + }, + }, + { + params: { + "property.iconEnd.b": "true", + "property.iconEnd.i": "icon-arrow-right", + "property.iconStart.b": "false", + "property.iconStart.i": "icon-heart-solid", + "property.label": "lets-go", + "property.variant": "secondary", + "property.state": "default", + "property.size": "medium", + "node.name": "button", + "node.type": "instance", + "node.children": "2", + "component.key": + "7e95f3069ff381e6d1ea1e34d13d82045be8e249", + "component.type": "component-set", + "component.name": "button", + "css.display": "flex", + "css.padding": + "var(--padding-default, 8px) var(--padding-baggy, 24px)", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-sm, 8px)", + "css.borderRadius": "var(--size-24, 24px)", + "css.background": + "var(--color-bg-brand-secondary, #7900D5)", + "variables.itemSpacing": "gap-sm", + "variables.paddingLeft": "padding-baggy", + "variables.paddingTop": "padding-default", + "variables.paddingRight": "padding-baggy", + "variables.paddingBottom": "padding-default", + "variables.topLeftRadius": "size-24", + "variables.topRightRadius": "size-24", + "variables.bottomLeftRadius": "size-24", + "variables.bottomRightRadius": "size-24", + "variables.fills": "color-bg-brand-secondary", + "autolayout.layoutMode": "horizontal", + "autolayout.paddingLeft": "24", + "autolayout.paddingRight": "24", + "autolayout.paddingTop": "8", + "autolayout.paddingBottom": "8", + "autolayout.itemSpacing": "8", + "autolayout.primaryAxisAlignItems": "center", + "autolayout.counterAxisAlignItems": "center", + }, + paramsRaw: { + "property.iconEnd.b": "true", + "property.iconEnd.i": "Icon Arrow - Right", + "property.iconStart.b": "false", + "property.iconStart.i": "Icon Heart - Solid", + "property.label": "Let's go!", + "property.variant": "Secondary", + "property.state": "Default", + "property.size": "Medium", + "node.name": "Button", + "node.type": "INSTANCE", + "node.children": "2", + "component.key": + "7e95f3069ff381e6d1ea1e34d13d82045be8e249", + "component.type": "COMPONENT_SET", + "component.name": "Button", + "css.display": "flex", + "css.padding": + "var(--padding-default, 8px) var(--padding-baggy, 24px)", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-sm, 8px)", + "css.borderRadius": "var(--size-24, 24px)", + "css.background": + "var(--color-bg-brand-secondary, #7900D5)", + "variables.itemSpacing": "gap/sm", + "variables.paddingLeft": "padding/baggy", + "variables.paddingTop": "padding/default", + "variables.paddingRight": "padding/baggy", + "variables.paddingBottom": "padding/default", + "variables.topLeftRadius": "size-24", + "variables.topRightRadius": "size-24", + "variables.bottomLeftRadius": "size-24", + "variables.bottomRightRadius": "size-24", + "variables.fills": "color/bg-brand-secondary", + "autolayout.layoutMode": "HORIZONTAL", + "autolayout.paddingLeft": "24", + "autolayout.paddingRight": "24", + "autolayout.paddingTop": "8", + "autolayout.paddingBottom": "8", + "autolayout.itemSpacing": "8", + "autolayout.primaryAxisAlignItems": "CENTER", + "autolayout.counterAxisAlignItems": "CENTER", + }, + template: { + "React-JAVASCRIPT": { + code: '}\n {{?property.iconEnd.b=true}}iconEnd={<{{property.iconEnd.i|pascal}} />}\n onClick={() => {}}\n>\n {{property.label|raw}}\n', + }, + }, + }, + ], + }, + }, + }, + ], + }, + }, + }; + const template = ` +{{?figma.children}}> + {{figma.children}} +{{?figma.children}}`; + const expectation = ` + Heyo look at this + + + + +`; + const result = await hydrateSnippets( + [{ language: "JAVASCRIPT", code: template, title: "React" }], + params, + "FRAME", + "", + 0, + {} + ); + const code = result.codegenResultArray[0].code; + if (code === expectation) { + return true; + } + const expectationLines = expectation.split("\n"); + const codeLines = code.split("\n"); + const diff: string[] = []; + expectationLines.forEach((line, i) => { + if (line !== codeLines[i]) { + diff.push(["E: " + line, "R: " + codeLines[i]].join("\n")); + } + }); + throw `Snippet hydration broken. + +${diff.join("\n\n")} +`; +} diff --git a/test.js b/test.js index e9de059..a98b07f 100644 --- a/test.js +++ b/test.js @@ -1,313 +1,8 @@ "use strict"; (() => { // src/params.ts - async function paramsFromNode(node, propertiesOnly = false) { - const { componentPropValuesMap, instanceParamsMap } = await componentPropertyDataFromNode(node); - const params = {}; - const paramsRaw = {}; - for (let key in componentPropValuesMap) { - const item = componentPropValuesMap[key]; - const itemKeys = Object.keys(item); - if (itemKeys.length > 1) { - itemKeys.forEach((type) => { - const value = `${item[type]}`; - const lowerChar = type.charAt(0).toLowerCase(); - params[`property.${key}.${lowerChar}`] = safeString(value); - paramsRaw[`property.${key}.${lowerChar}`] = value; - }); - } else { - const value = `${item[itemKeys[0]]}`; - params[`property.${key}`] = safeString(value); - paramsRaw[`property.${key}`] = value; - } - if (itemKeys.includes("INSTANCE_SWAP") && instanceParamsMap[key]) { - const keyPrefix = itemKeys.length > 1 ? `property.${key}.i` : `property.${key}`; - for (let k in instanceParamsMap[key].params) { - params[`${keyPrefix}.${k}`] = safeString( - instanceParamsMap[key].params[k] - ); - paramsRaw[`${keyPrefix}.${k}`] = instanceParamsMap[key].paramsRaw[k]; - } - } - } - if (propertiesOnly) { - return { params, paramsRaw }; - } - const initial = await initialParamsFromNode(node); - return { - params: Object.assign(params, initial.params), - paramsRaw: Object.assign(paramsRaw, initial.paramsRaw) - }; - } - async function componentPropertyDataFromNode(node) { - const componentPropObject = componentPropObjectFromNode(node); - const componentPropValuesMap = {}; - const isDefinitions = isComponentPropertyDefinitionsObject(componentPropObject); - const instanceParamsMap = {}; - for (let propertyName in componentPropObject) { - const value = isDefinitions ? componentPropObject[propertyName].defaultValue : componentPropObject[propertyName].value; - const type = componentPropObject[propertyName].type; - const cleanName = sanitizePropertyName(propertyName); - if (value !== void 0) { - componentPropValuesMap[cleanName] = componentPropValuesMap[cleanName] || {}; - if (typeof value === "string") { - if (type === "VARIANT") - componentPropValuesMap[cleanName].VARIANT = value; - if (type === "TEXT") - componentPropValuesMap[cleanName].TEXT = value; - if (type === "INSTANCE_SWAP") { - const foundNode = await figma.getNodeById(value); - const nodeName = nameFromFoundInstanceSwapNode(foundNode); - componentPropValuesMap[cleanName].INSTANCE_SWAP = nodeName; - if (foundNode) { - instanceParamsMap[cleanName] = await paramsFromNode( - foundNode, - true - ); - } - } - } else { - componentPropValuesMap[cleanName].BOOLEAN = value; - } - } - } - return { componentPropValuesMap, instanceParamsMap }; - } - function nameFromFoundInstanceSwapNode(node) { - return node && node.parent && node.parent.type === "COMPONENT_SET" ? node.parent.name : node ? node.name : ""; - } - async function initialParamsFromNode(node) { - const componentNode = getComponentNodeFromNode(node); - const css = await node.getCSSAsync(); - const autolayout = "inferredAutoLayout" in node ? node.inferredAutoLayout : void 0; - const paramsRaw = { - "node.name": node.name, - "node.type": node.type - }; - const params = { - "node.name": safeString(node.name), - "node.type": safeString(node.type) - }; - if ("key" in node) { - paramsRaw["node.key"] = node.key; - params["node.key"] = node.key; - } - if ("children" in node) { - const childCount = node.children.length.toString(); - paramsRaw["node.children"] = childCount; - params["node.children"] = childCount; - } - if (node.type === "TEXT") { - paramsRaw["node.characters"] = node.characters; - params["node.characters"] = safeString(node.characters); - if (node.textStyleId) { - if (node.textStyleId === figma.mixed) { - paramsRaw["node.textStyle"] = "figma.mixed"; - params["node.textStyle"] = "figma.mixed"; - } else { - const style = figma.getStyleById(node.textStyleId); - if (style) { - paramsRaw["node.textStyle"] = style.name; - params["node.textStyle"] = safeString(style.name); - } - } - } - } - if (componentNode && "key" in componentNode) { - paramsRaw["component.key"] = componentNode.key; - paramsRaw["component.type"] = componentNode.type; - paramsRaw["component.name"] = componentNode.name; - params["component.key"] = componentNode.key; - params["component.type"] = safeString(componentNode.type); - params["component.name"] = safeString(componentNode.name); - } - for (let key in css) { - const k = transformStringWithFilter(key, key, "camel"); - params[`css.${k}`] = css[key]; - paramsRaw[`css.${k}`] = css[key]; - } - if ("boundVariables" in node && node.boundVariables) { - const boundVariables = node.boundVariables; - for (let key in boundVariables) { - let vars = boundVariables[key]; - if (vars) { - if (!Array.isArray(vars)) { - vars = [vars]; - } - vars.forEach((v) => { - const va = figma.variables.getVariableById(v.id); - if (va) { - paramsRaw[`variables.${key}`] = va.name; - params[`variables.${key}`] = safeString(va.name); - for (let syntax in va.codeSyntax) { - const syntaxKey = syntax.charAt(0).toLowerCase(); - const syntaxName = syntax; - const value = va.codeSyntax[syntaxName]; - if (value) { - paramsRaw[`variables.${key}.${syntaxKey}`] = value; - params[`variables.${key}.${syntaxKey}`] = safeString(value); - } - } - } - }); - } - } - } - if (autolayout) { - const props = [ - "layoutMode", - "layoutWrap", - "paddingLeft", - "paddingRight", - "paddingTop", - "paddingBottom", - "itemSpacing", - "counterAxisSpacing", - "primaryAxisAlignItems", - "counterAxisAlignItems" - ]; - props.forEach((p) => { - const val = autolayout[p] + ""; - if (val !== "undefined" && val !== "null") { - paramsRaw[`autolayout.${p}`] = val; - params[`autolayout.${p}`] = safeString(val); - } - }); - } - return { params, paramsRaw }; - } - function isComponentPropertyDefinitionsObject(object) { - return object[Object.keys(object)[0]] && "defaultValue" in object[Object.keys(object)[0]]; - } - function componentPropObjectFromNode(node) { - if (node.type === "INSTANCE") - return node.componentProperties; - if (node.type === "COMPONENT_SET") - return node.componentPropertyDefinitions; - if (node.type === "COMPONENT") { - if (node.parent && node.parent.type === "COMPONENT_SET") { - const initialProps = Object.assign( - {}, - node.parent.componentPropertyDefinitions - ); - const nameProps = node.name.split(", "); - nameProps.forEach((prop) => { - const [propName, propValue] = prop.split("="); - initialProps[propName].defaultValue = propValue; - }); - return initialProps; - } else { - return node.componentPropertyDefinitions; - } - } - return {}; - } - function capitalize(name) { - return `${name.charAt(0).toUpperCase()}${name.slice(1)}`; - } - function downcase(name) { - return `${name.charAt(0).toLowerCase()}${name.slice(1)}`; - } - function numericGuard(name = "") { - if (name.charAt(0).match(/\d/)) { - name = `N${name}`; - } - return name; - } - function capitalizedNameFromName(name = "") { - name = numericGuard(name); - return name.split(/[^a-zA-Z\d]+/g).map(capitalize).join(""); - } - function sanitizePropertyName(name) { - name = name.replace(/#[^#]+$/g, ""); - return downcase(capitalizedNameFromName(name).replace(/^\d+/g, "")); - } - function getComponentNodeFromNode(node) { - const { type, parent } = node; - const parentType = parent ? parent.type : ""; - const isVariant = parentType === "COMPONENT_SET"; - if (type === "COMPONENT_SET" || type === "COMPONENT" && !isVariant) { - return node; - } else if (node.type === "COMPONENT" && node.parent && node.parent.type === "COMPONENT_SET") { - return node.parent; - } else if (type === "INSTANCE") { - const { mainComponent } = node; - return mainComponent ? mainComponent.parent && mainComponent.parent.type === "COMPONENT_SET" ? mainComponent.parent : mainComponent : null; - } - return null; - } - function safeString(string = "") { - string = string.replace(/([^a-zA-Z0-9-_// ])/g, ""); - if (!string.match(/^[A-Z0-9_]+$/)) { - string = string.replace(/([A-Z])/g, " $1"); - } - return string.replace(/([a-z])([0-9])/g, "$1 $2").replace(/([-_/])/g, " ").replace(/ +/g, " ").trim().toLowerCase().split(" ").join("-"); - } - - // src/pluginData.ts - var PLUGIN_DATA_NAMESPACE = "codesnippets"; - var PLUGIN_DATA_KEY = "snippets"; - var CODEGEN_LANGUAGES = [ - "BASH", - "CPP", - "CSS", - "GO", - "GRAPHQL", - "HTML", - "JAVASCRIPT", - "JSON", - "KOTLIN", - "PLAINTEXT", - "PYTHON", - "RUBY", - "RUST", - "SQL", - "SWIFT", - "TYPESCRIPT" - ]; - function getCodegenResultsFromPluginData(node) { - const pluginData = node.getSharedPluginData( - PLUGIN_DATA_NAMESPACE, - PLUGIN_DATA_KEY - ); - return pluginDataStringAsValidCodegenResults(pluginData) || []; - } - function valueIsCodegenLanguage(value) { - return CODEGEN_LANGUAGES.includes(value); - } - function objectIsCodegenResult(object) { - if (typeof object !== "object") - return false; - if (Object.keys(object).length !== 3) - return false; - if (!("title" in object && "code" in object && "language" in object)) - return false; - if (typeof object.title !== "string" || typeof object.code !== "string") - return false; - return valueIsCodegenLanguage(object.language); - } - function arrayContainsCodegenResults(array) { - let valid = true; - if (Array.isArray(array)) { - array.forEach((object) => { - if (!objectIsCodegenResult(object)) { - valid = false; - } - }); - } else { - valid = false; - } - return valid; - } - function pluginDataStringAsValidCodegenResults(pluginDataString) { - if (!pluginDataString) - return null; - try { - const parsed = JSON.parse(pluginDataString); - return arrayContainsCodegenResults(parsed) ? parsed : null; - } catch (e) { - return null; - } + function snippetIdFromCodegenResult(codegenResult) { + return `${codegenResult.title}-${codegenResult.language}`; } // src/snippets.ts @@ -326,74 +21,18 @@ `{{([?!])(${regexConditionals})}}`, "g" ); - async function nodeSnippetTemplateDataArrayFromNode(node, codeSnippetParamsMap, globalTemplates, indent = "", recursionIndex = 0, parentCodegenResult) { - const nodeSnippetTemplateDataArray = []; - const seenSnippetTemplates = {}; - async function processSnippetTemplatesForNode(snippetNode) { - const codegenResults = getCodegenResultsFromPluginData(snippetNode); - const matchingTemplates = (templates) => templates.filter( - ({ title, language }) => !parentCodegenResult || title === parentCodegenResult.title && language === parentCodegenResult.language - ); - const matchingCodegenResults = matchingTemplates(codegenResults); - const codegenResultTemplates = []; - if (matchingCodegenResults.length) { - const seenKey = JSON.stringify(matchingCodegenResults); - if (!seenSnippetTemplates[seenKey]) { - seenSnippetTemplates[seenKey] = 1; - codegenResultTemplates.push(...matchingCodegenResults); - } - } - if (globalTemplates.components) { - const componentTemplates = "key" in snippetNode ? globalTemplates.components[snippetNode.key] || [] : []; - codegenResultTemplates.push(...matchingTemplates(componentTemplates)); - } - if (!Object.keys(seenSnippetTemplates).length && !codegenResultTemplates.length && globalTemplates.types) { - const typeTemplates = globalTemplates.types[snippetNode.type] || []; - const seenKey = JSON.stringify(typeTemplates); - if (!seenSnippetTemplates[seenKey]) { - seenSnippetTemplates[seenKey] = 1; - const defaultTemplates = !typeTemplates.length && globalTemplates.types.DEFAULT ? globalTemplates.types.DEFAULT : []; - codegenResultTemplates.push(...matchingTemplates(typeTemplates)); - codegenResultTemplates.push(...matchingTemplates(defaultTemplates)); - } - } - const children = "children" in node ? node.children.filter((n) => n.visible) : []; - const nodeSnippetTemplateData = await hydrateSnippets( - codegenResultTemplates, - codeSnippetParamsMap, - snippetNode.type, - children, - indent, - recursionIndex, - globalTemplates - ); - nodeSnippetTemplateDataArray.push(nodeSnippetTemplateData); - } - if (node.type === "COMPONENT" && node.parent && node.parent.type === "COMPONENT_SET") { - await processSnippetTemplatesForNode(node.parent); - } else if (node.type === "INSTANCE") { - if (node.mainComponent) { - if (node.mainComponent.parent && node.mainComponent.parent.type === "COMPONENT_SET") { - await processSnippetTemplatesForNode(node.mainComponent.parent); - } - await processSnippetTemplatesForNode(node.mainComponent); - } - } - await processSnippetTemplatesForNode(node); - return nodeSnippetTemplateDataArray; - } function transformStringWithFilter(string, rawString, filter = "hyphen") { const splitString = string.split("-"); - const capitalize2 = (s) => s.charAt(0).toUpperCase() + s.substring(1); + const capitalize = (s) => s.charAt(0).toUpperCase() + s.substring(1); switch (filter) { case "camel": - return splitString.map((word, i) => i === 0 ? word : capitalize2(word)).join(""); + return splitString.map((word, i) => i === 0 ? word : capitalize(word)).join(""); case "constant": return splitString.join("_").toUpperCase(); case "hyphen": return splitString.join("-").toLowerCase(); case "pascal": - return splitString.map(capitalize2).join(""); + return splitString.map(capitalize).join(""); case "raw": return rawString; case "snake": @@ -401,66 +40,21 @@ } return splitString.join(" "); } - async function hydrateSnippets(codegenResultTemplatesArray, codeSnippetParamsMap, nodeType, nodeChildren, indent, recursionIndex, globalTemplates) { - const { paramsRaw, params } = codeSnippetParamsMap; + async function hydrateSnippets(codegenResultTemplatesArray, codeSnippetParamsMap, nodeType, indent, recursionIndex, globalTemplates) { const codegenResultArray = []; const codegenResultRawTemplatesArray = []; const resultPromises = codegenResultTemplatesArray.map( async (codegenResult, index) => { - const lines = codegenResult.code.split("\n"); - const code = []; - for (let i = 0; i < lines.length; i++) { - let line = lines[i]; - const [matches, qualifies] = lineConditionalMatch(line, params); - matches.forEach((match) => { - line = line.replace(match[0], ""); - }); - const symbolMatches = [...line.matchAll(regexSymbols)]; - if (qualifies && symbolMatches.length) { - let succeeded = true; - for (let j = 0; j < symbolMatches.length; j++) { - const symbolMatch = symbolMatches[j]; - const [match, param, _, filter] = symbolMatch.map( - (a) => a ? a.trim() : a - ); - if (param in params) { - const value = transformStringWithFilter( - params[param], - paramsRaw[param], - filter - ); - line = line.replace(match, value); - } else if (param === "figma.children" && recursionIndex < MAX_RECURSION) { - const indentMatch = line.match(/^[ \t]+/); - const indent2 = indentMatch ? indentMatch[0] : ""; - const childrenValue = await findChildrenSnippets( - codegenResult, - nodeChildren, - indent2, - recursionIndex + 1, - globalTemplates - ); - if (childrenValue) { - line = line.replace(/^[ \t]+/, ""); - line = line.replace(match, childrenValue); - } else { - succeeded = false; - } - } else { - succeeded = false; - } - } - if (succeeded) { - line = unescapeBrackets(line); - code.push(line); - } - } else if (qualifies) { - line = unescapeBrackets(line); - code.push(line); - } - } - const codeString = code.join("\n").replace(/\\\\\n/g, "").replace(/\\\n\\/g, "").replace(/\\\n/g, " "); - const indentedCodeString = indent + codeString.replace(/\n/g, ` + const snippetId = snippetIdFromCodegenResult(codegenResult); + const code = await hydrateCodeStringWithParams( + codegenResult.code, + codeSnippetParamsMap, + snippetId, + indent, + recursionIndex, + globalTemplates + ); + const indentedCodeString = indent + code.replace(/\n/g, ` ${indent}`); codegenResultArray[index] = { title: codegenResult.title, @@ -481,32 +75,89 @@ ${indent}`); codegenResultArray }; } - async function findChildrenSnippets(codegenResult, nodeChildren, indent, recursionIndex, globalTemplates) { - const string = []; - const childPromises = nodeChildren.map(async (child, index) => { - const paramsMap = await paramsFromNode(child); - const snippets = await nodeSnippetTemplateDataArrayFromNode( - child, - paramsMap, - globalTemplates, - indent, - recursionIndex + 1, - codegenResult + async function hydrateCodeStringWithParams(codeString, codeSnippetParamsMap, snippetId, indent, recursionIndex, globalTemplates) { + const { paramsRaw, params, template } = codeSnippetParamsMap; + const lines = codeString.split("\n"); + const code = []; + const templateChildren = template[snippetId] ? template[snippetId].children : void 0; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + const [matches, qualifies] = lineConditionalMatch( + line, + params, + templateChildren ); - const snippet = snippets.map( - (s) => s.codegenResultArray.find( - (r) => r.title === codegenResult.title && r.language === codegenResult.language - ) - ).find(Boolean); - if (snippet) { - string[index] = snippet.code; + matches.forEach((match) => { + line = line.replace(match[0], ""); + }); + const symbolMatches = [...line.matchAll(regexSymbols)]; + if (qualifies && symbolMatches.length) { + let succeeded = true; + for (let j = 0; j < symbolMatches.length; j++) { + const symbolMatch = symbolMatches[j]; + const [match, param, _, filter] = symbolMatch.map( + (a) => a ? a.trim() : a + ); + if (param in params) { + const value = transformStringWithFilter( + params[param], + paramsRaw[param], + filter + ); + line = line.replace(match, value); + } else if (param === "figma.children" && recursionIndex < MAX_RECURSION && templateChildren) { + const indentMatch = line.match(/^[ \t]+/); + const indent2 = indentMatch ? indentMatch[0] : ""; + const childrenValue = await findChildrenSnippets( + templateChildren, + indent2, + recursionIndex + 1, + globalTemplates + ); + if (childrenValue) { + line = line.replace(/^[ \t]+/, ""); + line = line.replace(match, childrenValue); + } else { + succeeded = false; + } + } else { + succeeded = false; + } + } + if (succeeded) { + line = unescapeBrackets(line); + code.push(line); + } + } else if (qualifies) { + line = unescapeBrackets(line); + code.push(line); } - return; - }); - await Promise.all(childPromises); + } + const singleLineFormatted = code.join(` +`).replace(/\\\\\n/g, "").replace(/\\\n\\/g, "").replace(/\\\n/g, " "); + return indent + singleLineFormatted.split("\n").join(` +${indent}`); + } + async function findChildrenSnippets(childrenSnippetParams, indent, recursionIndex, globalTemplates) { + const string = []; + for (let childSnippetParams of childrenSnippetParams) { + const snippetId = Object.keys(childSnippetParams.template)[0]; + const template = childSnippetParams.template[snippetId]; + if (template) { + const hydrated = await hydrateCodeStringWithParams( + template.code, + childSnippetParams, + snippetId, + indent, + recursionIndex, + globalTemplates + ); + string.push(hydrated); + } + } return string.filter(Boolean).join("\n"); } - function lineConditionalMatch(line, params) { + function lineConditionalMatch(line, params, templateChildren) { const matches = [...line.matchAll(regexConditional)]; if (!matches.length) { return [[], true]; @@ -526,10 +177,17 @@ ${indent}`); const matches2 = match2.match(/([^=]+)(=([^\}]+))?/); if (matches2) { const [_2, symbol, equals, value] = matches2; - const symbolIsDefined = symbol in params; - const paramsMatch = params[symbol] === value; - const presenceOnly = !Boolean(equals); - return presenceOnly ? symbolIsDefined : paramsMatch; + if (symbol === "figma.children") { + if (!equals && templateChildren) { + return Boolean(templateChildren.length); + } + return false; + } else { + const symbolIsDefined = symbol in params; + const paramsMatch = params[symbol] === value; + const presenceOnly = !Boolean(equals); + return presenceOnly ? symbolIsDefined : paramsMatch; + } } else { return false; } @@ -572,6 +230,7 @@ ${indent}`); async function test() { try { await testSnippets(); + await recursiveTest(); return "Tests succeed!"; } catch (e) { throw e; @@ -580,9 +239,12 @@ ${indent}`); async function testSnippets() { const result = await hydrateSnippets( [{ language: "PLAINTEXT", code: SNIPPET_TEST_CODE, title: "test" }], - { params: SNIPPET_TEST_PARAMS, paramsRaw: SNIPPET_TEST_PARAMS }, + { + params: SNIPPET_TEST_PARAMS, + paramsRaw: SNIPPET_TEST_PARAMS, + template: {} + }, "INSTANCE", - [], "", 0, {} @@ -594,4 +256,404 @@ ${indent}`); throw `Snippet hydration broken. Got: "${code}"`; } test().then(console.log).catch(console.error); + async function recursiveTest() { + const params = { + params: { + "node.name": "buttons-frame", + "node.type": "frame", + "node.children": "2", + "css.display": "flex", + "css.width": "400px", + "css.padding": "var(--padding-spacious, 16px) var(--padding-comfortable, 12px)", + "css.flexDirection": "column", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-lg, 16px)", + "css.border": "2px solid #E0E0E0", + "css.background": "var(--color-bg-subtle, #F0F0F0)", + "variables.itemSpacing": "gap-lg", + "variables.paddingLeft": "padding-comfortable", + "variables.paddingTop": "padding-spacious", + "variables.paddingRight": "padding-comfortable", + "variables.paddingBottom": "padding-spacious", + "variables.fills": "color-bg-subtle", + "autolayout.layoutMode": "vertical", + "autolayout.paddingLeft": "12", + "autolayout.paddingRight": "12", + "autolayout.paddingTop": "16", + "autolayout.paddingBottom": "16", + "autolayout.itemSpacing": "16", + "autolayout.primaryAxisAlignItems": "center", + "autolayout.counterAxisAlignItems": "center" + }, + paramsRaw: { + "node.name": "Buttons Frame", + "node.type": "FRAME", + "node.children": "2", + "css.display": "flex", + "css.width": "400px", + "css.padding": "var(--padding-spacious, 16px) var(--padding-comfortable, 12px)", + "css.flexDirection": "column", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-lg, 16px)", + "css.border": "2px solid #E0E0E0", + "css.background": "var(--color-bg-subtle, #F0F0F0)", + "variables.itemSpacing": "gap/lg", + "variables.paddingLeft": "padding/comfortable", + "variables.paddingTop": "padding/spacious", + "variables.paddingRight": "padding/comfortable", + "variables.paddingBottom": "padding/spacious", + "variables.fills": "color/bg-subtle", + "autolayout.layoutMode": "VERTICAL", + "autolayout.paddingLeft": "12", + "autolayout.paddingRight": "12", + "autolayout.paddingTop": "16", + "autolayout.paddingBottom": "16", + "autolayout.itemSpacing": "16", + "autolayout.primaryAxisAlignItems": "CENTER", + "autolayout.counterAxisAlignItems": "CENTER" + }, + template: { + "React-JAVASCRIPT": { + code: '\n{{?figma.children}}>\n {{figma.children}}\n{{?figma.children}}', + children: [ + { + params: { + "node.name": "heyo-look-at-this", + "node.type": "text", + "node.characters": "heyo-look-at-this", + "node.textStyle": "heading-02", + "css.color": "#000", + "css.fontFamily": "Inter", + "css.fontSize": "36px", + "css.fontStyle": "normal", + "css.fontWeight": "400", + "css.lineHeight": "normal" + }, + paramsRaw: { + "node.name": "Heyo look at this", + "node.type": "TEXT", + "node.characters": "Heyo look at this", + "node.textStyle": "Heading 02", + "css.color": "#000", + "css.fontFamily": "Inter", + "css.fontSize": "36px", + "css.fontStyle": "normal", + "css.fontWeight": "400", + "css.lineHeight": "normal" + }, + template: { + "React-JAVASCRIPT": { + code: '{{node.characters|raw}}' + } + } + }, + { + params: { + "node.name": "frame-2", + "node.type": "frame", + "node.children": "2", + "css.display": "flex", + "css.justifyContent": "flex-end", + "css.alignItems": "center", + "css.gap": "var(--gap-md, 12px)", + "variables.itemSpacing": "gap-md", + "autolayout.layoutMode": "horizontal", + "autolayout.paddingLeft": "0", + "autolayout.paddingRight": "0", + "autolayout.paddingTop": "0", + "autolayout.paddingBottom": "0", + "autolayout.itemSpacing": "12", + "autolayout.primaryAxisAlignItems": "max", + "autolayout.counterAxisAlignItems": "center" + }, + paramsRaw: { + "node.name": "Frame 2", + "node.type": "FRAME", + "node.children": "2", + "css.display": "flex", + "css.justifyContent": "flex-end", + "css.alignItems": "center", + "css.gap": "var(--gap-md, 12px)", + "variables.itemSpacing": "gap/md", + "autolayout.layoutMode": "HORIZONTAL", + "autolayout.paddingLeft": "0", + "autolayout.paddingRight": "0", + "autolayout.paddingTop": "0", + "autolayout.paddingBottom": "0", + "autolayout.itemSpacing": "12", + "autolayout.primaryAxisAlignItems": "MAX", + "autolayout.counterAxisAlignItems": "CENTER" + }, + template: { + "React-JAVASCRIPT": { + code: '\n{{?figma.children}}>\n {{figma.children}}\n{{?figma.children}}', + children: [ + { + params: { + "property.iconEnd.b": "false", + "property.iconEnd.i": "icon-refresh", + "property.iconStart.b": "false", + "property.iconStart.i": "icon-heart-solid", + "property.label": "cancel", + "property.variant": "inverse", + "property.state": "default", + "property.size": "small", + "node.name": "button", + "node.type": "instance", + "node.children": "1", + "component.key": "7e95f3069ff381e6d1ea1e34d13d82045be8e249", + "component.type": "component-set", + "component.name": "button", + "css.display": "flex", + "css.padding": "var(--padding-compact, 4px) var(--padding-spacious, 16px)", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-sm, 8px)", + "css.borderRadius": "var(--size-24, 24px)", + "css.background": "var(--color-bg-default, #FFF)", + "variables.itemSpacing": "gap-sm", + "variables.paddingLeft": "padding-spacious", + "variables.paddingTop": "padding-compact", + "variables.paddingRight": "padding-spacious", + "variables.paddingBottom": "padding-compact", + "variables.topLeftRadius": "size-24", + "variables.topRightRadius": "size-24", + "variables.bottomLeftRadius": "size-24", + "variables.bottomRightRadius": "size-24", + "variables.fills": "color-bg-default", + "autolayout.layoutMode": "horizontal", + "autolayout.paddingLeft": "16", + "autolayout.paddingRight": "16", + "autolayout.paddingTop": "4", + "autolayout.paddingBottom": "4", + "autolayout.itemSpacing": "8", + "autolayout.primaryAxisAlignItems": "center", + "autolayout.counterAxisAlignItems": "center" + }, + paramsRaw: { + "property.iconEnd.b": "false", + "property.iconEnd.i": "Icon Refresh", + "property.iconStart.b": "false", + "property.iconStart.i": "Icon Heart - Solid", + "property.label": "Cancel", + "property.variant": "Inverse", + "property.state": "Default", + "property.size": "Small", + "node.name": "Button", + "node.type": "INSTANCE", + "node.children": "1", + "component.key": "7e95f3069ff381e6d1ea1e34d13d82045be8e249", + "component.type": "COMPONENT_SET", + "component.name": "Button", + "css.display": "flex", + "css.padding": "var(--padding-compact, 4px) var(--padding-spacious, 16px)", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-sm, 8px)", + "css.borderRadius": "var(--size-24, 24px)", + "css.background": "var(--color-bg-default, #FFF)", + "variables.itemSpacing": "gap/sm", + "variables.paddingLeft": "padding/spacious", + "variables.paddingTop": "padding/compact", + "variables.paddingRight": "padding/spacious", + "variables.paddingBottom": "padding/compact", + "variables.topLeftRadius": "size-24", + "variables.topRightRadius": "size-24", + "variables.bottomLeftRadius": "size-24", + "variables.bottomRightRadius": "size-24", + "variables.fills": "color/bg-default", + "autolayout.layoutMode": "HORIZONTAL", + "autolayout.paddingLeft": "16", + "autolayout.paddingRight": "16", + "autolayout.paddingTop": "4", + "autolayout.paddingBottom": "4", + "autolayout.itemSpacing": "8", + "autolayout.primaryAxisAlignItems": "CENTER", + "autolayout.counterAxisAlignItems": "CENTER" + }, + template: { + "React-JAVASCRIPT": { + code: '}\n {{?property.iconEnd.b=true}}iconEnd={<{{property.iconEnd.i|pascal}} />}\n onClick={() => {}}\n>\n {{property.label|raw}}\n' + } + } + }, + { + params: { + "property.iconEnd.b": "true", + "property.iconEnd.i": "icon-arrow-right", + "property.iconStart.b": "false", + "property.iconStart.i": "icon-heart-solid", + "property.label": "lets-go", + "property.variant": "secondary", + "property.state": "default", + "property.size": "medium", + "node.name": "button", + "node.type": "instance", + "node.children": "2", + "component.key": "7e95f3069ff381e6d1ea1e34d13d82045be8e249", + "component.type": "component-set", + "component.name": "button", + "css.display": "flex", + "css.padding": "var(--padding-default, 8px) var(--padding-baggy, 24px)", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-sm, 8px)", + "css.borderRadius": "var(--size-24, 24px)", + "css.background": "var(--color-bg-brand-secondary, #7900D5)", + "variables.itemSpacing": "gap-sm", + "variables.paddingLeft": "padding-baggy", + "variables.paddingTop": "padding-default", + "variables.paddingRight": "padding-baggy", + "variables.paddingBottom": "padding-default", + "variables.topLeftRadius": "size-24", + "variables.topRightRadius": "size-24", + "variables.bottomLeftRadius": "size-24", + "variables.bottomRightRadius": "size-24", + "variables.fills": "color-bg-brand-secondary", + "autolayout.layoutMode": "horizontal", + "autolayout.paddingLeft": "24", + "autolayout.paddingRight": "24", + "autolayout.paddingTop": "8", + "autolayout.paddingBottom": "8", + "autolayout.itemSpacing": "8", + "autolayout.primaryAxisAlignItems": "center", + "autolayout.counterAxisAlignItems": "center" + }, + paramsRaw: { + "property.iconEnd.b": "true", + "property.iconEnd.i": "Icon Arrow - Right", + "property.iconStart.b": "false", + "property.iconStart.i": "Icon Heart - Solid", + "property.label": "Let's go!", + "property.variant": "Secondary", + "property.state": "Default", + "property.size": "Medium", + "node.name": "Button", + "node.type": "INSTANCE", + "node.children": "2", + "component.key": "7e95f3069ff381e6d1ea1e34d13d82045be8e249", + "component.type": "COMPONENT_SET", + "component.name": "Button", + "css.display": "flex", + "css.padding": "var(--padding-default, 8px) var(--padding-baggy, 24px)", + "css.justifyContent": "center", + "css.alignItems": "center", + "css.gap": "var(--gap-sm, 8px)", + "css.borderRadius": "var(--size-24, 24px)", + "css.background": "var(--color-bg-brand-secondary, #7900D5)", + "variables.itemSpacing": "gap/sm", + "variables.paddingLeft": "padding/baggy", + "variables.paddingTop": "padding/default", + "variables.paddingRight": "padding/baggy", + "variables.paddingBottom": "padding/default", + "variables.topLeftRadius": "size-24", + "variables.topRightRadius": "size-24", + "variables.bottomLeftRadius": "size-24", + "variables.bottomRightRadius": "size-24", + "variables.fills": "color/bg-brand-secondary", + "autolayout.layoutMode": "HORIZONTAL", + "autolayout.paddingLeft": "24", + "autolayout.paddingRight": "24", + "autolayout.paddingTop": "8", + "autolayout.paddingBottom": "8", + "autolayout.itemSpacing": "8", + "autolayout.primaryAxisAlignItems": "CENTER", + "autolayout.counterAxisAlignItems": "CENTER" + }, + template: { + "React-JAVASCRIPT": { + code: '}\n {{?property.iconEnd.b=true}}iconEnd={<{{property.iconEnd.i|pascal}} />}\n onClick={() => {}}\n>\n {{property.label|raw}}\n' + } + } + } + ] + } + } + } + ] + } + } + }; + const template = ` +{{?figma.children}}> + {{figma.children}} +{{?figma.children}}`; + const expectation = ` + Heyo look at this + + + + +`; + const result = await hydrateSnippets( + [{ language: "JAVASCRIPT", code: template, title: "React" }], + params, + "FRAME", + "", + 0, + {} + ); + const code = result.codegenResultArray[0].code; + if (code === expectation) { + return true; + } + const expectationLines = expectation.split("\n"); + const codeLines = code.split("\n"); + const diff = []; + expectationLines.forEach((line, i) => { + if (line !== codeLines[i]) { + diff.push(["E: " + line, "R: " + codeLines[i]].join("\n")); + } + }); + throw `Snippet hydration broken. + +${diff.join("\n\n")} +`; + } })();