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:
parent
7f5a388e2b
commit
f2ef9c976d
|
|
@ -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
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -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:
|
||||||
|
|
|
||||||
9
bun.lock
9
bun.lock
|
|
@ -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=="],
|
||||||
|
|
|
||||||
90
packages/cli/src/commands/query.ts
Normal file
90
packages/cli/src/commands/query.ts
Normal 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('')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ export type {
|
||||||
TreeNodeResult,
|
TreeNodeResult,
|
||||||
FindArgs,
|
FindArgs,
|
||||||
FindNodeResult,
|
FindNodeResult,
|
||||||
|
QueryArgs,
|
||||||
|
QueryNodeResult,
|
||||||
NodeArgs,
|
NodeArgs,
|
||||||
NodeResult,
|
NodeResult,
|
||||||
VariablesArgs,
|
VariablesArgs,
|
||||||
|
|
|
||||||
|
|
@ -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)}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
257
packages/core/src/xpath.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
124
tests/engine/xpath.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue