diff --git a/CHANGELOG.md b/CHANGELOG.md index cd15e1aa8..1dc5c6dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Features +- XPath query command — `open-pencil query design.fig "//FRAME[@width < 300]"` to find nodes by type, attributes, and tree structure using XPath selectors - CSS Grid layout mode — select a frame, click the grid icon in the auto layout toolbar to switch from flex to grid. Configure column/row tracks (fr, fixed px, auto), column and row gaps, and per-side padding. Powered by a [Yoga fork](https://github.com/open-pencil/yoga/tree/grid) with cherry-picked CSS Grid PRs from upstream - JSX and Tailwind CSS export for grid layouts — `grid grid-cols-N`, `gap-x-*`/`gap-y-*`, child `col-start-*`/`row-start-*`/`col-span-*`/`row-span-*` - Multi-provider AI support — connect to Anthropic, OpenAI, Google AI, or any OpenAI-compatible endpoint directly, in addition to OpenRouter. Per-provider API key storage, provider settings popover, automatic migration from single OpenRouter key diff --git a/README.md b/README.md index 1b395cfac..81a530b7f 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,18 @@ open-pencil info design.fig [0] [frame] "Guidance" (0:46571) ``` +### Query with XPath + +Use XPath selectors to find nodes by type, attributes, and structure: + +```sh +open-pencil query design.fig "//FRAME" # All frames +open-pencil query design.fig "//FRAME[@width < 300]" # Frames under 300px +open-pencil query design.fig "//TEXT[contains(@name, 'Button')]" # Text with 'Button' in name +open-pencil query design.fig "//*[@cornerRadius > 0]" # Rounded corners +open-pencil query design.fig "//SECTION//TEXT" # Text inside sections +``` + ### Export Render to PNG, JPG, WEBP, SVG — or export as JSX with Tailwind utility classes: diff --git a/bun.lock b/bun.lock index d14cdcb3d..ec6f63749 100644 --- a/bun.lock +++ b/bun.lock @@ -113,6 +113,7 @@ "culori": "^4.0.2", "diff": "^8.0.3", "fflate": "^0.8.2", + "fontoxpath": "^3.34.0", "fzstd": "^0.1.1", "sucrase": "^3.35.1", "yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2", @@ -1397,6 +1398,8 @@ "focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="], + "fontoxpath": ["fontoxpath@3.34.0", "", { "dependencies": { "prsc": "4.0.0", "xspattern": "^3.1.0" } }, "sha512-7FgBQRohn4WR/2eVdYqZtKBFZkJNaiP4kGdfqS6bAg465og05ixGA2OgfMdxsO1mnX67+64JX/8JKclVqS+bzA=="], + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], @@ -2005,6 +2008,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "prsc": ["prsc@4.0.0", "", {}, "sha512-OmXQ2v76RlXNx/gqv7+oB6jyKnudJ/rsMfAIVexhbDFxXAJPoWKMxJkZU2ohlu4miCiSQCG+horf2DV4/kNc1Q=="], + "pug": ["pug@3.0.3", "", { "dependencies": { "pug-code-gen": "^3.0.3", "pug-filters": "^4.0.0", "pug-lexer": "^5.0.1", "pug-linker": "^4.0.0", "pug-load": "^3.0.0", "pug-parser": "^6.0.0", "pug-runtime": "^3.0.1", "pug-strip-comments": "^2.0.0" } }, "sha512-uBi6kmc9f3SZ3PXxqcHiUZLmIXgfgWooKWXcwSGwQd2Zi5Rb0bT14+8CJjJgI8AB+nndLaNgHGrcc6bPIB665g=="], "pug-attrs": ["pug-attrs@3.0.0", "", { "dependencies": { "constantinople": "^4.0.1", "js-stringify": "^1.0.2", "pug-runtime": "^3.0.0" } }, "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA=="], @@ -2379,6 +2384,8 @@ "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + "whynot": ["whynot@5.0.0", "", {}, "sha512-cGGfDPYYqoHoMWqXVc3G+N74u1FhFhrBa+pOO906b9ktRc/JFUdwroTkfPqjKzyoCLVcKr6yy6bYIcWT7jTg2A=="], + "with": ["with@7.0.2", "", { "dependencies": { "@babel/parser": "^7.9.6", "@babel/types": "^7.9.6", "assert-never": "^1.2.1", "babel-walk": "3.0.0-canary-5" } }, "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w=="], "workbox-background-sync": ["workbox-background-sync@7.4.0", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.0" } }, "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w=="], @@ -2427,6 +2434,8 @@ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "xspattern": ["xspattern@3.1.0", "", { "dependencies": { "whynot": "^5.0.0" } }, "sha512-rxtOX8ORJizRqswIdZ72G3thKrHd0Q+TvyUCMByc5VdjDz1nkuZKfPXM3xGdrGi0fwEy+DDJN7DqtAZ06Re4bA=="], + "y-indexeddb": ["y-indexeddb@9.0.12", "", { "dependencies": { "lib0": "^0.2.74" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg=="], "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="], diff --git a/packages/cli/src/commands/query.ts b/packages/cli/src/commands/query.ts new file mode 100644 index 000000000..cd9eca42b --- /dev/null +++ b/packages/cli/src/commands/query.ts @@ -0,0 +1,90 @@ +import { defineCommand } from 'citty' + +import { loadDocument } from '../headless' +import { isAppMode, requireFile, rpc } from '../app-client' +import { fmtList, printError, bold, entity, formatType } from '../format' +import { executeRpcCommand } from '@open-pencil/core' + +import type { QueryNodeResult } from '@open-pencil/core' + +async function getData( + file: string | undefined, + args: { selector: string; page?: string; limit?: string } +): Promise { + const rpcArgs = { + selector: args.selector, + page: args.page, + limit: args.limit ? Number(args.limit) : undefined + } + if (isAppMode(file)) return rpc('query', rpcArgs) + const graph = await loadDocument(requireFile(file)) + return await executeRpcCommand(graph, 'query', rpcArgs) as QueryNodeResult[] | { error: string } +} + +export default defineCommand({ + meta: { + description: `Query nodes using XPath selectors + +Examples: + open-pencil query file.fig "//FRAME" # All frames + open-pencil query file.fig "//FRAME[@width < 300]" # Frames narrower than 300px + open-pencil query file.fig "//COMPONENT[starts-with(@name, 'Button')]" # Components starting with Button + open-pencil query file.fig "//SECTION/FRAME" # Direct frame children of sections + open-pencil query file.fig "//SECTION//TEXT" # All text inside sections + open-pencil query file.fig "//*[@cornerRadius > 0]" # Any node with corner radius` + }, + args: { + file: { + type: 'positional', + description: '.fig file path (omit to connect to running app)', + required: false + }, + selector: { + type: 'positional', + description: + 'XPath selector (e.g., //FRAME[@width < 300], //TEXT[contains(@name, "Label")])', + required: true + }, + page: { type: 'string', description: 'Page name (default: all pages)' }, + limit: { type: 'string', description: 'Max results (default: 1000)', default: '1000' }, + json: { type: 'boolean', description: 'Output as JSON' } + }, + async run({ args }) { + const results = await getData(args.file, { + selector: args.selector, + page: args.page, + limit: args.limit + }) + + if ('error' in results) { + printError(results.error) + process.exit(1) + } + + if (args.json) { + console.log(JSON.stringify(results, null, 2)) + return + } + + if (results.length === 0) { + console.log('No nodes found.') + return + } + + console.log('') + console.log(bold(` Found ${results.length} node${results.length > 1 ? 's' : ''}`)) + console.log('') + console.log( + fmtList( + results.map((n) => ({ + header: entity( + formatType(n.type), + `${n.name} ${n.width}×${n.height}`, + n.id + ) + })) + ) + ) + console.log('') + } +}) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5a466f215..41bfb031c 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import evalCmd from './commands/eval' import exportCmd from './commands/export' import find from './commands/find' import info from './commands/info' +import query from './commands/query' import node from './commands/node' import pages from './commands/pages' import tree from './commands/tree' @@ -25,6 +26,7 @@ const main = defineCommand({ export: exportCmd, find, info, + query, node, pages, tree, diff --git a/packages/core/package.json b/packages/core/package.json index b5c0c90eb..3ef08d76f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,6 +40,7 @@ "culori": "^4.0.2", "diff": "^8.0.3", "fflate": "^0.8.2", + "fontoxpath": "^3.34.0", "fzstd": "^0.1.1", "sucrase": "^3.35.1", "yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b5ac99ff3..1efa040f8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,9 +53,11 @@ export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api' export { ALL_TOOLS, defineTool, toolsToAI } from './tools' export type { ToolDef, ParamDef, ParamType } from './tools' export { executeRpcCommand, ALL_RPC_COMMANDS } from './rpc' +export { queryByXPath, matchByXPath } from './xpath' +export type { XPathQueryOptions } from './xpath' export type { InfoResult, PageItem, TreeArgs, TreeResult, TreeNodeResult, - FindArgs, FindNodeResult, NodeArgs, NodeResult, + FindArgs, FindNodeResult, QueryArgs, QueryNodeResult, NodeArgs, NodeResult, VariablesArgs, VariablesResult, AnalyzeColorsArgs, AnalyzeColorsResult, AnalyzeTypographyArgs, AnalyzeTypographyResult, diff --git a/packages/core/src/rpc/commands.ts b/packages/core/src/rpc/commands.ts index a7b48fac2..710044ccf 100644 --- a/packages/core/src/rpc/commands.ts +++ b/packages/core/src/rpc/commands.ts @@ -1,10 +1,11 @@ import { colorToHex, colorDistance as colorDist } from '../color' +import { queryByXPath } from '../xpath' import type { Color } from '../types' import type { SceneGraph, SceneNode, Variable } from '../scene-graph' export interface RpcCommand { name: string - execute: (graph: SceneGraph, args: A) => R + execute: (graph: SceneGraph, args: A) => R | Promise } /** Walk descendants. Callback returns `false` to stop traversal. */ @@ -192,6 +193,47 @@ export const findCommand: RpcCommand = { } } +// ── query (xpath) ── + +export interface QueryArgs { + selector: string + page?: string + limit?: number +} + +export interface QueryNodeResult { + id: string + name: string + type: string + x: number + y: number + width: number + height: number +} + +export const queryCommand: RpcCommand = { + name: 'query', + execute: async (graph, args) => { + try { + const nodes = await queryByXPath(graph, args.selector, { + page: args.page, + limit: args.limit + }) + return nodes.map((n) => ({ + id: n.id, + name: n.name, + type: n.type, + x: Math.round(n.x), + y: Math.round(n.y), + width: Math.round(n.width), + height: Math.round(n.height) + })) + } catch (err) { + return { error: `XPath error: ${err instanceof Error ? err.message : String(err)}` } + } + } +} + // ── node ── export interface NodeArgs { @@ -609,6 +651,7 @@ export const ALL_RPC_COMMANDS = [ pagesCommand, treeCommand, findCommand, + queryCommand, nodeCommand, variablesCommand, analyzeColorsCommand, diff --git a/packages/core/src/rpc/index.ts b/packages/core/src/rpc/index.ts index 03fd823fe..b3a239679 100644 --- a/packages/core/src/rpc/index.ts +++ b/packages/core/src/rpc/index.ts @@ -7,6 +7,8 @@ export type { TreeNodeResult, FindArgs, FindNodeResult, + QueryArgs, + QueryNodeResult, NodeArgs, NodeResult, VariablesArgs, diff --git a/packages/core/src/tools/read.ts b/packages/core/src/tools/read.ts index 2bc0dfa02..a48dec580 100644 --- a/packages/core/src/tools/read.ts +++ b/packages/core/src/tools/read.ts @@ -1,4 +1,5 @@ import { defineTool, nodeSummary, nodeToResult } from './schema' +import { queryByXPath } from '../xpath' import type { FigmaNodeProxy } from '../figma-api' @@ -206,3 +207,36 @@ export const listFonts = defineTool({ return { count: result.length, fonts: result } } }) + +export const queryNodes = defineTool({ + name: 'query_nodes', + description: `Query nodes using XPath selectors. Node types are element names (FRAME, TEXT, RECTANGLE, ELLIPSE, etc.). Attributes: name, width, height, x, y, visible, opacity, cornerRadius, fontSize, fontFamily, fontWeight, layoutMode, itemSpacing, paddingTop/Right/Bottom/Left, strokeWeight, rotation, locked, blendMode, text, lineHeight, letterSpacing. + +Examples: + //FRAME — all frames + //FRAME[@width < 300] — frames narrower than 300px + //COMPONENT[starts-with(@name, 'Button')] — components starting with "Button" + //SECTION/FRAME — direct frame children of sections + //SECTION//TEXT — all text nodes inside sections + //*[@cornerRadius > 0] — any node with corner radius + //TEXT[contains(@text, 'Hello')] — text nodes containing "Hello"`, + params: { + selector: { type: 'string', description: 'XPath selector', required: true }, + page: { type: 'string', description: 'Page name (default: current page)' }, + limit: { type: 'number', description: 'Max results (default: 1000)' } + }, + execute: async (figma, args) => { + try { + const nodes = await queryByXPath(figma.graph, args.selector, { + page: args.page ?? figma.currentPage.name, + limit: args.limit + }) + return { + count: nodes.length, + nodes: nodes.map((n) => ({ id: n.id, name: n.name, type: n.type })) + } + } catch (err) { + return { error: `XPath error: ${err instanceof Error ? err.message : String(err)}` } + } + } +}) diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts index 2e9d15c87..8179431c9 100644 --- a/packages/core/src/tools/registry.ts +++ b/packages/core/src/tools/registry.ts @@ -1,7 +1,7 @@ import type { ToolDef } from './schema' import { - getSelection, getPageTree, getNode, findNodes, getComponents, + getSelection, getPageTree, getNode, findNodes, queryNodes, getComponents, listPages, switchPage, getCurrentPage, pageBounds, selectNodes, listFonts } from './read' import { @@ -41,6 +41,7 @@ export const ALL_TOOLS: ToolDef[] = [ getPageTree, getNode, findNodes, + queryNodes, getComponents, listPages, switchPage, diff --git a/packages/core/src/xpath.ts b/packages/core/src/xpath.ts new file mode 100644 index 000000000..c55c09e41 --- /dev/null +++ b/packages/core/src/xpath.ts @@ -0,0 +1,257 @@ +import type { IDomFacade } from 'fontoxpath' +import type { SceneGraph, SceneNode } from './scene-graph' + +const NODE_TYPES = { + ELEMENT_NODE: 1, + ATTRIBUTE_NODE: 2, + TEXT_NODE: 3, + DOCUMENT_NODE: 9 +} + +const QUERYABLE_ATTRS = [ + 'name', + 'width', + 'height', + 'x', + 'y', + 'visible', + 'opacity', + 'cornerRadius', + 'fontSize', + 'fontFamily', + 'fontWeight', + 'layoutMode', + 'itemSpacing', + 'paddingTop', + 'paddingBottom', + 'paddingLeft', + 'paddingRight', + 'strokeWeight', + 'rotation', + 'locked', + 'blendMode', + 'text', + 'lineHeight', + 'letterSpacing' +] + +interface XPathDocument { + nodeType: number + nodeName: string + documentElement: XPathNode + _children?: XPathNode[] +} + +interface XPathAttr { + nodeType: number + nodeName: string + name: string + localName: string + namespaceURI: null + prefix: null + value: string + ownerElement: XPathNode +} + +interface XPathNode { + nodeType: number + nodeName: string + localName: string + namespaceURI: null + prefix: null + _sceneNode: SceneNode + _attrs?: XPathAttr[] + _parent?: XPathNode | null + _children?: XPathNode[] +} + +function wrapNode( + graph: SceneGraph, + node: SceneNode, + parent?: XPathNode | XPathDocument | null +): XPathNode { + const wrapped: XPathNode = { + nodeType: NODE_TYPES.ELEMENT_NODE, + nodeName: node.type, + localName: node.type, + namespaceURI: null, + prefix: null, + _sceneNode: node, + _parent: parent as XPathNode | null + } + return wrapped +} + +function createDocument(graph: SceneGraph, rootNode: SceneNode): XPathDocument { + const doc: XPathDocument = { + nodeType: NODE_TYPES.DOCUMENT_NODE, + nodeName: '#document', + documentElement: null as unknown as XPathNode + } + const root = wrapNode(graph, rootNode, doc as unknown as XPathNode) + doc.documentElement = root + doc._children = [root] + return doc +} + +function getAttrs(wrapped: XPathNode): XPathAttr[] { + if (wrapped._attrs) return wrapped._attrs + + const node = wrapped._sceneNode + const attrs: XPathAttr[] = [] + + for (const attrName of QUERYABLE_ATTRS) { + if (attrName in node) { + const value = (node as unknown as Record)[attrName] + if (value === undefined || value === null || typeof value === 'symbol') continue + attrs.push({ + nodeType: NODE_TYPES.ATTRIBUTE_NODE, + nodeName: attrName, + name: attrName, + localName: attrName, + namespaceURI: null, + prefix: null, + value: String(value), + ownerElement: wrapped + }) + } + } + + wrapped._attrs = attrs + return attrs +} + +function getChildren(graph: SceneGraph, wrapped: XPathNode): XPathNode[] { + if (wrapped._children) return wrapped._children + + const node = wrapped._sceneNode + wrapped._children = node.childIds + .map((id) => graph.getNode(id)) + .filter((n): n is SceneNode => n !== undefined) + .map((child) => wrapNode(graph, child, wrapped)) + + return wrapped._children +} + +function isDocument(node: unknown): node is XPathDocument { + return (node as XPathDocument).nodeType === NODE_TYPES.DOCUMENT_NODE +} + +function createDomFacade(graph: SceneGraph) { + return { + getAllAttributes(node: XPathNode | XPathDocument): XPathAttr[] { + if (isDocument(node)) return [] + return getAttrs(node) + }, + + getAttribute(node: XPathNode | XPathDocument, attributeName: string): string | null { + if (isDocument(node)) return null + const sceneNode = node._sceneNode + if (attributeName in sceneNode) { + const value = (sceneNode as unknown as Record)[attributeName] + if (value === undefined || value === null || typeof value === 'symbol') return null + return String(value) + } + return null + }, + + getChildNodes(node: XPathNode | XPathDocument): XPathNode[] { + if (isDocument(node)) return node._children ?? [] + return getChildren(graph, node) + }, + + getData(node: XPathAttr): string { + return node.value + }, + + getFirstChild(node: XPathNode | XPathDocument): XPathNode | null { + if (isDocument(node)) return node.documentElement + const children = getChildren(graph, node) + return children[0] ?? null + }, + + getLastChild(node: XPathNode | XPathDocument): XPathNode | null { + if (isDocument(node)) return node.documentElement + const children = getChildren(graph, node) + return children[children.length - 1] ?? null + }, + + getNextSibling(node: XPathNode | XPathDocument): XPathNode | null { + if (isDocument(node)) return null + const parent = node._parent + if (!parent) return null + const siblings = getChildren(graph, parent) + const idx = siblings.indexOf(node) + return siblings[idx + 1] ?? null + }, + + getParentNode(node: XPathNode | XPathDocument): XPathNode | XPathDocument | null { + if (isDocument(node)) return null + return node._parent ?? null + }, + + getPreviousSibling(node: XPathNode | XPathDocument): XPathNode | null { + if (isDocument(node)) return null + const parent = node._parent + if (!parent) return null + const siblings = getChildren(graph, parent) + const idx = siblings.indexOf(node) + return idx > 0 ? siblings[idx - 1] : null + } + } +} + +export interface XPathQueryOptions { + limit?: number + page?: string +} + +export async function queryByXPath( + graph: SceneGraph, + selector: string, + options: XPathQueryOptions = {} +): Promise { + const { limit = 1000 } = options + const pages = graph.getPages() + const targetPages = options.page + ? pages.filter((p) => p.name === options.page) + : pages + + if (targetPages.length === 0) return [] + + const { evaluateXPathToNodes } = await import('fontoxpath') + const domFacade = createDomFacade(graph) as unknown as IDomFacade + const results: SceneNode[] = [] + + for (const page of targetPages) { + const doc = createDocument(graph, page) + const nodes = evaluateXPathToNodes(selector, doc, domFacade) + + for (const node of nodes) { + if (results.length >= limit) break + const sceneNode = (node as XPathNode)._sceneNode + if (sceneNode.type !== 'CANVAS') { + results.push(sceneNode) + } + } + + if (results.length >= limit) break + } + + return results +} + +export async function matchByXPath( + graph: SceneGraph, + selector: string, + node: SceneNode +): Promise { + const { evaluateXPathToBoolean } = await import('fontoxpath') + const domFacade = createDomFacade(graph) as unknown as IDomFacade + const wrapped = wrapNode(graph, node) + try { + return evaluateXPathToBoolean(`self::*[${selector}]`, wrapped, domFacade) + } catch { + return false + } +} diff --git a/packages/docs/programmable/cli/inspecting.md b/packages/docs/programmable/cli/inspecting.md index 0cb2e7f5f..cad79a778 100644 --- a/packages/docs/programmable/cli/inspecting.md +++ b/packages/docs/programmable/cli/inspecting.md @@ -56,6 +56,65 @@ open-pencil find design.fig --name "Button" Both flags can be combined to narrow results further. +## Query with XPath + +Use XPath selectors to find nodes by type, attributes, and tree structure: + +```sh +open-pencil query design.fig "//FRAME" +``` + +### Useful patterns + +**By type:** + +```sh +open-pencil query design.fig "//TEXT" # All text nodes +open-pencil query design.fig "//COMPONENT" # All components +open-pencil query design.fig "//INSTANCE" # All instances +``` + +**By attributes:** + +```sh +open-pencil query design.fig "//FRAME[@width < 300]" # Frames under 300px wide +open-pencil query design.fig "//*[@cornerRadius > 0]" # Rounded corners +open-pencil query design.fig "//*[@visible = false]" # Hidden nodes +open-pencil query design.fig "//TEXT[@fontSize >= 24]" # Large text +open-pencil query design.fig "//*[@opacity < 1]" # Semi-transparent nodes +``` + +**By name and text content:** + +```sh +open-pencil query design.fig "//TEXT[contains(@name, 'Button')]" # Name contains 'Button' +open-pencil query design.fig "//TEXT[contains(@text, 'Hello')]" # Text content contains 'Hello' +``` + +**By hierarchy:** + +```sh +open-pencil query design.fig "//SECTION//TEXT" # Text inside sections +open-pencil query design.fig "//FRAME/TEXT" # Direct text children of frames +open-pencil query design.fig "//COMPONENT_SET//INSTANCE" # Instances inside component sets +``` + +### Queryable attributes + +`name`, `width`, `height`, `x`, `y`, `visible`, `opacity`, `cornerRadius`, `fontSize`, `fontFamily`, `fontWeight`, `layoutMode`, `itemSpacing`, `paddingTop`, `paddingRight`, `paddingBottom`, `paddingLeft`, `strokeWeight`, `rotation`, `locked`, `blendMode`, `text`, `lineHeight`, `letterSpacing` + +### Example output + +``` + Found 5 nodes + +[0] [frame] "Logo 92×32" (0:9) +[1] [frame] "logo-short-6 31×32" (0:10) +[2] [frame] "wrapper 128×73" (0:20) +[3] [frame] "pen-drawing 148×52" (0:21) +[4] [frame] "surprised-emoji 32×32" (0:26) +``` + ## Node Details Inspect all properties of a specific node by its ID: diff --git a/tests/engine/tools.test.ts b/tests/engine/tools.test.ts index 23eb2e507..8da7c2dc1 100644 --- a/tests/engine/tools.test.ts +++ b/tests/engine/tools.test.ts @@ -315,6 +315,86 @@ describe('find_nodes', () => { }) }) +describe('query_nodes', () => { + test('finds all frames with //FRAME', async () => { + const { figma } = setup() + const f1 = figma.createFrame() + f1.resize(200, 200) + f1.name = 'Frame A' + const f2 = figma.createFrame() + f2.resize(300, 300) + f2.name = 'Frame B' + figma.createRectangle() + + const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')! + const result = await tool.execute(figma, { selector: '//FRAME' }) as any + expect(result.count).toBe(2) + expect(result.nodes.every((n: any) => n.type === 'FRAME')).toBe(true) + }) + + test('finds by attribute //RECTANGLE[@width < 200]', async () => { + const { figma } = setup() + const small = figma.createRectangle() + small.resize(100, 50) + small.name = 'Small' + const big = figma.createRectangle() + big.resize(400, 400) + big.name = 'Big' + + const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')! + const result = await tool.execute(figma, { selector: '//RECTANGLE[@width < 200]' }) as any + expect(result.count).toBe(1) + expect(result.nodes[0].name).toBe('Small') + }) + + test('finds by name with contains', async () => { + const { figma } = setup() + const t1 = figma.createText() + t1.name = 'Label Primary' + const t2 = figma.createText() + t2.name = 'Title' + const t3 = figma.createText() + t3.name = 'Label Secondary' + + const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')! + const result = await tool.execute(figma, { + selector: '//TEXT[contains(@name, "Label")]' + }) as any + expect(result.count).toBe(2) + expect(result.nodes.every((n: any) => n.name.includes('Label'))).toBe(true) + }) + + test('returns error for invalid xpath', async () => { + const { figma } = setup() + const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')! + const result = await tool.execute(figma, { selector: '///invalid[[[[' }) as any + expect(result.error).toBeTruthy() + expect(result.error).toContain('XPath error') + }) + + test('respects limit param', async () => { + const { figma } = setup() + for (let i = 0; i < 10; i++) { + const r = figma.createRectangle() + r.name = `Rect ${i}` + } + + const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')! + const result = await tool.execute(figma, { selector: '//RECTANGLE', limit: 3 }) as any + expect(result.count).toBe(3) + }) + + test('returns empty array when nothing matches', async () => { + const { figma } = setup() + figma.createRectangle() + + const tool = ALL_TOOLS.find((t) => t.name === 'query_nodes')! + const result = await tool.execute(figma, { selector: '//ELLIPSE' }) as any + expect(result.count).toBe(0) + expect(result.nodes).toEqual([]) + }) +}) + describe('get_node', () => { test('returns node details', () => { const { figma } = setup() diff --git a/tests/engine/xpath.test.ts b/tests/engine/xpath.test.ts new file mode 100644 index 000000000..839c62a79 --- /dev/null +++ b/tests/engine/xpath.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test } from 'bun:test' + +import { FigmaAPI, SceneGraph, matchByXPath, queryByXPath } from '@open-pencil/core' + +function setup() { + const graph = new SceneGraph() + const figma = new FigmaAPI(graph) + return { graph, figma } +} + +describe('queryByXPath', () => { + test('finds nodes by type', async () => { + const { graph, figma } = setup() + figma.createRectangle() + figma.createRectangle() + figma.createText() + + const results = await queryByXPath(graph, '//RECTANGLE') + expect(results.length).toBe(2) + expect(results.every((n) => n.type === 'RECTANGLE')).toBe(true) + }) + + test('filters by attribute comparison', async () => { + const { graph, figma } = setup() + const small = figma.createRectangle() + small.resize(50, 50) + small.name = 'Small' + + const big = figma.createRectangle() + big.resize(300, 300) + big.name = 'Big' + + const results = await queryByXPath(graph, '//RECTANGLE[@width < 200]') + expect(results.length).toBe(1) + expect(results[0].name).toBe('Small') + }) + + test('handles descendant axis //FRAME//TEXT', async () => { + const { graph, figma } = setup() + const frame = figma.createFrame() + frame.resize(400, 400) + frame.name = 'Container' + + const text = figma.createText() + text.name = 'Inside' + frame.appendChild(text) + + const outsideText = figma.createText() + outsideText.name = 'Outside' + + const results = await queryByXPath(graph, '//FRAME//TEXT') + expect(results.length).toBe(1) + expect(results[0].name).toBe('Inside') + }) + + test('respects limit option', async () => { + const { graph, figma } = setup() + for (let i = 0; i < 10; i++) { + const r = figma.createRectangle() + r.name = `Rect ${i}` + } + + const results = await queryByXPath(graph, '//RECTANGLE', { limit: 3 }) + expect(results.length).toBe(3) + }) + + test('filters by page option', async () => { + const { graph, figma } = setup() + const rect1 = figma.createRectangle() + rect1.name = 'Page1Rect' + const page1Name = figma.currentPage.name + + const page2 = figma.createPage() + page2.name = 'Page 2' + figma.currentPage = page2 + const rect2 = figma.createRectangle() + rect2.name = 'Page2Rect' + + const page1Results = await queryByXPath(graph, '//RECTANGLE', { page: page1Name }) + expect(page1Results.length).toBe(1) + expect(page1Results[0].name).toBe('Page1Rect') + + const page2Results = await queryByXPath(graph, '//RECTANGLE', { page: 'Page 2' }) + expect(page2Results.length).toBe(1) + expect(page2Results[0].name).toBe('Page2Rect') + }) + + test('returns empty for no matches', async () => { + const { graph, figma } = setup() + figma.createRectangle() + + const results = await queryByXPath(graph, '//ELLIPSE') + expect(results.length).toBe(0) + }) + + test('handles invalid selector gracefully (throws)', async () => { + const { graph } = setup() + expect(queryByXPath(graph, '///invalid[[[[')).rejects.toThrow() + }) +}) + +describe('matchByXPath', () => { + test('returns true for matching node', async () => { + const { graph, figma } = setup() + const rect = figma.createRectangle() + rect.resize(100, 100) + rect.name = 'TestRect' + + const sceneNode = graph.getNode(rect.id)! + const result = await matchByXPath(graph, '@name = "TestRect"', sceneNode) + expect(result).toBe(true) + }) + + test('returns false for non-matching node', async () => { + const { graph, figma } = setup() + const rect = figma.createRectangle() + rect.resize(100, 100) + rect.name = 'Other' + + const sceneNode = graph.getNode(rect.id)! + const result = await matchByXPath(graph, '@name = "TestRect"', sceneNode) + expect(result).toBe(false) + }) +})