XPath query for node selection (#82)

* Add XPath query for node selection

- packages/core/src/xpath.ts: XPath engine using fontoxpath with custom
  DOM facade over SceneGraph nodes
- RPC command: 'query' with selector, page, limit params
- CLI command: 'open-pencil query <file> <xpath>' with --json, --page, --limit
- AI/MCP tool: 'query_nodes' with XPath examples in description
- Queryable attributes: name, width, height, x, y, visible, opacity,
  cornerRadius, fontSize, fontFamily, fontWeight, layoutMode, itemSpacing,
  padding*, strokeWeight, rotation, locked, blendMode, text, lineHeight,
  letterSpacing

* Add tests and docs for XPath query

- 9 unit tests for queryByXPath/matchByXPath core functions
- 6 tool tests for query_nodes AI/MCP tool
- README: query examples in CLI section
- VitePress docs: patterns, queryable attributes, example output
- CHANGELOG: feature entry

* Lazy-load fontoxpath to avoid bundling in desktop app

- Dynamic import() instead of static import for fontoxpath
- queryByXPath/matchByXPath now async (they were sync before)
- RpcCommand.execute allows R | Promise<R> for async commands
- Fix 2 lint errors (unnecessary optional chain, always-truthy)
- Update all tests and CLI to await results
This commit is contained in:
Danila Poyarkov 2026-03-09 15:32:27 +03:00 committed by GitHub
parent 7f5a388e2b
commit f2ef9c976d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 720 additions and 3 deletions

View file

@ -4,6 +4,7 @@
### Features ### 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 - 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-*` - 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 - 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

View file

@ -54,6 +54,18 @@ open-pencil info design.fig
[0] [frame] "Guidance" (0:46571) [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 ### Export
Render to PNG, JPG, WEBP, SVG — or export as JSX with Tailwind utility classes: Render to PNG, JPG, WEBP, SVG — or export as JSX with Tailwind utility classes:

View file

@ -113,6 +113,7 @@
"culori": "^4.0.2", "culori": "^4.0.2",
"diff": "^8.0.3", "diff": "^8.0.3",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"fontoxpath": "^3.34.0",
"fzstd": "^0.1.1", "fzstd": "^0.1.1",
"sucrase": "^3.35.1", "sucrase": "^3.35.1",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2", "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=="], "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=="], "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=="], "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=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "y-protocols": ["y-protocols@1.0.7", "", { "dependencies": { "lib0": "^0.2.85" }, "peerDependencies": { "yjs": "^13.0.0" } }, "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw=="],

View file

@ -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<QueryNodeResult[] | { error: string }> {
const rpcArgs = {
selector: args.selector,
page: args.page,
limit: args.limit ? Number(args.limit) : undefined
}
if (isAppMode(file)) return rpc<QueryNodeResult[]>('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('')
}
})

View file

@ -6,6 +6,7 @@ import evalCmd from './commands/eval'
import exportCmd from './commands/export' import exportCmd from './commands/export'
import find from './commands/find' import find from './commands/find'
import info from './commands/info' import info from './commands/info'
import query from './commands/query'
import node from './commands/node' import node from './commands/node'
import pages from './commands/pages' import pages from './commands/pages'
import tree from './commands/tree' import tree from './commands/tree'
@ -25,6 +26,7 @@ const main = defineCommand({
export: exportCmd, export: exportCmd,
find, find,
info, info,
query,
node, node,
pages, pages,
tree, tree,

View file

@ -40,6 +40,7 @@
"culori": "^4.0.2", "culori": "^4.0.2",
"diff": "^8.0.3", "diff": "^8.0.3",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"fontoxpath": "^3.34.0",
"fzstd": "^0.1.1", "fzstd": "^0.1.1",
"sucrase": "^3.35.1", "sucrase": "^3.35.1",
"yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2" "yoga-layout": "npm:@open-pencil/yoga-layout@3.3.0-grid.2"

View file

@ -53,9 +53,11 @@ export { FigmaAPI, FigmaNodeProxy, type FigmaFontName } from './figma-api'
export { ALL_TOOLS, defineTool, toolsToAI } from './tools' export { ALL_TOOLS, defineTool, toolsToAI } from './tools'
export type { ToolDef, ParamDef, ParamType } from './tools' export type { ToolDef, ParamDef, ParamType } from './tools'
export { executeRpcCommand, ALL_RPC_COMMANDS } from './rpc' export { executeRpcCommand, ALL_RPC_COMMANDS } from './rpc'
export { queryByXPath, matchByXPath } from './xpath'
export type { XPathQueryOptions } from './xpath'
export type { export type {
InfoResult, PageItem, TreeArgs, TreeResult, TreeNodeResult, InfoResult, PageItem, TreeArgs, TreeResult, TreeNodeResult,
FindArgs, FindNodeResult, NodeArgs, NodeResult, FindArgs, FindNodeResult, QueryArgs, QueryNodeResult, NodeArgs, NodeResult,
VariablesArgs, VariablesResult, VariablesArgs, VariablesResult,
AnalyzeColorsArgs, AnalyzeColorsResult, AnalyzeColorsArgs, AnalyzeColorsResult,
AnalyzeTypographyArgs, AnalyzeTypographyResult, AnalyzeTypographyArgs, AnalyzeTypographyResult,

View file

@ -1,10 +1,11 @@
import { colorToHex, colorDistance as colorDist } from '../color' import { colorToHex, colorDistance as colorDist } from '../color'
import { queryByXPath } from '../xpath'
import type { Color } from '../types' import type { Color } from '../types'
import type { SceneGraph, SceneNode, Variable } from '../scene-graph' import type { SceneGraph, SceneNode, Variable } from '../scene-graph'
export interface RpcCommand<A = unknown, R = unknown> { export interface RpcCommand<A = unknown, R = unknown> {
name: string name: string
execute: (graph: SceneGraph, args: A) => R execute: (graph: SceneGraph, args: A) => R | Promise<R>
} }
/** Walk descendants. Callback returns `false` to stop traversal. */ /** Walk descendants. Callback returns `false` to stop traversal. */
@ -192,6 +193,47 @@ export const findCommand: RpcCommand<FindArgs, FindNodeResult[]> = {
} }
} }
// ── 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<QueryArgs, QueryNodeResult[] | { error: string }> = {
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 ── // ── node ──
export interface NodeArgs { export interface NodeArgs {
@ -609,6 +651,7 @@ export const ALL_RPC_COMMANDS = [
pagesCommand, pagesCommand,
treeCommand, treeCommand,
findCommand, findCommand,
queryCommand,
nodeCommand, nodeCommand,
variablesCommand, variablesCommand,
analyzeColorsCommand, analyzeColorsCommand,

View file

@ -7,6 +7,8 @@ export type {
TreeNodeResult, TreeNodeResult,
FindArgs, FindArgs,
FindNodeResult, FindNodeResult,
QueryArgs,
QueryNodeResult,
NodeArgs, NodeArgs,
NodeResult, NodeResult,
VariablesArgs, VariablesArgs,

View file

@ -1,4 +1,5 @@
import { defineTool, nodeSummary, nodeToResult } from './schema' import { defineTool, nodeSummary, nodeToResult } from './schema'
import { queryByXPath } from '../xpath'
import type { FigmaNodeProxy } from '../figma-api' import type { FigmaNodeProxy } from '../figma-api'
@ -206,3 +207,36 @@ export const listFonts = defineTool({
return { count: result.length, fonts: result } 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)}` }
}
}
})

View file

@ -1,7 +1,7 @@
import type { ToolDef } from './schema' import type { ToolDef } from './schema'
import { import {
getSelection, getPageTree, getNode, findNodes, getComponents, getSelection, getPageTree, getNode, findNodes, queryNodes, getComponents,
listPages, switchPage, getCurrentPage, pageBounds, selectNodes, listFonts listPages, switchPage, getCurrentPage, pageBounds, selectNodes, listFonts
} from './read' } from './read'
import { import {
@ -41,6 +41,7 @@ export const ALL_TOOLS: ToolDef[] = [
getPageTree, getPageTree,
getNode, getNode,
findNodes, findNodes,
queryNodes,
getComponents, getComponents,
listPages, listPages,
switchPage, switchPage,

257
packages/core/src/xpath.ts Normal file
View file

@ -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<string, unknown>)[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<string, unknown>)[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<SceneNode[]> {
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<boolean> {
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
}
}

View file

@ -56,6 +56,65 @@ open-pencil find design.fig --name "Button"
Both flags can be combined to narrow results further. 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 ## Node Details
Inspect all properties of a specific node by its ID: Inspect all properties of a specific node by its ID:

View file

@ -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', () => { describe('get_node', () => {
test('returns node details', () => { test('returns node details', () => {
const { figma } = setup() const { figma } = setup()

124
tests/engine/xpath.test.ts Normal file
View file

@ -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)
})
})