commit 588cfc5bb3a0f04630bac30c7ee54032d640c11b Author: wehub-resource-sync Date: Mon Jul 13 12:45:31 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.claude/commands/release.md b/.claude/commands/release.md new file mode 100644 index 0000000..f251f46 --- /dev/null +++ b/.claude/commands/release.md @@ -0,0 +1,47 @@ +# Release + +Review and publish a new release. + +## Steps + +1. **Check for a release-please PR:** + Run `gh pr list --repo GLips/Figma-Context-MCP --label "autorelease: pending" --json number,title,url` to find the open release PR. + + If no release PR exists, inform the user: "No pending release PR. Release-please creates one automatically when conventional commits (`fix:`, `feat:`) land on `main`." + +2. **Show what's in the release:** + Run `gh pr view --json body` to display the pending changelog and version bump. Summarize: + + - New version number + - Number of features, fixes, and other changes + - List of included commits + +3. **Ask for confirmation:** + Use AskUserQuestion: "Merge this release PR to publish v to npm?" + + - **Merge and publish** — Proceed with merge + - **Review diff first** — Show `gh pr diff ` + - **Cancel** — Stop without merging + +4. **Merge the release PR:** + Run `gh pr merge --rebase --repo GLips/Figma-Context-MCP` (or merge via the GitHub UI). + + **Use rebase, not squash.** Feature PRs are squash-merged (so each conventional-commit title, + with its `(#NNN)`, feeds the changelog), but the release PR is the single `chore(main): release +X.Y.Z` commit release-please authored. Rebase replays it onto `main` verbatim — bot authorship, + clean subject, no `(#NNN)`. Squash would rewrite all three and diverge from every prior release + (check `git log` for `chore(main): release` commits — they're all single-parent, bot-authored, + no PR suffix). Merging through the UI does the same thing. + +5. **Verify:** + Run `gh run list --repo GLips/Figma-Context-MCP --limit 1` to confirm the Release workflow triggered. + Report the workflow run URL so the user can monitor npm publish. + + The Release workflow also bumps `server.json` and publishes to npm (OIDC) and the MCP registry — + all hands-off. No manual steps beyond merging the PR. + +6. **Write the curated release notes:** + Once the workflow has published the GitHub Release (the tag exists), run `/release-notes` to + replace the mechanical, auto-generated Release body with brand-voice highlights. release-please + only produces a terse commit-title list; `/release-notes` turns it into something worth reading. + `CHANGELOG.md` is left as-is — release-please owns it. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7c0a306 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Your Figma API access token +# Get it from your Figma account settings: https://www.figma.com/developers/api#access-tokens +FIGMA_API_KEY=your_figma_api_key_here + +# Figma file key for testing +# This is the ID in your Figma URL: https://www.figma.com/file/{FILE_KEY}/filename +FIGMA_FILE_KEY=your_figma_file_key_here + +# Figma node ID for Testing +# This is the node-id parameter in your Figma URL: ?node-id={NODE_ID} +FIGMA_NODE_ID=your_figma_node_id_here + +# Server configuration +PORT=3333 + +# Output format: "tree", "yaml", or "json". Defaults to "tree" — it's the most +# compact (~half the size of yaml) and best for LLM consumers. Use "json" if a +# consumer parses it more reliably, or "yaml" for a more human-readable middle. +# +# OUTPUT_FORMAT="yaml" \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..df5a106 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: GLips diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..49c24a3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,88 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "" +labels: bug +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Software Versions** + +- Figma Developer MCP: Run the MCP with `--version`—either npx or locally, depending on how you're running it. +- Node.js: `node --version` +- NPM: `npm --version` +- Operating System: +- Client: e.g. Cursor, VSCode, Claude Desktop, etc. +- Client Version: + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. Often a screenshot of your entire chat window where you're trying to trigger the MCP is helpful. + +**Server Configuration** +Provide your MCP JSON configuration, if applicable. E.g. + +``` + "figma-developer-mcp": { + "command": "npx", + "args": [ + "figma-developer-mcp", + "--figma-api-key=REDACTED", + "--stdio" + ] + } +``` + +**Command Line Logs** +If you're running the MCP locally on the command line, include all the logs for those like so: + +``` +> npx figma-developer-mcp --figma-api-key=REDACTED + +Configuration: +- FIGMA_API_KEY: ****8pXg (source: cli) +- PORT: 3333 (source: default) + +Initializing Figma MCP Server in HTTP mode on 127.0.0.1:3333... +HTTP server listening on port 3333 +StreamableHTTP endpoint available at http://127.0.0.1:3333/mcp +StreamableHTTP endpoint available at http://127.0.0.1:3333/sse (backward compat) +``` + +**MCP Logs** +If you're running the MCP in a code editor like Cursor, there are MCP-specific logs that provide more context on any errors. In Cursor, you can find them by clicking `CMD + Shift + P` and looking for `Developer: Show Logs...`. Within the show logs window, you can find `Cursor MCP`—copy and paste the contents there into the bug report. + +``` +2025-03-18 11:36:22.251 [info] pnpx: Handling CreateClient action +2025-03-18 11:36:22.251 [info] pnpx: getOrCreateClient for stdio server. process.platform: darwin isElectron: true +2025-03-18 11:36:22.251 [info] pnpx: Starting new stdio process with command: pnpx figma-developer-mcp --figma-api-key=REDACTED --stdio +2025-03-18 11:36:23.987 [info] pnpx: Successfully connected to stdio server +2025-03-18 11:36:23.987 [info] pnpx: Storing stdio client +2025-03-18 11:36:23.988 [info] MCP: Handling ListOfferings action +2025-03-18 11:36:23.988 [error] MCP: No server info found +2025-03-18 11:36:23.988 [info] pnpx: Handling ListOfferings action +2025-03-18 11:36:23.988 [info] pnpx: Listing offerings +2025-03-18 11:36:23.988 [info] pnpx: getOrCreateClient for stdio server. process.platform: darwin isElectron: true +2025-03-18 11:36:23.988 [info] pnpx: Reusing existing stdio client +2025-03-18 11:36:23.988 [info] pnpx: Connected to stdio server, fetching offerings +2025-03-18 11:36:24.005 [info] listOfferings: Found 2 tools +2025-03-18 11:36:24.005 [info] pnpx: Found 2 tools, 0 resources, and 0 resource templates +2025-03-18 11:36:24.005 [info] npx: Handling ListOfferings action +2025-03-18 11:36:24.005 [error] npx: No server info found +``` + +**Additional context** +Add any other context about the problem here. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..26d9797 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,19 @@ +name: "Setup and install" +description: "Common setup steps for Actions" + +runs: + using: composite + steps: + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.10.0 + - name: Install Node.js v20 + uses: actions/setup-node@v4 + with: + node-version: 20.17.0 + cache: "pnpm" + + - name: Install PNPM Dependencies + shell: bash + run: pnpm install diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5873ff6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + pull_request: + branches: + - main + +jobs: + ci: + name: Lint, Type Check, Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/setup + + - name: Scan for hidden characters + run: node scripts/scan-hidden-chars.mjs + + - name: Check formatting + run: pnpm prettier --check "src/**/*.ts" + + - name: Lint + run: pnpm lint + + - name: Type check + run: pnpm type-check + + - name: Test + run: pnpm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e40f82c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,70 @@ +name: Release + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + id-token: write # Required for npm OIDC trusted publishing + +jobs: + release-please: + if: ${{ github.repository_owner == 'GLips' }} + runs-on: ubuntu-latest + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + steps: + - uses: googleapis/release-please-action@v4 + id: release + + - uses: actions/checkout@v4 + if: ${{ steps.release.outputs.release_created }} + + - uses: pnpm/action-setup@v4 + with: + version: 10.10.0 + if: ${{ steps.release.outputs.release_created }} + + - uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: "https://registry.npmjs.org" + if: ${{ steps.release.outputs.release_created }} + + - name: Install dependencies + run: pnpm install + if: ${{ steps.release.outputs.release_created }} + + - name: Type check + run: pnpm type-check + if: ${{ steps.release.outputs.release_created }} + + - name: Build + run: pnpm build + if: ${{ steps.release.outputs.release_created }} + + - name: Publish to npm + run: pnpm publish --no-git-checks + env: + NODE_ENV: production + if: ${{ steps.release.outputs.release_created }} + + - name: Update server.json version + run: | + VERSION="${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" + jq --arg v "$VERSION" '.version = $v | .packages[0].version = $v' server.json > server.tmp && mv server.tmp server.json + if: ${{ steps.release.outputs.release_created }} + + - name: Install mcp-publisher + run: | + curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher + if: ${{ steps.release.outputs.release_created }} + + - name: Publish to MCP Registry + run: | + ./mcp-publisher login github-oidc + ./mcp-publisher publish + if: ${{ steps.release.outputs.release_created }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ce39f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Dependencies +node_modules +.pnpm-store +package-lock.json + +# Build output +dist + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/* +!.vscode/extensions.json +!.vscode/settings.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Testing +coverage +test-output + +# OS +.DS_Store +Thumbs.db + +# mcp-publisher CLI tool files +.mcpregistry* + +# Private planning / brainstorm / ideas workspace — version-controlled in a +# separate private repo (FramelinkAI/mcp-docs) cloned to this path. +/docs/ + +# Local experiment scratchpad +/trials/ + +.mcp.json +advisor-plans/ +.tk +scripts/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..54c6511 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v24 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5e24f14 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "trailingComma": "all", + "singleQuote": false, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..794cd27 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.13.2" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..57b55b1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,288 @@ +# figma-developer-mcp + +## [0.13.2](https://github.com/GLips/Figma-Context-MCP/compare/v0.13.1...v0.13.2) (2026-06-18) + + +### Bug Fixes + +* apply paint-level opacity to gradient fills ([#399](https://github.com/GLips/Figma-Context-MCP/issues/399)) ([7fc8829](https://github.com/GLips/Figma-Context-MCP/commit/7fc8829acef7b3d7ae3deede05d94f109053e976)) + +## [0.13.1](https://github.com/GLips/Figma-Context-MCP/compare/v0.13.0...v0.13.1) (2026-06-18) + + +### Bug Fixes + +* drop redundant and auto-generated node names from serialized output ([#397](https://github.com/GLips/Figma-Context-MCP/issues/397)) ([b53727a](https://github.com/GLips/Figma-Context-MCP/commit/b53727ad7f4f870ae424f8f6817e9c29cd68cc63)) + +## [0.13.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.12.0...v0.13.0) (2026-06-18) + + +### Features + +* CSS Grid layout support ([#347](https://github.com/GLips/Figma-Context-MCP/issues/347)) ([5c1da79](https://github.com/GLips/Figma-Context-MCP/commit/5c1da79be91864a1e363566216ac37f7f208388f)) +* **extractors:** deduplicate repeated styles and subtrees to shrink output ([#389](https://github.com/GLips/Figma-Context-MCP/issues/389)) ([a43e465](https://github.com/GLips/Figma-Context-MCP/commit/a43e465186075dad09078ae5c860f662c3ebc5d1)) +* **fills:** flatten all-solid fill stacks into a single resolved color ([#390](https://github.com/GLips/Figma-Context-MCP/issues/390)) ([dcf11d2](https://github.com/GLips/Figma-Context-MCP/commit/dcf11d2a1edecd2640a8421685f5417f10acdaf4)) +* **format:** default output to tree instead of yaml ([#394](https://github.com/GLips/Figma-Context-MCP/issues/394)) ([4e3a08b](https://github.com/GLips/Figma-Context-MCP/commit/4e3a08b7ca343f5edf8da26679394e23579c8733)) +* **layout:** emit the requested root as contextual sizing with a designed reference ([#393](https://github.com/GLips/Figma-Context-MCP/issues/393)) ([ef888b5](https://github.com/GLips/Figma-Context-MCP/commit/ef888b55f345daff1711810b3ae277bbe3876311)) +* **strokes:** include strokeAlign in simplified stroke output ([#386](https://github.com/GLips/Figma-Context-MCP/issues/386)) ([c807a27](https://github.com/GLips/Figma-Context-MCP/commit/c807a276df6f065eecc88534c95777bab8aa18ad)) +* support request bearer oauth tokens over HTTP ([#384](https://github.com/GLips/Figma-Context-MCP/issues/384)) ([22426e6](https://github.com/GLips/Figma-Context-MCP/commit/22426e677d13d87e8564acd3c96407100c93fef6)) + + +### Bug Fixes + +* **effects:** halve Figma blur radius for CSS and omit zero-radius blur ([#392](https://github.com/GLips/Figma-Context-MCP/issues/392)) ([49a0c73](https://github.com/GLips/Figma-Context-MCP/commit/49a0c7305620b8267b2410e948f271cbcd764c84)) +* **images:** report real SVG dimensions instead of 0x0 ([#396](https://github.com/GLips/Figma-Context-MCP/issues/396)) ([3c7524c](https://github.com/GLips/Figma-Context-MCP/commit/3c7524c87d5fdb7ca3e628218961a5af57ace61b)) +* **text:** emit letterSpacing and relative lineHeight as em, not % ([#391](https://github.com/GLips/Figma-Context-MCP/issues/391)) ([0cb97d0](https://github.com/GLips/Figma-Context-MCP/commit/0cb97d04de2b6ab82e0713bff047237c8867df8c)) + +## [0.12.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.11.0...v0.12.0) (2026-05-27) + +### Features + +- **serialize:** add experimental tree output format for better token efficiency ([#370](https://github.com/GLips/Figma-Context-MCP/issues/370)) ([9ecbc5a](https://github.com/GLips/Figma-Context-MCP/commit/9ecbc5aec8e71dda2ee3b1804623df82db5663d7)) +- support per-request Figma API keys ([#365](https://github.com/GLips/Figma-Context-MCP/issues/365)) ([fe3b504](https://github.com/GLips/Figma-Context-MCP/commit/fe3b504d75b671896a557188a9ad801b7bac40ee)) + +### Bug Fixes + +- guide LLMs to fix bad node-id errors (proto/branch/figjam URLs) ([#371](https://github.com/GLips/Figma-Context-MCP/issues/371)) ([c6697bc](https://github.com/GLips/Figma-Context-MCP/commit/c6697bc492f0d25c4fff469c471820dede1c7e5c)) +- **images:** omit null imageRef so foreign-pasted images render via nodeId ([#368](https://github.com/GLips/Figma-Context-MCP/issues/368)) ([166f87d](https://github.com/GLips/Figma-Context-MCP/commit/166f87dcc610634a0e067dd5acc03d1fc0c411a4)) +- **layout:** include positions for children of SECTION nodes ([#381](https://github.com/GLips/Figma-Context-MCP/issues/381)) ([5088fba](https://github.com/GLips/Figma-Context-MCP/commit/5088fbafdebabc31feb45b71356dfeb8c669ed15)) +- **layout:** respect parent axis for dimensions ([#379](https://github.com/GLips/Figma-Context-MCP/issues/379)) ([a54cbc2](https://github.com/GLips/Figma-Context-MCP/commit/a54cbc23f36a87912a526444e464fdca4fda5fbd)) +- **mcp:** prevent late progress notifications from crashing stdio clients ([#366](https://github.com/GLips/Figma-Context-MCP/issues/366)) ([b97585c](https://github.com/GLips/Figma-Context-MCP/commit/b97585caf502f0e0b41cfd8f0a2f3f11559e1e77)) +- reject ambiguous localPath inputs in download_figma_images ([#367](https://github.com/GLips/Figma-Context-MCP/issues/367)) ([eaeec68](https://github.com/GLips/Figma-Context-MCP/commit/eaeec68041584114acb23cda05b8838a5e204341)) +- stop collapsing auto-layout frames to a single IMAGE-SVG ([#380](https://github.com/GLips/Figma-Context-MCP/issues/380)) ([c036111](https://github.com/GLips/Figma-Context-MCP/commit/c0361119dabc3b244687e8f455bb37822067db0f)) +- surface Figma 403 response body to help LLMs self-heal based on actual error ([#360](https://github.com/GLips/Figma-Context-MCP/issues/360)) ([12280ba](https://github.com/GLips/Figma-Context-MCP/commit/12280ba22a8d398c35db360a336356430dd0b182)) + +## [0.11.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.10.1...v0.11.0) (2026-04-20) + +### Features + +- rich text styling ([#351](https://github.com/GLips/Figma-Context-MCP/issues/351)) ([759d0e4](https://github.com/GLips/Figma-Context-MCP/commit/759d0e4f7877677980d9cee18c8f895bee655394)) + +### Bug Fixes + +- stop routing all traffic through EnvHttpProxyAgent by default ([#359](https://github.com/GLips/Figma-Context-MCP/issues/359)) ([a22f28f](https://github.com/GLips/Figma-Context-MCP/commit/a22f28f23f9cf5444d509b9d041d3c162e1cefd6)) + +## [0.10.1](https://github.com/GLips/Figma-Context-MCP/compare/v0.10.0...v0.10.1) (2026-04-10) + +### Bug Fixes + +- add actionable 403 error message with troubleshooting link ([9230bd0](https://github.com/GLips/Figma-Context-MCP/commit/9230bd02a63085d88ca5d3687275f2cba9557309)) +- throw actionable error for missing nodes, add error_category to telemetry ([#344](https://github.com/GLips/Figma-Context-MCP/issues/344)) ([334ae2b](https://github.com/GLips/Figma-Context-MCP/commit/334ae2bbecbd3583922098787877448337acf6cb)) + +## [0.10.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.9.0...v0.10.0) (2026-04-10) + +### Features + +- add anonymous PostHog telemetry ([#342](https://github.com/GLips/Figma-Context-MCP/issues/342)) ([6c0666a](https://github.com/GLips/Figma-Context-MCP/commit/6c0666a7c96e62b39f730a96d24eacb8f3a35cf6)) + +## [0.9.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.8.1...v0.9.0) (2026-04-09) + +### Features + +- add component property support (BOOLEAN & TEXT) ([#340](https://github.com/GLips/Figma-Context-MCP/issues/340)) ([b0f9efc](https://github.com/GLips/Figma-Context-MCP/commit/b0f9efcc0680012eac4a760ec6826a7605b38fb6)) +- add proxy support for managed networks ([#338](https://github.com/GLips/Figma-Context-MCP/issues/338)) ([32d5779](https://github.com/GLips/Figma-Context-MCP/commit/32d57790317e57a35dfc8df0de4c6ac830268b31)) +- add support for using as a CLI via `fetch` subcommand to retrieve design data directly ([#331](https://github.com/GLips/Figma-Context-MCP/issues/331)) ([dd237c8](https://github.com/GLips/Figma-Context-MCP/commit/dd237c8e87565cee42d706b8f374fc4bc411066b)) + +### Bug Fixes + +- **layout:** suppress computed gap values when using SPACE_BETWEEN ([#341](https://github.com/GLips/Figma-Context-MCP/issues/341)) ([309c60e](https://github.com/GLips/Figma-Context-MCP/commit/309c60e6d59eb2fb8fdc0acc85dd81b1644b1f12)), closes [#169](https://github.com/GLips/Figma-Context-MCP/issues/169) + +## [0.8.1](https://github.com/GLips/Figma-Context-MCP/compare/v0.8.0...v0.8.1) (2026-04-07) + +### Bug Fixes + +- disambiguate named styles with duplicate names ([#319](https://github.com/GLips/Figma-Context-MCP/issues/319)) ([a077ace](https://github.com/GLips/Figma-Context-MCP/commit/a077ace9809bf6b14c4e4a9906065fb3cea2d24f)) +- include BOOLEAN_OPERATION in SVG container collapse ([354679e](https://github.com/GLips/Figma-Context-MCP/commit/354679eab17389c551a435ca7c5224a250446301)) +- include BOOLEAN_OPERATION in SVG container collapse ([19c50b3](https://github.com/GLips/Figma-Context-MCP/commit/19c50b3ad3ecf12ce4b4bedc0aefff718b3b89f9)) +- replace jimp with selective @jimp/\* imports to fix ESM crash ([#333](https://github.com/GLips/Figma-Context-MCP/issues/333)) ([dd47ebf](https://github.com/GLips/Figma-Context-MCP/commit/dd47ebf82520c6147b913415db99c3b4caaa40b2)), closes [#329](https://github.com/GLips/Figma-Context-MCP/issues/329) + +## [0.8.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.7.1...v0.8.0) (2026-03-24) + +### ⚠ BREAKING CHANGES + +- switch to stateless HTTP transport ([#304](https://github.com/GLips/Figma-Context-MCP/issues/304)) + +### Features + +- add progress notifications and async tree walker ([#305](https://github.com/GLips/Figma-Context-MCP/issues/305)) ([b5724ad](https://github.com/GLips/Figma-Context-MCP/commit/b5724ade8234e73fe94467c6bfad5e020552f0e2)) + +### Performance Improvements + +- fix O(n²) bottlenecks in simplification and YAML serialization ([#307](https://github.com/GLips/Figma-Context-MCP/issues/307)) ([29cff0c](https://github.com/GLips/Figma-Context-MCP/commit/29cff0cbd6d2fd0459900e9c3cbc49f64e47075d)) + +### Code Refactoring + +- switch to stateless HTTP transport ([#304](https://github.com/GLips/Figma-Context-MCP/issues/304)) ([9dfb1cb](https://github.com/GLips/Figma-Context-MCP/commit/9dfb1cb65a081655d7dca5f076ab76f5d7e9edc0)) + +## [0.7.1](https://github.com/GLips/Figma-Context-MCP/compare/v0.7.0...v0.7.1) (2026-03-20) + +### Bug Fixes + +- handle drive root paths in image directory security check ([#301](https://github.com/GLips/Figma-Context-MCP/issues/301)) ([9f32616](https://github.com/GLips/Figma-Context-MCP/commit/9f32616caa29b1dbdd5c5a9dcfafa3dd717070a3)) + +## [0.7.0](https://github.com/GLips/Figma-Context-MCP/compare/v0.6.6...v0.7.0) (2026-03-19) + +### ⚠ BREAKING CHANGES + +- getServerConfig() no longer takes an isStdioMode parameter. It now detects stdio mode internally and returns it as part of ServerConfig. + +### Features + +- add --image-dir config for image download path control ([#297](https://github.com/GLips/Figma-Context-MCP/issues/297)) ([0417766](https://github.com/GLips/Figma-Context-MCP/commit/0417766eb5fc1e0b76e55da497961f9aee2f62f7)) +- replace yargs with cleye for CLI flag parsing ([#285](https://github.com/GLips/Figma-Context-MCP/issues/285)) ([0092ee7](https://github.com/GLips/Figma-Context-MCP/commit/0092ee789fce01b9ef1dab5e8f32c52e71107dbb)) +- support gifRef for downloading animated GIF embeds ([#286](https://github.com/GLips/Figma-Context-MCP/issues/286)) ([f1ec913](https://github.com/GLips/Figma-Context-MCP/commit/f1ec9133c31a351b55651126c20ea2f842c0a9ee)) + +### Bug Fixes + +- remove inline release-type so release-please reads config file ([a03cd68](https://github.com/GLips/Figma-Context-MCP/commit/a03cd68826da1c1596273a223a612eb919832397)) +- replace sharp dependency with js-native jimp for image manipulation ([#289](https://github.com/GLips/Figma-Context-MCP/issues/289)) ([62b9f94](https://github.com/GLips/Figma-Context-MCP/commit/62b9f94b1607dd08daeaa90e8ace0a896fe6eb50)) +- skip jimp processing for SVGs and prevent image-fill collapse ([#298](https://github.com/GLips/Figma-Context-MCP/issues/298)) ([a4a4b13](https://github.com/GLips/Figma-Context-MCP/commit/a4a4b13ec7cae5d603022b1c8719cc717749195b)) + +## [0.6.6](https://github.com/GLips/Figma-Context-MCP/compare/v0.6.5...v0.6.6) (2026-03-04) + +### Bug Fixes + +- use Node 24 in release workflow for npm OIDC support ([11ba7c6](https://github.com/GLips/Figma-Context-MCP/commit/11ba7c6a2e22910c483592ba7cdc1966fcdc9166)) + +## [0.6.5](https://github.com/GLips/Figma-Context-MCP/compare/v0.6.4...v0.6.5) (2026-03-04) + +### Bug Fixes + +- upgrade MCP SDK to 1.27.1 and modernize tool registration ([#282](https://github.com/GLips/Figma-Context-MCP/issues/282)) ([4153e5f](https://github.com/GLips/Figma-Context-MCP/commit/4153e5f857aa708ee9ee10156e553c1289f03cf7)) + +## 0.6.4 + +### Patch Changes + +- [#250](https://github.com/GLips/Figma-Context-MCP/pull/250) [`9966623`](https://github.com/GLips/Figma-Context-MCP/commit/996662352cdeaa8e6d4a6f64154d6135c00a35ee) Thanks [@GLips](https://github.com/GLips)! - Collapse containers that only have vector children to better handle SVG image downloads and also make output size smaller. + +## 0.6.3 + +### Patch Changes + +- [#246](https://github.com/GLips/Figma-Context-MCP/pull/246) [`7f4b585`](https://github.com/GLips/Figma-Context-MCP/commit/7f4b5859454b0567c2121ff22c69a0344680b124) Thanks [@GLips](https://github.com/GLips)! - Updates to validate user input, run HTTP server on localhost only + +## 0.6.2 + +### Patch Changes + +- [#244](https://github.com/GLips/Figma-Context-MCP/pull/244) [`8277424`](https://github.com/GLips/Figma-Context-MCP/commit/8277424205e6421a133ac38086f6eb7ac124ea65) Thanks [@GLips](https://github.com/GLips)! - Support imports without starting server or looking for env vars. + +## 0.6.1 + +### Patch Changes + +- [#240](https://github.com/GLips/Figma-Context-MCP/pull/240) [`2b1923d`](https://github.com/GLips/Figma-Context-MCP/commit/2b1923dcf50275a3d4daf9279265d27c6fadb2f7) Thanks [@GLips](https://github.com/GLips)! - Fix issue where importing package triggered config check. + +- [#239](https://github.com/GLips/Figma-Context-MCP/pull/239) [`00bad7d`](https://github.com/GLips/Figma-Context-MCP/commit/00bad7dae48a6d0cc55d78560cc691a39271f151) Thanks [@Hengkai-Ye](https://github.com/Hengkai-Ye)! - Fix: Make sure LLM provides a filename extension when calling download_figma_images + +## 0.6.0 + +### Minor Changes + +- [#233](https://github.com/GLips/Figma-Context-MCP/pull/233) [`26a048b`](https://github.com/GLips/Figma-Context-MCP/commit/26a048bbd09db2b7e5265b5777609fb619617068) Thanks [@scarf005](https://github.com/scarf005)! - Return named styles from Figma instead of auto-generated IDs when they exist. + +## 0.5.2 + +### Patch Changes + +- [#227](https://github.com/GLips/Figma-Context-MCP/pull/227) [`68fbc87`](https://github.com/GLips/Figma-Context-MCP/commit/68fbc87645d25c57252d4d9bec5f43ee4238b09f) Thanks [@fightZy](https://github.com/fightZy)! - Update Node ID regex to support additional formats, e.g. multiple nodes. + +## 0.5.1 + +### Patch Changes + +- [#205](https://github.com/GLips/Figma-Context-MCP/pull/205) [`618bbe9`](https://github.com/GLips/Figma-Context-MCP/commit/618bbe98c49428e617de0240f0e9c2842867ae9b) Thanks [@GLips](https://github.com/GLips)! - Calculate gradient values instead of passing raw Figma data. + +## 0.5.0 + +### Minor Changes + +- [#197](https://github.com/GLips/Figma-Context-MCP/pull/197) [`d67ff14`](https://github.com/GLips/Figma-Context-MCP/commit/d67ff143347bb1dbc152157b75d6e8b290dabb0f) Thanks [@GLips](https://github.com/GLips)! - Improve structure of MCP files, change strategy used for parsing Figma files to make it more flexible and extensible. + +- [#199](https://github.com/GLips/Figma-Context-MCP/pull/199) [`a8b59bf`](https://github.com/GLips/Figma-Context-MCP/commit/a8b59bf079128c9dba0bf6d8cd1601b8a6654b88) Thanks [@GLips](https://github.com/GLips)! - Add support for pattern fills in Figma. + +- [#203](https://github.com/GLips/Figma-Context-MCP/pull/203) [`edf4182`](https://github.com/GLips/Figma-Context-MCP/commit/edf41826f5bd4ebe6ea353a9c9b8be669f0ae659) Thanks [@GLips](https://github.com/GLips)! - Add support for Fill, Fit, Crop and Tile image types in Figma. Adds image post-processing step. + +### Patch Changes + +- [#202](https://github.com/GLips/Figma-Context-MCP/pull/202) [`4a44681`](https://github.com/GLips/Figma-Context-MCP/commit/4a44681903f1c071c5892454d19370ed89ecd0a3) Thanks [@GLips](https://github.com/GLips)! - Add --skip-image-downloads option to CLI args and SKIP_IMAGE_DOWNLOADS env var to hide the download image tool when set. + +## 0.4.3 + +### Patch Changes + +- [#179](https://github.com/GLips/Figma-Context-MCP/pull/179) [`17988a0`](https://github.com/GLips/Figma-Context-MCP/commit/17988a0b5543330c6b8f7f24baa33b65a0da7957) Thanks [@GLips](https://github.com/GLips)! - Update curl command in fetchWithRetry to include error handling options, ensure errors are actually caught properly and returned to users. + +## 0.4.2 + +### Patch Changes + +- [#170](https://github.com/GLips/Figma-Context-MCP/pull/170) [`d560252`](https://github.com/GLips/Figma-Context-MCP/commit/d56025286e8c3c24d75f170974c12f96d32fda8b) Thanks [@GLips](https://github.com/GLips)! - Add support for custom .env files. + +## 0.4.1 + +### Patch Changes + +- [#161](https://github.com/GLips/Figma-Context-MCP/pull/161) [`8d34c6c`](https://github.com/GLips/Figma-Context-MCP/commit/8d34c6c23df3b2be5d5366723aeefdc2cca0a904) Thanks [@YossiSaadi](https://github.com/YossiSaadi)! - Add --json CLI flag and OUTPUT_FORMAT env var to support JSON output format in addition to YAML. + +## 0.4.0 + +### Minor Changes + +- [#126](https://github.com/GLips/Figma-Context-MCP/pull/126) [`6e99226`](https://github.com/GLips/Figma-Context-MCP/commit/6e9922693dcff70b69be6b505e24062a89e821f0) Thanks [@habakan](https://github.com/habakan)! - Add SVG export options to control text outlining, id inclusion, and whether strokes should be simplified. + +### Patch Changes + +- [#153](https://github.com/GLips/Figma-Context-MCP/pull/153) [`4d58e83`](https://github.com/GLips/Figma-Context-MCP/commit/4d58e83d2e56e2bc1a4799475f29ffe2a18d6868) Thanks [@miraclehen](https://github.com/miraclehen)! - Refactor layout positioning logic and add pixel rounding. + +- [#112](https://github.com/GLips/Figma-Context-MCP/pull/112) [`c48b802`](https://github.com/GLips/Figma-Context-MCP/commit/c48b802ff653cfc46fe6077a8dc96bd4a15edb40) Thanks [@dgxyzw](https://github.com/dgxyzw)! - Change format of component properties in simplified response. + +- [#150](https://github.com/GLips/Figma-Context-MCP/pull/150) [`4a4318f`](https://github.com/GLips/Figma-Context-MCP/commit/4a4318faa6c2eb91a08e6cc2e41e3f9e2f499a41) Thanks [@GLips](https://github.com/GLips)! - Add curl fallback to make API requests more robust in corporate environments + +- [#149](https://github.com/GLips/Figma-Context-MCP/pull/149) [`46550f9`](https://github.com/GLips/Figma-Context-MCP/commit/46550f91340969cf3683f4537aefc87d807f1b64) Thanks [@miraclehen](https://github.com/miraclehen)! - Resolve promise in image downloading function only after file is finished writing. + +## 0.3.1 + +### Patch Changes + +- [#133](https://github.com/GLips/Figma-Context-MCP/pull/133) [`983375d`](https://github.com/GLips/Figma-Context-MCP/commit/983375d3fe7f2c4b48ce770b13e5b8cb06b162d0) Thanks [@dgomez-orangeloops](https://github.com/dgomez-orangeloops)! - Auto-update package version in code. + +## 0.3.0 + +### Minor Changes + +- [#122](https://github.com/GLips/Figma-Context-MCP/pull/122) [`60c663e`](https://github.com/GLips/Figma-Context-MCP/commit/60c663e6a83886b03eb2cde7c60433439e2cedd0) Thanks [@YossiSaadi](https://github.com/YossiSaadi)! - Include component and component set names to help LLMs find pre-existing components in code + +- [#109](https://github.com/GLips/Figma-Context-MCP/pull/109) [`64a1b10`](https://github.com/GLips/Figma-Context-MCP/commit/64a1b10fb62e4ccb5d456d4701ab1fac82084af3) Thanks [@jonmabe](https://github.com/jonmabe)! - Add OAuth token support using Authorization Bearer method for alternate Figma auth. + +- [#128](https://github.com/GLips/Figma-Context-MCP/pull/128) [`3761a70`](https://github.com/GLips/Figma-Context-MCP/commit/3761a70db57b3f038335a5fb568c2ca5ff45ad21) Thanks [@miraclehen](https://github.com/miraclehen)! - Handle size calculations for non-AutoLayout elements and absolutely positioned elements. + +### Patch Changes + +- [#106](https://github.com/GLips/Figma-Context-MCP/pull/106) [`4237a53`](https://github.com/GLips/Figma-Context-MCP/commit/4237a5363f696dcf7abe046940180b6861bdcf22) Thanks [@saharis9988](https://github.com/saharis9988)! - Remove empty keys from simplified design output. + +- [#119](https://github.com/GLips/Figma-Context-MCP/pull/119) [`d69d96f`](https://github.com/GLips/Figma-Context-MCP/commit/d69d96fd8a99c9b59111d9c89613a74c1ac7aa7d) Thanks [@cooliceman](https://github.com/cooliceman)! - Add scale support for PNG images pulled via download_figma_images tool. + +- [#129](https://github.com/GLips/Figma-Context-MCP/pull/129) [`56f968c`](https://github.com/GLips/Figma-Context-MCP/commit/56f968cd944cbf3058f71f3285c363e895dcf91d) Thanks [@fightZy](https://github.com/fightZy)! - Make shadows on text nodes apply text-shadow rather than box-shadow + +## 0.2.2 + +### Patch Changes + +- fd10a46: - Update HTTP server creation method to no longer subclass McpServer + - Change logging behavior on HTTP server +- 6e2c8f5: Minor bump, testing fix for hanging CF DOs + +## 0.2.2-beta.1 + +### Patch Changes + +- 6e2c8f5: Minor bump, testing fix for hanging CF DOs + +## 0.2.2-beta.0 + +### Patch Changes + +- fd10a46: - Update HTTP server creation method to no longer subclass McpServer + - Change logging behavior on HTTP server diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..04ed201 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,179 @@ +# Framelink MCP for Figma + +Framelink MCP for Figma is a Model Context Protocol (MCP) server that gives AI coding tools (Cursor, etc.) access to Figma design data. It fetches Figma files/nodes via the Figma API, simplifies the response to include only relevant layout and styling information, and serves it to AI clients. + +## Build & Development Commands + +```bash +pnpm install # Install dependencies +pnpm build # Build with tsup (outputs to dist/) +pnpm dev # Development mode with watch + auto-restart (HTTP) +pnpm dev:cli # Development mode (stdio) +pnpm test # Run Vitest tests +pnpm type-check # TypeScript type checking only +pnpm lint # ESLint +pnpm format # Prettier formatting +pnpm inspect # Run MCP inspector for debugging +``` + +### Running the Server + +```bash +pnpm start # HTTP mode (default port 3333) +pnpm start:cli # stdio mode for MCP clients +``` + +### Running a Single Test + +```bash +pnpm test -- path/to/test.ts +pnpm test -- --testNamePattern="pattern" +``` + +### Releasing + +Releases are automated via [release-please](https://github.com/googleapis/release-please). On merge to `main`, release-please reads conventional commit prefixes (`fix:`, `feat:`, `feat!:`) and maintains a release PR. Merging the release PR publishes to npm via OIDC trusted publishing. + +### PR Title Convention + +PRs are squash-merged, so the PR title becomes the commit message that release-please parses. Always use [Conventional Commit](https://www.conventionalcommits.org/) prefixes in PR titles. + +## Architecture + +### Entry Points + +- `src/bin.ts` — CLI entry point, calls `startServer()` +- `src/server.ts` — Server initialization, handles stdio vs HTTP mode selection +- `src/mcp-server.ts` — Library re-exports for external consumers (`createServer`, `startServer`, etc.) +- `src/index.ts` — Library exports (extractors, types) + +### Transport Modes + +The server supports two transports (configured in `src/server.ts`): + +- **stdio** — For direct MCP client integration (activated with `--stdio` flag or `NODE_ENV=cli`) +- **StreamableHTTP** — Stateless HTTP transport at `/mcp` (also served at `/sse` for backward compatibility with existing client configs) + +### Core Data Flow + +1. **MCP Tools** (`src/mcp/tools/`) — Define tool schemas and handlers + + - `get_figma_data` — Fetches and simplifies Figma design data + - `download_figma_images` — Downloads images from Figma + +2. **Figma Service** (`src/services/figma.ts`) — API client for Figma REST API + + - Handles auth (Personal Access Token or OAuth) + - Methods: `getRawFile()`, `getRawNode()`, `downloadImages()` + +3. **Extractor System** (`src/extractors/`) — Transforms raw Figma API responses + + - `design-extractor.ts` — Entry point, parses API response and calls extractors + - `node-walker.ts` — Recursive traversal applying extractors to each node + - `built-in.ts` — Built-in extractors: `layoutExtractor`, `textExtractor`, `visualsExtractor`, `componentExtractor` + - Extractors are composable; `allExtractors` combines all built-ins + +4. **Transformers** (`src/transformers/`) — Convert specific Figma properties + - `layout.ts` — Layout/positioning transforms + - `style.ts` — Visual styling (fills, strokes) + - `effects.ts` — Effects (shadows, blurs) + - `text.ts` — Text content and styling + - `component.ts` — Component metadata + +### Configuration + +`src/config.ts` handles CLI args and environment variables: + +- `FIGMA_API_KEY` or `--figma-api-key` — Personal Access Token +- `FIGMA_OAUTH_TOKEN` or `--figma-oauth-token` — OAuth Bearer token +- `PORT` or `--port` — HTTP server port (default: 3333) +- `OUTPUT_FORMAT` or `--format` — Output format: `tree` (default), `yaml`, or `json` +- `--json` — Back-compat alias for `--format=json` +- `--skip-image-downloads` — Disable image download tool + +### Path Alias + +The codebase uses `~/` as an alias for `src/` (configured in tsconfig.json and vitest.config.ts). + +## Philosophy + +From CONTRIBUTING.md — important context for development: + +1. **Unix Philosophy** — Tools should have one job and few arguments. Keep tools simple to avoid confusing LLMs. +2. **Focused Scope** — The server only handles "ingesting designs for AI consumption." Out of scope: image manipulation, CMS syncing, code generation, third-party integrations. +3. **Project-level Config** — Options unlikely to change between requests should be CLI arguments, not tool parameters. + +## Token Efficiency + +The simplified output is consumed by LLMs, so every field costs context budget. Keep it lean: + +- Omit default values where LLMs can reliably infer the expectation without explicit data — emit only deviations. (e.g. `strokeAlign: INSIDE` matches the default CSS `border` an LLM already produces, so it is dropped; only `OUTSIDE`/`CENTER` are emitted.) + +## Quality + +This codebase will outlive you. Every shortcut becomes someone else's burden. Every hack compounds into technical debt that slows the whole team down. + +For each proposed change, examine the existing system and redesign it into the most elegant solution that would have emerged if the change had been a foundational assumption from the start. + +You are not just writing code. You are shaping the future of this project. The patterns you establish will be copied. The corners you cut will be cut again. + +Fight entropy. Leave the codebase better than you found it. + +## Comment Policy + +### Unacceptable Comments + +- Comments that repeat what code does +- Commented-out code (delete it) +- Obvious comments ("increment counter") +- Comments instead of good naming + +### Great Comments + +- **Why this exists** — what problem does this solve, why is it valuable +- **Why it works this way** — important design decisions and their rationale +- **Why NOT** — approaches you considered and rejected, to prevent re-attempting failed ideas +- **Warnings** — non-obvious gotchas, ordering dependencies, "this must happen before X" +- **Domain bridges** — when code implements complex domain logic (finance calculations, protocol specs, algorithms) that can't fully express the underlying concept +- **Looks wrong** — when code appears unused, redundant, or incorrect but exists for a non-obvious reason (e.g., interface contracts for test implementations, load-bearing side effects) +- **Negative space** — when code deliberately doesn't handle something and that absence is intentional (e.g., "Does not retry—caller handles backoff" prevents someone from "helpfully" adding retry logic that breaks upstream assumptions) + +## Testing Philosophy + +Write tests. Not too many. Mostly integration. + +- Every test has a cost: maintenance, false positives, slower CI. Tests must earn their place. +- Most features need 2-5 tests. Some need zero. +- Zero tests is valid for: simple CRUD, styling, config changes, framework-convention code, etc. +- Design for testability using "functional core, imperative shell": keep pure business logic separate from code that does IO. + +### Principles + +- **Test behavior, not implementation.** Tests should verify what the code does, not how it does it. Only use methods available on the public interface to verify behavior. +- **Don't test what the type system guarantees.** If TypeScript enforces it at compile time, a runtime test adds no value. +- **Don't test the framework.** Don't verify that Express routes, React renders, or ORM queries work — test _your_ logic. +- **Prefer real implementations over mocks.** Mocks couple tests to implementation details and hide real bugs. Only mock at system boundaries (network, filesystem, time). + +### Only test behavior where: + +- A failure would frustrate or block real users +- The behavior is non-obvious and could regress silently +- It's a critical integration point or state transition + +### Skip testing: + +- Implementation details, private methods, trivial code +- Edge cases that won't occur in practice +- Variations that test the same underlying behavior + +## Error Handling + +Trust internal code and framework guarantees. Only validate at system boundaries — user input, external APIs, file I/O. Don't add try/catch, fallbacks, or defensive checks for scenarios that can't happen in practice. Let errors propagate naturally; the caller that knows how to handle them should be the one catching them. + +## External Libraries + +Use the context7 MCP first to gather information on unfamiliar libraries or APIs. If that fails, you may search the code directly or search the web for more detail. + +## Communication Style + +When reviewing plans, providing feedback, or analyzing approaches, be genuinely critical. Flag real risks, tradeoffs, and things that will break rather than being agreeable. Grounded, opinionated analysis is more valuable than polite agreement. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..05a7bd1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,184 @@ +# Contributing to Framelink MCP for Figma + +Thank you for your interest in contributing to the Framelink MCP for Figma! This guide will help you get started with contributing to this project. + +## Philosophy + +### Unix Philosophy for Tools + +This project adheres to the Unix philosophy: tools should have one job and few arguments. We keep our tools as simple as possible to avoid confusing LLMs during calling. Configurable options that are more project-level (i.e., unlikely to change between requests for Figma data) are best set as command line arguments rather than being exposed as tool parameters. + +### MCP Server Scope + +The MCP server should only focus on **ingesting designs for AI consumption**. This is our core responsibility and what we do best. Additional features are best handled externally by other specialized tools. Examples of features that would be out of scope include: + +- Image conversion, cropping, or other image manipulation +- Syncing design data to CMSes or databases +- Code generation or framework-specific output +- Third-party integrations unrelated to design ingestion + +This focused approach ensures: + +- Clear boundaries and responsibilities +- Better maintainability +- Easier testing and debugging +- More reliable integration with AI tools + +## Getting Started + +### Prerequisites + +- Node.js 18.0.0 or higher +- pnpm (recommended package manager) +- A Figma API access token ([how to create one](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens)) + +### Development Setup + +1. **Clone the repository:** + + ```bash + git clone https://github.com/GLips/Figma-Context-MCP.git + cd Figma-Context-MCP + ``` + +2. **Install dependencies:** + + ```bash + pnpm install + ``` + +3. **Set up environment variables:** + Create a `.env` file in the root directory: + + ``` + FIGMA_API_KEY=your_figma_api_key_here + ``` + +4. **Build the project:** + + ```bash + pnpm build + ``` + +5. **Run tests:** + + ```bash + pnpm test + ``` + +6. **Start development server:** + + ```bash + pnpm dev + ``` + +7. **Test locally:** + + `pnpm dev` will start a local server you can connect to via Streamable HTTP. To connect to it, you can add the following configuration to your MCP JSON config file. Note, some MCP clients use a different format. [See the Framelink docs](https://www.framelink.ai/docs/quickstart#configure-ide) for more information on specific clients. + + ```bash + "mcpServers": { + "Framelink MCP for Figma - Local StreamableHTTP": { + "url": "http://localhost:3333/mcp" + }, + } + ``` + +### Development Commands + +- `pnpm dev` - Start development server with watch mode +- `pnpm build` - Build the project +- `pnpm type-check` - Run TypeScript type checking +- `pnpm test` - Run tests +- `pnpm lint` - Run ESLint +- `pnpm format` - Format code with Prettier +- `pnpm inspect` - Run MCP inspector for debugging + +## Code Style and Standards + +### TypeScript + +- Use TypeScript for all new code +- Follow TypeScript settings as defined in `tsconfig.json` + +### Code Formatting + +- Use Prettier for code formatting (run `pnpm format`) +- Use ESLint for code linting (run `pnpm lint`) +- Follow existing code patterns and conventions + +## Project Structure + +``` +src/ +├── cli.ts # Command line interface +├── config.ts # Configuration management +├── index.ts # Main entry point +├── server.ts # MCP server implementation +├── mcp/ # MCP-specific code +│ ├── index.ts +│ └── tools/ # MCP tools +├── services/ # Core business logic +├── transformers/ # Data transformation logic +├── utils/ # Utility functions +└── tests/ # Test files +``` + +## Contributing Guidelines + +### Before You Start + +1. Check existing issues and PRs to avoid duplicates +2. For major changes, create an issue first to discuss the approach +3. Keep changes focused and atomic + +### Pull Request Process + +1. **Fork the repository** and create a feature branch +2. **Make your changes** following the code style guidelines +3. **Add tests** for new functionality +4. **Run the test suite** to ensure nothing is broken: + ```bash + pnpm test + pnpm type-check + pnpm lint + ``` +5. **Update documentation** if needed +6. **Submit a pull request** with a clear description that includes context and motivation for the changes + +### Commit Messages + +This project uses [Conventional Commits](https://www.conventionalcommits.org/) to automate versioning and changelog generation. The maintainer applies the correct prefix when squash-merging your PR — you don't need to worry about this in your individual commits. + +For reference, these prefixes determine version bumps: + +- `fix: ` — patch release (0.6.4 → 0.6.5) +- `feat: ` — minor release (0.6.4 → 0.7.0) +- `feat!: ` or `BREAKING CHANGE:` footer — major release (0.6.4 → 1.0.0) +- `chore:`, `docs:`, `test:`, `refactor:` — no release triggered + +### What We're Looking For + +- **New features** - Expand the server's capabilities to support more Figma features +- **Bug fixes** - Help us improve reliability +- **Performance improvements** - Make the server faster +- **Documentation improvements** - Help others understand the project +- **Test coverage** - Improve our test suite +- **Code quality** - Refactoring and clean-up + +### What We're Not Looking For + +- Features that go beyond design ingestion (see Philosophy section) +- Breaking changes without discussion +- Code that doesn't follow our style guidelines +- Features without tests + +## Getting Help + +- **Documentation**: Check the [Framelink docs](https://framelink.ai/docs) +- **Issues**: Search existing issues or create a new one +- **Discord**: Join our [Discord community](https://framelink.ai/discord) + +## License + +By contributing to this project, you agree that your contributions will be licensed under the MIT License. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..16d9efe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Graham Lipsman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cdbf3d --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ + + + + Framelink + + + +
+

Framelink MCP for Figma

+

Give your coding agent access to your Figma data.
Implement designs in any framework in one-shot.

+ + weekly downloads + + + MIT License + + + Discord + +
+ + Twitter + +
+ +
+ +Give [Cursor](https://cursor.sh/) and other AI-powered coding tools access to your Figma files with this [Model Context Protocol](https://modelcontextprotocol.io/introduction) server. + +When Cursor has access to Figma design data, it's **way** better at one-shotting designs accurately than alternative approaches like pasting screenshots. + +

See quickstart instructions →

+ +## Demo + +[Watch a demo of building a UI in Cursor with Figma design data](https://youtu.be/6G9yb-LrEqg) + +[![Watch the video](https://img.youtube.com/vi/6G9yb-LrEqg/maxresdefault.jpg)](https://youtu.be/6G9yb-LrEqg) + +## How it works + +1. Open your IDE's chat (e.g. agent mode in Cursor). +2. Paste a link to a Figma file, frame, or group. +3. Ask Cursor to do something with the Figma file—e.g. implement the design. +4. Cursor will fetch the relevant metadata from Figma and use it to write your code. + +This MCP server is specifically designed for use with Cursor. Before responding with context from the [Figma API](https://www.figma.com/developers/api), it simplifies and translates the response so only the most relevant layout and styling information is provided to the model. + +Reducing the amount of context provided to the model helps make the AI more accurate and the responses more relevant. + +## Getting Started + +Many code editors and other AI clients use a configuration file to manage MCP servers. + +The `figma-developer-mcp` server can be configured by adding the following to your configuration file. + +> NOTE: You will need to create a Figma access token to use this server. Instructions on how to create a Figma API access token can be found [here](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens). + +### MacOS / Linux + +```json +{ + "mcpServers": { + "Framelink MCP for Figma": { + "command": "npx", + "args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"] + } + } +} +``` + +### Windows + +```json +{ + "mcpServers": { + "Framelink MCP for Figma": { + "command": "cmd", + "args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"] + } + } +} +``` + +Or you can set `FIGMA_API_KEY` and `PORT` in the `env` field. + +If you need more information on how to configure the Framelink MCP for Figma, see the [Framelink docs](https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=referral&utm_campaign=readme). + +## Star History + +Star History Chart + +## Learn More + +The Framelink MCP for Figma is simple but powerful. Get the most out of it by learning more at the [Framelink](https://framelink.ai?utm_source=github&utm_medium=referral&utm_campaign=readme) site. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..0840842 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`GLips/Figma-Context-MCP` +- 原始仓库:https://github.com/GLips/Figma-Context-MCP +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..2a017b7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,125 @@ +# Figma MCP Server Roadmap + +This roadmap outlines planned improvements and features for the Figma MCP Server project. Items are organized by development phases and effort levels. + +## Overview + +The Figma MCP Server enables AI coding assistants to access Figma design data directly, improving the accuracy of design-to-code translations. This roadmap focuses on expanding capabilities, improving developer experience, and ensuring robust enterprise support. + +## Core Feature Enhancements 🚀 + +_High impact, foundational improvements_ + +### Component & Prototype Support (High Priority) + +- [ ] **Add dedicated tool for component extraction** ([#124](https://github.com/GLips/Figma-Context-MCP/issues/124)) + - [ ] Create `get_figma_components` tool for fetching full component/component set design data including variants and properties +- [ ] **Improve INSTANCE support** + - [ ] Return only overridden values + - [ ] Hide children of INSTANCE except for slot type children or if full data is explicitly requested via new tool call parameter +- [ ] **Prototype support** + - [ ] Extract interactivity data (e.g. actions on hover, click, etc.) + - [ ] Return data on animations / transitions + - [?] State management hints + +### Parsing Logic + +- [ ] Inline variables that only show up once, and keep global vars only for variables that are reused + +### Image & Asset Handling + +- [ ] **Fix masked / cropped image exports** + - [ ] Correctly export cropped images ([#162](https://github.com/GLips/Figma-Context-MCP/issues/162)) + - [?] Support complex mask shapes and transformations + - [?] Pull image fills/vectors out to top level for better AI visibility +- [ ] **Improve SVG handling** + - [ ] Better icon identification, e.g. if all components of a frame are VECTOR, download the full frame as an SVG + - [?] Add support for raw path data in response—not sure if this is valuable yet + +### Layout Improvements + +- [ ] **Smart wrapped layout detection** + - [?] Detect and convert fixed-width children to percentage-based widths + - [ ] Better flexbox wrap support + - [ ] Grid layout detection for wrapped items + - [ ] Support for Figma's new grid layout + +### Advanced Styling + +- [ ] **Enhanced gradient support** + - [ ] Make sure gradients are exported correctly in CSS syntax ([#152](https://github.com/GLips/Figma-Context-MCP/issues/152)) +- [ ] **Grid system support** + - [ ] Support for Figma's new grid autolayout (an addition to the long-existing flex autolayout) + - [ ] Legacy "layout guide" grids +- [ ] **Named styles extraction** + - [ ] Export style names associated with different layouts, colors, text, etc. for easier identification by the LLM (can use `/v1/styles/:key` endpoint) + +### Text & Typography + +- [ ] **Text styling** + - [ ] Add support for formatted text in text fields ([#159](https://github.com/GLips/Figma-Context-MCP/issues/159)) + - [ ] Add support for mixed text styles (e.g. multiple colors) ([#140](https://github.com/GLips/Figma-Context-MCP/issues/140)) + +## Enterprise & Advanced Features 🏢 + +_Features for scaling and enterprise adoption_ + +### Enterprise Support + +- [ ] **Variable System Enhancements** + - [ ] Port `deduceVariablesFromTokens` for non-Enterprise users (see [tothienbao6a0's fork](https://github.com/tothienbao6a0/Figma-Context-MCP/blob/d9b035de76f44c952382b8155a5d5bf938e52a77/src/services/variable-deduction.ts#L30) for inspiration?) + - [ ] Add `getFigmaVariables` for Enterprise plans + - [?] Export design tokens in standard formats + +## Developer Experience 🛠️ + +_Improving usability and integration_ + +### Performance & Reliability + +- [ ] **Better error handling** + - [x] Retry logic for API failures + - [ ] Detailed error messages which the LLM can expand on for users + +### Documentation & Testing + +- [ ] **Test coverage improvements** + - [ ] Unit tests for all transformers + - [ ] Integration tests with mock Figma API + - [ ] E2E tests to visually check the implementation of an LLM coding agent prompted with MCP server output—likely uses a custom test framework to kick off e.g. Claude Code in the background + +## Quick Wins 🎪 + +_Low effort, high impact_ + +- [ ] Better handling of text overflow (e.g. auto width, auto height, fixed width + truncate text setting) +- [ ] Double check to make sure blend modes are forwarded properly in the simplified response + +## Technical Debt 🧹 + +_Code quality and maintenance_ + +- [ ] Clean up image download code (noted in mcp.ts) +- [ ] Refactor `convertAlign` function (layout.ts) +- [ ] Standardize error handling across services + +## Research & Exploration 🔬 + +_Investigate feasibility / value_ + +- [ ] Figma plugin companion 🚀🚀🚀 +- [ ] **Design System Integration** + - [ ] Token extraction and mapping + - [ ] Component dependency graphs +- [ ] **Figma File Metadata** + - [ ] Investigate how we can use frames that are marked "Ready for Dev" + - [ ] Investigate feasibility of pulling in annotations via the Figma API + - [ ] Investigate feasibility/value of using—and even modifying—"Dev Resources" links via Figma API + +## Contributing + +We welcome contributions! Please check the issues labeled with "good first issue" or "help wanted". For major features, please open an issue first to discuss the implementation approach. + +--- + +_This roadmap is subject to change based on community feedback and priorities. Last updated: June 2025_ diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6c79a4a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities responsibly. + +**Do NOT open a public GitHub issue for security vulnerabilities.** + +### How to Report + +1. **GitHub Security Advisories**: [Report privately](https://github.com/GLips/Figma-Context-MCP/security/advisories/new) +2. **Email**: Contact the maintainers directly + +### Response Timeline + +- Acknowledgment: 48 hours +- Assessment: 1 week +- Fix: Based on severity + +## Supported Versions + +| Version | Supported | +|---------|:---------:| +| Latest | ✅ | + +## MCP Security Best Practices + +1. Review server permissions before connecting +2. Use environment variables for secrets +3. Limit server access to required tools only +4. Keep dependencies updated diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f7e644b --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,55 @@ +import js from "@eslint/js"; +import tseslint from "@typescript-eslint/eslint-plugin"; +import tsparser from "@typescript-eslint/parser"; +import prettier from "eslint-config-prettier"; +import globals from "globals"; + +export default [ + js.configs.recommended, + { + files: ["**/*.ts", "**/*.tsx"], + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, + globals: { + ...globals.node, + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + ...tseslint.configs.recommended.rules, + "no-undef": "off", // TypeScript handles this; no-undef doesn't understand TS types like NodeJS + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "@typescript-eslint/no-explicit-any": "warn", + }, + }, + { + files: ["**/*.ts", "**/*.tsx"], + rules: prettier.rules, + }, + { + files: ["**/*.test.ts", "**/*.test.tsx", "**/tests/**/*.ts"], + languageOptions: { + globals: { + ...globals.jest, // vitest globals are the same names + }, + }, + }, + { + files: ["scripts/**/*.mjs"], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, + { + ignores: ["dist/**", "node_modules/**"], + }, +]; diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..a38ceed --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,15 @@ +pre-commit: + parallel: true + commands: + format: + glob: "*.{ts,js,json,md}" + run: pnpm prettier --write {staged_files} && git add {staged_files} + lint: + glob: "*.{ts,js}" + run: pnpm eslint {staged_files} + type-check: + glob: "*.{ts,js}" + run: pnpm type-check + scan-hidden-chars: + glob: "*.{ts,js,mjs,cjs,json,md,yml,yaml}" + run: node scripts/scan-hidden-chars.mjs {staged_files} diff --git a/package.json b/package.json new file mode 100644 index 0000000..64a618a --- /dev/null +++ b/package.json @@ -0,0 +1,87 @@ +{ + "name": "figma-developer-mcp", + "version": "0.13.2", + "mcpName": "io.github.GLips/Figma-Context-MCP", + "description": "Give your coding agent access to your Figma data. Implement designs in any framework in one-shot.", + "type": "module", + "main": "dist/index.js", + "bin": { + "figma-developer-mcp": "dist/bin.js" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup --dts", + "type-check": "tsc --noEmit", + "test": "vitest run", + "start": "node dist/bin.js", + "start:cli": "cross-env NODE_ENV=cli node dist/bin.js", + "start:http": "node dist/bin.js", + "dev": "cross-env NODE_ENV=development tsup --watch", + "dev:cli": "cross-env NODE_ENV=development tsup --watch -- --stdio", + "lint": "eslint .", + "format": "prettier --write \"src/**/*.ts\"", + "inspect": "pnpx @modelcontextprotocol/inspector", + "benchmark:simplify": "tsx scripts/benchmark-simplify.ts", + "prepack": "pnpm build" + }, + "engines": { + "node": ">=20.20.0" + }, + "packageManager": "pnpm@10.10.0", + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild", + "lefthook" + ] + }, + "repository": { + "type": "git", + "url": "git+https://github.com/GLips/Figma-Context-MCP.git" + }, + "homepage": "https://www.framelink.ai", + "keywords": [ + "figma", + "mcp", + "typescript" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@figma/rest-api-spec": "^0.37.0", + "@jimp/core": "^1.6.0", + "@jimp/js-gif": "^1.6.0", + "@jimp/js-jpeg": "^1.6.0", + "@jimp/js-png": "^1.6.0", + "@jimp/plugin-crop": "^1.6.0", + "@modelcontextprotocol/sdk": "1.29.0", + "cleye": "^2.2.1", + "cross-env": "^7.0.3", + "dotenv": "^16.4.7", + "express": "^5.2.1", + "js-yaml": "^4.1.1", + "posthog-node": "^5.29.2", + "remeda": "^2.20.1", + "undici": "^7.25.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/express": "^5.0.0", + "@types/js-yaml": "^4.0.9", + "@types/node": "^25.3.3", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^9.39.3", + "eslint-config-prettier": "^10.0.1", + "globals": "^17.3.0", + "lefthook": "^2.0.15", + "prettier": "^3.5.0", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.7.3", + "vitest": "^4.0.18" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..f4ef152 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3602 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@figma/rest-api-spec': + specifier: ^0.37.0 + version: 0.37.0 + '@jimp/core': + specifier: ^1.6.0 + version: 1.6.0 + '@jimp/js-gif': + specifier: ^1.6.0 + version: 1.6.0 + '@jimp/js-jpeg': + specifier: ^1.6.0 + version: 1.6.0 + '@jimp/js-png': + specifier: ^1.6.0 + version: 1.6.0 + '@jimp/plugin-crop': + specifier: ^1.6.0 + version: 1.6.0 + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0(zod@3.25.76) + cleye: + specifier: ^2.2.1 + version: 2.2.1 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 + dotenv: + specifier: ^16.4.7 + version: 16.4.7 + express: + specifier: ^5.2.1 + version: 5.2.1 + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 + posthog-node: + specifier: ^5.29.2 + version: 5.29.2 + remeda: + specifier: ^2.20.1 + version: 2.20.1 + undici: + specifier: ^7.25.0 + version: 7.25.0 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@eslint/js': + specifier: ^9.33.0 + version: 9.33.0 + '@types/express': + specifier: ^5.0.0 + version: 5.0.0 + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^25.3.3 + version: 25.3.3 + '@typescript-eslint/eslint-plugin': + specifier: ^8.56.1 + version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.7.3))(eslint@9.39.3)(typescript@5.7.3) + '@typescript-eslint/parser': + specifier: ^8.56.1 + version: 8.56.1(eslint@9.39.3)(typescript@5.7.3) + eslint: + specifier: ^9.39.3 + version: 9.39.3 + eslint-config-prettier: + specifier: ^10.0.1 + version: 10.0.1(eslint@9.39.3) + globals: + specifier: ^17.3.0 + version: 17.3.0 + lefthook: + specifier: ^2.0.15 + version: 2.0.15 + prettier: + specifier: ^3.5.0 + version: 3.5.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.7.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.7.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.3.3)(tsx@4.21.0) + +packages: + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.4': + resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.33.0': + resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.3': + resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@figma/rest-api-spec@0.37.0': + resolution: {integrity: sha512-KKeCgSRiO2gajT/sKCAsn48ubl8NRbYqyIvfkk5+NPK2ar+DVuLKg3fl3/cR1WharC5Dg4V5a9JzDsYv04k50g==} + + '@hono/node-server@1.19.10': + resolution: {integrity: sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.6': + resolution: {integrity: sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} + engines: {node: '>=18.18'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jimp/core@1.6.0': + resolution: {integrity: sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w==} + engines: {node: '>=18'} + + '@jimp/file-ops@1.6.0': + resolution: {integrity: sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ==} + engines: {node: '>=18'} + + '@jimp/js-gif@1.6.0': + resolution: {integrity: sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g==} + engines: {node: '>=18'} + + '@jimp/js-jpeg@1.6.0': + resolution: {integrity: sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA==} + engines: {node: '>=18'} + + '@jimp/js-png@1.6.0': + resolution: {integrity: sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg==} + engines: {node: '>=18'} + + '@jimp/plugin-crop@1.6.0': + resolution: {integrity: sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang==} + engines: {node: '>=18'} + + '@jimp/types@1.6.0': + resolution: {integrity: sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg==} + engines: {node: '>=18'} + + '@jimp/utils@1.6.0': + resolution: {integrity: sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA==} + engines: {node: '>=18'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@posthog/core@1.25.2': + resolution: {integrity: sha512-h2FO7ut/BbfwpAXWpwdDHTzQgUo9ibDFEs6ZO+3cI3KPWQt5XwczK1OLAuPprcjm8T/jl0SH8jSFo5XdU4RbTg==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tokenizer/token@0.3.0': + resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + + '@types/body-parser@1.19.5': + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.6': + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@5.0.6': + resolution: {integrity: sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==} + + '@types/express@5.0.0': + resolution: {integrity: sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==} + + '@types/http-errors@2.0.4': + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + '@types/node@16.9.1': + resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} + + '@types/node@20.17.0': + resolution: {integrity: sha512-a7zRo0f0eLo9K5X9Wp5cAqTUNGzuFLDG2R7C4HY2BhcMAsxgSPuRvAC1ZB6QkuUQXf0YZAgfOX2ZyrBa2n4nHQ==} + + '@types/node@25.3.3': + resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} + + '@types/qs@6.9.18': + resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.4': + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + + '@types/serve-static@1.15.7': + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + await-to-js@3.0.0: + resolution: {integrity: sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g==} + engines: {node: '>=6.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.3: + resolution: {integrity: sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cleye@2.2.1: + resolution: {integrity: sha512-eZzJGlG3N6+IsKV+297HIRS2fyRsLMOrx62hGUmmcyOtP/I+L7JVeSKZH49WZUdVB8NoaZOUvq01363I/PHJiA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + content-disposition@1.0.0: + resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} + engines: {node: '>= 0.6'} + + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.0.1: + resolution: {integrity: sha512-lZBts941cyJyeaooiKxAtzoPHTN+GbQTJFAIdQbRhA4/8whaAraEh47Whw/ZFfrjNSnlAxqfm9i0XVAEkULjCw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.3: + resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + eventsource-parser@3.0.0: + resolution: {integrity: sha512-T1C0XCUimhxVQzW4zFipdx0SficT651NnkR0ZSH3yQwh+mFMdLfgjABVi4YtMTtaL4s168593DaoaRLMqryavA==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.5: + resolution: {integrity: sha512-LT/5J605bx5SNyE+ITBDiM3FxffBiq9un7Vx0EwMDM3vg8sWKx/tO2zC+LMqZ+smAM0F2hblaDZUVZF0te2pSw==} + engines: {node: '>=18.0.0'} + + exif-parser@0.1.12: + resolution: {integrity: sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + file-type@16.5.4: + resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} + engines: {node: '>=10'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.2: + resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.2.7: + resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.10.0: + resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==} + + gifwrap@0.10.1: + resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.3.0: + resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hono@4.12.5: + resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + image-q@4.0.0: + resolution: {integrity: sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + jpeg-js@0.4.4: + resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + lefthook-darwin-arm64@2.0.15: + resolution: {integrity: sha512-ygAqG/NzOgY9bEiqeQtiOmCRTtp9AmOd3eyrpEaSrRB9V9f3RHRgWDrWbde9BiHSsCzcbeY9/X2NuKZ69eUsNA==} + cpu: [arm64] + os: [darwin] + + lefthook-darwin-x64@2.0.15: + resolution: {integrity: sha512-3wA30CzdSL5MFKD6dk7v8BMq7ScWQivpLbmIn3Pv67AaBavN57N/hcdGqOFnDDFI5WazVwDY7UqDfMIk5HZjEA==} + cpu: [x64] + os: [darwin] + + lefthook-freebsd-arm64@2.0.15: + resolution: {integrity: sha512-FbYBBLVbX8BjdO+icN1t/pC3TOW3FAvTKv/zggBKNihv6jHNn/3s/0j2xIS0k0Pw9oOE7MVmEni3qp2j5vqHrQ==} + cpu: [arm64] + os: [freebsd] + + lefthook-freebsd-x64@2.0.15: + resolution: {integrity: sha512-udHMjh1E8TfC0Z7Y249XZMATJOyj1Jxlj9JoEinkoBvAsePFKDEQg5teuXuTGhjsHYpqVekfSvLNNfHKUUbbjw==} + cpu: [x64] + os: [freebsd] + + lefthook-linux-arm64@2.0.15: + resolution: {integrity: sha512-1HAPmdYhfcOlubv63sTnWtW2rFuC+kT1MvC3JvdrS5V6zrOImbBSnYZMJX/Dd3w4pm0x2ZJb9T+uef8a0jUQkg==} + cpu: [arm64] + os: [linux] + + lefthook-linux-x64@2.0.15: + resolution: {integrity: sha512-Pho87mlNFH47zc4fPKzQSp8q9sWfIFW/KMMZfx/HZNmX25aUUTOqMyRwaXxtdAo/hNJ9FX4JeuZWq9Y3iyM5VA==} + cpu: [x64] + os: [linux] + + lefthook-openbsd-arm64@2.0.15: + resolution: {integrity: sha512-pet03Edlj1QeFUgxcIK1xu8CeZA+ejYplvPgdfe//69+vQFGSDaEx3H2mVx8RqzWfmMbijM2/WfkZXR2EVw3bw==} + cpu: [arm64] + os: [openbsd] + + lefthook-openbsd-x64@2.0.15: + resolution: {integrity: sha512-i+a364CcSAeIO5wQzLMHsthHt/v6n3XwhKmRq/VBzPOUv9KutNeF55yCE/6lvuvzwxpdEfBjh6cXPERC0yp98w==} + cpu: [x64] + os: [openbsd] + + lefthook-windows-arm64@2.0.15: + resolution: {integrity: sha512-69u5GdVOT4QIxc2TK5ce0cTXLzwB55Pk9ZnnJNFf1XsyZTGcg9bUWYYTyD12CIIXbVTa0RVXIIrbU9UgP8O1AQ==} + cpu: [arm64] + os: [win32] + + lefthook-windows-x64@2.0.15: + resolution: {integrity: sha512-/zYEndCUgj8XK+4wvLYLRk3AcfKU6zWf2GHx+tcZ4K2bLaQdej4m+OqmQsVpUlF8N2tN9hfwlj1D50uz75LUuQ==} + cpu: [x64] + os: [win32] + + lefthook@2.0.15: + resolution: {integrity: sha512-sl5rePO6UUOLKp6Ci+MMKOc86zicBaPUCvSw2Cq4gCAgTmxpxhIjhz7LOu2ObYerVRPpTq3gvzPTjI71UotjnA==} + hasBin: true + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.2.4: + resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + omggif@1.0.10: + resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@8.2.0: + resolution: {integrity: sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==} + engines: {node: '>=16'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + peek-readable@4.1.0: + resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + pkce-challenge@5.0.0: + resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} + engines: {node: '>=16.20.0'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + posthog-node@5.29.2: + resolution: {integrity: sha512-rI7kkF0XqDc0G1qjx+Hb4iuY9NAlL+XQNoGOpnEpRNTUcXvjY6WlsRGZ9m2whgc39emrrYdszi/YT8wZkr2xsg==} + engines: {node: ^20.20.0 || >=22.22.0} + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.5.0: + resolution: {integrity: sha512-quyMrVt6svPS7CjQ9gKb3GLEX/rl3BCL2oa/QkNcXv4YNVBC9olt3s+H7ukto06q7B1Qz46PbrKLO34PR6vXcA==} + engines: {node: '>=14'} + hasBin: true + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readable-web-to-node-stream@3.0.4: + resolution: {integrity: sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==} + engines: {node: '>=8'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + remeda@2.20.1: + resolution: {integrity: sha512-gsEsSmjE0CHkNp6xEsWsU/6JVNWq7rqw+ZfzNMbVV4YFIPtTj/i0FfxurTRI6Z9sAnQufU9de2Cb3xHsUTFTMA==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strtok3@6.3.0: + resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} + engines: {node: '>=10'} + + sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + terminal-columns@2.0.0: + resolution: {integrity: sha512-6IByuUjyNZJXUtwDNm+OIe62zgwwaRbH+WMNTcx05O2G5V9WhvluAAHJY8OvUdwmzMPpqAD/7EUpGdI6ae1aiQ==} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinycolor2@1.6.0: + resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + token-types@4.2.1: + resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} + engines: {node: '>=10'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@4.34.1: + resolution: {integrity: sha512-6kSc32kT0rbwxD6QL1CYe8IqdzN/J/ILMrNK+HMQCKH3insCDRY/3ITb0vcBss0a3t72fzh2YSzj8ko1HgwT3g==} + engines: {node: '>=16'} + + type-flag@4.0.3: + resolution: {integrity: sha512-YA09cL07U7hSV+/doSfKl+RkIZ2olCnevZsVgAuyBUG3h2ROf9Oh2vmbq5Rf26aA9/qu9RtStuc7ap5PC6k/vw==} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript@5.7.3: + resolution: {integrity: sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3)': + dependencies: + eslint: 9.39.3 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.4': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.33.0': {} + + '@eslint/js@9.39.3': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@figma/rest-api-spec@0.37.0': {} + + '@hono/node-server@1.19.10(hono@4.12.5)': + dependencies: + hono: 4.12.5 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.6': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.3.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jimp/core@1.6.0': + dependencies: + '@jimp/file-ops': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + await-to-js: 3.0.0 + exif-parser: 0.1.12 + file-type: 16.5.4 + mime: 3.0.0 + + '@jimp/file-ops@1.6.0': {} + + '@jimp/js-gif@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + gifwrap: 0.10.1 + omggif: 1.0.10 + + '@jimp/js-jpeg@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + jpeg-js: 0.4.4 + + '@jimp/js-png@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + pngjs: 7.0.0 + + '@jimp/plugin-crop@1.6.0': + dependencies: + '@jimp/core': 1.6.0 + '@jimp/types': 1.6.0 + '@jimp/utils': 1.6.0 + zod: 3.25.76 + + '@jimp/types@1.6.0': + dependencies: + zod: 3.25.76 + + '@jimp/utils@1.6.0': + dependencies: + '@jimp/types': 1.6.0 + tinycolor2: 1.6.0 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.10(hono@4.12.5) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.5 + cross-spawn: 7.0.6 + eventsource: 3.0.5 + eventsource-parser: 3.0.0 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.12.5 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.0 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@posthog/core@1.25.2': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@tokenizer/token@0.3.0': {} + + '@types/body-parser@1.19.5': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.17.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 20.17.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.6': {} + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@5.0.6': + dependencies: + '@types/node': 20.17.0 + '@types/qs': 6.9.18 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + '@types/express@5.0.0': + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 5.0.6 + '@types/qs': 6.9.18 + '@types/serve-static': 1.15.7 + + '@types/http-errors@2.0.4': {} + + '@types/js-yaml@4.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/mime@1.3.5': {} + + '@types/node@16.9.1': {} + + '@types/node@20.17.0': + dependencies: + undici-types: 6.19.8 + + '@types/node@25.3.3': + dependencies: + undici-types: 7.18.2 + + '@types/qs@6.9.18': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.4': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.17.0 + + '@types/serve-static@1.15.7': + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 20.17.0 + '@types/send': 0.17.4 + + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.7.3))(eslint@9.39.3)(typescript@5.7.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3)(typescript@5.7.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3)(typescript@5.7.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 9.39.3 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.1(eslint@9.39.3)(typescript@5.7.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.7.3) + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + eslint: 9.39.3 + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.1(typescript@5.7.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.7.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.7.3)': + dependencies: + typescript: 5.7.3 + + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3)(typescript@5.7.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.7.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3)(typescript@5.7.3) + debug: 4.4.3 + eslint: 9.39.3 + ts-api-utils: 2.4.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.7.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@5.7.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.7.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.7.3) + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@9.39.3)(typescript@5.7.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.7.3) + eslint: 9.39.3 + typescript: 5.7.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.3)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.3.3)(tsx@4.21.0) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.1: {} + + any-promise@1.3.0: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + await-to-js@3.0.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bundle-require@5.1.0(esbuild@0.27.3): + dependencies: + esbuild: 0.27.3 + load-tsconfig: 0.2.5 + + bytes@3.1.2: {} + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.3: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.2.7 + + callsites@3.1.0: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cleye@2.2.1: + dependencies: + terminal-columns: 2.0.0 + type-flag: 4.0.3 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + content-disposition@1.0.0: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.1: {} + + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + depd@2.0.0: {} + + dotenv@16.4.7: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.0.1(eslint@9.39.3): + dependencies: + eslint: 9.39.3 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.3: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.4 + '@eslint/js': 9.39.3 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.6 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.6 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + events@3.3.0: {} + + eventsource-parser@3.0.0: {} + + eventsource@3.0.5: + dependencies: + eventsource-parser: 3.0.0 + + exif-parser@0.1.12: {} + + expect-type@1.3.0: {} + + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.0 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@16.5.4: + dependencies: + readable-web-to-node-stream: 3.0.4 + strtok3: 6.3.0 + token-types: 4.2.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.0 + rollup: 4.59.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.2 + keyv: 4.5.4 + + flatted@3.3.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.2.7: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.10.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + gifwrap@0.10.1: + dependencies: + image-q: 4.0.0 + omggif: 1.0.10 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + globals@14.0.0: {} + + globals@17.3.0: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hono@4.12.5: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + image-q@4.0.0: + dependencies: + '@types/node': 16.9.1 + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jose@6.1.3: {} + + joycon@3.1.1: {} + + jpeg-js@0.4.4: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + lefthook-darwin-arm64@2.0.15: + optional: true + + lefthook-darwin-x64@2.0.15: + optional: true + + lefthook-freebsd-arm64@2.0.15: + optional: true + + lefthook-freebsd-x64@2.0.15: + optional: true + + lefthook-linux-arm64@2.0.15: + optional: true + + lefthook-linux-x64@2.0.15: + optional: true + + lefthook-openbsd-arm64@2.0.15: + optional: true + + lefthook-openbsd-x64@2.0.15: + optional: true + + lefthook-windows-arm64@2.0.15: + optional: true + + lefthook-windows-x64@2.0.15: + optional: true + + lefthook@2.0.15: + optionalDependencies: + lefthook-darwin-arm64: 2.0.15 + lefthook-darwin-x64: 2.0.15 + lefthook-freebsd-arm64: 2.0.15 + lefthook-freebsd-x64: 2.0.15 + lefthook-linux-arm64: 2.0.15 + lefthook-linux-x64: 2.0.15 + lefthook-openbsd-arm64: 2.0.15 + lefthook-openbsd-x64: 2.0.15 + lefthook-windows-arm64: 2.0.15 + lefthook-windows-x64: 2.0.15 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + lru-cache@10.4.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@3.0.0: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + + minipass@7.1.2: {} + + mlly@1.8.0: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.1.1: {} + + omggif@1.0.10: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-json-from-dist@1.0.1: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parseurl@1.3.3: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@8.2.0: {} + + pathe@2.0.3: {} + + peek-readable@4.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + pirates@4.0.6: {} + + pkce-challenge@5.0.0: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + pngjs@7.0.0: {} + + postcss-load-config@6.0.1(postcss@8.5.6)(tsx@4.21.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.6 + tsx: 4.21.0 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + posthog-node@5.29.2: + dependencies: + '@posthog/core': 1.25.2 + + prelude-ls@1.2.1: {} + + prettier@3.5.0: {} + + process@0.11.10: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readable-web-to-node-stream@3.0.4: + dependencies: + readable-stream: 4.7.0 + + readdirp@4.1.2: {} + + remeda@2.20.1: + dependencies: + type-fest: 4.34.1 + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.2.0 + transitivePeerDependencies: + - supports-color + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.0: + dependencies: + ansi-regex: 6.1.0 + + strip-json-comments@3.1.1: {} + + strtok3@6.3.0: + dependencies: + '@tokenizer/token': 0.3.0 + peek-readable: 4.1.0 + + sucrase@3.35.0: + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + commander: 4.1.1 + glob: 10.5.0 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + terminal-columns@2.0.0: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinycolor2@1.6.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + toidentifier@1.0.1: {} + + token-types@4.2.1: + dependencies: + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tree-kill@1.2.2: {} + + ts-api-utils@2.4.0(typescript@5.7.3): + dependencies: + typescript: 5.7.3 + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.7.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.3) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.3 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.6)(tsx@4.21.0) + resolve-from: 5.0.0 + rollup: 4.59.0 + source-map: 0.7.6 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.6 + typescript: 5.7.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.10.0 + optionalDependencies: + fsevents: 2.3.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@4.34.1: {} + + type-flag@4.0.3: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@5.7.3: {} + + ufo@1.6.3: {} + + undici-types@6.19.8: {} + + undici-types@7.18.2: {} + + undici@7.25.0: {} + + unpipe@1.0.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vary@1.1.2: {} + + vite@7.3.1(@types/node@25.3.3)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.3.3 + fsevents: 2.3.3 + tsx: 4.21.0 + + vitest@4.0.18(@types/node@25.3.3)(tsx@4.21.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.3)(tsx@4.21.0)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@25.3.3)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.3.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + wrappy@1.0.2: {} + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..69437f5 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,10 @@ +{ + "packages": { + ".": { + "release-type": "node", + "changelog-path": "CHANGELOG.md", + "bump-minor-pre-major": true, + "include-component-in-tag": false + } + } +} diff --git a/server.json b/server.json new file mode 100644 index 0000000..ed0269c --- /dev/null +++ b/server.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.GLips/Figma-Context-MCP", + "description": "Give your coding agent access to your Figma data. Implement designs in any framework in one-shot.", + "repository": { + "url": "https://github.com/GLips/Figma-Context-MCP", + "source": "github" + }, + "version": "0.6.4", + "packages": [ + { + "registryType": "npm", + "identifier": "figma-developer-mcp", + "version": "0.6.4", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "positional", + "value": "--stdio" + } + ], + "environmentVariables": [ + { + "description": "Your Figma Personal Access Token, learn more here: https://www.figma.com/developers/api#access-tokens", + "isRequired": true, + "format": "string", + "isSecret": true, + "name": "FIGMA_API_KEY" + } + ] + } + ] +} diff --git a/src/bin.ts b/src/bin.ts new file mode 100644 index 0000000..c607534 --- /dev/null +++ b/src/bin.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import { cli } from "cleye"; +import { getServerConfig, UsageError } from "./config.js"; +import { startServer } from "./server.js"; +import { fetchCommand } from "./commands/fetch.js"; + +const argv = cli({ + name: "figma-developer-mcp", + version: process.env.NPM_PACKAGE_VERSION ?? "unknown", + flags: { + figmaApiKey: { + type: String, + description: "Figma API key (Personal Access Token)", + }, + figmaOauthToken: { + type: String, + description: "Figma OAuth Bearer token", + }, + env: { + type: String, + description: "Path to custom .env file to load environment variables from", + }, + port: { + type: Number, + description: "Port to run the server on", + }, + host: { + type: String, + description: "Host to run the server on", + }, + json: { + type: Boolean, + description: "Output data from tools in JSON format. Back-compat alias for --format=json.", + }, + format: { + type: String, + description: "Output format for design data: tree (default, compact), yaml, or json.", + }, + skipImageDownloads: { + type: Boolean, + description: "Do not register the download_figma_images tool (skip image downloads)", + }, + imageDir: { + type: String, + description: + "Base directory for image downloads. The download tool will only write files within this directory. Defaults to the current working directory.", + }, + proxy: { + type: String, + description: + "HTTP proxy URL for networks that require a proxy (e.g. http://proxy:8080). Pass 'none' to ignore HTTP_PROXY/HTTPS_PROXY from the environment and connect directly.", + }, + stdio: { + type: Boolean, + description: "Run in stdio transport mode for MCP clients", + }, + noTelemetry: { + type: Boolean, + description: "Disable usage telemetry (telemetry is on by default)", + }, + }, + commands: [fetchCommand], +}); + +// Subcommand callbacks execute during cli() — only start the server when no subcommand ran. +if (!argv.command) { + main().catch((error) => { + if (error instanceof UsageError) { + console.error(error.message); + } else { + console.error("Failed to start server:", error); + } + process.exit(1); + }); +} + +async function main(): Promise { + // NODE_ENV=cli is a legacy backdoor for stdio mode + const isStdio = argv.flags.stdio === true || process.env.NODE_ENV === "cli"; + const config = getServerConfig({ ...argv.flags, stdio: isStdio }); + await startServer(config); +} diff --git a/src/commands/fetch.ts b/src/commands/fetch.ts new file mode 100644 index 0000000..7ab4d89 --- /dev/null +++ b/src/commands/fetch.ts @@ -0,0 +1,135 @@ +import { type Command, command } from "cleye"; +import { + loadEnvFile, + parseOutputFormat, + resolveAuth, + requireGlobalCredentials, + UsageError, +} from "~/config.js"; +import { FigmaService } from "~/services/figma.js"; +import { parseFigmaUrl } from "~/utils/figma-url.js"; +import { authMode, initTelemetry, captureGetFigmaDataCall, shutdown } from "~/telemetry/index.js"; +import { getFigmaData } from "~/services/get-figma-data.js"; +import type { OutputFormat } from "~/utils/serialize.js"; + +export const fetchCommand: Command = command( + { + name: "fetch", + description: "Fetch simplified Figma data and print to stdout", + parameters: ["[url]"], + flags: { + fileKey: { + type: String, + description: "Figma file key (overrides URL)", + }, + nodeId: { + type: String, + description: "Node ID, format 1234:5678 (overrides URL)", + }, + depth: { + type: Number, + description: "Tree traversal depth", + }, + json: { + type: Boolean, + description: "Output JSON instead of YAML. Back-compat alias for --format=json.", + }, + format: { + type: String, + description: "Output format: yaml (default), json, or tree.", + }, + figmaApiKey: { + type: String, + description: "Figma API key", + }, + figmaOauthToken: { + type: String, + description: "Figma OAuth token", + }, + env: { + type: String, + description: "Path to .env file", + }, + noTelemetry: { + type: Boolean, + description: "Disable usage telemetry", + }, + }, + }, + (argv) => { + run(argv.flags, argv._) + .catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }) + .finally(() => shutdown()); + }, +); + +async function run( + flags: { + fileKey?: string; + nodeId?: string; + depth?: number; + json?: boolean; + format?: string; + figmaApiKey?: string; + figmaOauthToken?: string; + env?: string; + noTelemetry?: boolean; + }, + positionals: string[], +) { + const url = positionals[0]; + let fileKey = flags.fileKey; + let nodeId = flags.nodeId; + + if (url) { + try { + const parsed = parseFigmaUrl(url); + fileKey ??= parsed.fileKey; + nodeId ??= parsed.nodeId; + } catch (error) { + if (!fileKey) throw error; + // fileKey provided via flag — malformed URL is non-fatal + } + } + + if (!fileKey) { + throw new UsageError("Either a Figma URL or --file-key is required"); + } + + loadEnvFile(flags.env); + const auth = resolveAuth(flags); + // The fetch CLI has no per-request credential channel (unlike HTTP mode). + // Fail fast so the user gets an actionable error instead of an HTTP-shaped + // one from `getAuthHeaders`. + requireGlobalCredentials(auth); + + // Initialize telemetry only after input validation succeeds, so every + // captured event corresponds to an actual fetch attempt (not a usage error). + initTelemetry({ + optOut: flags.noTelemetry, + immediateFlush: true, + redactFromErrors: [auth.figmaApiKey, auth.figmaOAuthToken], + }); + + const mode = authMode(auth); + // The fetch CLI stays yaml-by-default (unlike the MCP server, which defaults + // to tree): its output is piped into standard tooling (`> out.yaml`, `| jq`), + // where tree's bespoke indented format isn't parseable. Tree's token-efficiency + // win is for LLM consumers, not shell pipelines. + const outputFormat: OutputFormat = + parseOutputFormat(flags.format, "--format") ?? (flags.json ? "json" : "yaml"); + + const result = await getFigmaData( + new FigmaService(auth), + { fileKey, nodeId, depth: flags.depth }, + outputFormat, + { + onComplete: (outcome) => + captureGetFigmaDataCall(outcome, { transport: "cli", authMode: mode }), + }, + ); + console.log(result.formatted); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..97c98bc --- /dev/null +++ b/src/config.ts @@ -0,0 +1,242 @@ +import { config as loadEnv } from "dotenv"; +import { resolve as resolvePath } from "path"; +import type { FigmaAuthOptions } from "./services/figma.js"; +import { resolveTelemetryEnabled } from "./telemetry/index.js"; +import { VALID_OUTPUT_FORMATS, isOutputFormat, type OutputFormat } from "./utils/serialize.js"; + +export type Source = "cli" | "env" | "default"; + +export interface Resolved { + value: T; + source: Source; +} + +export interface ServerFlags { + figmaApiKey?: string; + figmaOauthToken?: string; + env?: string; + port?: number; + host?: string; + json?: boolean; + format?: string; + skipImageDownloads?: boolean; + imageDir?: string; + proxy?: string; + stdio?: boolean; + noTelemetry?: boolean; +} + +export interface ServerConfig { + auth: FigmaAuthOptions; + port: number; + host: string; + proxy: string | undefined; + outputFormat: OutputFormat; + skipImageDownloads: boolean; + imageDir: string; + isStdioMode: boolean; + noTelemetry: boolean; + configSources: Record; +} + +/** Resolve a config value through the priority chain: CLI flag → env var → default. */ +export function resolve(flag: T | undefined, env: T | undefined, fallback: T): Resolved { + if (flag !== undefined) return { value: flag, source: "cli" }; + if (env !== undefined) return { value: env, source: "env" }; + return { value: fallback, source: "default" }; +} + +export function envStr(name: string): string | undefined { + return process.env[name] || undefined; +} + +export function envInt(...names: string[]): number | undefined { + for (const name of names) { + const val = process.env[name]; + if (val) return parseInt(val, 10); + } + return undefined; +} + +export function envBool(name: string): boolean | undefined { + const val = process.env[name]; + if (val === "true") return true; + if (val === "false") return false; + return undefined; +} + +// Throws on invalid input so callers control how the failure surfaces — the +// server entry point exits the process, but the `fetch` CLI command needs to +// run its `finally` (telemetry shutdown) before exiting, which `process.exit` +// would bypass. +export function parseOutputFormat( + value: string | undefined, + source: string, +): OutputFormat | undefined { + if (value === undefined) return undefined; + if (isOutputFormat(value)) return value; + throw new Error( + `Invalid ${source} value '${value}'. Expected one of: ${VALID_OUTPUT_FORMATS.join(", ")}`, + ); +} + +function maskApiKey(key: string): string { + if (!key || key.length <= 4) return "****"; + return `****${key.slice(-4)}`; +} + +export function loadEnvFile(envPath?: string): string { + const envFilePath = envPath ? resolvePath(envPath) : resolvePath(process.cwd(), ".env"); + loadEnv({ path: envFilePath, override: true }); + return envFilePath; +} + +export function resolveAuth(flags: { + figmaApiKey?: string; + figmaOauthToken?: string; +}): FigmaAuthOptions { + const figmaApiKey = resolve(flags.figmaApiKey, envStr("FIGMA_API_KEY"), ""); + const figmaOauthToken = resolve(flags.figmaOauthToken, envStr("FIGMA_OAUTH_TOKEN"), ""); + + const useOAuth = Boolean(figmaOauthToken.value); + const auth: FigmaAuthOptions = { + figmaApiKey: figmaApiKey.value, + figmaOAuthToken: figmaOauthToken.value, + useOAuth, + }; + + return auth; +} + +/** + * Thrown for user-fixable input errors (missing credentials, missing file key, + * etc.). CLI entry points catch this and print the bare message with exit 1, + * distinct from unexpected crashes that get a "Failed to start server:" prefix + * and stack trace. Throwing (vs. process.exit) keeps validators pure and safe + * for library consumers of `~/mcp-server`. + */ +export class UsageError extends Error { + constructor(message: string) { + super(message); + this.name = "UsageError"; + } +} + +/** + * Fail fast when global credentials are required but missing. HTTP mode skips + * this check so it can accept per-request `X-Figma-Token` headers; stdio and + * the `fetch` CLI have no way to receive request-time auth and must have + * something resolvable at startup or they'd just defer the failure to the + * first tool call with a misleading "send X-Figma-Token" message. + */ +export function requireGlobalCredentials(auth: FigmaAuthOptions): void { + if (auth.figmaApiKey || auth.figmaOAuthToken) return; + throw new UsageError( + "Either FIGMA_API_KEY or FIGMA_OAUTH_TOKEN is required (via CLI argument or .env file)", + ); +} + +export function getServerConfig(flags: ServerFlags): ServerConfig { + // Load .env before resolving env-backed values + const envFilePath = loadEnvFile(flags.env); + const envFileSource: Source = flags.env !== undefined ? "cli" : "default"; + + // Auth + const auth = resolveAuth(flags); + + // Resolve config values: CLI flag → env var → default + const figmaApiKey = resolve(flags.figmaApiKey, envStr("FIGMA_API_KEY"), ""); + const figmaOauthToken = resolve(flags.figmaOauthToken, envStr("FIGMA_OAUTH_TOKEN"), ""); + const port = resolve(flags.port, envInt("FRAMELINK_PORT", "PORT"), 3333); + const host = resolve(flags.host, envStr("FRAMELINK_HOST"), "127.0.0.1"); + const skipImageDownloads = resolve( + flags.skipImageDownloads, + envBool("SKIP_IMAGE_DOWNLOADS"), + false, + ); + const envImageDir = envStr("IMAGE_DIR"); + const imageDir = resolve( + flags.imageDir ? resolvePath(flags.imageDir) : undefined, + envImageDir ? resolvePath(envImageDir) : undefined, + process.cwd(), + ); + + // Only resolve explicit proxy config here. Standard env vars (HTTPS_PROXY, HTTP_PROXY, + // NO_PROXY) are handled by undici's EnvHttpProxyAgent at the dispatcher level, which + // correctly respects NO_PROXY exclusions. + const proxy = resolve(flags.proxy, envStr("FIGMA_PROXY"), undefined); + + // --format wins; --json is a back-compat alias for --format=json. Invalid + // user-supplied values fail loudly at startup rather than silently coercing. + const formatFromFlag = + parseOutputFormat(flags.format, "--format") ?? (flags.json ? "json" : undefined); + const formatFromEnv = parseOutputFormat(envStr("OUTPUT_FORMAT"), "OUTPUT_FORMAT"); + const outputFormat = resolve(formatFromFlag, formatFromEnv, "tree"); + + const isStdioMode = flags.stdio === true; + + const noTelemetry = flags.noTelemetry ?? false; + const telemetrySource: Source = + flags.noTelemetry === true + ? "cli" + : process.env.FRAMELINK_TELEMETRY !== undefined || process.env.DO_NOT_TRACK !== undefined + ? "env" + : "default"; + + const configSources: Record = { + envFile: envFileSource, + figmaApiKey: figmaApiKey.source, + figmaOauthToken: figmaOauthToken.source, + port: port.source, + host: host.source, + proxy: proxy.source, + outputFormat: outputFormat.source, + skipImageDownloads: skipImageDownloads.source, + imageDir: imageDir.source, + telemetry: telemetrySource, + }; + + if (!isStdioMode) { + console.log("\nConfiguration:"); + console.log(`- ENV_FILE: ${envFilePath} (source: ${configSources.envFile})`); + if (auth.useOAuth) { + console.log( + `- FIGMA_OAUTH_TOKEN: ${maskApiKey(auth.figmaOAuthToken)} (source: ${configSources.figmaOauthToken})`, + ); + console.log("- Authentication Method: OAuth Bearer Token"); + } else if (auth.figmaApiKey) { + console.log( + `- FIGMA_API_KEY: ${maskApiKey(auth.figmaApiKey)} (source: ${configSources.figmaApiKey})`, + ); + console.log("- Authentication Method: Personal Access Token (X-Figma-Token)"); + } else { + console.log("- Authentication Method: Per-request X-Figma-Token header"); + } + console.log(`- FRAMELINK_PORT: ${port.value} (source: ${configSources.port})`); + console.log(`- FRAMELINK_HOST: ${host.value} (source: ${configSources.host})`); + console.log(`- PROXY: ${proxy.value ? "configured" : "none"} (source: ${configSources.proxy})`); + console.log(`- OUTPUT_FORMAT: ${outputFormat.value} (source: ${configSources.outputFormat})`); + console.log( + `- SKIP_IMAGE_DOWNLOADS: ${skipImageDownloads.value} (source: ${configSources.skipImageDownloads})`, + ); + console.log(`- IMAGE_DIR: ${imageDir.value} (source: ${configSources.imageDir})`); + const telemetryEnabled = resolveTelemetryEnabled(noTelemetry); + console.log( + `- TELEMETRY: ${telemetryEnabled ? "enabled" : "disabled"} (source: ${configSources.telemetry})`, + ); + console.log(); + } + + return { + auth, + port: port.value, + host: host.value, + proxy: proxy.value, + outputFormat: outputFormat.value, + skipImageDownloads: skipImageDownloads.value, + imageDir: imageDir.value, + isStdioMode, + noTelemetry, + configSources, + }; +} diff --git a/src/extractors/README.md b/src/extractors/README.md new file mode 100644 index 0000000..1945084 --- /dev/null +++ b/src/extractors/README.md @@ -0,0 +1,125 @@ +# Flexible Figma Data Extractors + +This module provides a flexible, single-pass system for extracting data from Figma design files. It allows you to compose different extractors based on your specific needs, making it perfect for different LLM use cases where you want to optimize context window usage. + +## Architecture + +The system is built in clean layers: + +1. **Strategy Layer**: Define what you want to extract +2. **Traversal Layer**: Single-pass tree walking with configurable extractors +3. **Extraction Layer**: Pure functions that transform individual node data + +## Basic Usage + +```typescript +import { extractFromDesign, allExtractors, layoutAndText, contentOnly } from "figma-mcp/extractors"; + +// Extract everything (equivalent to current parseNode) +const fullData = extractFromDesign(nodes, allExtractors); + +// Extract only layout + text for content planning +const layoutData = extractFromDesign(nodes, layoutAndText, { + maxDepth: 3, +}); + +// Extract only text content for copy audits +const textData = extractFromDesign(nodes, contentOnly, { + nodeFilter: (node) => node.type === "TEXT", +}); +``` + +## Built-in Extractors + +### Individual Extractors + +- `layoutExtractor` - Layout properties (positioning, sizing, flex properties) +- `textExtractor` - Text content and typography styles +- `visualsExtractor` - Visual appearance (fills, strokes, effects, opacity, borders) +- `componentExtractor` - Component instance data + +### Convenience Combinations + +- `allExtractors` - Everything (replicates current behavior) +- `layoutAndText` - Layout + text (good for content analysis) +- `contentOnly` - Text only (good for copy extraction) +- `visualsOnly` - Visual styles only (good for design systems) +- `layoutOnly` - Layout only (good for structure analysis) + +## Creating Custom Extractors + +```typescript +import type { ExtractorFn } from "figma-mcp/extractors"; + +// Custom extractor that identifies design system components +const designSystemExtractor: ExtractorFn = (node, result, context) => { + if (node.name.startsWith("DS/")) { + result.isDesignSystemComponent = true; + result.dsCategory = node.name.split("/")[1]; + } +}; + +// Use it with other extractors +const data = extractFromDesign(nodes, [layoutExtractor, designSystemExtractor]); +``` + +## Filtering and Options + +```typescript +// Limit traversal depth +const shallowData = extractFromDesign(nodes, allExtractors, { + maxDepth: 2, +}); + +// Filter to specific node types +const frameData = extractFromDesign(nodes, layoutAndText, { + nodeFilter: (node) => ["FRAME", "GROUP"].includes(node.type), +}); + +// Custom filtering logic +const buttonData = extractFromDesign(nodes, allExtractors, { + nodeFilter: (node) => node.name.toLowerCase().includes("button"), +}); +``` + +## LLM Context Optimization + +The flexible system is designed for different LLM use cases: + +```typescript +// For large designs - extract incrementally +function extractForLLM(nodes, phase) { + switch (phase) { + case "structure": + return extractFromDesign(nodes, layoutOnly, { maxDepth: 3 }); + + case "content": + return extractFromDesign(nodes, contentOnly); + + case "styling": + return extractFromDesign(nodes, visualsOnly, { maxDepth: 2 }); + + case "full": + return extractFromDesign(nodes, allExtractors); + } +} +``` + +## Benefits + +1. **Single Tree Walk** - Efficient processing, no matter how many extractors +2. **Composable** - Mix and match extractors for your specific needs +3. **Extensible** - Easy to add custom extractors for domain-specific logic +4. **Type Safe** - Full TypeScript support with proper inference +5. **Context Optimized** - Perfect for LLM context window management +6. **Backward Compatible** - Works alongside existing parsing logic + +## Migration Path + +The new system works alongside the current `parseNode` function. You can: + +1. Start using the new extractors for new use cases +2. Gradually migrate existing functionality +3. Keep the current API for general-purpose parsing + +The `allExtractors` combination provides equivalent functionality to the current `parseNode` behavior. diff --git a/src/extractors/built-in.ts b/src/extractors/built-in.ts new file mode 100644 index 0000000..fb835b8 --- /dev/null +++ b/src/extractors/built-in.ts @@ -0,0 +1,437 @@ +import type { + ExtractorFn, + GlobalVars, + StyleTypes, + TraversalContext, + SimplifiedNode, +} from "./types.js"; +import { buildSimplifiedLayout } from "~/transformers/layout.js"; +import { buildSimplifiedStrokes, flattenSolidFills, parsePaint } from "~/transformers/style.js"; +import { buildSimplifiedEffects } from "~/transformers/effects.js"; +import { + buildFormattedText, + extractTextStyle, + hasTextStyle, + isTextNode, + type SimplifiedTextStyle, +} from "~/transformers/text.js"; +import { + simplifyComponentProperties, + simplifyPropertyDefinitions, + simplifyPropertyReferences, +} from "~/transformers/component.js"; +import { hasAutoLayout, hasValue, isRectangleCornerRadii } from "~/utils/identity.js"; +import { isVisible, stableStringify } from "~/utils/common.js"; +import { createHash } from "node:crypto"; +import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; + +// Reverse lookup cache: serialized style value → varId. +// Keyed on the GlobalVars instance so it's automatically scoped to each +// extraction run and garbage-collected when the run's context is released. +const styleCaches = new WeakMap>(); + +// Separate cache for inline text-style override refs. Kept distinct from +// `styleCaches` so the `ts` namespace never aliases with `style_*`, `fill_*`, +// etc. — a base textStyle that happens to serialize identically to an inline +// delta would otherwise return the wrong prefix, bleeding `style_XXXXXX` IDs +// into the middle of `text` strings and vice versa. +const inlineTextStyleCaches = new WeakMap>(); + +function getStyleCache(globalVars: GlobalVars): Map { + let cache = styleCaches.get(globalVars); + if (!cache) { + cache = new Map(); + styleCaches.set(globalVars, cache); + } + return cache; +} + +function getInlineTextStyleCache(globalVars: GlobalVars): Map { + let cache = inlineTextStyleCaches.get(globalVars); + if (!cache) { + cache = new Map(); + inlineTextStyleCaches.set(globalVars, cache); + } + return cache; +} + +/** + * Find an existing global style variable with the same value, or create one. + */ +function findOrCreateVar(globalVars: GlobalVars, value: StyleTypes, prefix: string): string { + const cache = getStyleCache(globalVars); + const key = stableStringify(value); + + const existing = cache.get(key); + if (existing) return existing; + + // Content-addressed id so the same value yields the same id across runs, making + // output byte-stable (the value→id cache already dedups within a single run). + const fullHash = createHash("sha1").update(key).digest("hex"); + + // Truncated-hash collision guard. The 8-hex slice (32 bits) keeps refs short but + // can alias two different style values. We reached here on a cache miss, so a + // taken slot means a genuine collision — reusing the id would overwrite the + // other value and every node referencing it would silently resolve to the wrong + // style. Lengthen this value's id until the slot is free. Deterministic because + // the walk order is stable, so the same file reproduces the same ids. + let length = 8; + let varId = `${prefix}_${fullHash.slice(0, length)}`; + while (globalVars.styles[varId] !== undefined && length < fullHash.length) { + length += 4; + varId = `${prefix}_${fullHash.slice(0, length)}`; + } + + globalVars.styles[varId] = value; + cache.set(key, varId); + return varId; +} + +/** + * Register a style value, preferring a Figma named style when available. + * Falls back to an auto-generated deduplicating variable ID. + */ +function registerStyle( + node: FigmaDocumentNode, + context: TraversalContext, + value: StyleTypes, + styleKeys: string[], + prefix: string, +): string { + const styleMatch = getStyleMatch(node, context, styleKeys); + if (styleMatch) { + const styleKey = resolveStyleKey(context, styleMatch, value); + context.globalVars.styles[styleKey] = value; + // Mark as a named style so the finalize pass keeps it hoisted even if only + // one node uses it — a named Figma style is design-system intent, not noise. + context.traversalState.namedStyleKeys.add(styleKey); + return styleKey; + } + return findOrCreateVar(context.globalVars, value, prefix); +} + +/** + * Extracts layout-related properties from a node. + */ +export const layoutExtractor: ExtractorFn = (node, result, context) => { + const layout = buildSimplifiedLayout(node, context.parent); + if (Object.keys(layout).length > 1) { + result.layout = findOrCreateVar(context.globalVars, layout, "layout"); + } +}; + +/** + * Register an inline text-style override delta and return its short ID + * (`ts1`, `ts2`, …). Unlike `registerStyle`, these IDs come from a sequential + * counter on the traversal state — they appear inline in formatted text + * (`{ts1}…{/ts1}`), where short IDs matter for token efficiency and readability. + * + * Uses its own dedup cache (`inlineTextStyleCaches`), separate from the + * generic `styleCaches`. The two namespaces must not alias: if a base + * textStyle serializes identically to an inline delta, the inline caller must + * still get a `tsN` ID, not a `style_XXXXXX` ID that happens to be cached. + */ +function registerInlineTextStyle(context: TraversalContext, delta: SimplifiedTextStyle): string { + const cache = getInlineTextStyleCache(context.globalVars); + const key = stableStringify(delta); + const existing = cache.get(key); + if (existing) return existing; + context.traversalState.tsCounter += 1; + const id = `ts${context.traversalState.tsCounter}`; + context.globalVars.styles[id] = delta; + cache.set(key, id); + return id; +} + +/** + * Extracts text content and text styling from a node. + */ +export const textExtractor: ExtractorFn = (node, result, context) => { + // Extract text content — formatted with markdown + inline style refs when + // the node has per-character overrides, otherwise just the raw string. + if (isTextNode(node)) { + const rich = buildFormattedText(node, (delta) => registerInlineTextStyle(context, delta)); + if (rich.text) { + result.text = rich.text; + } + if (rich.boldWeight !== undefined) { + result.boldWeight = rich.boldWeight; + } + } + + // Extract text style + if (hasTextStyle(node)) { + const textStyle = extractTextStyle(node); + if (textStyle) { + result.textStyle = registerStyle(node, context, textStyle, ["text", "typography"], "style"); + } + } +}; + +/** + * Extracts visual appearance properties (fills, strokes, effects, opacity, border radius). + */ +export const visualsExtractor: ExtractorFn = (node, result, context) => { + // Check if node has children to determine CSS properties + const hasChildren = + hasValue("children", node) && Array.isArray(node.children) && node.children.length > 0; + + // fills + if (hasValue("fills", node) && Array.isArray(node.fills) && node.fills.length) { + const visibleFills = node.fills.filter(isVisible); + // An all-solid stack collapses to the single resolved color a viewer sees, + // removing the layer-order ambiguity that misleads LLM consumers. Mixed + // stacks (gradient/image/pattern or a non-normal blend) can't be folded and + // fall back to the per-paint array, reversed into CSS top-first order. + const flattened = flattenSolidFills(visibleFills); + const fills = flattened + ? [flattened] + : visibleFills.map((fill) => parsePaint(fill, hasChildren)).reverse(); + result.fills = registerStyle(node, context, fills, ["fill", "fills"], "fill"); + } + + // strokes + // Only the stroke color array is registered as a (potentially named) shared style. + // Figma named styles only apply to paint, not to stroke width / dashes / per-side + // weights, so those stay as plain sibling fields and are never deduplicated. + const strokes = buildSimplifiedStrokes(node, hasChildren); + if (strokes.colors.length) { + result.strokes = registerStyle(node, context, strokes.colors, ["stroke", "strokes"], "fill"); + if (strokes.strokeWeight) result.strokeWeight = strokes.strokeWeight; + if (strokes.strokeDashes) result.strokeDashes = strokes.strokeDashes; + if (strokes.strokeWeights) result.strokeWeights = strokes.strokeWeights; + if (strokes.strokeAlign) result.strokeAlign = strokes.strokeAlign; + } + + // effects + const effects = buildSimplifiedEffects(node); + if (Object.keys(effects).length) { + result.effects = registerStyle(node, context, effects, ["effect", "effects"], "effect"); + } + + // opacity + if (hasValue("opacity", node) && typeof node.opacity === "number" && node.opacity !== 1) { + result.opacity = node.opacity; + } + + // border radius + if (hasValue("cornerRadius", node) && typeof node.cornerRadius === "number") { + result.borderRadius = `${node.cornerRadius}px`; + } + if (hasValue("rectangleCornerRadii", node, isRectangleCornerRadii)) { + result.borderRadius = `${node.rectangleCornerRadii[0]}px ${node.rectangleCornerRadii[1]}px ${node.rectangleCornerRadii[2]}px ${node.rectangleCornerRadii[3]}px`; + } +}; + +/** + * Extracts component-related properties from nodes. + * Handles three cases: INSTANCE property values, property references on any node, + * and property definitions on COMPONENT/COMPONENT_SET nodes. + */ +export const componentExtractor: ExtractorFn = (node, result, context) => { + // Instance nodes: componentId + simplified componentProperties + if (node.type === "INSTANCE") { + if (hasValue("componentId", node)) { + result.componentId = node.componentId; + } + if (hasValue("componentProperties", node)) { + const props = simplifyComponentProperties( + node.componentProperties as Record, + ); + if (Object.keys(props).length > 0) { + result.componentProperties = props; + } + } + } + + // Any node with property references: annotate with simplified refs + if ( + "componentPropertyReferences" in node && + node.componentPropertyReferences && + typeof node.componentPropertyReferences === "object" + ) { + const refs = simplifyPropertyReferences( + node.componentPropertyReferences as Record, + ); + if (Object.keys(refs).length > 0) { + result.componentPropertyReferences = refs; + } + } + + // Component/ComponentSet definitions: collect property definitions + if ( + (node.type === "COMPONENT" || node.type === "COMPONENT_SET") && + "componentPropertyDefinitions" in node && + node.componentPropertyDefinitions && + typeof node.componentPropertyDefinitions === "object" + ) { + const defs = simplifyPropertyDefinitions( + node.componentPropertyDefinitions as Record< + string, + { type: string; defaultValue: boolean | string } + >, + ); + if (Object.keys(defs).length > 0) { + context.traversalState.componentPropertyDefinitions[node.id] = defs; + } + } +}; + +type StyleMatch = { name: string; id: string }; + +// Helper to fetch a Figma style name for specific style keys on a node +function getStyleMatch( + node: FigmaDocumentNode, + context: TraversalContext, + keys: string[], +): StyleMatch | undefined { + if (!hasValue("styles", node)) return undefined; + const styleMap = node.styles as Record; + for (const key of keys) { + const styleId = styleMap[key]; + if (styleId) { + const meta = context.extraStyles?.[styleId]; + if (meta?.name) return { name: meta.name, id: styleId }; + } + } + return undefined; +} + +// Figma style names aren't unique — a file can use a local style and an imported +// library style that share a name (e.g., "Heading / Large"). Collapse same-name +// same-value entries; disambiguate same-name different-value by appending the id. +function resolveStyleKey( + context: TraversalContext, + styleMatch: StyleMatch, + value: StyleTypes, +): string { + const existing = context.globalVars.styles[styleMatch.name]; + if (!existing) return styleMatch.name; + if (stableStringify(existing) === stableStringify(value)) return styleMatch.name; + + return `${styleMatch.name} (${styleMatch.id})`; +} + +// -------------------- CONVENIENCE COMBINATIONS -------------------- + +/** + * All extractors - replicates the current parseNode behavior. + */ +export const allExtractors = [layoutExtractor, textExtractor, visualsExtractor, componentExtractor]; + +/** + * Layout and text only - useful for content analysis and layout planning. + */ +export const layoutAndText = [layoutExtractor, textExtractor]; + +/** + * Text content only - useful for content audits and copy extraction. + */ +export const contentOnly = [textExtractor]; + +/** + * Visuals only - useful for design system analysis and style extraction. + */ +export const visualsOnly = [visualsExtractor]; + +/** + * Layout only - useful for structure analysis. + */ +export const layoutOnly = [layoutExtractor]; + +// -------------------- AFTER CHILDREN HELPERS -------------------- + +/** + * Node types that can be exported as SVG images. + * When a collapsible container holds only these types, the container can be flattened to + * IMAGE-SVG. BOOLEAN_OPERATION is in both this set and the container set below because it's + * both collapsible AND SVG-eligible as a child (boolean ops always produce vector output). + * + * Tightly coupled to node-walker.ts, which renames VECTOR → IMAGE-SVG before this set is consulted. + */ +export const SVG_ELIGIBLE_TYPES = new Set([ + "IMAGE-SVG", // VECTOR nodes are converted to IMAGE-SVG, or containers that were collapsed + "BOOLEAN_OPERATION", + "STAR", + "LINE", + "ELLIPSE", + "REGULAR_POLYGON", + "RECTANGLE", +]); + +/** Container node types eligible to collapse into a single IMAGE-SVG. */ +const COLLAPSIBLE_CONTAINER_TYPES = new Set(["FRAME", "GROUP", "INSTANCE", "BOOLEAN_OPERATION"]); + +/** + * Auto-layout signals authored structure — the spacing/arrangement of children is + * intentional, so we normally preserve the container even when all its children are + * SVG-eligible (charts, toolbars, layout test frames, swatch grids, tile mosaics). + * Above this many children, though, we assume the container is a decorative pattern + * (dotted backgrounds, noise grids) where the payload cost of preserving every leaf + * outweighs the structural value, and we collapse anyway. + * + * Applies to both flex (HORIZONTAL/VERTICAL) and GRID auto-layout, since both signal + * authored intent. + * + * Pivot point chosen empirically: real charts and structural displays rarely exceed ~10 + * primitives; decorative patterns typically have many dozens. Tune if real-world output + * shows either category mis-classified. + */ +const SVG_COLLAPSE_AUTOLAYOUT_THRESHOLD = 10; + +/** + * afterChildren callback that collapses SVG-heavy containers to IMAGE-SVG. + * + * Collapses when: + * - container is a FRAME, GROUP, INSTANCE, or BOOLEAN_OPERATION + * - all children are SVG-eligible types + * - neither the node nor any direct child has an image fill + * - container is NOT auto-layout, OR child count is past the decorative-pattern threshold + * + * The auto-layout carve-out preserves authored layouts (bar charts, button rows, swatch + * grids) that happen to bottom out in shape primitives. The count threshold reclaims + * payload for decorative patterns built with auto-layout (e.g., grids of dots). + * + * @param node - Original Figma node + * @param result - SimplifiedNode being built + * @param children - Processed children + * @returns Children to include (empty array if collapsed) + */ +export function collapseSvgContainers( + node: FigmaDocumentNode, + result: SimplifiedNode, + children: SimplifiedNode[], +): SimplifiedNode[] { + if (!COLLAPSIBLE_CONTAINER_TYPES.has(node.type)) return children; + // `type` is optional on SimplifiedNode only because post-walk template refs + // drop it; at afterChildren time (mid-walk) every child still has a type, so + // the `?? ""` is a type-level concession that never matches at runtime. + if (!children.every((child) => SVG_ELIGIBLE_TYPES.has(child.type ?? ""))) return children; + if (hasImageFillOnSelfOrDirectChildren(node)) return children; + + if (hasAutoLayout(node) && children.length < SVG_COLLAPSE_AUTOLAYOUT_THRESHOLD) { + return children; + } + + result.type = "IMAGE-SVG"; + return []; +} + +/** + * Check whether a node or its direct children have image fills. + * + * Only direct children need checking because afterChildren runs bottom-up: + * if a deeper descendant has image fills, its parent won't collapse (stays FRAME), + * and FRAME isn't SVG-eligible, so the chain breaks naturally at each level. + */ +function hasImageFillOnSelfOrDirectChildren(node: FigmaDocumentNode): boolean { + if (hasValue("fills", node) && node.fills.some((fill) => fill.type === "IMAGE")) { + return true; + } + if (hasValue("children", node)) { + return node.children.some( + (child) => hasValue("fills", child) && child.fills.some((fill) => fill.type === "IMAGE"), + ); + } + return false; +} diff --git a/src/extractors/design-extractor.ts b/src/extractors/design-extractor.ts new file mode 100644 index 0000000..d590ef8 --- /dev/null +++ b/src/extractors/design-extractor.ts @@ -0,0 +1,110 @@ +import type { + GetFileResponse, + GetFileNodesResponse, + Node as FigmaDocumentNode, + Component, + ComponentSet, + Style, +} from "@figma/rest-api-spec"; +import { simplifyComponents, simplifyComponentSets } from "~/transformers/component.js"; +import { tagError } from "~/utils/error-meta.js"; +import type { ExtractorFn, TraversalOptions, SimplifiedDesign } from "./types.js"; +import { extractFromDesign } from "./node-walker.js"; +import { finalizeDesign } from "./finalize.js"; + +/** + * Extract a complete SimplifiedDesign from raw Figma API response using extractors. + */ +export async function simplifyRawFigmaObject( + apiResponse: GetFileResponse | GetFileNodesResponse, + nodeExtractors: ExtractorFn[], + options: TraversalOptions = {}, +): Promise { + // Extract components, componentSets, and raw nodes from API response + const { metadata, rawNodes, components, componentSets, extraStyles } = + parseAPIResponse(apiResponse); + + // Process nodes using the flexible extractor system + const { + nodes: extractedNodes, + globalVars: walkedGlobalVars, + traversalState, + } = await extractFromDesign(rawNodes, nodeExtractors, options, { styles: {} }, extraStyles); + + // Finalize pass: count-gate style hoisting (and, later, element dedup). Runs + // here, after the full walk, because it needs whole-tree usage counts the + // single-pass extractors can't see. See finalize.ts. + const { nodes, globalVars, elements } = finalizeDesign( + extractedNodes, + walkedGlobalVars, + traversalState.namedStyleKeys, + ); + + return { + ...metadata, + nodes, + components: simplifyComponents(components, traversalState.componentPropertyDefinitions), + componentSets: simplifyComponentSets( + componentSets, + traversalState.componentPropertyDefinitions, + ), + globalVars, + elements, + }; +} + +/** + * Parse the raw Figma API response to extract metadata, nodes, and components. + */ +function parseAPIResponse(data: GetFileResponse | GetFileNodesResponse) { + const aggregatedComponents: Record = {}; + const aggregatedComponentSets: Record = {}; + let extraStyles: Record = {}; + let nodesToParse: Array; + + if ("nodes" in data) { + // GetFileNodesResponse + const [nodeId, nodeData] = Object.entries(data.nodes)[0]; + if (nodeData === null) { + tagError( + new Error( + `Node ${nodeId} was not found in the Figma file. Likely causes: ` + + `(1) The source URL was a /proto/, /figjam/, /slides/, /board/, or /deck/ link — ` + + `only /design/ and /file/ URLs are supported by the Figma REST API. ` + + `(2) The node is inside a Figma branch — branches have their own fileKey ` + + `(the value after /branch/ in the URL), use that instead of the parent file's key. ` + + `(3) The link is stale or the node was deleted. ` + + `Ask the user for a fresh /design/ URL pointing to the specific frame.`, + ), + { category: "not_found" }, + ); + } + + Object.assign(aggregatedComponents, nodeData.components); + Object.assign(aggregatedComponentSets, nodeData.componentSets); + if (nodeData.styles) { + Object.assign(extraStyles, nodeData.styles); + } + nodesToParse = [nodeData.document]; + } else { + // GetFileResponse + Object.assign(aggregatedComponents, data.components); + Object.assign(aggregatedComponentSets, data.componentSets); + if (data.styles) { + extraStyles = data.styles; + } + nodesToParse = data.document.children; + } + + const { name } = data; + + return { + metadata: { + name, + }, + rawNodes: nodesToParse, + extraStyles, + components: aggregatedComponents, + componentSets: aggregatedComponentSets, + }; +} diff --git a/src/extractors/finalize.ts b/src/extractors/finalize.ts new file mode 100644 index 0000000..d699377 --- /dev/null +++ b/src/extractors/finalize.ts @@ -0,0 +1,269 @@ +import { createHash } from "node:crypto"; +import { stableStringify } from "~/utils/common.js"; +import type { ElementBody, GlobalVars, SimplifiedNode } from "./types.js"; + +/** + * Post-walk deduplication pass. + * + * Both features here need GLOBAL knowledge that the single-pass extractor walk + * can't have: you can't tell whether a style or a subtree is used once or a + * hundred times until the whole tree is built. So rather than fight the + * composable-extractor model, we run this as a finalize pass over the + * already-built design, after the walk completes. + * + * Two transformations, in this order: + * 1. Count-gated style hoisting — a style stays in globalVars only when 2+ + * nodes reference it (or it's a named Figma style); single-use styles are + * inlined back onto their node, dropping the indirection tax. + * 2. Element templates — node bodies (everything except id/name/children) that + * appear 2+ times are emitted once into `elements` and each occurrence is + * replaced by a compact `{ id, name, template, children? }` reference. + * + * The order is for simplicity (hash bodies that are already gated), NOT a + * correctness requirement. Style ids are content-addressed (see findOrCreateVar), + * so two structurally-identical subtrees already carry byte-identical refs before + * gating — they hash to the same template with or without it. And gating only + * rewrites single-use styles, whose lone reference necessarily sits on a unique, + * non-repeated body (any repeated body would push the style's count to >= 2), so + * gating never touches a node that participates in templating. Hashing before or + * after gating yields the same templates either way. + * + * A final step (inlineExclusiveStyles) collapses the double indirection that + * arises when a surviving style turns out to be used only by the instances of a + * single deduplicated element — see below. + */ +export function finalizeDesign( + nodes: SimplifiedNode[], + globalVars: GlobalVars, + namedStyleKeys: Set, +): { + nodes: SimplifiedNode[]; + globalVars: GlobalVars; + elements: Record; +} { + // Per-style usage counts, taken before dedup while every node still carries + // its own style fields. Reused by both the inlining and expansion steps. + const styleCounts = countStyleRefs(nodes); + + const styles = inlineSingleUseStyles(nodes, globalVars.styles, namedStyleKeys, styleCounts); + const { elements, instanceCounts } = deduplicateElements(nodes); + inlineExclusiveStyles(elements, instanceCounts, styles, styleCounts, namedStyleKeys); + + return { nodes, globalVars: { styles }, elements }; +} + +// Node fields that carry a style reference (a globalVars key) and, after gating, +// may instead carry the inline style value. These are the only fields counted +// and inlined. `styles` is intentionally excluded — it's never populated. +const STYLE_REF_FIELDS = ["layout", "fills", "strokes", "effects", "textStyle"] as const; + +// Inline text-style deltas live under `ts1`, `ts2`, ... and are referenced from +// inside `text` strings (`{ts1}…{/ts1}`), not from node style fields. They are +// their own indirection mechanism with no node-field reference to count, so the +// gate must leave them alone — never inline or drop them. +const INLINE_TEXT_STYLE_KEY = /^ts\d+$/; + +/** + * Feature 1: replace single-use style refs with their inline value, returning the + * styles that stay hoisted in globalVars (used by 2+ nodes, or named styles). + * Mutates the passed nodes in place (they're owned by this call). A single-use + * value is referenced by exactly one node, so sharing the value object on inline + * creates no aliasing. + */ +function inlineSingleUseStyles( + nodes: SimplifiedNode[], + styles: GlobalVars["styles"], + namedStyleKeys: Set, + counts: Map, +): GlobalVars["styles"] { + const inlineKeys = new Set(); + const dropKeys = new Set(); + for (const key of Object.keys(styles)) { + if (INLINE_TEXT_STYLE_KEY.test(key)) continue; // referenced from text, leave hoisted + if (namedStyleKeys.has(key)) { + // Named styles are design-system intent, normally kept hoisted — but only + // while something still references them. A named style can reach zero + // references when its only node is dropped after registration (e.g. + // collapseSvgContainers registers a vector child's style, then folds the + // child away). A hoisted entry nothing points to is orphan noise, so drop + // it. (Non-named zero-count styles fall through to inlineKeys below and are + // likewise dropped — nothing references them, so nothing gets inlined.) + if ((counts.get(key) ?? 0) === 0) dropKeys.add(key); + continue; + } + if ((counts.get(key) ?? 0) >= 2) continue; // shared, keep hoisted + inlineKeys.add(key); + } + + const walk = (ns: SimplifiedNode[]): void => { + for (const node of ns) { + for (const field of STYLE_REF_FIELDS) { + const value = node[field]; + if (typeof value === "string" && inlineKeys.has(value)) { + // Widened SimplifiedNode field types make this legal; TS can't narrow per-field. + (node as unknown as Record)[field] = styles[value]; + } + } + if (node.children) walk(node.children); + } + }; + walk(nodes); + + const surviving: GlobalVars["styles"] = {}; + for (const [key, value] of Object.entries(styles)) { + if (!inlineKeys.has(key) && !dropKeys.has(key)) surviving[key] = value; + } + return surviving; +} + +function countStyleRefs(nodes: SimplifiedNode[]): Map { + const counts = new Map(); + const walk = (ns: SimplifiedNode[]): void => { + for (const node of ns) { + for (const field of STYLE_REF_FIELDS) { + const value = node[field]; + if (typeof value === "string") counts.set(value, (counts.get(value) ?? 0) + 1); + } + if (node.children) walk(node.children); + } + }; + walk(nodes); + return counts; +} + +/** + * Feature 2: hash each node body and replace bodies that repeat 2+ times with a + * template reference, returning the element table and each element's instance + * count. Mutates nodes in place. + */ +function deduplicateElements(nodes: SimplifiedNode[]): { + elements: Record; + instanceCounts: Map; +} { + const bodiesByHash = new Map(); + const hashByNode = new Map(); + collectElements(nodes, bodiesByHash, hashByNode); + + const elements: Record = {}; + const instanceCounts = new Map(); + for (const [hash, { body, count }] of bodiesByHash) { + if (count >= 2) { + elements[hash] = body; + instanceCounts.set(hash, count); + } + } + + applyTemplateRefs(nodes, hashByNode, elements); + return { elements, instanceCounts }; +} + +/** + * Stretch optimization: collapse double indirection. When a surviving style is + * referenced only by the instances of a single deduplicated element, the output + * pays twice — `template → style ref → value`. Inline the value into the element + * body and drop the global entry so it's just `template → value`. + * + * The test: a style whose total pre-dedup reference count equals an element's + * instance count, and which appears in that element's body, can only have come + * from that element's instances (any other use would push the count higher). + * Named styles are left hoisted — surfacing design-system intent is worth the + * indirection. A style appearing on two fields of the same body (count = 2× + * instances) simply won't match and stays hoisted; safe, if not optimal. + */ +function inlineExclusiveStyles( + elements: Record, + instanceCounts: Map, + styles: GlobalVars["styles"], + counts: Map, + namedStyleKeys: Set, +): void { + for (const [hash, body] of Object.entries(elements)) { + const instanceCount = instanceCounts.get(hash); + if (instanceCount === undefined) continue; + const writable = body as Record; + for (const field of STYLE_REF_FIELDS) { + const ref = writable[field]; + if (typeof ref !== "string") continue; + if (namedStyleKeys.has(ref) || INLINE_TEXT_STYLE_KEY.test(ref)) continue; + if (!(ref in styles)) continue; + if (counts.get(ref) === instanceCount) { + writable[field] = styles[ref]; + delete styles[ref]; + } + } + } +} + +// Per-instance keys excluded from the hashed body. Everything else (type and all +// styling) is intrinsic to the element and gets shared across instances. +const ELEMENT_OMIT_KEYS = new Set(["id", "name", "children"]); + +function bodyOf(node: SimplifiedNode): ElementBody { + const source = node as unknown as Record; + const body: Record = {}; + for (const key of Object.keys(source)) { + if (!ELEMENT_OMIT_KEYS.has(key)) body[key] = source[key]; + } + return body as ElementBody; +} + +function collectElements( + nodes: SimplifiedNode[], + bodiesByHash: Map, + hashByNode: Map, +): void { + for (const node of nodes) { + const body = bodyOf(node); + // Skip type-only bodies. A `{type}` element would cost more than it saves — + // a `template=EL-xxxx` ref plus a global entry, versus the bare `[TYPE]` it + // replaces. Dedup must never grow the payload; bodies with any real styling + // pay for themselves at 2+ uses and scale with repetition. + if (Object.keys(body).length > 1) { + const str = stableStringify(body); + const id = elementId(str, bodiesByHash); + const entry = bodiesByHash.get(id); + if (entry) entry.count += 1; + else bodiesByHash.set(id, { body, str, count: 1 }); + hashByNode.set(node, id); + } + if (node.children) collectElements(node.children, bodiesByHash, hashByNode); + } +} + +/** + * Content-addressed element id, with a truncated-hash collision guard. The 8-hex + * slice (32 bits) keeps template refs short but can alias two distinct bodies; + * letting them share an id would make applyTemplateRefs merge two different + * elements into one. On a clash we lengthen this body's id until the slot is free + * or already holds the same body. Deterministic because the walk order is stable. + */ +function elementId( + str: string, + bodiesByHash: Map, +): string { + const fullHash = createHash("sha1").update(str).digest("hex"); + for (let length = 8; length < fullHash.length; length += 4) { + const id = `EL-${fullHash.slice(0, length)}`; + const entry = bodiesByHash.get(id); + if (!entry || entry.str === str) return id; + } + return `EL-${fullHash}`; +} + +function applyTemplateRefs( + nodes: SimplifiedNode[], + hashByNode: Map, + elements: Record, +): void { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.children) applyTemplateRefs(node.children, hashByNode, elements); + + const hash = hashByNode.get(node); + if (hash && elements[hash]) { + const ref: SimplifiedNode = { id: node.id, name: node.name, template: hash }; + if (node.children && node.children.length > 0) ref.children = node.children; + nodes[i] = ref; + } + } +} diff --git a/src/extractors/index.ts b/src/extractors/index.ts new file mode 100644 index 0000000..8c3dd08 --- /dev/null +++ b/src/extractors/index.ts @@ -0,0 +1,34 @@ +// Types +export type { + ExtractorFn, + NodeCounter, + SimplifiedNode, + TraversalContext, + TraversalOptions, + TraversalState, + GlobalVars, + StyleTypes, +} from "./types.js"; + +// Core traversal function +export { extractFromDesign } from "./node-walker.js"; + +// Design-level extraction (unified nodes + components) +export { simplifyRawFigmaObject } from "./design-extractor.js"; + +// Built-in extractors and afterChildren helpers +export { + layoutExtractor, + textExtractor, + visualsExtractor, + componentExtractor, + // Convenience combinations + allExtractors, + layoutAndText, + contentOnly, + visualsOnly, + layoutOnly, + // afterChildren helpers + collapseSvgContainers, + SVG_ELIGIBLE_TYPES, +} from "./built-in.js"; diff --git a/src/extractors/node-walker.ts b/src/extractors/node-walker.ts new file mode 100644 index 0000000..914fef3 --- /dev/null +++ b/src/extractors/node-walker.ts @@ -0,0 +1,182 @@ +import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; +import { isVisible } from "~/utils/common.js"; +import { hasValue } from "~/utils/identity.js"; +import { computeGridChildOrder } from "~/transformers/layout.js"; +import type { Style } from "@figma/rest-api-spec"; +import type { + ExtractorFn, + NodeCounter, + TraversalContext, + TraversalOptions, + TraversalState, + GlobalVars, + SimplifiedNode, +} from "./types.js"; + +// Yield the event loop every N nodes so heartbeats, SIGINT, and +// other async work can run during large file processing. +const YIELD_INTERVAL = 100; + +async function maybeYield(counter: NodeCounter): Promise { + counter.count++; + if (counter.count % YIELD_INTERVAL === 0) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +/** + * Extract data from Figma nodes using a flexible, single-pass approach. + * + * @param nodes - The Figma nodes to process + * @param extractors - Array of extractor functions to apply during traversal + * @param options - Traversal options (filtering, depth limits, etc.) + * @param globalVars - Global variables for style deduplication + * @returns Object containing processed nodes and updated global variables + */ +export async function extractFromDesign( + nodes: FigmaDocumentNode[], + extractors: ExtractorFn[], + options: TraversalOptions = {}, + globalVars: GlobalVars = { styles: {} }, + extraStyles?: Record, +): Promise<{ + nodes: SimplifiedNode[]; + globalVars: GlobalVars; + traversalState: TraversalState; +}> { + const context: TraversalContext = { + globalVars, + extraStyles, + currentDepth: 0, + traversalState: { componentPropertyDefinitions: {}, tsCounter: 0, namedStyleKeys: new Set() }, + nodeCounter: options.nodeCounter ?? { count: 0 }, + }; + + const processedNodes: SimplifiedNode[] = []; + for (const node of nodes) { + if (!shouldProcessNode(node, context, options)) continue; + const result = await processNodeWithExtractors(node, extractors, context, options); + if (result !== null) processedNodes.push(result); + } + + return { + nodes: processedNodes, + globalVars: context.globalVars, + traversalState: context.traversalState, + }; +} + +/** + * Process a single node with all provided extractors in one pass. + */ +async function processNodeWithExtractors( + node: FigmaDocumentNode, + extractors: ExtractorFn[], + context: TraversalContext, + options: TraversalOptions, +): Promise { + if (!shouldProcessNode(node, context, options)) { + return null; + } + + await maybeYield(context.nodeCounter); + + // Always include base metadata + const result: SimplifiedNode = { + id: node.id, + name: node.name, + type: node.type === "VECTOR" ? "IMAGE-SVG" : node.type, + }; + + // Apply all extractors to this node in a single pass + for (const extractor of extractors) { + extractor(node, result, context); + } + + // Handle children recursively + if (shouldTraverseChildren(node, context, options)) { + const childContext: TraversalContext = { + ...context, + currentDepth: context.currentDepth + 1, + parent: node, + // COMPONENT nodes define properties; INSTANCE nodes resolve them + insideComponentDefinition: + node.type === "COMPONENT" || node.type === "COMPONENT_SET" + ? true + : node.type === "INSTANCE" + ? false + : context.insideComponentDefinition, + }; + + // Use the same pattern as the existing parseNode function + if (hasValue("children", node) && node.children.length > 0) { + // Grid containers: emit children in grid-flow (anchor) order rather than + // Figma's z-order, so CSS auto-placement lands them in the right cells. + // See computeGridChildOrder for details. + const order = computeGridChildOrder(node) ?? node.children.map((_, i) => i); + const children: SimplifiedNode[] = []; + for (const idx of order) { + const child = node.children[idx]; + if (!shouldProcessNode(child, childContext, options)) continue; + const processed = await processNodeWithExtractors(child, extractors, childContext, options); + if (processed !== null) children.push(processed); + } + + if (children.length > 0) { + // Allow custom logic to modify parent and control which children to include + const childrenToInclude = options.afterChildren + ? options.afterChildren(node, result, children) + : children; + + if (childrenToInclude.length > 0) { + result.children = childrenToInclude; + } + } + } + } + + return result; +} + +/** + * Determine if a node should be processed based on filters. + */ +function shouldProcessNode( + node: FigmaDocumentNode, + context: TraversalContext, + options: TraversalOptions, +): boolean { + if (!isVisible(node)) { + // Rescue hidden nodes controlled by a boolean property inside component definitions + const hasVisibleRef = + "componentPropertyReferences" in node && + node.componentPropertyReferences && + typeof node.componentPropertyReferences === "object" && + "visible" in node.componentPropertyReferences; + if (!(hasVisibleRef && context.insideComponentDefinition)) { + return false; + } + } + + if (options.nodeFilter && !options.nodeFilter(node)) { + return false; + } + + return true; +} + +/** + * Determine if we should traverse into a node's children. + */ +function shouldTraverseChildren( + _node: FigmaDocumentNode, + context: TraversalContext, + options: TraversalOptions, +): boolean { + // Check depth limit + if (options.maxDepth !== undefined && context.currentDepth >= options.maxDepth) { + return false; + } + + return true; +} diff --git a/src/extractors/types.ts b/src/extractors/types.ts new file mode 100644 index 0000000..c74350c --- /dev/null +++ b/src/extractors/types.ts @@ -0,0 +1,177 @@ +import type { Node as FigmaDocumentNode, Style } from "@figma/rest-api-spec"; +import type { SimplifiedTextStyle } from "~/transformers/text.js"; +import type { SimplifiedLayout } from "~/transformers/layout.js"; +import type { SimplifiedFill, SimplifiedStroke } from "~/transformers/style.js"; +import type { SimplifiedEffects } from "~/transformers/effects.js"; +import type { + SimplifiedComponentDefinition, + SimplifiedComponentSetDefinition, + SimplifiedPropertyDefinition, +} from "~/transformers/component.js"; + +export type StyleTypes = + | SimplifiedTextStyle + | SimplifiedFill[] + | SimplifiedLayout + | SimplifiedStroke + | SimplifiedEffects + | string; + +export type GlobalVars = { + styles: Record; +}; + +export interface TraversalContext { + globalVars: GlobalVars; + extraStyles?: Record; + currentDepth: number; + parent?: FigmaDocumentNode; + insideComponentDefinition?: boolean; + traversalState: TraversalState; + /** + * Per-call mutable counter shared with the caller. Lives on the context so + * walker recursion can increment it without touching module-global state — + * concurrent extractFromDesign calls (e.g. overlapping HTTP requests) each + * own their counter and never collide. + */ + nodeCounter: NodeCounter; +} + +/** + * Mutable progress counter passed into traversal. Callers can read `count` + * during traversal (for live progress indicators) and after it returns + * (as the final node-walked metric). + */ +export type NodeCounter = { count: number }; + +export interface TraversalState { + componentPropertyDefinitions: Record>; + /** + * Sequential counter for inline text-style override IDs (`ts1`, `ts2`, ...). + * Lives on the traversal state so every text node in a run shares the same + * namespace, which lets `{tsN}…{/tsN}` references appear inline in text + * content with short, readable identifiers. + */ + tsCounter: number; + /** + * globalVars keys that correspond to named Figma styles (vs. auto-generated + * content-hash ids). The finalize pass keeps these hoisted even at a single + * use, because a named style encodes design-system intent worth surfacing. + * Collected during the walk because the post-walk pass can't otherwise tell a + * named-style key apart from an auto-generated one by inspection. + */ + namedStyleKeys: Set; +} + +export interface TraversalOptions { + maxDepth?: number; + nodeFilter?: (node: FigmaDocumentNode) => boolean; + /** + * Called after children are processed, allowing modification of the parent node + * and control over which children to include in the output. + * + * @param node - Original Figma node + * @param result - SimplifiedNode being built (can be mutated) + * @param children - Processed children + * @returns Children to include (return empty array to omit children) + */ + afterChildren?: ( + node: FigmaDocumentNode, + result: SimplifiedNode, + children: SimplifiedNode[], + ) => SimplifiedNode[]; + /** + * Optional caller-supplied counter. The walker increments it as it processes + * nodes, so callers that need a live readout (e.g. progress heartbeats) or a + * post-call metric can read from the same object. If omitted, the walker + * creates its own internal counter. + */ + nodeCounter?: NodeCounter; +} + +/** + * An extractor function that can modify a SimplifiedNode during traversal. + * + * @param node - The current Figma node being processed + * @param result - SimplifiedNode object being built—this can be mutated inside the extractor + * @param context - Traversal context including globalVars and parent info. This can also be mutated inside the extractor. + */ +export type ExtractorFn = ( + node: FigmaDocumentNode, + result: SimplifiedNode, + context: TraversalContext, +) => void; + +export interface SimplifiedDesign { + name: string; + nodes: SimplifiedNode[]; + components: Record; + componentSets: Record; + globalVars: GlobalVars; + /** + * Deduplicated element bodies, keyed by content hash (`EL-xxxxxxxx`). Populated + * by the finalize pass: when a node body (everything except id/name/children) + * appears 2+ times, it is emitted here once and each occurrence is replaced by + * a compact `template` reference. Empty when nothing repeats. + */ + elements: Record; +} + +/** + * A node body with the per-instance keys removed. This is what gets hoisted into + * `SimplifiedDesign.elements` and referenced by `SimplifiedNode.template`. `type` + * is part of the body (it's intrinsic to the element), so a template reference + * carries no `type` of its own — consumers resolve it via the element entry. + */ +export type ElementBody = Omit; + +export interface SimplifiedNode { + id: string; + // Always populated during simplification, but the serialization pass drops it + // when it is noise (auto-generated like `Rectangle 12`, or redundant with the + // node's `text`), so the output shape treats it as optional. + name?: string; + type?: string; // e.g. FRAME, TEXT, INSTANCE, RECTANGLE, etc. Absent on template refs (type lives in the element). + /** + * Reference into `SimplifiedDesign.elements`. When set, the node's body lives + * in the shared element and only id/name/children/template are kept here. + */ + template?: string; + // text + text?: string; + textStyle?: string | SimplifiedTextStyle; + /** + * The numeric font weight that `**bold**` inside `text` maps to. Only emitted + * when a text node has per-character bold overrides heavier than its base + * `style.fontWeight`, so the consumer knows how to realize markdown bold. + */ + boldWeight?: number; + // appearance — each style field holds either a globalVars reference (when the + // value is shared by 2+ nodes or is a named Figma style) or the inline value + // itself (single-use values, after the finalize pass). + fills?: string | SimplifiedFill[]; + styles?: string; + strokes?: string | SimplifiedFill[]; + // Non-stylable stroke properties are kept on the node when stroke uses a named color style + strokeWeight?: string; + strokeDashes?: number[]; + strokeWeights?: string; + strokeAlign?: "INSIDE" | "OUTSIDE" | "CENTER"; + effects?: string | SimplifiedEffects; + opacity?: number; + borderRadius?: string; + // layout & alignment + layout?: string | SimplifiedLayout; + componentId?: string; + componentProperties?: Record; + componentPropertyReferences?: Record; + // children + children?: SimplifiedNode[]; +} + +export interface BoundingBox { + x: number; + y: number; + width: number; + height: number; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..0367719 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +// Re-export extractor types only +export type { SimplifiedDesign } from "./extractors/types.js"; + +// Flexible extractor system +export type { + ExtractorFn, + TraversalContext, + TraversalOptions, + GlobalVars, + StyleTypes, +} from "./extractors/index.js"; + +export { + extractFromDesign, + simplifyRawFigmaObject, + layoutExtractor, + textExtractor, + visualsExtractor, + componentExtractor, + allExtractors, + layoutAndText, + contentOnly, + visualsOnly, + layoutOnly, + collapseSvgContainers, +} from "./extractors/index.js"; diff --git a/src/mcp-server.ts b/src/mcp-server.ts new file mode 100644 index 0000000..cd2c97b --- /dev/null +++ b/src/mcp-server.ts @@ -0,0 +1,6 @@ +// Re-export server-related functionality for users who want MCP server capabilities +export { createServer } from "./mcp/index.js"; +export type { FigmaService } from "./services/figma.js"; +export { getServerConfig, UsageError } from "./config.js"; +export type { ServerConfig } from "./config.js"; +export { startServer, startHttpServer, stopHttpServer } from "./server.js"; diff --git a/src/mcp/index.ts b/src/mcp/index.ts new file mode 100644 index 0000000..1ff6199 --- /dev/null +++ b/src/mcp/index.ts @@ -0,0 +1,123 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { FigmaService, type FigmaAuthOptions } from "../services/figma.js"; +import { Logger } from "../utils/logger.js"; +import { authMode, type AuthMode, type ClientInfo, type Transport } from "~/telemetry/index.js"; +import type { OutputFormat } from "~/utils/serialize.js"; +import { installValidationRejectCapture } from "./validation-capture.js"; +import type { ToolExtra } from "./progress.js"; +import { + downloadFigmaImagesTool, + getFigmaDataTool, + type DownloadImagesParams, + type GetFigmaDataParams, +} from "./tools/index.js"; + +const serverInfo = { + name: "Figma MCP Server", + version: process.env.NPM_PACKAGE_VERSION ?? "unknown", + description: + "Gives AI coding agents access to Figma design data, providing layout, styling, and content information for implementing designs.", +}; + +type ServerTransport = Extract; + +export type CreateServerOptions = { + transport: ServerTransport; + outputFormat?: OutputFormat; + skipImageDownloads?: boolean; + imageDir?: string; +}; + +function createServer( + authOptions: FigmaAuthOptions, + { transport, outputFormat = "tree", skipImageDownloads = false, imageDir }: CreateServerOptions, +) { + const server = new McpServer(serverInfo); + const figmaService = new FigmaService(authOptions); + const mode = authMode(authOptions); + + const getClientInfo = (): ClientInfo | undefined => { + const info = server.server.getClientVersion(); + if (!info) return undefined; + return { name: info.name, version: info.version }; + }; + + registerTools(server, figmaService, { + transport, + authMode: mode, + outputFormat, + skipImageDownloads, + imageDir, + getClientInfo, + }); + + installValidationRejectCapture(server, { + transport, + authMode: mode, + outputFormat, + getClientInfo, + }); + + Logger.isHTTP = transport !== "stdio"; + + return server; +} + +type RegisterToolsOptions = { + transport: ServerTransport; + authMode: AuthMode; + outputFormat: OutputFormat; + skipImageDownloads: boolean; + imageDir?: string; + getClientInfo: () => ClientInfo | undefined; +}; + +function registerTools( + server: McpServer, + figmaService: FigmaService, + options: RegisterToolsOptions, +): void { + server.registerTool( + getFigmaDataTool.name, + { + title: "Get Figma Data", + description: getFigmaDataTool.description, + inputSchema: getFigmaDataTool.parametersSchema, + annotations: { readOnlyHint: true }, + }, + (params: GetFigmaDataParams, extra: ToolExtra) => + getFigmaDataTool.handler( + params, + figmaService, + options.outputFormat, + options.transport, + options.authMode, + options.getClientInfo(), + extra, + ), + ); + + if (!options.skipImageDownloads) { + server.registerTool( + downloadFigmaImagesTool.name, + { + title: "Download Figma Images", + description: downloadFigmaImagesTool.getDescription(options.imageDir), + inputSchema: downloadFigmaImagesTool.parametersSchema, + annotations: { openWorldHint: true }, + }, + (params: DownloadImagesParams, extra: ToolExtra) => + downloadFigmaImagesTool.handler( + params, + figmaService, + options.imageDir, + options.transport, + options.authMode, + options.getClientInfo(), + extra, + ), + ); + } +} + +export { createServer }; diff --git a/src/mcp/progress.ts b/src/mcp/progress.ts new file mode 100644 index 0000000..f92f6a0 --- /dev/null +++ b/src/mcp/progress.ts @@ -0,0 +1,57 @@ +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js"; + +export type ToolExtra = RequestHandlerExtra; + +/** No-ops silently when the client didn't ask for progress (no progressToken). */ +export async function sendProgress( + extra: ToolExtra, + progress: number, + total?: number, + message?: string, +): Promise { + const progressToken = extra._meta?.progressToken; + if (progressToken === undefined) return; + + await extra.sendNotification({ + method: "notifications/progress", + params: { progressToken, progress, total, message }, + }); +} + +/** + * Send periodic progress notifications during a long-running operation. + * Keeps clients with resetTimeoutOnProgress alive during slow I/O like + * Figma API calls that can take up to ~55 seconds. Returns an async stop + * function that must be awaited when the operation completes or errors — + * it both clears the interval and waits for the most recent in-flight + * send so a tick that fired microseconds before stop cannot land on the + * wire after the tool's response (which would orphan its progressToken + * and crash strict clients — see issue #362). + */ +export function startProgressHeartbeat( + extra: ToolExtra, + message: string | (() => string), + intervalMs = 3_000, +): () => Promise { + const progressToken = extra._meta?.progressToken; + if (progressToken === undefined) return async () => {}; + + let tick = 0; + let lastSend: Promise | undefined; + const interval = setInterval(() => { + tick++; + const msg = typeof message === "function" ? message() : message; + lastSend = extra + .sendNotification({ + method: "notifications/progress", + params: { progressToken, progress: tick, message: msg }, + }) + .catch(() => clearInterval(interval)); + }, intervalMs); + + return async () => { + clearInterval(interval); + await lastSend; + }; +} diff --git a/src/mcp/tools/download-figma-images-tool.ts b/src/mcp/tools/download-figma-images-tool.ts new file mode 100644 index 0000000..2510356 --- /dev/null +++ b/src/mcp/tools/download-figma-images-tool.ts @@ -0,0 +1,254 @@ +import path from "path"; +import { z } from "zod"; +import { + type AuthMode, + type ClientInfo, + captureDownloadImagesCall, + captureValidationReject, + type Transport, +} from "~/telemetry/index.js"; +import { downloadFigmaImages as runDownloadFigmaImages } from "../../services/download-figma-images.js"; +import type { FigmaService } from "../../services/figma.js"; +import { type ResolveLocalPathFailureReason, resolveLocalPath } from "../../utils/local-path.js"; +import { Logger } from "../../utils/logger.js"; +import { sendProgress, startProgressHeartbeat, type ToolExtra } from "../progress.js"; + +const parameters = { + fileKey: z + .string() + .regex(/^[a-zA-Z0-9]+$/, "File key must be alphanumeric") + .describe("The key of the Figma file containing the images"), + nodes: z + .object({ + nodeId: z + .string() + .regex( + /^I?\d+[:|-]\d+(?:;\d+[:|-]\d+)*$/, + "Node ID must be like '1234:5678' or 'I5666:180910;1:10515;1:10336'", + ) + .describe("The ID of the Figma image node to fetch, formatted as 1234:5678"), + imageRef: z + .string() + .optional() + .describe( + "If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images or animated GIFs (use gifRef instead), or when an IMAGE fill is present without an imageRef — in that case the node is rendered as PNG via nodeId.", + ), + gifRef: z + .string() + .optional() + .describe( + "If a node has a gifRef fill (animated GIF), you must include this variable to download the animated GIF. When gifRef is present in the Figma data, use it instead of imageRef to get the animated file rather than a static snapshot.", + ), + fileName: z + .string() + .regex( + /^[a-zA-Z0-9_.-]+\.(png|svg|gif)$/, + "File names must contain only letters, numbers, underscores, dots, or hyphens, and end with .png, .svg, or .gif.", + ) + .describe( + "The local name for saving the fetched file, including extension. png, svg, or gif.", + ), + needsCropping: z + .boolean() + .optional() + .describe("Whether this image needs cropping based on its transform matrix"), + cropTransform: z + .array(z.array(z.number())) + .optional() + .describe("Figma transform matrix for image cropping"), + requiresImageDimensions: z + .boolean() + .optional() + .describe("Whether this image requires dimension information for CSS variables"), + filenameSuffix: z + .string() + .regex( + /^[a-zA-Z0-9_-]+$/, + "Suffix must contain only letters, numbers, underscores, or hyphens", + ) + .optional() + .describe( + "Suffix to add to filename for unique cropped images, provided in the Figma data (e.g., 'abc123')", + ), + }) + .array() + .describe("The nodes to fetch as images"), + pngScale: z + .number() + .positive() + .optional() + .default(2) + .describe( + "Export scale for PNG images. Optional, defaults to 2 if not specified. Affects PNG images only.", + ), + localPath: z + .string() + .describe( + "The directory to save images in. Provide a path relative to the server's image directory (e.g., 'public/images' or 'assets/icons'). Either separator works. Absolute paths are accepted only if they point inside the image directory. The directory is created if missing.", + ), +}; + +const parametersSchema = z.object(parameters); +export type DownloadImagesParams = z.infer; + +async function downloadFigmaImages( + params: DownloadImagesParams, + figmaService: FigmaService, + imageDir: string | undefined, + transport: Transport, + authMode: AuthMode, + clientInfo: ClientInfo | undefined, + extra: ToolExtra, +) { + try { + const { fileKey, nodes, localPath, pngScale } = parametersSchema.parse(params); + + // Resolve localPath against the configured image directory. The resolver + // accepts relative paths and absolute paths that fall under imageDir; it + // rejects absolute paths pointing elsewhere (which would silently miswrite + // under the previous join-based logic). See utils/local-path.ts. + const baseDir = imageDir ?? process.cwd(); + const resolution = resolveLocalPath(localPath, baseDir); + if (!resolution.ok) { + // Path-traversal rejection happens after schema validation, so the SDK + // wrapper in mcp/index.ts never sees it. Capture it here as a validation + // reject so we can track how often LLMs trip over the localPath contract. + const details = rejectionDetails(resolution.reason, localPath, baseDir); + captureValidationReject( + { + tool: "download_figma_images", + field: "localPath", + rule: details.rule, + message: details.telemetryMessage, + }, + { transport, authMode, clientInfo }, + ); + return { + isError: true, + content: [{ type: "text" as const, text: details.userMessage }], + }; + } + const resolvedPath = resolution.resolvedPath; + + await sendProgress(extra, 0, 3, "Resolving image downloads"); + + let stopHeartbeat: (() => void) | undefined; + const { downloads, successCount } = await runDownloadFigmaImages( + figmaService, + { fileKey, nodes, localPath: resolvedPath, pngScale }, + { + onDownloadStart: async (downloadCount) => { + await sendProgress(extra, 1, 3, `Resolved ${downloadCount} images, downloading`); + stopHeartbeat = startProgressHeartbeat(extra, "Downloading images"); + }, + onDownloadComplete: () => { + stopHeartbeat?.(); + }, + onComplete: (outcome) => + captureDownloadImagesCall(outcome, { + transport, + authMode, + clientInfo, + }), + }, + ); + + await sendProgress(extra, 2, 3, `Downloaded ${successCount} images, formatting response`); + + const imagesList = downloads + .map(({ result, requestedFileNames }) => { + const fileName = path.basename(result.filePath); + const dimensions = `${result.finalDimensions.width}x${result.finalDimensions.height}`; + const cropStatus = result.wasCropped ? " (cropped)" : ""; + + const dimensionInfo = result.cssVariables + ? `${dimensions} | ${result.cssVariables}` + : dimensions; + + const aliasText = + requestedFileNames.length > 1 + ? ` (also requested as: ${requestedFileNames.filter((name) => name !== fileName).join(", ")})` + : ""; + + return `- ${fileName}: ${dimensionInfo}${cropStatus}${aliasText}`; + }) + .join("\n"); + + return { + content: [ + { + type: "text" as const, + // Echo the absolute resolved path so callers can immediately verify + // where files landed. If imageDir defaulted to a server cwd that + // doesn't match the user's project, this surfaces the mismatch + // instead of letting it look like a successful no-op. Code-fenced + // so markdown-rendering clients don't fight separator characters. + text: `Downloaded ${successCount} images to \`${resolvedPath}\`:\n${imagesList}`, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + Logger.error(`Error downloading images from ${params.fileKey}:`, error); + return { + isError: true, + content: [ + { + type: "text" as const, + text: `Failed to download images: ${message}`, + }, + ], + }; + } +} + +function getDescription(imageDir?: string) { + const baseDir = imageDir ?? process.cwd(); + return `Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes. Images will be saved relative to the server's image directory: ${baseDir}`; +} + +function rejectionDetails( + reason: ResolveLocalPathFailureReason, + localPath: string, + baseDir: string, +): { + rule: ResolveLocalPathFailureReason; + userMessage: string; + telemetryMessage: string; +} { + switch (reason) { + case "drive_letter_on_posix": + return { + rule: reason, + telemetryMessage: `Drive-letter path on POSIX server: ${localPath}`, + userMessage: + `Invalid path: "${localPath}" starts with a Windows drive letter, but this server is running on POSIX where drive letters don't exist. ` + + `The server's image directory is "${baseDir}"; pass a path relative to it (e.g., "public/images").`, + }; + case "outside_image_dir": { + // Leading-slash inputs used to be silently re-interpreted as relative + // under the old path.join hack. They now reject; spell out the retry so + // an LLM doesn't have to guess that "/public/images" should have been + // "public/images". + const leadingSlashHint = /^[/\\]/.test(localPath) + ? ` If you meant a directory inside the image directory, drop the leading slash (e.g., use "${localPath.replace(/^[/\\]+/, "")}").` + : ""; + return { + rule: reason, + telemetryMessage: `Path resolves outside allowed image directory: ${localPath}`, + userMessage: + `Invalid path: "${localPath}" resolves outside the allowed image directory. ` + + `The server's image directory is "${baseDir}". Provide a path relative to this directory (e.g., "public/images" or "assets/icons").` + + leadingSlashHint, + }; + } + } +} + +// Export tool configuration +export const downloadFigmaImagesTool = { + name: "download_figma_images", + getDescription, + parametersSchema, + handler: downloadFigmaImages, +} as const; diff --git a/src/mcp/tools/get-figma-data-tool.ts b/src/mcp/tools/get-figma-data-tool.ts new file mode 100644 index 0000000..89c19c2 --- /dev/null +++ b/src/mcp/tools/get-figma-data-tool.ts @@ -0,0 +1,115 @@ +import { z } from "zod"; +import { FigmaService } from "~/services/figma.js"; +import { Logger } from "~/utils/logger.js"; +import { sendProgress, startProgressHeartbeat, type ToolExtra } from "~/mcp/progress.js"; +import { + captureGetFigmaDataCall, + type AuthMode, + type ClientInfo, + type Transport, +} from "~/telemetry/index.js"; +import { getFigmaData as runGetFigmaData } from "~/services/get-figma-data.js"; +import type { OutputFormat } from "~/utils/serialize.js"; + +const parameters = { + fileKey: z + .string() + .regex(/^[a-zA-Z0-9]+$/, "File key must be alphanumeric") + .describe( + "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)//...", + ), + nodeId: z + .string() + .regex( + /^I?\d+[:|-]\d+(?:;\d+[:|-]\d+)*$/, + "Node ID must be like '1234:5678' or 'I5666:180910;1:10515;1:10336'", + ) + .optional() + .describe( + "The ID of the node to fetch, often found as URL parameter node-id=, always use if provided. Use format '1234:5678' for a standard node, or 'I5666:180910;1:10515;1:10336' for a deeply nested instance node (the semicolon-joined path represents the instance override chain — it's still a single node ID, not multiple nodes).", + ), + depth: z + .number() + .optional() + .describe( + "OPTIONAL. Do NOT use unless explicitly requested by the user. Controls how many levels deep to traverse the node tree.", + ), +}; + +const parametersSchema = z.object(parameters); +export type GetFigmaDataParams = z.infer; + +async function getFigmaData( + params: GetFigmaDataParams, + figmaService: FigmaService, + outputFormat: OutputFormat, + transport: Transport, + authMode: AuthMode, + clientInfo: ClientInfo | undefined, + extra: ToolExtra, +) { + try { + const { fileKey, nodeId: rawNodeId, depth } = parametersSchema.parse(params); + + // Replace - with : in nodeId for our query — Figma API expects :. + // MCP-specific input quirk, so it lives here rather than in the shared core. + const nodeId = rawNodeId?.replace(/-/g, ":"); + + Logger.log( + `Fetching ${depth ? `${depth} layers deep` : "all layers"} of ${ + nodeId ? `node ${nodeId} from file` : `full file` + } ${fileKey}`, + ); + + let stopFetchHeartbeat: (() => Promise) | undefined; + let stopSimplifyHeartbeat: (() => Promise) | undefined; + + const result = await runGetFigmaData(figmaService, { fileKey, nodeId, depth }, outputFormat, { + onFetchStart: async () => { + await sendProgress(extra, 0, 3, "Fetching design data from Figma API"); + stopFetchHeartbeat = startProgressHeartbeat(extra, "Waiting for Figma API response"); + }, + onFetchComplete: async () => { + await stopFetchHeartbeat?.(); + }, + onSimplifyStart: async (progress) => { + await sendProgress(extra, 1, 3, "Fetched design data, simplifying"); + stopSimplifyHeartbeat = startProgressHeartbeat( + extra, + () => `Simplifying design data (${progress.getNodeCount()} nodes processed)`, + ); + }, + onSimplifyComplete: async () => { + await stopSimplifyHeartbeat?.(); + }, + onSerializeStart: async () => { + await sendProgress(extra, 2, 3, "Simplified design, serializing response"); + }, + onComplete: (outcome) => + captureGetFigmaDataCall(outcome, { transport, authMode, clientInfo }), + }); + + Logger.log(`Successfully extracted data: ${result.metrics.simplifiedNodeCount} nodes`); + Logger.log("Sending result to client"); + + return { + content: [{ type: "text" as const, text: result.formatted }], + }; + } catch (error) { + const message = error instanceof Error ? error.message : JSON.stringify(error); + Logger.error(`Error fetching file ${params.fileKey}:`, message); + return { + isError: true, + content: [{ type: "text" as const, text: `Error fetching file: ${message}` }], + }; + } +} + +// Export tool configuration +export const getFigmaDataTool = { + name: "get_figma_data", + description: + "Get comprehensive Figma file data including layout, content, visuals, and component information", + parametersSchema, + handler: getFigmaData, +} as const; diff --git a/src/mcp/tools/index.ts b/src/mcp/tools/index.ts new file mode 100644 index 0000000..a312b24 --- /dev/null +++ b/src/mcp/tools/index.ts @@ -0,0 +1,4 @@ +export { getFigmaDataTool } from "./get-figma-data-tool.js"; +export { downloadFigmaImagesTool } from "./download-figma-images-tool.js"; +export type { DownloadImagesParams } from "./download-figma-images-tool.js"; +export type { GetFigmaDataParams } from "./get-figma-data-tool.js"; diff --git a/src/mcp/validation-capture.ts b/src/mcp/validation-capture.ts new file mode 100644 index 0000000..40de593 --- /dev/null +++ b/src/mcp/validation-capture.ts @@ -0,0 +1,161 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import { + captureValidationReject, + type ClientInfo, + type Transport, + type AuthMode, +} from "~/telemetry/index.js"; +import type { OutputFormat } from "~/utils/serialize.js"; + +/** + * The MCP SDK validates tool input against the registered zod schema BEFORE + * calling our handler — failed validations bubble up as `McpError` and never + * reach our code, so we can't observe them from inside the tool callback. + * + * To capture validation rejects we monkey-patch the McpServer instance's + * `validateToolInput` method (private SDK API). Knowing exactly what shape of + * tool call LLMs are getting wrong is one of the most actionable analytics + * signals available — worth the small coupling cost. We patch the instance + * (not the prototype) so concurrent McpServer instances in HTTP mode each + * own their patched copy. + * + * Hardening notes: + * - Fails open if `validateToolInput` ever disappears from the SDK — losing + * telemetry is preferable to crashing the server during creation. + * - The second parse (used to extract structured field/rule info) runs inside + * its own try/catch so a future schema with side-effecting refinements can't + * corrupt the SDK's normal error response. + * - Prefers `safeParseAsync` so async-aware schemas don't throw on sync parse. + * - Normalizes numeric path segments to `[]` so `nodes.0.fileName` and + * `nodes.1.fileName` collapse to `nodes[].fileName` instead of inflating + * `validation_field` cardinality in PostHog. + * - Replaces the SDK's noisy "MCP error -32602: Input validation error: ..." + * message with a clean, LLM-friendly version: "Invalid : ". + */ +export function installValidationRejectCapture( + server: McpServer, + context: { + transport: Transport; + authMode: AuthMode; + outputFormat: OutputFormat; + getClientInfo: () => ClientInfo | undefined; + }, +): void { + type ZodIssue = { + path?: Array; + code?: string; + message?: string; + }; + type SafeParseResult = { success: boolean; error?: { issues?: ZodIssue[] } }; + type ValidatableSchema = { + safeParse?: (args: unknown) => SafeParseResult; + safeParseAsync?: (args: unknown) => Promise; + }; + type ValidatedTool = { inputSchema?: ValidatableSchema }; + type Patchable = { + validateToolInput?: (tool: ValidatedTool, args: unknown, toolName: string) => Promise; + }; + + const patchable = server as unknown as Patchable; + // Fail open if the SDK ever renames or removes this private method — we + // lose validation reject telemetry, but the server still starts. + if (typeof patchable.validateToolInput !== "function") { + return; + } + const original = patchable.validateToolInput.bind(server); + + patchable.validateToolInput = async (tool, args, toolName) => { + try { + return await original(tool, args, toolName); + } catch (error) { + if (error instanceof McpError && error.code === ErrorCode.InvalidParams) { + const issue = await extractIssue(tool, args); + + if (toolName === "get_figma_data" || toolName === "download_figma_images") { + captureValidationReject( + { + tool: toolName, + field: normalizeFieldPath(issue?.path), + rule: issue?.code ?? "unknown", + message: issue?.message ?? error.message, + outputFormat: context.outputFormat, + }, + { + transport: context.transport, + authMode: context.authMode, + clientInfo: context.getClientInfo(), + }, + ); + } + + // Replace the SDK's noisy "MCP error -32602: Input validation error: + // Invalid arguments for tool ...: [{...JSON...}]" with a clean message + // the LLM can act on. The SDK's createToolError uses error.message + // regardless of error type, so a plain Error works fine here. + if (issue) { + throw new Error(`Invalid ${normalizeFieldPath(issue.path)}: ${issue.message}`); + } + } + throw error; + } + }; +} + +type ZodIssue = { + path?: Array; + code?: string; + message?: string; +}; + +/** + * Best-effort structured issue extraction. Prefers `safeParseAsync` to align + * with how the SDK validates — async schemas (refinements/transforms) need it. + * Wrapped in try/catch so a future schema with side effects can't corrupt the + * SDK's normal error response. + */ +async function extractIssue( + tool: { + inputSchema?: { + safeParse?: (args: unknown) => { success: boolean; error?: { issues?: ZodIssue[] } }; + safeParseAsync?: ( + args: unknown, + ) => Promise<{ success: boolean; error?: { issues?: ZodIssue[] } }>; + }; + }, + args: unknown, +): Promise { + try { + const schema = tool.inputSchema; + if (schema?.safeParseAsync) { + const result = await schema.safeParseAsync(args); + if (!result.success) return result.error?.issues?.[0]; + } else if (schema?.safeParse) { + const result = schema.safeParse(args); + if (!result.success) return result.error?.issues?.[0]; + } + } catch { + // The second parse can throw if a future schema has side-effecting + // refinements. Falling back loses field/rule precision but keeps the + // SDK's normal error response intact for the client. + } + return undefined; +} + +/** + * Render a zod error path with array indexes collapsed to `[]`. Without this, + * `nodes.0.fileName` / `nodes.1.fileName` would explode validation_field + * cardinality with no analytical value. + */ +export function normalizeFieldPath(path: Array | undefined): string { + if (!path || path.length === 0) return "(root)"; + let out = ""; + for (const segment of path) { + if (typeof segment === "number") { + out += "[]"; + } else { + out += out.length > 0 ? "." + segment : segment; + } + } + return out; +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..136c8fc --- /dev/null +++ b/src/server.ts @@ -0,0 +1,268 @@ +import { type NextFunction, type Request, type Response } from "express"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js"; +import { Server } from "http"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ProxyAgent, EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; +import { Logger } from "./utils/logger.js"; +import { hasProxyEnv, setProxyMode } from "./utils/proxy-env.js"; +import { createServer, type CreateServerOptions } from "./mcp/index.js"; +import { requireGlobalCredentials, type ServerConfig } from "./config.js"; +import type { FigmaAuthOptions } from "./services/figma.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { ErrorCode } from "@modelcontextprotocol/sdk/types.js"; +import * as telemetry from "./telemetry/index.js"; + +let httpServer: Server | null = null; + +type ActiveConnection = { + transport: StreamableHTTPServerTransport; + server: McpServer; +}; +const activeConnections = new Set(); + +/** + * Start the MCP server in either stdio or HTTP mode. + */ +export async function startServer(config: ServerConfig): Promise { + // Stdio has no per-request credential channel, so the server is unusable + // without something resolved at startup. Fail fast BEFORE any side effects + // (proxy install, telemetry init) — preserves the pre-PR behavior where + // resolveAuth() exited early during config resolution. + if (config.isStdioMode) { + requireGlobalCredentials(config.auth); + } + + // Three outcomes: explicit proxy URL → ProxyAgent; no proxy but env vars set + // → EnvHttpProxyAgent; otherwise Node's default (includes `--proxy=none`, + // which lets users opt out of system-level proxy vars misbehaving for + // api.figma.com — see issue #358). + // + // We deliberately do NOT install EnvHttpProxyAgent when no proxy vars are + // present, so a stale or incidental var in the user's shell (VPN client, + // old dev setup) can't silently route Figma traffic through an intermediary + // that may return 403. + if (config.proxy && config.proxy !== "none") { + setGlobalDispatcher(new ProxyAgent(config.proxy)); + setProxyMode("explicit"); + } else if (!config.proxy && hasProxyEnv()) { + setGlobalDispatcher(new EnvHttpProxyAgent()); + setProxyMode("env"); + } + + const telemetryEnabled = telemetry.initTelemetry({ + optOut: config.noTelemetry, + redactFromErrors: [config.auth.figmaApiKey, config.auth.figmaOAuthToken], + }); + + if (telemetryEnabled) { + // stderr (not Logger.log) because in HTTP mode Logger.log writes to stdout, + // and in stdio mode stdout is reserved for MCP protocol messages. stderr + // is safe in both modes. + process.stderr.write( + "Usage telemetry enabled. Disable: FRAMELINK_TELEMETRY=off or DO_NOT_TRACK=1\n", + ); + } + + const serverOptions = { + transport: config.isStdioMode ? ("stdio" as const) : ("http" as const), + outputFormat: config.outputFormat, + skipImageDownloads: config.skipImageDownloads, + imageDir: config.imageDir, + }; + + if (config.isStdioMode) { + // MCP clients spawn stdio servers with whatever cwd they were started in, + // which is rarely the user's project root. Warn so a missing --image-dir + // doesn't silently send images to e.g. the client's install directory. + // Gated on !skipImageDownloads — without the download tool the warning + // is misleading. + if (config.configSources.imageDir === "default" && !config.skipImageDownloads) { + process.stderr.write( + `Warning: --image-dir not set; download_figma_images will save under the server's cwd (${config.imageDir}). ` + + `MCP clients often launch the server outside your project root — set IMAGE_DIR or pass --image-dir to make this explicit.\n`, + ); + } + const server = createServer(config.auth, serverOptions); + const transport = new StdioServerTransport(); + await server.connect(transport); + registerShutdownHandlers(async () => {}); + } else { + console.log(`Initializing Figma MCP Server in HTTP mode on ${config.host}:${config.port}...`); + await startHttpServer(config.host, config.port, config.auth, serverOptions); + + registerShutdownHandlers(async () => { + Logger.log("Shutting down server..."); + await stopHttpServer(); + Logger.log("Server shutdown complete"); + }); + } +} + +/** + * Register SIGINT + SIGTERM handlers that run mode-specific cleanup and then + * flush telemetry before exiting. MCP hosts commonly send SIGTERM, so both + * signals must be handled in both transport modes. + * + * Idempotent: if both signals fire (or a signal fires twice) the second + * invocation is ignored so we never double-shutdown. + */ +function registerShutdownHandlers(onShutdown: () => Promise): void { + let shuttingDown = false; + const handle = async () => { + if (shuttingDown) return; + shuttingDown = true; + // onShutdown may throw (e.g. stopHttpServer failures); telemetry.shutdown + // swallows its own errors (see src/telemetry/client.ts). Use try/finally + // so process.exit(0) always runs regardless of onShutdown failure. + try { + await onShutdown(); + } finally { + await telemetry.shutdown(); + process.exit(0); + } + }; + process.on("SIGINT", handle); + process.on("SIGTERM", handle); +} + +export async function startHttpServer( + host: string, + port: number, + baseAuth: FigmaAuthOptions, + serverOptions: Omit, +): Promise { + if (httpServer) { + throw new Error("HTTP server is already running"); + } + + const app = createMcpExpressApp({ host }); + + const handlePost = async (req: Request, res: Response) => { + Logger.log("Received StreamableHTTP request"); + const requestKey = getRequestApiKey(req); + const requestBearerToken = getRequestBearerToken(req); + const auth = resolveRequestAuth(baseAuth, requestKey, requestBearerToken); + const requestSecrets = [requestKey, requestBearerToken].filter( + (secret): secret is string => !!secret, + ); + + // Request-level credentials are not known to telemetry's init-time + // redaction list, so make them available only for this request scope. + await telemetry.withRequestSecrets(requestSecrets, async () => { + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + const mcpServer = createServer(auth, { ...serverOptions, transport: "http" }); + const conn: ActiveConnection = { transport, server: mcpServer }; + activeConnections.add(conn); + res.on("close", () => { + activeConnections.delete(conn); + transport.close(); + mcpServer.close(); + }); + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + Logger.log("StreamableHTTP request handled"); + }); + }; + + const handleMethodNotAllowed = (_req: Request, res: Response) => { + res.status(405).set("Allow", "POST").send("Method Not Allowed"); + }; + + // Mount stateless StreamableHTTP on both /mcp and /sse. + // Serving StreamableHTTP at /sse lets existing client configs keep working — + // modern MCP clients probe with a POST before falling back to SSE. + for (const path of ["/mcp", "/sse"]) { + app.post(path, handlePost); + app.get(path, handleMethodNotAllowed); + app.delete(path, handleMethodNotAllowed); + } + + // Express 5 forwards rejected promises from async handlers here. + // Return a JSON-RPC error instead of Express's default HTML 500. + app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { + Logger.log("Unhandled error:", err); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: "2.0", + error: { code: ErrorCode.InternalError, message: "Internal server error" }, + id: null, + }); + } + }); + + return new Promise((resolve, reject) => { + const server = app.listen(port, host, () => { + Logger.log(`HTTP server listening on port ${port}`); + Logger.log(`StreamableHTTP endpoint available at http://${host}:${port}/mcp`); + Logger.log( + `StreamableHTTP endpoint available at http://${host}:${port}/sse (backward compat)`, + ); + resolve(server); + }); + server.once("error", (err) => { + httpServer = null; + reject(err); + }); + httpServer = server; + }); +} + +function resolveRequestAuth( + baseAuth: FigmaAuthOptions, + requestKey: string | undefined, + requestBearerToken: string | undefined, +): FigmaAuthOptions { + if (requestKey) { + return { + figmaApiKey: requestKey, + figmaOAuthToken: "", + useOAuth: false, + }; + } + + if (requestBearerToken) { + return { + figmaApiKey: "", + figmaOAuthToken: requestBearerToken, + useOAuth: true, + }; + } + + return baseAuth; +} + +function getRequestApiKey(req: Request): string | undefined { + const value = req.headers["x-figma-token"]; + if (Array.isArray(value)) return value[0]?.trim() || undefined; + return value?.trim() || undefined; +} + +function getRequestBearerToken(req: Request): string | undefined { + const value = req.headers.authorization; + const header = Array.isArray(value) ? value[0] : value; + const match = header?.match(/^Bearer\s+(.+)$/i); + return match?.[1]?.trim() || undefined; +} + +export async function stopHttpServer(): Promise { + if (!httpServer) { + throw new Error("HTTP server is not running"); + } + + // Gracefully close all active MCP connections before tearing down the server + for (const conn of activeConnections) { + await conn.transport.close(); + await conn.server.close(); + } + activeConnections.clear(); + + return new Promise((resolve, reject) => { + httpServer!.close((err) => { + httpServer = null; + if (err) reject(err); + else resolve(); + }); + httpServer!.closeAllConnections(); + }); +} diff --git a/src/services/download-figma-images.ts b/src/services/download-figma-images.ts new file mode 100644 index 0000000..759e7c9 --- /dev/null +++ b/src/services/download-figma-images.ts @@ -0,0 +1,182 @@ +import type { Transform } from "@figma/rest-api-spec"; +import type { FigmaService } from "~/services/figma.js"; +import type { ImageProcessingResult } from "~/utils/image-processing.js"; +import { tagError } from "~/utils/error-meta.js"; + +/** + * Structural shape for a single download request. Matches the relevant + * fields of the tool's zod schema without coupling the core to the schema + * itself — callers can pass any object that conforms. + */ +export type DownloadImageNode = { + nodeId: string; + imageRef?: string; + gifRef?: string; + fileName: string; + needsCropping?: boolean; + cropTransform?: Transform; + requiresImageDimensions?: boolean; + filenameSuffix?: string; +}; + +export type DownloadFigmaImagesInput = { + fileKey: string; + nodes: DownloadImageNode[]; + localPath: string; + pngScale?: number; +}; + +export type DownloadFigmaImagesResult = { + downloads: Array<{ + result: ImageProcessingResult; + requestedFileNames: string[]; + }>; + successCount: number; +}; + +export type DownloadImagesOutcome = { + input: DownloadFigmaImagesInput; + durationMs: number; + imageCount: number; + successCount?: number; + error?: unknown; +}; + +export type DownloadFigmaImagesHooks = { + onDownloadStart?: (downloadCount: number) => void | Promise; + onDownloadComplete?: () => void; + /** + * Fires exactly once per call, after the pipeline completes (success or + * failure). Observer errors are swallowed silently — a broken observer + * must never break the pipeline. + */ + onComplete?: (outcome: DownloadImagesOutcome) => void; +}; + +/** + * Shared pipeline for "download figma images": resolve the set of unique + * downloads (deduping image fills by imageRef), invoke the Figma service, + * and return per-download results keyed back to the original requested + * filenames so the caller can render aliases. + * + * Param parsing, path validation, and MCP-specific result formatting stay + * at the edge in the tool handler. + */ +export async function downloadFigmaImages( + figmaService: FigmaService, + input: DownloadFigmaImagesInput, + hooks: DownloadFigmaImagesHooks = {}, +): Promise { + const startedAt = Date.now(); + const imageCount = input.nodes.length; + let successCount: number | undefined; + let caughtError: unknown; + + try { + const { fileKey, nodes, localPath, pngScale } = input; + + // Resolve the set of unique downloads and track which requested + // filenames each one satisfies (so the caller can render aliases). + const downloadItems: Array<{ + fileName: string; + needsCropping: boolean; + cropTransform?: Transform; + requiresImageDimensions: boolean; + imageRef?: string; + gifRef?: string; + nodeId?: string; + }> = []; + const downloadToRequests = new Map(); + const seenDownloads = new Map(); + + for (const rawNode of nodes) { + const { nodeId: rawNodeId, ...node } = rawNode; + + // Replace - with : in nodeId for our query — Figma API expects :. + const nodeId = rawNodeId?.replace(/-/g, ":"); + + let finalFileName = node.fileName; + if (node.filenameSuffix && !finalFileName.includes(node.filenameSuffix)) { + const ext = finalFileName.split(".").pop(); + const nameWithoutExt = finalFileName.substring(0, finalFileName.lastIndexOf(".")); + finalFileName = `${nameWithoutExt}-${node.filenameSuffix}.${ext}`; + } + + const downloadItem = { + fileName: finalFileName, + needsCropping: node.needsCropping || false, + cropTransform: node.cropTransform, + requiresImageDimensions: node.requiresImageDimensions || false, + }; + + if (node.gifRef) { + // GIF fills are always unique downloads (animated, no dedup needed). + const downloadIndex = downloadItems.length; + downloadItems.push({ ...downloadItem, gifRef: node.gifRef }); + downloadToRequests.set(downloadIndex, [finalFileName]); + } else if (node.imageRef) { + const uniqueKey = `${node.imageRef}-${node.filenameSuffix || "none"}`; + + if (!node.filenameSuffix && seenDownloads.has(uniqueKey)) { + const downloadIndex = seenDownloads.get(uniqueKey)!; + const requests = downloadToRequests.get(downloadIndex)!; + if (!requests.includes(finalFileName)) { + requests.push(finalFileName); + } + + if (downloadItem.requiresImageDimensions) { + downloadItems[downloadIndex].requiresImageDimensions = true; + } + } else { + const downloadIndex = downloadItems.length; + downloadItems.push({ ...downloadItem, imageRef: node.imageRef }); + downloadToRequests.set(downloadIndex, [finalFileName]); + seenDownloads.set(uniqueKey, downloadIndex); + } + } else { + // Rendered nodes are always unique. + const downloadIndex = downloadItems.length; + downloadItems.push({ ...downloadItem, nodeId }); + downloadToRequests.set(downloadIndex, [finalFileName]); + } + } + + await hooks.onDownloadStart?.(downloadItems.length); + let allDownloads: ImageProcessingResult[]; + try { + allDownloads = await figmaService.downloadImages(fileKey, localPath, downloadItems, { + pngScale, + }); + } catch (error) { + tagError(error, { phase: "download" }); + } finally { + hooks.onDownloadComplete?.(); + } + + successCount = allDownloads.filter(Boolean).length; + + const downloads = allDownloads.map((result, index) => ({ + result, + requestedFileNames: downloadToRequests.get(index) ?? [result.filePath], + })); + + return { downloads, successCount }; + } catch (error) { + caughtError = error; + throw error; + } finally { + if (hooks.onComplete) { + try { + hooks.onComplete({ + input, + durationMs: Date.now() - startedAt, + imageCount, + successCount, + error: caughtError, + }); + } catch { + // intentionally empty + } + } + } +} diff --git a/src/services/errors/forbidden.ts b/src/services/errors/forbidden.ts new file mode 100644 index 0000000..0dfe601 --- /dev/null +++ b/src/services/errors/forbidden.ts @@ -0,0 +1,58 @@ +import { HttpError } from "~/utils/fetch-json.js"; +import { proxyMode, type ProxyMode } from "~/utils/proxy-env.js"; + +const FORBIDDEN_CAUSES = [ + "- The access token is missing required scopes (File content: Read, Dev resources: Read)", + "- The access token has been revoked, mistyped, or (for OAuth) expired — PATs don't expire, OAuth tokens do", + "- The access token doesn't have permission to this specific file — it must be owned by or shared with the token's account, and for team/org files the account must belong to that team", + "- The file's share settings don't allow viewers to copy/share/export", + "- An HTTP intermediary (corporate proxy, firewall, VPN) rejected the request before it reached Figma", +]; + +const FORBIDDEN_CAUSES_HEADER_WITH_BODY = + "Depending on the specific error message above, the issue may be one of the following:"; +const FORBIDDEN_CAUSES_HEADER_WITHOUT_BODY = "The issue is typically one of the following:"; + +const TROUBLESHOOTING_GUIDE = + "Troubleshooting guide: https://www.framelink.ai/docs/troubleshooting#cannot-access-file"; + +const LLM_INSTRUCTIONS = + "Instructions: explain the specific reason from the response body above to the user in plain language and walk them through resolving it."; + +const PROXY_HINTS: Record = { + none: undefined, + explicit: + "Note: this server is configured to route requests through an explicit proxy (--proxy/FIGMA_PROXY). If the proxy may be the source of the 403, unset it, change it to --proxy=none, or bypass it for this host.", + env: "Note: this server picked up a proxy from HTTP_PROXY/HTTPS_PROXY in your environment. If the proxy may be the source of the 403, set NO_PROXY=api.figma.com, pass --proxy=none, or unset HTTP_PROXY/HTTPS_PROXY.", +}; + +/** + * Build a user-facing 403 message. Figma returns distinct `err` strings for + * distinct causes (missing PAT scopes, expired OAuth token, un-exportable + * file, etc.) and each has a different fix. We surface the response body + * verbatim when we have it and list the common causes it could map to — + * rather than string-matching here (fragile as Figma's wording drifts) or + * dumping only a canned list (which is often generic for the specific case). + * Full per-error resolution steps live in the docs so they can be updated + * without a release. + */ +export function buildForbiddenMessage(endpoint: string, error: unknown): string { + const body = error instanceof HttpError ? error.responseBody : undefined; + const proxyHint = PROXY_HINTS[proxyMode()]; + const causesHeader = body + ? FORBIDDEN_CAUSES_HEADER_WITH_BODY + : FORBIDDEN_CAUSES_HEADER_WITHOUT_BODY; + + const sections: string[][] = [ + [ + `Request to Figma API endpoint '${endpoint}' returned 403 Forbidden.`, + ...(body ? [`Response body: ${body}`] : []), + ], + [causesHeader, ...FORBIDDEN_CAUSES], + [TROUBLESHOOTING_GUIDE], + ...(body ? [[LLM_INSTRUCTIONS]] : []), + ...(proxyHint ? [[proxyHint]] : []), + ]; + + return sections.map((section) => section.join("\n")).join("\n\n"); +} diff --git a/src/services/errors/index.ts b/src/services/errors/index.ts new file mode 100644 index 0000000..0634327 --- /dev/null +++ b/src/services/errors/index.ts @@ -0,0 +1,2 @@ +export { buildForbiddenMessage } from "./forbidden.js"; +export { buildRateLimitMessage } from "./rate-limit.js"; diff --git a/src/services/errors/rate-limit.ts b/src/services/errors/rate-limit.ts new file mode 100644 index 0000000..cf6e686 --- /dev/null +++ b/src/services/errors/rate-limit.ts @@ -0,0 +1,38 @@ +import { HttpError } from "~/utils/fetch-json.js"; + +/** + * Build a user-facing 429 message from the Figma rate-limit response headers. + * Figma includes plan tier, seat-level limit type, retry-after, and an upgrade + * link — all of which let us give targeted guidance instead of a generic + * "try again later." + * + * See https://developers.figma.com/docs/rest-api/rate-limits/ + */ +export function buildRateLimitMessage(error: unknown): string { + const headers = error instanceof HttpError ? error.responseHeaders : {}; + const retryAfter = headers["retry-after"]; + const planTier = headers["x-figma-plan-tier"]; + const limitType = headers["x-figma-rate-limit-type"]; + const upgradeLink = headers["x-figma-upgrade-link"]; + + let message = "Figma API rate limit hit (429)."; + + if (retryAfter) { + message += ` Retry after ${retryAfter} seconds.`; + } + + if (limitType === "low") { + message += " Your Figma seat type (Viewer or Collaborator) has a lower API rate limit."; + } + + if (planTier === "starter" || planTier === "student") { + message += ` Your ${planTier} plan has limited API access.`; + } + + if (upgradeLink) { + message += ` Upgrade: ${upgradeLink}`; + } + + message += " See https://developers.figma.com/docs/rest-api/rate-limits/"; + return message; +} diff --git a/src/services/figma.ts b/src/services/figma.ts new file mode 100644 index 0000000..b056844 --- /dev/null +++ b/src/services/figma.ts @@ -0,0 +1,343 @@ +import type { + GetImagesResponse, + GetFileResponse, + GetFileNodesResponse, + GetImageFillsResponse, + Transform, +} from "@figma/rest-api-spec"; +import { downloadAndProcessImage, type ImageProcessingResult } from "~/utils/image-processing.js"; +import { Logger, writeLogs } from "~/utils/logger.js"; +import { fetchJSON } from "~/utils/fetch-json.js"; +import { getErrorMeta } from "~/utils/error-meta.js"; +import { buildForbiddenMessage, buildRateLimitMessage } from "./errors/index.js"; + +export type FigmaAuthOptions = { + figmaApiKey: string; + figmaOAuthToken: string; + useOAuth: boolean; +}; + +type SvgOptions = { + outlineText: boolean; + includeId: boolean; + simplifyStroke: boolean; +}; + +export class FigmaService { + private readonly apiKey: string; + private readonly oauthToken: string; + private readonly useOAuth: boolean; + private readonly baseUrl = "https://api.figma.com/v1"; + + constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) { + this.apiKey = figmaApiKey || ""; + this.oauthToken = figmaOAuthToken || ""; + this.useOAuth = !!useOAuth && !!this.oauthToken; + } + + private getAuthHeaders(): Record { + if (this.useOAuth) { + Logger.log("Using OAuth Bearer token for authentication"); + return { Authorization: `Bearer ${this.oauthToken}` }; + } + + if (!this.apiKey) { + throw new Error( + "Figma API authentication is required. Configure FIGMA_API_KEY or FIGMA_OAUTH_TOKEN on the server, or send X-Figma-Token / Authorization: Bearer on the HTTP request.", + ); + } + + Logger.log("Using Personal Access Token for authentication"); + return { "X-Figma-Token": this.apiKey }; + } + + /** + * Filters out null values from Figma image responses. This ensures we only work with valid image URLs. + */ + private filterValidImages( + images: { [key: string]: string | null } | undefined, + ): Record { + if (!images) return {}; + return Object.fromEntries(Object.entries(images).filter(([, value]) => !!value)) as Record< + string, + string + >; + } + + private async request(endpoint: string): Promise { + const { data } = await this.requestWithSize(endpoint); + return data; + } + + /** + * Like `request`, but also surfaces the raw response body size so callers + * can record it for telemetry. Only used by endpoints whose payload size + * we care about (`getRawFile` / `getRawNode`); image-fetching endpoints + * continue to use `request` unchanged. + */ + private async requestWithSize(endpoint: string): Promise<{ data: T; rawSize: number }> { + try { + Logger.log(`Calling ${this.baseUrl}${endpoint}`); + const headers = this.getAuthHeaders(); + + return await fetchJSON(`${this.baseUrl}${endpoint}`, { + headers, + redactFromResponseBody: [this.apiKey, this.oauthToken], + }); + } catch (error) { + const meta = getErrorMeta(error); + if (meta.http_status === 429) { + throw new Error(buildRateLimitMessage(error), { cause: error }); + } + if (meta.http_status === 403) { + throw new Error(buildForbiddenMessage(endpoint, error), { cause: error }); + } + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to make request to Figma API endpoint '${endpoint}': ${errorMessage}`, + { cause: error }, + ); + } + } + + /** + * Builds URL query parameters for SVG image requests. + */ + private buildSvgQueryParams(svgIds: string[], svgOptions: SvgOptions): string { + const params = new URLSearchParams({ + ids: svgIds.join(","), + format: "svg", + svg_outline_text: String(svgOptions.outlineText), + svg_include_id: String(svgOptions.includeId), + svg_simplify_stroke: String(svgOptions.simplifyStroke), + }); + return params.toString(); + } + + /** + * Gets download URLs for image fills without downloading them. + * + * @returns Map of imageRef to download URL + */ + async getImageFillUrls(fileKey: string): Promise> { + const endpoint = `/files/${fileKey}/images`; + const response = await this.request(endpoint); + return response.meta.images || {}; + } + + /** + * Gets download URLs for rendered nodes without downloading them. + * + * @returns Map of node ID to download URL + */ + async getNodeRenderUrls( + fileKey: string, + nodeIds: string[], + format: "png" | "svg", + options: { pngScale?: number; svgOptions?: SvgOptions } = {}, + ): Promise> { + if (nodeIds.length === 0) return {}; + + if (format === "png") { + const scale = options.pngScale || 2; + const endpoint = `/images/${fileKey}?ids=${nodeIds.join(",")}&format=png&scale=${scale}`; + const response = await this.request(endpoint); + return this.filterValidImages(response.images); + } else { + const svgOptions = options.svgOptions || { + outlineText: true, + includeId: false, + simplifyStroke: true, + }; + const params = this.buildSvgQueryParams(nodeIds, svgOptions); + const endpoint = `/images/${fileKey}?${params}`; + const response = await this.request(endpoint); + return this.filterValidImages(response.images); + } + } + + /** + * Download images method with post-processing support for cropping and returning image dimensions. + * + * Supports: + * - Image fills vs rendered nodes (based on imageRef vs nodeId) + * - PNG vs SVG format (based on filename extension) + * - Image cropping based on transform matrices + * - CSS variable generation for image dimensions + * + * @returns Array of local file paths for successfully downloaded images + */ + async downloadImages( + fileKey: string, + localPath: string, + items: Array<{ + imageRef?: string; + gifRef?: string; + nodeId?: string; + fileName: string; + needsCropping?: boolean; + cropTransform?: Transform; + requiresImageDimensions?: boolean; + }>, + options: { pngScale?: number; svgOptions?: SvgOptions } = {}, + ): Promise { + if (items.length === 0) return []; + + const resolvedPath = localPath; + const { pngScale = 2, svgOptions } = options; + const downloadPromises: Promise[] = []; + + // Separate items by type: image/gif fills vs rendered nodes + const imageFills = items.filter( + (item): item is typeof item & ({ imageRef: string } | { gifRef: string }) => + !!item.imageRef || !!item.gifRef, + ); + const renderNodes = items.filter( + (item): item is typeof item & { nodeId: string } => !!item.nodeId, + ); + + // Download image fills (static images and animated GIFs) with processing + if (imageFills.length > 0) { + const fillUrls = await this.getImageFillUrls(fileKey); + const fillDownloads = imageFills + .map( + ({ + imageRef, + gifRef, + fileName, + needsCropping, + cropTransform, + requiresImageDimensions, + }) => { + // gifRef takes priority when present — it points to the animated GIF file. + // imageRef only points to a static snapshot frame for GIF nodes. + const fillRef = gifRef ?? imageRef; + const imageUrl = fillRef ? fillUrls[fillRef] : undefined; + return imageUrl + ? downloadAndProcessImage( + fileName, + resolvedPath, + imageUrl, + needsCropping, + cropTransform, + requiresImageDimensions, + ) + : null; + }, + ) + .filter((promise): promise is Promise => promise !== null); + + if (fillDownloads.length > 0) { + downloadPromises.push(Promise.all(fillDownloads)); + } + } + + // Download rendered nodes with processing + if (renderNodes.length > 0) { + const pngNodes = renderNodes.filter((node) => !node.fileName.toLowerCase().endsWith(".svg")); + const svgNodes = renderNodes.filter((node) => node.fileName.toLowerCase().endsWith(".svg")); + + // Download PNG renders + if (pngNodes.length > 0) { + const pngUrls = await this.getNodeRenderUrls( + fileKey, + pngNodes.map((n) => n.nodeId), + "png", + { pngScale }, + ); + const pngDownloads = pngNodes + .map(({ nodeId, fileName, needsCropping, cropTransform, requiresImageDimensions }) => { + const imageUrl = pngUrls[nodeId]; + return imageUrl + ? downloadAndProcessImage( + fileName, + resolvedPath, + imageUrl, + needsCropping, + cropTransform, + requiresImageDimensions, + ) + : null; + }) + .filter((promise): promise is Promise => promise !== null); + + if (pngDownloads.length > 0) { + downloadPromises.push(Promise.all(pngDownloads)); + } + } + + // Download SVG renders + if (svgNodes.length > 0) { + const svgUrls = await this.getNodeRenderUrls( + fileKey, + svgNodes.map((n) => n.nodeId), + "svg", + { svgOptions }, + ); + const svgDownloads = svgNodes + .map(({ nodeId, fileName, needsCropping, cropTransform, requiresImageDimensions }) => { + const imageUrl = svgUrls[nodeId]; + return imageUrl + ? downloadAndProcessImage( + fileName, + resolvedPath, + imageUrl, + needsCropping, + cropTransform, + requiresImageDimensions, + ) + : null; + }) + .filter((promise): promise is Promise => promise !== null); + + if (svgDownloads.length > 0) { + downloadPromises.push(Promise.all(svgDownloads)); + } + } + } + + const results = await Promise.all(downloadPromises); + return results.flat(); + } + + /** + * Get raw Figma API response for a file (for use with flexible extractors). + * + * Returns the parsed body alongside the raw body size in bytes so callers + * can record payload size in telemetry. + */ + async getRawFile( + fileKey: string, + depth?: number | null, + ): Promise<{ data: GetFileResponse; rawSize: number }> { + const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`; + Logger.log(`Retrieving raw Figma file: ${fileKey} (depth: ${depth ?? "default"})`); + + const result = await this.requestWithSize(endpoint); + writeLogs("figma-raw.json", result.data); + + return result; + } + + /** + * Get raw Figma API response for specific nodes (for use with flexible extractors). + * + * Returns the parsed body alongside the raw body size in bytes so callers + * can record payload size in telemetry. + */ + async getRawNode( + fileKey: string, + nodeId: string, + depth?: number | null, + ): Promise<{ data: GetFileNodesResponse; rawSize: number }> { + const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`; + Logger.log( + `Retrieving raw Figma node: ${nodeId} from ${fileKey} (depth: ${depth ?? "default"})`, + ); + + const result = await this.requestWithSize(endpoint); + writeLogs("figma-raw.json", result.data); + + return result; + } +} diff --git a/src/services/get-figma-data-metrics.ts b/src/services/get-figma-data-metrics.ts new file mode 100644 index 0000000..2295fc9 --- /dev/null +++ b/src/services/get-figma-data-metrics.ts @@ -0,0 +1,195 @@ +import type { GetFileResponse, GetFileNodesResponse, Node } from "@figma/rest-api-spec"; +import type { SimplifiedDesign, SimplifiedNode } from "~/extractors/types.js"; +import type { SimplifiedFill } from "~/transformers/style.js"; + +export type GetFigmaDataMetrics = { + rawSizeKb: number; + simplifiedSizeKb: number; + /** + * Total Figma nodes walked in the raw API response, before extraction and + * filtering. Reflects the complexity of the tree the user asked about. + */ + rawNodeCount: number; + /** + * Total nodes in the simplified output tree (recursive, including nested + * children). Reflects the complexity of the payload sent to the LLM. + */ + simplifiedNodeCount: number; + /** + * Maximum depth of the simplified output tree. Root nodes are at depth 1. + */ + maxDepth: number; + /** + * Number of named (published) styles defined on the raw Figma response — + * i.e. reusable styles the user created in the Figma Styles panel (fills, + * text, effects, grids). High count is a design-system maturity signal. + */ + namedStyleCount: number; + /** Total component + component set definitions on the simplified design. */ + componentCount: number; + /** Simplified nodes with `type === "INSTANCE"`. */ + instanceCount: number; + /** Simplified nodes with `type === "TEXT"`. */ + textNodeCount: number; + /** + * Simplified nodes whose fills or strokes reference a globalVars style + * containing at least one image-backed fill (IMAGE or PATTERN). + */ + imageNodeCount: number; + /** Sum of `componentProperties` keys across all simplified nodes. */ + componentPropertyCount: number; + /** True if any node in the raw API response has non-empty `boundVariables`. */ + hasVariables: boolean; + /** Wall-clock ms spent on the Figma API fetch (network + parse). */ + fetchMs: number; + /** Wall-clock ms spent on the simplification walk. */ + simplifyMs: number; + /** Wall-clock ms spent serializing to YAML/JSON. */ + serializeMs: number; +}; + +/** + * Collect globalVars style keys whose value contains at least one image-backed + * fill (IMAGE or PATTERN). Covers both plain fill arrays and stroke objects + * whose `colors` array holds fills. Used to classify simplified nodes as + * "image nodes" via their `fills`/`strokes` key references. + */ +function hasImageFill(fills: SimplifiedFill[]): boolean { + return fills.some( + (fill) => + typeof fill === "object" && + fill !== null && + (fill.type === "IMAGE" || fill.type === "PATTERN"), + ); +} + +function collectImageStyleKeys(design: SimplifiedDesign): Set { + const keys = new Set(); + + for (const [key, value] of Object.entries(design.globalVars.styles)) { + if (Array.isArray(value)) { + if (hasImageFill(value)) keys.add(key); + } else if ( + typeof value === "object" && + value !== null && + "colors" in value && + Array.isArray(value.colors) && + hasImageFill(value.colors) + ) { + keys.add(key); + } + } + return keys; +} + +/** + * Walk the simplified design once to collect all shape metrics. Single-pass + * over the tree keeps this cheap even on large files. + */ +export function measureSimplifiedDesign(design: SimplifiedDesign): { + simplifiedNodeCount: number; + maxDepth: number; + instanceCount: number; + textNodeCount: number; + imageNodeCount: number; + componentPropertyCount: number; + componentCount: number; +} { + const imageStyleKeys = collectImageStyleKeys(design); + + let simplifiedNodeCount = 0; + let maxDepth = 0; + let instanceCount = 0; + let textNodeCount = 0; + let imageNodeCount = 0; + let componentPropertyCount = 0; + + // A template reference keeps no body of its own — type, fills, and strokes + // live in the shared element. Resolve through it so metrics stay accurate + // whether or not a node was deduplicated. + const isImageStyle = (value: SimplifiedNode["fills"]): boolean => + typeof value === "string" + ? imageStyleKeys.has(value) + : Array.isArray(value) && hasImageFill(value); + + const walk = (node: SimplifiedNode, depth: number): void => { + simplifiedNodeCount++; + if (depth > maxDepth) maxDepth = depth; + const body = node.template ? design.elements[node.template] : node; + if (body?.type === "INSTANCE") instanceCount++; + if (body?.type === "TEXT") textNodeCount++; + if (isImageStyle(body?.fills) || isImageStyle(body?.strokes)) { + imageNodeCount++; + } + // Read through `body`: a deduplicated instance keeps only id/name/template, + // so its componentProperties live in the shared element, not on the node. + if (body?.componentProperties) { + componentPropertyCount += Object.keys(body.componentProperties).length; + } + if (node.children) { + for (const child of node.children) walk(child, depth + 1); + } + }; + for (const root of design.nodes) walk(root, 1); + + return { + simplifiedNodeCount, + maxDepth, + instanceCount, + textNodeCount, + imageNodeCount, + componentPropertyCount, + componentCount: + Object.keys(design.components).length + Object.keys(design.componentSets).length, + }; +} + +/** + * Count the named (published) styles referenced in the raw Figma response. + * `GetFileResponse` carries the full `styles` dict at the root; `GetFileNodesResponse` + * carries one `styles` dict per requested node entry, so we merge them by style ID + * so a style referenced by multiple nodes counts once. + */ +export function countNamedStyles(raw: GetFileResponse | GetFileNodesResponse): number { + if ("document" in raw) { + return Object.keys(raw.styles ?? {}).length; + } + const seen = new Set(); + for (const entry of Object.values(raw.nodes)) { + for (const id of Object.keys(entry.styles ?? {})) seen.add(id); + } + return seen.size; +} + +/** + * Early-exiting walk over the raw Figma API response looking for any node with + * a non-empty `boundVariables` mapping. Per the Figma REST API spec, node-level + * `boundVariables` covers fills, strokes, size, padding, corner radii, text, + * and component properties — a single check per node is enough for a boolean + * presence signal. Walking inline Paint/effect structs would be redundant. + */ +export function detectVariables(raw: GetFileResponse | GetFileNodesResponse): boolean { + const roots: Node[] = + "document" in raw ? [raw.document] : Object.values(raw.nodes).map((entry) => entry.document); + + const visit = (node: Node): boolean => { + if ( + "boundVariables" in node && + node.boundVariables && + Object.keys(node.boundVariables).length > 0 + ) { + return true; + } + if ("children" in node && Array.isArray(node.children)) { + for (const child of node.children) { + if (visit(child as Node)) return true; + } + } + return false; + }; + + for (const root of roots) { + if (visit(root)) return true; + } + return false; +} diff --git a/src/services/get-figma-data.ts b/src/services/get-figma-data.ts new file mode 100644 index 0000000..a144a4a --- /dev/null +++ b/src/services/get-figma-data.ts @@ -0,0 +1,178 @@ +import type { GetFileResponse, GetFileNodesResponse } from "@figma/rest-api-spec"; +import { FigmaService } from "~/services/figma.js"; +import { + simplifyRawFigmaObject, + allExtractors, + collapseSvgContainers, +} from "~/extractors/index.js"; +import { writeLogs } from "~/utils/logger.js"; +import { serializeResult, type OutputFormat } from "~/utils/serialize.js"; +import { wrapForSerialization } from "~/utils/serializable-design.js"; +import { tagError } from "~/utils/error-meta.js"; +import { + type GetFigmaDataMetrics, + measureSimplifiedDesign, + countNamedStyles, + detectVariables, +} from "~/services/get-figma-data-metrics.js"; + +export type { GetFigmaDataMetrics } from "~/services/get-figma-data-metrics.js"; + +export type GetFigmaDataInput = { + fileKey: string; + nodeId?: string; + depth?: number; +}; + +export type GetFigmaDataResult = { + formatted: string; + metrics: GetFigmaDataMetrics; +}; + +export type GetFigmaDataOutcome = { + input: GetFigmaDataInput; + outputFormat: OutputFormat; + durationMs: number; + metrics?: GetFigmaDataMetrics; + error?: unknown; +}; + +/** + * Live progress reader exposed to onSimplifyStart so callers can render + * heartbeats showing real-time node counts. Closes over the per-call counter + * the walker is incrementing — no module-global state involved. + */ +export type SimplifyProgress = { + getNodeCount: () => number; +}; + +export type GetFigmaDataHooks = { + onFetchStart?: () => void | Promise; + onFetchComplete?: () => void | Promise; + onSimplifyStart?: (progress: SimplifyProgress) => void | Promise; + onSimplifyComplete?: () => void | Promise; + onSerializeStart?: () => void | Promise; + /** + * Fires exactly once per call, after the pipeline completes (success or + * failure). Lets shells observe outcomes without embedding telemetry + * bookkeeping in the core. Observer errors are swallowed silently — a + * broken observer must never break the pipeline. + */ + onComplete?: (outcome: GetFigmaDataOutcome) => void; +}; + +/** + * Shared pipeline for "get figma data": fetch raw response, simplify, serialize. + * Used by both the MCP `get_figma_data` tool and the `fetch` CLI command, which + * differ only in how they wrap this pipeline (progress notifications vs. plain + * stdout) and how they report errors (MCP envelope vs. process exit). + * + * Hooks are optional — the MCP tool uses them to drive progress heartbeats; the + * CLI passes none. + */ +export async function getFigmaData( + figmaService: FigmaService, + input: GetFigmaDataInput, + outputFormat: OutputFormat, + hooks: GetFigmaDataHooks = {}, +): Promise { + const { fileKey, nodeId, depth } = input; + const startedAt = Date.now(); + let metrics: GetFigmaDataMetrics | undefined; + let caughtError: unknown; + // Per-call counter shared with the walker. Lives in the call closure so + // overlapping HTTP requests each have their own — no module-global state. + const nodeCounter = { count: 0 }; + + try { + await hooks.onFetchStart?.(); + let rawResult: { data: GetFileResponse | GetFileNodesResponse; rawSize: number }; + const fetchStart = Date.now(); + try { + if (nodeId) { + rawResult = await figmaService.getRawNode(fileKey, nodeId, depth); + } else { + rawResult = await figmaService.getRawFile(fileKey, depth); + } + } catch (error) { + tagError(error, { phase: "fetch" }); + } finally { + await hooks.onFetchComplete?.(); + } + const fetchMs = Date.now() - fetchStart; + const rawApiResponse = rawResult.data; + const rawSizeKb = rawResult.rawSize / 1024; + + await hooks.onSimplifyStart?.({ getNodeCount: () => nodeCounter.count }); + let simplifiedDesign; + const simplifyStart = Date.now(); + try { + simplifiedDesign = await simplifyRawFigmaObject(rawApiResponse, allExtractors, { + maxDepth: depth, + afterChildren: collapseSvgContainers, + nodeCounter, + }); + } catch (error) { + tagError(error, { phase: "simplify" }); + } finally { + await hooks.onSimplifyComplete?.(); + } + const simplifyMs = Date.now() - simplifyStart; + + writeLogs("figma-simplified.json", simplifiedDesign); + + const rawNodeCount = nodeCounter.count; + const hasVariables = detectVariables(rawApiResponse); + const namedStyleCount = countNamedStyles(rawApiResponse); + const measured = measureSimplifiedDesign(simplifiedDesign); + + await hooks.onSerializeStart?.(); + const serializeStart = Date.now(); + let formatted: string; + try { + formatted = serializeResult(wrapForSerialization(simplifiedDesign), outputFormat); + } catch (error) { + tagError(error, { phase: "serialize" }); + } + const simplifiedSizeKb = Buffer.byteLength(formatted, "utf8") / 1024; + const serializeMs = Date.now() - serializeStart; + + metrics = { + rawSizeKb, + simplifiedSizeKb, + rawNodeCount, + simplifiedNodeCount: measured.simplifiedNodeCount, + maxDepth: measured.maxDepth, + namedStyleCount, + componentCount: measured.componentCount, + instanceCount: measured.instanceCount, + textNodeCount: measured.textNodeCount, + imageNodeCount: measured.imageNodeCount, + componentPropertyCount: measured.componentPropertyCount, + hasVariables, + fetchMs, + simplifyMs, + serializeMs, + }; + return { formatted, metrics }; + } catch (error) { + caughtError = error; + throw error; + } finally { + if (hooks.onComplete) { + // Observer errors must never break the pipeline — e.g. a telemetry + // failure should not mask the tool's real result or its original error. + try { + hooks.onComplete({ + input, + outputFormat, + durationMs: Date.now() - startedAt, + metrics, + error: caughtError, + }); + } catch { + // intentionally empty + } + } + } +} diff --git a/src/telemetry/capture.ts b/src/telemetry/capture.ts new file mode 100644 index 0000000..c9d8a01 --- /dev/null +++ b/src/telemetry/capture.ts @@ -0,0 +1,152 @@ +import { getErrorMeta } from "~/utils/error-meta.js"; +import type { GetFigmaDataOutcome } from "~/services/get-figma-data.js"; +import type { DownloadImagesOutcome } from "~/services/download-figma-images.js"; +import { captureEvent } from "./client.js"; +import type { + CommonCallProps, + GetFigmaDataCall, + DownloadFigmaImagesCall, + ToolCallProperties, + ToolCallContext, + ValidationRejectInput, +} from "./types.js"; + +function captureToolCall(props: ToolCallProperties): void { + captureEvent("tool_called", props as unknown as Record); +} + +function errorFields( + error: unknown, +): Pick< + CommonCallProps, + | "is_error" + | "error_type" + | "error_message" + | "error_phase" + | "error_category" + | "http_status" + | "network_code" + | "fs_code" + | "is_retryable" +> { + if (error === undefined) return { is_error: false }; + const meta = getErrorMeta(error); + const rawMessage = error instanceof Error ? error.message : String(error); + return { + is_error: true, + error_type: error instanceof Error ? error.constructor.name : "Unknown", + error_message: rawMessage, + error_phase: meta.phase, + error_category: meta.category, + http_status: meta.http_status, + network_code: meta.network_code, + fs_code: meta.fs_code, + is_retryable: meta.is_retryable, + }; +} + +function toGetFigmaDataEvent( + outcome: GetFigmaDataOutcome, + context: ToolCallContext, +): GetFigmaDataCall { + return { + tool: "get_figma_data", + duration_ms: outcome.durationMs, + transport: context.transport, + auth_mode: context.authMode, + client_name: context.clientInfo?.name, + client_version: context.clientInfo?.version, + output_format: outcome.outputFormat, + depth: outcome.input.depth ?? null, + has_node_id: Boolean(outcome.input.nodeId), + raw_size_kb: outcome.metrics?.rawSizeKb, + simplified_size_kb: outcome.metrics?.simplifiedSizeKb, + raw_node_count: outcome.metrics?.rawNodeCount, + simplified_node_count: outcome.metrics?.simplifiedNodeCount, + max_depth: outcome.metrics?.maxDepth, + named_style_count: outcome.metrics?.namedStyleCount, + component_count: outcome.metrics?.componentCount, + instance_count: outcome.metrics?.instanceCount, + text_node_count: outcome.metrics?.textNodeCount, + image_node_count: outcome.metrics?.imageNodeCount, + component_property_count: outcome.metrics?.componentPropertyCount, + has_variables: outcome.metrics?.hasVariables, + fetch_ms: outcome.metrics?.fetchMs, + simplify_ms: outcome.metrics?.simplifyMs, + serialize_ms: outcome.metrics?.serializeMs, + ...errorFields(outcome.error), + }; +} + +function toDownloadImagesEvent( + outcome: DownloadImagesOutcome, + context: ToolCallContext, +): DownloadFigmaImagesCall { + return { + tool: "download_figma_images", + duration_ms: outcome.durationMs, + transport: context.transport, + auth_mode: context.authMode, + client_name: context.clientInfo?.name, + client_version: context.clientInfo?.version, + image_count: outcome.imageCount, + success_count: outcome.successCount, + ...errorFields(outcome.error), + }; +} + +export function captureGetFigmaDataCall( + outcome: GetFigmaDataOutcome, + context: ToolCallContext, +): void { + captureToolCall(toGetFigmaDataEvent(outcome, context)); +} + +export function captureDownloadImagesCall( + outcome: DownloadImagesOutcome, + context: ToolCallContext, +): void { + captureToolCall(toDownloadImagesEvent(outcome, context)); +} + +/** + * Capture a tool call that was rejected by input validation. Fires a regular + * `tool_called` event so PostHog dashboards can aggregate it alongside successful + * calls (filter by `error_phase == "validate"`). Most tool-specific metric + * fields are absent because the pipeline never ran — only validation context. + */ +export function captureValidationReject( + input: ValidationRejectInput, + context: ToolCallContext, +): void { + const common: CommonCallProps = { + duration_ms: input.durationMs ?? 0, + transport: context.transport, + auth_mode: context.authMode, + client_name: context.clientInfo?.name, + client_version: context.clientInfo?.version, + is_error: true, + error_type: "ValidationError", + error_message: input.message, + error_phase: "validate", + error_category: "invalid_input", + validation_field: input.field, + validation_rule: input.rule, + }; + + if (input.tool === "get_figma_data") { + captureToolCall({ + ...common, + tool: "get_figma_data", + output_format: input.outputFormat ?? "tree", + depth: null, + has_node_id: false, + }); + } else { + captureToolCall({ + ...common, + tool: "download_figma_images", + image_count: 0, + }); + } +} diff --git a/src/telemetry/client.ts b/src/telemetry/client.ts new file mode 100644 index 0000000..24b02a7 --- /dev/null +++ b/src/telemetry/client.ts @@ -0,0 +1,182 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; +import { PostHog } from "posthog-node"; +import { proxyMode, type ProxyMode } from "~/utils/proxy-env.js"; +import type { InitTelemetryOptions } from "./types.js"; + +// Write-only project key for the Framelink MCP analytics project. +// This is intentionally embedded in the published package — it's a public +// ingest key that cannot read data, only send events. +const POSTHOG_API_KEY = "phc_w69pYvKwGNLsUHU4TGGpgAiscm8nhjudHgAJzAdzXkJV"; +const POSTHOG_HOST = "https://us.i.posthog.com"; + +type CommonProperties = { + server_version: string; + os_platform: NodeJS.Platform; + nodejs_major: number; + is_ci: boolean; + /** + * Which dispatcher the server installed for outbound fetches: + * `none` (Node default), `explicit` (--proxy/FIGMA_PROXY), or `env` + * (EnvHttpProxyAgent driven by HTTP_PROXY/HTTPS_PROXY/NO_PROXY). + * + * Lets us correlate failure rates (especially auth-category 403s — see + * issue #358) with the proxy configuration a user was actually running + * under, without logging the proxy URL itself. + */ + proxy_mode: ProxyMode; +}; + +let client: PostHog | undefined; +let sessionId: string | undefined; +let commonProps: CommonProperties | undefined; +let disabled = true; +let initialized = false; +let redactionSecrets: string[] = []; + +// Per-request redaction context. The init-time `redactionSecrets` list only +// covers credentials known at startup; with HTTP `X-Figma-Token` auth the +// real credential arrives per request and must not leak into telemetry. Each +// HTTP handler runs its body inside `withRequestSecrets(...)`, and capture +// merges the request-scoped list with the global one. AsyncLocalStorage +// propagates the value through the request's promise chain without us having +// to thread it down through every call site. +const requestSecrets = new AsyncLocalStorage(); + +export function withRequestSecrets( + secrets: readonly string[], + fn: () => Promise, +): Promise { + // Filter empties to avoid `replaceAll("", ...)`, which loops over every + // character of the message and produces nonsense output. + const filtered = secrets.filter(Boolean); + if (filtered.length === 0) return fn(); + return requestSecrets.run(filtered, fn); +} + +function parseNodeMajor(version: string): number { + return Number.parseInt(version.split(".")[0], 10); +} + +function redactErrorMessage(message: string): string { + let result = message; + for (const secret of redactionSecrets) { + result = result.replaceAll(secret, "[REDACTED]"); + } + for (const secret of requestSecrets.getStore() ?? []) { + result = result.replaceAll(secret, "[REDACTED]"); + } + return result; +} + +/** + * Telemetry is enabled by default. Any single opt-out signal disables it — + * the `optOut` flag (CLI), FRAMELINK_TELEMETRY=off, or a truthy DO_NOT_TRACK. + * Signals are OR'd, not prioritized, so users can't accidentally re-enable + * telemetry by setting one variable when another is already opting out. + * + * DO_NOT_TRACK follows the https://consoledonottrack.com/ convention: any + * non-empty value other than "0" means opt-out. + */ +export function resolveTelemetryEnabled(optOut?: boolean): boolean { + if (optOut === true) return false; + if (process.env.FRAMELINK_TELEMETRY === "off") return false; + const doNotTrack = process.env.DO_NOT_TRACK; + if (doNotTrack && doNotTrack !== "0") return false; + return true; +} + +export function initTelemetry(opts?: InitTelemetryOptions): boolean { + if (initialized) return !disabled; + + if (!resolveTelemetryEnabled(opts?.optOut)) { + disabled = true; + // Intentionally do NOT mark `initialized` here. An opted-out init must + // not poison subsequent re-init attempts (e.g. tests that opt out then + // opt in to verify capture). Re-running resolveTelemetryEnabled is cheap. + return false; + } + + initialized = true; + disabled = false; + sessionId = randomUUID(); + redactionSecrets = (opts?.redactFromErrors ?? []).filter(Boolean); + + commonProps = { + server_version: process.env.NPM_PACKAGE_VERSION ?? "unknown", + os_platform: process.platform, + nodejs_major: parseNodeMajor(process.versions.node), + is_ci: Boolean(process.env.CI), + proxy_mode: proxyMode(), + }; + + // disableGeoip: false is load-bearing — the Node SDK defaults GeoIP to off, + // and our geography analytics depend on it being enabled. + client = new PostHog(POSTHOG_API_KEY, { + host: POSTHOG_HOST, + disableGeoip: false, + ...(opts?.immediateFlush ? { flushAt: 1, flushInterval: 0 } : {}), + }); + + return true; +} + +/** + * Low-level event capture. Handles disabled state, redaction, and common + * property merging. Capture functions in capture.ts shape the event and + * delegate here. + * + * Telemetry must never surface errors to callers — this runs inside lifecycle + * observers where throwing would mask the tool's real return value (or its + * original error). Swallow silently; no logging because telemetry is supposed + * to be invisible. + */ +const MAX_ERROR_MESSAGE_LENGTH = 2000; + +function truncateForTelemetry(message: string): string { + return message.length > MAX_ERROR_MESSAGE_LENGTH + ? message.slice(0, MAX_ERROR_MESSAGE_LENGTH) + "…[truncated]" + : message; +} + +export function captureEvent(event: string, properties: Record): void { + if (disabled || !client || !sessionId || !commonProps) return; + + // Redact secrets BEFORE truncating so a token straddling the cut point + // can't survive as a partial match. + const errorMessage = properties.error_message; + const processed = + typeof errorMessage === "string" + ? { + ...properties, + error_message: truncateForTelemetry(redactErrorMessage(errorMessage)), + } + : properties; + + try { + client.capture({ + distinctId: sessionId, + event, + properties: { ...commonProps, ...processed }, + }); + } catch { + // intentionally empty + } +} + +export async function shutdown(): Promise { + if (disabled || !client) return; + + const current = client; + client = undefined; + disabled = true; + try { + await current.shutdown(); + } catch { + // Telemetry shutdown must never break callers — the server.ts shutdown + // handler and the fetch.ts cleye chain both depend on this resolving. + } + // Reset so the module can be re-initialized in the same process (relevant + // for tests; harmless in production where shutdown runs only at exit). + initialized = false; +} diff --git a/src/telemetry/index.ts b/src/telemetry/index.ts new file mode 100644 index 0000000..61e8d78 --- /dev/null +++ b/src/telemetry/index.ts @@ -0,0 +1,29 @@ +import type { FigmaAuthOptions } from "~/services/figma.js"; +import type { AuthMode } from "./types.js"; + +export { initTelemetry, shutdown, resolveTelemetryEnabled, withRequestSecrets } from "./client.js"; +export { + captureGetFigmaDataCall, + captureDownloadImagesCall, + captureValidationReject, +} from "./capture.js"; +export type { + Transport, + AuthMode, + InitTelemetryOptions, + ClientInfo, + ToolCallContext, + ValidationRejectInput, +} from "./types.js"; + +/** + * Single source of truth for converting auth options into a telemetry + * `AuthMode`. `none` represents an HTTP server with no global credentials and + * no `X-Figma-Token` on the request — the call will fail in `getAuthHeaders`, + * and reporting it as `api_key` would skew multi-tenant deployment metrics. + */ +export function authMode(auth: FigmaAuthOptions): AuthMode { + if (auth.useOAuth) return "oauth"; + if (auth.figmaApiKey) return "api_key"; + return "none"; +} diff --git a/src/telemetry/types.ts b/src/telemetry/types.ts new file mode 100644 index 0000000..63d88ce --- /dev/null +++ b/src/telemetry/types.ts @@ -0,0 +1,87 @@ +import type { OutputFormat } from "~/utils/serialize.js"; + +export type Transport = "stdio" | "http" | "cli"; +export type AuthMode = "oauth" | "api_key" | "none"; +export type ClientInfo = { name?: string; version?: string }; + +export interface InitTelemetryOptions { + optOut?: boolean; + /** + * Flush events immediately instead of batching. For short-lived processes + * (e.g. the `fetch` CLI command) that would otherwise exit before the + * default flush interval fires and drop the event. + */ + immediateFlush?: boolean; + /** + * Strings to scrub from `error_message` before sending events to PostHog. + * The shell passes whatever it considers sensitive (API keys, OAuth tokens, + * etc). Empty strings are filtered automatically so callers don't have to. + */ + redactFromErrors?: string[]; +} + +export type ToolCallContext = { + transport: Transport; + authMode: AuthMode; + clientInfo?: ClientInfo; +}; + +export type ValidationRejectInput = { + tool: "get_figma_data" | "download_figma_images"; + durationMs?: number; + field: string; + rule: string; + message: string; + outputFormat?: OutputFormat; +}; + +// Event schemas — used by capture.ts for shaping PostHog events. + +export type CommonCallProps = { + duration_ms: number; + transport: Transport; + auth_mode: AuthMode; + client_name?: string; + client_version?: string; + is_error: boolean; + error_type?: string; + error_message?: string; + error_phase?: string; + error_category?: string; + http_status?: number; + network_code?: string; + fs_code?: string; + is_retryable?: boolean; + validation_field?: string; + validation_rule?: string; +}; + +export type GetFigmaDataCall = CommonCallProps & { + tool: "get_figma_data"; + output_format: OutputFormat; + raw_size_kb?: number; + simplified_size_kb?: number; + raw_node_count?: number; + simplified_node_count?: number; + max_depth?: number; + named_style_count?: number; + component_count?: number; + instance_count?: number; + text_node_count?: number; + image_node_count?: number; + component_property_count?: number; + has_variables?: boolean; + fetch_ms?: number; + simplify_ms?: number; + serialize_ms?: number; + depth: number | null; + has_node_id: boolean; +}; + +export type DownloadFigmaImagesCall = CommonCallProps & { + tool: "download_figma_images"; + image_count: number; + success_count?: number; +}; + +export type ToolCallProperties = GetFigmaDataCall | DownloadFigmaImagesCall; diff --git a/src/tests/benchmark.test.ts b/src/tests/benchmark.test.ts new file mode 100644 index 0000000..a1e86ac --- /dev/null +++ b/src/tests/benchmark.test.ts @@ -0,0 +1,16 @@ +import yaml from "js-yaml"; + +describe("Benchmarks", () => { + const data = { + name: "John Doe", + age: 30, + email: "john.doe@example.com", + }; + + it("YAML should be token efficient", () => { + const yamlResult = yaml.dump(data); + const jsonResult = JSON.stringify(data); + + expect(yamlResult.length).toBeLessThan(jsonResult.length); + }); +}); diff --git a/src/tests/built-in-style-key.test.ts b/src/tests/built-in-style-key.test.ts new file mode 100644 index 0000000..33459a8 --- /dev/null +++ b/src/tests/built-in-style-key.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { Node as FigmaNode, Style, TypeStyle } from "@figma/rest-api-spec"; +import { extractFromDesign } from "~/extractors/node-walker.js"; +import { allExtractors } from "~/extractors/built-in.js"; +import type { GlobalVars } from "~/extractors/types.js"; + +// resolveStyleKey decides whether a node's named Figma style collapses onto an +// existing same-name entry or gets a disambiguating ` (id)` suffix. The decision +// is a deep-equality check, so it must be order-insensitive: Figma can hand back +// semantically identical style objects with their keys in different orders, and a +// key-order-sensitive compare would spuriously split one style into two keys +// (wasted tokens + misleading distinct keys). These tests pin that behavior +// through the public extraction pipeline. + +const STYLE_ID = "S:abc123"; + +// Minimal TEXT node carrying a named text-style reference. We cast through +// `unknown` because the full Figma node union is deeply discriminated and the +// extractor only reads these few fields. +function namedTextNode(style: Partial): FigmaNode { + return { + id: "text:1", + name: "Heading", + type: "TEXT", + visible: true, + characters: "Hello", + style, + styles: { text: STYLE_ID }, + characterStyleOverrides: [], + styleOverrideTable: {}, + lineTypes: [], + lineIndentations: [], + } as unknown as FigmaNode; +} + +const extraStyles: Record = { + [STYLE_ID]: { + key: "k", + name: "Heading / Large", + description: "", + remote: false, + styleType: "TEXT", + } as Style, +}; + +async function extractWithSeed(seed: Record) { + const globalVars: GlobalVars = { styles: { "Heading / Large": seed } }; + return extractFromDesign( + [namedTextNode({ fontFamily: "Inter", fontWeight: 700, fontSize: 24 })], + allExtractors, + {}, + globalVars, + extraStyles, + ); +} + +describe("resolveStyleKey — canonical (order-insensitive) comparison", () => { + it("collapses a same-name style whose existing entry differs only in key order", async () => { + // The node's extracted text style serializes to fontFamily/fontWeight/fontSize + // (insertion order). The pre-existing entry holds the same values with keys in + // a different order — a key-order-sensitive compare would split these. + const { globalVars } = await extractWithSeed({ + fontFamily: "Inter", + fontSize: 24, + fontWeight: 700, + }); + + expect(Object.keys(globalVars.styles)).toEqual(["Heading / Large"]); + }); + + it("disambiguates a same-name style with genuinely different values", async () => { + const { globalVars } = await extractWithSeed({ + fontFamily: "Inter", + fontSize: 99, + fontWeight: 700, + }); + + const keys = Object.keys(globalVars.styles); + expect(keys).toContain("Heading / Large"); + expect(keys).toContain(`Heading / Large (${STYLE_ID})`); + expect(keys).toHaveLength(2); + }); +}); diff --git a/src/tests/config.test.ts b/src/tests/config.test.ts new file mode 100644 index 0000000..e885200 --- /dev/null +++ b/src/tests/config.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { envBool, envInt, envStr, resolve } from "~/config.js"; + +describe("resolve", () => { + it("CLI flag wins over env and default", () => { + const result = resolve("from-cli", "from-env", "fallback"); + expect(result).toEqual({ value: "from-cli", source: "cli" }); + }); + + it("env wins over default when flag is undefined", () => { + const result = resolve(undefined, "from-env", "fallback"); + expect(result).toEqual({ value: "from-env", source: "env" }); + }); + + it("default is used when both flag and env are undefined", () => { + const result = resolve(undefined, undefined, "fallback"); + expect(result).toEqual({ value: "fallback", source: "default" }); + }); + + it("preserves falsy flag values (false, 0) instead of falling through", () => { + expect(resolve(false, true, true)).toEqual({ value: false, source: "cli" }); + expect(resolve(0, 42, 99)).toEqual({ value: 0, source: "cli" }); + }); +}); + +describe("envStr", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns the env var value when set", () => { + vi.stubEnv("TEST_STR", "hello"); + expect(envStr("TEST_STR")).toBe("hello"); + }); + + it("returns undefined when env var is not set", () => { + expect(envStr("TEST_STR_MISSING")).toBeUndefined(); + }); + + it("returns undefined when env var is empty string", () => { + vi.stubEnv("TEST_STR_EMPTY", ""); + expect(envStr("TEST_STR_EMPTY")).toBeUndefined(); + }); +}); + +describe("envInt", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns parsed integer when set", () => { + vi.stubEnv("TEST_INT", "42"); + expect(envInt("TEST_INT")).toBe(42); + }); + + it("tries names in order and returns first match", () => { + vi.stubEnv("TEST_INT_B", "99"); + expect(envInt("TEST_INT_A", "TEST_INT_B")).toBe(99); + }); + + it("returns undefined when none of the names are set", () => { + expect(envInt("NOPE_A", "NOPE_B")).toBeUndefined(); + }); +}); + +describe("envBool", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns true for "true"', () => { + vi.stubEnv("TEST_BOOL", "true"); + expect(envBool("TEST_BOOL")).toBe(true); + }); + + it('returns false for "false"', () => { + vi.stubEnv("TEST_BOOL", "false"); + expect(envBool("TEST_BOOL")).toBe(false); + }); + + it("returns undefined for any other value", () => { + vi.stubEnv("TEST_BOOL", "yes"); + expect(envBool("TEST_BOOL")).toBeUndefined(); + }); + + it("returns undefined when not set", () => { + expect(envBool("TEST_BOOL_MISSING")).toBeUndefined(); + }); +}); diff --git a/src/tests/effects.test.ts b/src/tests/effects.test.ts new file mode 100644 index 0000000..6a39786 --- /dev/null +++ b/src/tests/effects.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { buildSimplifiedEffects } from "~/transformers/effects.js"; +import type { Node as FigmaNode } from "@figma/rest-api-spec"; + +// Only the effects array is read; cast through unknown like the other walker tests. +function nodeWithEffects(effects: unknown[]): FigmaNode { + return { type: "FRAME", effects } as unknown as FigmaNode; +} + +describe("buildSimplifiedEffects — blur", () => { + // Figma's blur radius is ~2x the CSS blur() radius, so a Figma 32 must render + // as blur(16px). Covers both the layer-blur (filter) and background-blur + // (backdrop-filter) paths. + it("halves a layer blur radius onto the CSS filter property", () => { + const result = buildSimplifiedEffects( + nodeWithEffects([{ type: "LAYER_BLUR", radius: 32, visible: true }]), + ); + expect(result.filter).toBe("blur(16px)"); + }); + + it("halves a background blur radius onto the CSS backdrop-filter property", () => { + const result = buildSimplifiedEffects( + nodeWithEffects([{ type: "BACKGROUND_BLUR", radius: 32, visible: true }]), + ); + expect(result.backdropFilter).toBe("blur(16px)"); + }); + + // A zero-radius blur is a no-op; emitting blur(0px) is dead output. + it("omits a zero-radius blur entirely", () => { + const result = buildSimplifiedEffects( + nodeWithEffects([ + { type: "LAYER_BLUR", radius: 0, visible: true }, + { type: "BACKGROUND_BLUR", radius: 0, visible: true }, + ]), + ); + expect(result.filter).toBeUndefined(); + expect(result.backdropFilter).toBeUndefined(); + }); +}); diff --git a/src/tests/error-meta.test.ts b/src/tests/error-meta.test.ts new file mode 100644 index 0000000..7e29a72 --- /dev/null +++ b/src/tests/error-meta.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { tagError, getErrorMeta } from "~/utils/error-meta.js"; + +describe("error-meta cause chain", () => { + it("merges meta across nested causes, with outer winning on conflict", () => { + // Innermost: HTTP error tagged with status + retryability (like fetchJSON does) + const innerError = new Error("Fetch failed with status 429"); + try { + tagError(innerError, { http_status: 429, is_retryable: true }); + } catch { + // tagError throws — caught here so we can use the tagged error + } + + // Middle wrapper: figma.ts wrapping the inner error with endpoint context + const wrappedError = new Error( + "Failed to make request to Figma API endpoint '/files/abc': Fetch failed with status 429", + { cause: innerError }, + ); + + // Outer: get-figma-data.ts tagging with phase + try { + tagError(wrappedError, { phase: "fetch" }); + } catch { + // intentionally swallowed + } + + const meta = getErrorMeta(wrappedError); + + // Outer phase tag survives + expect(meta.phase).toBe("fetch"); + // Inner machine data walks up through `cause` + expect(meta.http_status).toBe(429); + expect(meta.is_retryable).toBe(true); + }); + + it("outer tag wins when both layers set the same field", () => { + const inner = new Error("inner"); + try { + tagError(inner, { phase: "fetch", http_status: 500 }); + } catch { + // intentionally swallowed + } + + const outer = new Error("outer", { cause: inner }); + try { + tagError(outer, { phase: "simplify" }); + } catch { + // intentionally swallowed + } + + const meta = getErrorMeta(outer); + expect(meta.phase).toBe("simplify"); // outer wins + expect(meta.http_status).toBe(500); // not overridden + }); + + it("returns empty object for untagged errors", () => { + const meta = getErrorMeta(new Error("plain")); + expect(meta).toEqual({}); + }); + + it("survives circular cause chains without infinite looping", () => { + const a = new Error("a") as Error & { cause?: unknown }; + const b = new Error("b") as Error & { cause?: unknown }; + a.cause = b; + b.cause = a; + try { + tagError(a, { phase: "fetch" }); + } catch { + // intentionally swallowed + } + expect(() => getErrorMeta(a)).not.toThrow(); + expect(getErrorMeta(a).phase).toBe("fetch"); + }); +}); diff --git a/src/tests/finalize.test.ts b/src/tests/finalize.test.ts new file mode 100644 index 0000000..4dc9de5 --- /dev/null +++ b/src/tests/finalize.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { finalizeDesign } from "~/extractors/finalize.js"; +import type { GlobalVars, SimplifiedNode, StyleTypes } from "~/extractors/types.js"; + +// finalizeDesign is the pure functional core of the dedup features: given the +// already-walked node tree + globalVars (style fields hold globalVars refs, as +// the walker emits them), it gates single-use styles inline. Testing it directly +// keeps these fast and free of Figma-fixture noise. + +// Solid fills serialize to hex-string arrays in real output (see style.ts). +const RED: StyleTypes = ["#FF0000"]; +const BLUE: StyleTypes = ["#0000FF"]; + +function node(overrides: Partial & { id: string }): SimplifiedNode { + return { name: overrides.id, type: "FRAME", ...overrides }; +} + +describe("count-gated style hoisting", () => { + it("inlines a single-use style onto its node and drops it from globalVars", () => { + const nodes = [node({ id: "1", fills: "fill_red" })]; + const globalVars: GlobalVars = { styles: { fill_red: RED } }; + + const result = finalizeDesign(nodes, globalVars, new Set()); + + expect(result.nodes[0].fills).toEqual(RED); + expect(result.globalVars.styles).toEqual({}); + }); + + it("keeps a 2+-use style hoisted and referenced by id", () => { + // Distinct bodies (FRAME vs RECTANGLE) so element dedup doesn't fold them — + // this isolates style gating: the shared fill is referenced, not inlined. + const nodes = [ + node({ id: "1", type: "FRAME", fills: "fill_red" }), + node({ id: "2", type: "RECTANGLE", fills: "fill_red" }), + ]; + const globalVars: GlobalVars = { styles: { fill_red: RED } }; + + const result = finalizeDesign(nodes, globalVars, new Set()); + + expect(result.nodes[0].fills).toBe("fill_red"); + expect(result.nodes[1].fills).toBe("fill_red"); + expect(result.globalVars.styles).toEqual({ fill_red: RED }); + }); + + it("keeps a single-use named Figma style hoisted (design-system intent)", () => { + const nodes = [node({ id: "1", type: "TEXT", textStyle: "Heading / Large" })]; + const globalVars: GlobalVars = { styles: { "Heading / Large": { fontSize: 24 } } }; + + const result = finalizeDesign(nodes, globalVars, new Set(["Heading / Large"])); + + expect(result.nodes[0].textStyle).toBe("Heading / Large"); + expect(result.globalVars.styles).toEqual({ "Heading / Large": { fontSize: 24 } }); + }); + + it("drops a named style that no surviving node references (orphaned by node removal)", () => { + // A named style can reach zero references when its only node is dropped after + // registration — e.g. collapseSvgContainers registers a vector child's named + // style, then folds the child away. A hoisted entry nothing points to is just + // orphan noise, so it must be dropped rather than force-kept as "intent". + const nodes = [node({ id: "1", type: "FRAME" })]; // references no style + const globalVars: GlobalVars = { styles: { "Heading / Large": { fontSize: 24 } } }; + + const result = finalizeDesign(nodes, globalVars, new Set(["Heading / Large"])); + + expect(result.globalVars.styles).toEqual({}); + }); + + it("never inlines or drops inline-text-style (ts*) entries — they're referenced from text", () => { + const nodes = [node({ id: "1", type: "TEXT", text: "a {ts1}b{/ts1}" })]; + const globalVars: GlobalVars = { styles: { ts1: { fontWeight: 700 } } }; + + const result = finalizeDesign(nodes, globalVars, new Set()); + + expect(result.globalVars.styles).toEqual({ ts1: { fontWeight: 700 } }); + }); +}); + +describe("element templates", () => { + it("dedupes two identical subtrees into one element entry + two template refs", () => { + // Both cards share fill_red (count 2 → stays a ref), so their post-gating + // bodies are byte-identical and hash to the same template. A third node + // (distinct body) also uses fill_red so it stays a hoisted ref rather than + // being inlined by exclusive-style expansion. + const nodes = [ + node({ id: "1", name: "Card A", fills: "fill_red" }), + node({ id: "2", name: "Card B", fills: "fill_red" }), + node({ id: "9", name: "Header", type: "RECTANGLE", fills: "fill_red" }), + ]; + const globalVars: GlobalVars = { styles: { fill_red: RED } }; + + const result = finalizeDesign(nodes, globalVars, new Set()); + + const [hash] = Object.keys(result.elements); + expect(Object.keys(result.elements)).toHaveLength(1); + expect(result.elements[hash]).toEqual({ type: "FRAME", fills: "fill_red" }); + + // Each card keeps its per-instance id/name, body replaced by the ref. + expect(result.nodes[0]).toEqual({ id: "1", name: "Card A", template: hash }); + expect(result.nodes[1]).toEqual({ id: "2", name: "Card B", template: hash }); + // The shared style stays hoisted (referenced from the element body + Header). + expect(result.globalVars.styles).toEqual({ fill_red: RED }); + }); + + it("inlines a style used only by one deduplicated element, dropping the global entry", () => { + // fill_red is used by exactly the two cards (count 2) that fold into one + // element (2 instances). Exclusive → expanded inline, global entry removed, + // collapsing template → ref → value down to template → value. + const nodes = [ + node({ id: "1", name: "Card A", fills: "fill_red" }), + node({ id: "2", name: "Card B", fills: "fill_red" }), + ]; + const globalVars: GlobalVars = { styles: { fill_red: RED } }; + + const result = finalizeDesign(nodes, globalVars, new Set()); + + const [hash] = Object.keys(result.elements); + expect(result.elements[hash]).toEqual({ type: "FRAME", fills: RED }); + expect(result.globalVars.styles).toEqual({}); + }); + + it("does not expand a named style even when exclusive to one element", () => { + const nodes = [ + node({ id: "1", name: "Card A", type: "TEXT", textStyle: "Heading / Large" }), + node({ id: "2", name: "Card B", type: "TEXT", textStyle: "Heading / Large" }), + ]; + const globalVars: GlobalVars = { styles: { "Heading / Large": { fontSize: 24 } } }; + + const result = finalizeDesign(nodes, globalVars, new Set(["Heading / Large"])); + + const [hash] = Object.keys(result.elements); + expect(result.elements[hash]).toEqual({ type: "TEXT", textStyle: "Heading / Large" }); + expect(result.globalVars.styles).toEqual({ "Heading / Large": { fontSize: 24 } }); + }); + + it("leaves a unique subtree inline (no template)", () => { + const nodes = [ + node({ id: "1", name: "Card A", fills: "fill_red" }), + node({ id: "2", name: "Card B", fills: "fill_red" }), + node({ id: "3", name: "Solo", fills: "fill_blue" }), + ]; + const globalVars: GlobalVars = { styles: { fill_red: RED, fill_blue: BLUE } }; + + const result = finalizeDesign(nodes, globalVars, new Set()); + + // The solo card's body is unique → no template, and its single-use fill + // inlines onto the node. + expect(result.nodes[2].template).toBeUndefined(); + expect(result.nodes[2].fills).toEqual(BLUE); + expect(result.nodes[2].type).toBe("FRAME"); + }); + + it("dedupes repeated children while keeping per-instance ids", () => { + // A grid of two identical cards, each wrapping an identical icon. + const card = (id: string): SimplifiedNode => + node({ + id, + name: `Card ${id}`, + fills: "fill_red", + children: [node({ id: `${id}-icon`, name: "Icon", type: "IMAGE-SVG", opacity: 0.5 })], + }); + const nodes = [card("1"), card("2")]; + const globalVars: GlobalVars = { styles: { fill_red: RED } }; + + const result = finalizeDesign(nodes, globalVars, new Set()); + + // Two distinct templates: the card body and the icon body. + expect(Object.keys(result.elements)).toHaveLength(2); + expect(result.nodes[0].template).toBe(result.nodes[1].template); + expect(result.nodes[0].children?.[0].template).toBe(result.nodes[1].children?.[0].template); + expect(result.nodes[0].children?.[0].id).toBe("1-icon"); + expect(result.nodes[1].children?.[0].id).toBe("2-icon"); + }); + + it("does not dedupe type-only bodies (a template would grow the payload)", () => { + const nodes = [node({ id: "1" }), node({ id: "2" })]; + + const result = finalizeDesign(nodes, { styles: {} }, new Set()); + + expect(result.elements).toEqual({}); + expect(result.nodes[0].template).toBeUndefined(); + expect(result.nodes[0].type).toBe("FRAME"); + }); + + it("is deterministic: identical input yields identical template hashes", () => { + const build = (): SimplifiedNode[] => [ + node({ id: "1", fills: "fill_red" }), + node({ id: "2", fills: "fill_red" }), + ]; + const a = finalizeDesign(build(), { styles: { fill_red: RED } }, new Set()); + const b = finalizeDesign(build(), { styles: { fill_red: RED } }, new Set()); + + expect(Object.keys(a.elements)).toEqual(Object.keys(b.elements)); + }); +}); diff --git a/src/tests/gradient.test.ts b/src/tests/gradient.test.ts new file mode 100644 index 0000000..28dab32 --- /dev/null +++ b/src/tests/gradient.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from "vitest"; +import { parsePaint } from "~/transformers/style.js"; +import type { Paint } from "@figma/rest-api-spec"; + +// A vertical (180deg) black→transparent linear gradient. Only `opacity` varies +// between cases, so any difference in the emitted stops is the paint-level +// opacity being applied (or dropped). +function verticalBlackToTransparent(opacity?: number): Paint { + return { + type: "GRADIENT_LINEAR", + ...(opacity === undefined ? {} : { opacity }), + gradientHandlePositions: [ + { x: 0.5, y: 0 }, + { x: 0.5, y: 1 }, + { x: 1, y: 0 }, + ], + gradientStops: [ + { position: 0, color: { r: 0, g: 0, b: 0, a: 1 } }, + { position: 1, color: { r: 0, g: 0, b: 0, a: 0 } }, + ], + } as unknown as Paint; +} + +function gradientCss(opacity?: number): string { + const result = parsePaint(verticalBlackToTransparent(opacity)) as { gradient: string }; + return result.gradient; +} + +describe("parsePaint — gradient paint opacity", () => { + it("multiplies paint opacity into each stop's alpha", () => { + expect(gradientCss(0.5)).toContain("rgba(0, 0, 0, 0.5) 0%"); + }); + + // A fully-transparent stop stays transparent regardless of paint opacity. + it("leaves an alpha-0 stop transparent", () => { + expect(gradientCss(0.5)).toContain("rgba(0, 0, 0, 0) 100%"); + }); + + // Regression guard: the default (opacity 1 / absent) must not change output. + it("emits opaque stops when paint opacity is absent", () => { + expect(gradientCss(undefined)).toContain("rgba(0, 0, 0, 1) 0%"); + }); + + // Paint opacity and a stop's intrinsic alpha are multiplicative, not either-or: + // a stop at alpha 0.4 under a 0.5-opacity paint resolves to 0.2. + it("multiplies paint opacity with a stop's intrinsic alpha", () => { + const paint = { + type: "GRADIENT_LINEAR", + opacity: 0.5, + gradientHandlePositions: [ + { x: 0.5, y: 0 }, + { x: 0.5, y: 1 }, + { x: 1, y: 0 }, + ], + gradientStops: [{ position: 0, color: { r: 0, g: 0, b: 0, a: 0.4 } }], + } as unknown as Paint; + const { gradient } = parsePaint(paint) as { gradient: string }; + expect(gradient).toContain("rgba(0, 0, 0, 0.2)"); + }); + + // Non-linear types route stop formatting through a different geometry mapper; + // confirm paint opacity reaches a radial gradient's stops too. + it("applies paint opacity to non-linear (radial) gradients", () => { + const paint = { + type: "GRADIENT_RADIAL", + opacity: 0.5, + gradientHandlePositions: [ + { x: 0.5, y: 0.5 }, + { x: 1, y: 0.5 }, + { x: 0.5, y: 1 }, + ], + gradientStops: [{ position: 0, color: { r: 0, g: 0, b: 0, a: 1 } }], + } as unknown as Paint; + const { gradient } = parsePaint(paint) as { gradient: string }; + expect(gradient).toContain("rgba(0, 0, 0, 0.5)"); + }); +}); + +// Each gradient type maps to a specific CSS function (linear/radial/conic) and a +// specific geometry string. These pin the full output per type so a refactor of +// the type→renderer dispatch can't silently swap a wrapper or drop geometry. +function renderGradient(type: string, handles: { x: number; y: number }[]): string { + const paint = { + type, + gradientHandlePositions: handles, + gradientStops: [{ position: 0, color: { r: 0, g: 0, b: 0, a: 1 } }], + } as unknown as Paint; + return (parsePaint(paint) as { gradient: string }).gradient; +} + +describe("parsePaint — gradient type to CSS function + geometry", () => { + // Centered handles shared by the three non-linear types: center, edge, width. + const centered = [ + { x: 0.5, y: 0.5 }, + { x: 1, y: 0.5 }, + { x: 0.5, y: 1 }, + ]; + + it("renders a linear gradient with a degree angle", () => { + const handles = [ + { x: 0.5, y: 0 }, + { x: 0.5, y: 1 }, + { x: 1, y: 0 }, + ]; + expect(renderGradient("GRADIENT_LINEAR", handles)).toBe( + "linear-gradient(180deg, rgba(0, 0, 0, 1) 0%)", + ); + }); + + it("renders a radial gradient as a circle", () => { + expect(renderGradient("GRADIENT_RADIAL", centered)).toBe( + "radial-gradient(circle at 50% 50%, rgba(0, 0, 0, 1) 0%)", + ); + }); + + it("renders an angular gradient as a conic gradient", () => { + expect(renderGradient("GRADIENT_ANGULAR", centered)).toBe( + "conic-gradient(from 90deg at 50% 50%, rgba(0, 0, 0, 1) 0%)", + ); + }); + + it("renders a diamond gradient as a radial ellipse", () => { + expect(renderGradient("GRADIENT_DIAMOND", centered)).toBe( + "radial-gradient(ellipse at 50% 50%, rgba(0, 0, 0, 1) 0%)", + ); + }); +}); diff --git a/src/tests/http-header-auth.test.ts b/src/tests/http-header-auth.test.ts new file mode 100644 index 0000000..8578aa4 --- /dev/null +++ b/src/tests/http-header-auth.test.ts @@ -0,0 +1,198 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { Server } from "http"; +import type { AddressInfo } from "net"; +import { startHttpServer, stopHttpServer } from "~/server.js"; +import type { FigmaAuthOptions } from "~/services/figma.js"; + +const figmaFileResponse = { + name: "Auth Test File", + lastModified: "2026-01-01T00:00:00Z", + thumbnailUrl: "", + version: "1", + document: { + id: "0:0", + name: "Document", + type: "DOCUMENT", + children: [ + { + id: "1:1", + name: "Page", + type: "CANVAS", + visible: true, + children: [], + }, + ], + }, + components: {}, + componentSets: {}, + schemaVersion: 0, + styles: {}, +}; + +const emptyAuth = { + figmaApiKey: "", + figmaOAuthToken: "", + useOAuth: false, +}; + +describe("HTTP header Figma API key authentication", () => { + let client: Client; + let httpServer: Server | undefined; + let fetchMock: ReturnType; + + beforeEach(() => { + const realFetch = globalThis.fetch; + fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).startsWith("https://api.figma.com")) { + return Response.json(figmaFileResponse); + } + return realFetch(input, init); + }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(async () => { + await client?.close(); + if (httpServer) { + await stopHttpServer(); + httpServer = undefined; + } + vi.unstubAllGlobals(); + }); + + async function connectClient( + headers?: Record, + baseAuth: FigmaAuthOptions = emptyAuth, + ) { + httpServer = await startHttpServer("127.0.0.1", 0, baseAuth, {}); + const port = (httpServer.address() as AddressInfo).port; + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`), { + requestInit: headers ? { headers } : undefined, + }); + client = new Client({ name: "http-header-auth-test", version: "1.0.0" }); + await client.connect(transport); + } + + function firstFigmaRequestHeaders(): Record { + const figmaCall = fetchMock.mock.calls.find(([input]) => + String(input).startsWith("https://api.figma.com"), + ); + const init = figmaCall?.[1] as RequestInit & { headers?: Record }; + return init.headers ?? {}; + } + + function figmaRequestCount(): number { + return fetchMock.mock.calls.filter(([input]) => + String(input).startsWith("https://api.figma.com"), + ).length; + } + + it("uses X-Figma-Token from the HTTP request for get_figma_data", async () => { + await connectClient({ "X-Figma-Token": "request-key" }); + + const result = await client.request( + { + method: "tools/call", + params: { + name: "get_figma_data", + arguments: { fileKey: "abc123" }, + }, + }, + CallToolResultSchema, + ); + + expect(result.isError).toBeUndefined(); + expect(firstFigmaRequestHeaders()).toMatchObject({ "X-Figma-Token": "request-key" }); + }); + + it("uses X-Figma-Token from the HTTP request instead of the server API key", async () => { + await connectClient( + { "X-Figma-Token": "request-key" }, + { figmaApiKey: "server-key", figmaOAuthToken: "", useOAuth: false }, + ); + + const result = await client.request( + { + method: "tools/call", + params: { + name: "get_figma_data", + arguments: { fileKey: "abc123" }, + }, + }, + CallToolResultSchema, + ); + + expect(result.isError).toBeUndefined(); + expect(firstFigmaRequestHeaders()).toMatchObject({ "X-Figma-Token": "request-key" }); + }); + + it("uses Authorization bearer tokens from the HTTP request for get_figma_data", async () => { + await connectClient({ Authorization: "Bearer request-oauth-token" }); + + const result = await client.request( + { + method: "tools/call", + params: { + name: "get_figma_data", + arguments: { fileKey: "abc123" }, + }, + }, + CallToolResultSchema, + ); + + expect(result.isError).toBeUndefined(); + expect(firstFigmaRequestHeaders()).toMatchObject({ + Authorization: "Bearer request-oauth-token", + }); + }); + + it("uses HTTP Authorization bearer tokens instead of the server API key", async () => { + await connectClient( + { Authorization: "Bearer request-oauth-token" }, + { figmaApiKey: "server-key", figmaOAuthToken: "", useOAuth: false }, + ); + + const result = await client.request( + { + method: "tools/call", + params: { + name: "get_figma_data", + arguments: { fileKey: "abc123" }, + }, + }, + CallToolResultSchema, + ); + + expect(result.isError).toBeUndefined(); + expect(firstFigmaRequestHeaders()).toMatchObject({ + Authorization: "Bearer request-oauth-token", + }); + }); + + it("returns a tool error when no server or request credentials are available", async () => { + await connectClient(); + + const result = await client.request( + { + method: "tools/call", + params: { + name: "get_figma_data", + arguments: { fileKey: "abc123" }, + }, + }, + CallToolResultSchema, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].type).toBe("text"); + if (result.content[0].type === "text") { + expect(result.content[0].text).toContain( + "send X-Figma-Token / Authorization: Bearer on the HTTP request", + ); + } + expect(figmaRequestCount()).toBe(0); + }); +}); diff --git a/src/tests/image-processing.test.ts b/src/tests/image-processing.test.ts new file mode 100644 index 0000000..11f4deb --- /dev/null +++ b/src/tests/image-processing.test.ts @@ -0,0 +1,105 @@ +import path from "path"; +import os from "os"; +import fs from "fs"; +import { createJimp } from "@jimp/core"; +import png from "@jimp/js-png"; +import jpeg from "@jimp/js-jpeg"; +import * as crop from "@jimp/plugin-crop"; + +const Jimp = createJimp({ formats: [png, jpeg], plugins: [crop.methods] }); +import { + getImageDimensions, + applyCropTransform, + parseSvgDimensions, +} from "../utils/image-processing.js"; +import type { Transform } from "@figma/rest-api-spec"; + +describe("image processing", () => { + let tmpDir: string; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "image-processing-test-")); + }); + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true }); + }); + + async function createTemp(name: string, width: number, height: number): Promise { + const filePath = path.join(tmpDir, name); + const image = new Jimp({ width, height, color: 0xff0000ff }); + await image.write(filePath as `${string}.${string}`); + return filePath; + } + + describe("getImageDimensions", () => { + it("reads correct dimensions from a PNG", async () => { + const filePath = await createTemp("test-200x100.png", 200, 100); + const dims = await getImageDimensions(filePath); + expect(dims).toEqual({ width: 200, height: 100 }); + }); + + it("reads correct dimensions from a JPEG", async () => { + const filePath = await createTemp("test-300x150.jpg", 300, 150); + const dims = await getImageDimensions(filePath); + expect(dims).toEqual({ width: 300, height: 150 }); + }); + }); + + describe("parseSvgDimensions", () => { + it("reads width/height attributes (Figma's typical export shape)", () => { + const svg = ``; + expect(parseSvgDimensions(svg)).toEqual({ width: 52, height: 52 }); + }); + + it("falls back to viewBox when width/height are percentages", () => { + const svg = ``; + expect(parseSvgDimensions(svg)).toEqual({ width: 24, height: 16 }); + }); + + it("falls back to viewBox when width/height are absent", () => { + const svg = ``; + expect(parseSvgDimensions(svg)).toEqual({ width: 120, height: 80 }); + }); + + it("returns 0x0 when no usable size is declared", () => { + expect(parseSvgDimensions(``)).toEqual({ + width: 0, + height: 0, + }); + }); + }); + + describe("applyCropTransform", () => { + it("crops to the correct dimensions", async () => { + const filePath = await createTemp("test-crop-400x400.png", 400, 400); + + // Crop to the top-left quarter: scale 0.5 in both axes, no translation + const transform: Transform = [ + [0.5, 0, 0], + [0, 0.5, 0], + ]; + + await applyCropTransform(filePath, transform); + + const dims = await getImageDimensions(filePath); + expect(dims).toEqual({ width: 200, height: 200 }); + }); + + it("returns original image unchanged for invalid crop dimensions", async () => { + const filePath = await createTemp("test-crop-invalid.png", 100, 100); + + // Zero scale produces invalid (0-width) crop region + const transform: Transform = [ + [0, 0, 0], + [0, 0, 0], + ]; + + const result = await applyCropTransform(filePath, transform); + expect(result).toBe(filePath); + + const dims = await getImageDimensions(filePath); + expect(dims).toEqual({ width: 100, height: 100 }); + }); + }); +}); diff --git a/src/tests/integration.test.ts b/src/tests/integration.test.ts new file mode 100644 index 0000000..a13a623 --- /dev/null +++ b/src/tests/integration.test.ts @@ -0,0 +1,70 @@ +import { createServer } from "../mcp/index.js"; +import { config } from "dotenv"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import yaml from "js-yaml"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; + +config(); + +const describeOrSkip = process.env.RUN_FIGMA_INTEGRATION === "1" ? describe : describe.skip; + +describeOrSkip("Figma MCP Server Tests", () => { + let server: McpServer; + let client: Client; + let figmaApiKey: string; + let figmaFileKey: string; + + beforeAll(async () => { + figmaApiKey = process.env.FIGMA_API_KEY || ""; + figmaFileKey = process.env.FIGMA_FILE_KEY || ""; + + server = createServer( + { + figmaApiKey, + figmaOAuthToken: "", + useOAuth: false, + }, + { transport: "stdio", outputFormat: "yaml" }, + ); + + client = new Client({ + name: "figma-test-client", + version: "1.0.0", + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + }); + + afterAll(async () => { + await client.close(); + }); + + describe("Get Figma Data", () => { + it("should be able to get Figma file data", async () => { + const args = { + fileKey: figmaFileKey, + }; + + const result = await client.request( + { + method: "tools/call", + params: { + name: "get_figma_data", + arguments: args, + }, + }, + CallToolResultSchema, + ); + + const firstContent = result.content[0]; + const content = firstContent.type === "text" ? firstContent.text : ""; + const parsed = yaml.load(content); + + expect(parsed).toBeDefined(); + }, 60000); + }); +}); diff --git a/src/tests/layout-alignment.test.ts b/src/tests/layout-alignment.test.ts new file mode 100644 index 0000000..f032cb3 --- /dev/null +++ b/src/tests/layout-alignment.test.ts @@ -0,0 +1,793 @@ +import { describe, test, expect } from "vitest"; +import { buildSimplifiedLayout, computeGridChildOrder } from "~/transformers/layout.js"; +import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; + +function makeFrame(overrides: Record = {}) { + return { + clipsContent: true, + layoutMode: "HORIZONTAL", + children: [], + primaryAxisAlignItems: "MIN", + counterAxisAlignItems: "MIN", + ...overrides, + } as unknown as FigmaDocumentNode; +} + +function makeChild(overrides: Record = {}) { + return { + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + ...overrides, + }; +} + +describe("layout alignment", () => { + describe("justifyContent (primary axis)", () => { + const cases: [string, string | undefined][] = [ + ["MIN", undefined], + ["MAX", "flex-end"], + ["CENTER", "center"], + ["SPACE_BETWEEN", "space-between"], + ]; + + test.each(cases)("row: %s → %s", (figmaValue, expected) => { + const node = makeFrame({ + layoutMode: "HORIZONTAL", + primaryAxisAlignItems: figmaValue, + }); + expect(buildSimplifiedLayout(node).justifyContent).toBe(expected); + }); + + test.each(cases)("column: %s → %s", (figmaValue, expected) => { + const node = makeFrame({ + layoutMode: "VERTICAL", + primaryAxisAlignItems: figmaValue, + }); + expect(buildSimplifiedLayout(node).justifyContent).toBe(expected); + }); + }); + + describe("alignItems (counter axis)", () => { + const cases: [string, string | undefined][] = [ + ["MIN", undefined], + ["MAX", "flex-end"], + ["CENTER", "center"], + ["BASELINE", "baseline"], + ]; + + test.each(cases)("row: %s → %s", (figmaValue, expected) => { + const node = makeFrame({ + layoutMode: "HORIZONTAL", + counterAxisAlignItems: figmaValue, + }); + expect(buildSimplifiedLayout(node).alignItems).toBe(expected); + }); + + test.each(cases)("column: %s → %s", (figmaValue, expected) => { + const node = makeFrame({ + layoutMode: "VERTICAL", + counterAxisAlignItems: figmaValue, + }); + expect(buildSimplifiedLayout(node).alignItems).toBe(expected); + }); + }); + + describe("gap suppression with SPACE_BETWEEN", () => { + test("primary: itemSpacing suppressed when SPACE_BETWEEN", () => { + const node = makeFrame({ + primaryAxisAlignItems: "SPACE_BETWEEN", + itemSpacing: 10, + }); + expect(buildSimplifiedLayout(node).gap).toBeUndefined(); + }); + + test("primary: itemSpacing preserved for other alignment modes", () => { + const node = makeFrame({ + primaryAxisAlignItems: "MIN", + itemSpacing: 10, + }); + expect(buildSimplifiedLayout(node).gap).toBe("10px"); + }); + + test("counter: counterAxisSpacing suppressed when SPACE_BETWEEN", () => { + const node = makeFrame({ + layoutWrap: "WRAP", + counterAxisAlignContent: "SPACE_BETWEEN", + counterAxisSpacing: 24, + primaryAxisAlignItems: "SPACE_BETWEEN", + itemSpacing: 10, + }); + expect(buildSimplifiedLayout(node).gap).toBeUndefined(); + }); + + test("counter: counterAxisSpacing preserved when AUTO", () => { + const node = makeFrame({ + layoutWrap: "WRAP", + counterAxisAlignContent: "AUTO", + counterAxisSpacing: 24, + itemSpacing: 10, + }); + expect(buildSimplifiedLayout(node).gap).toBe("24px 10px"); + }); + + test("wrapped row: both gaps emit CSS shorthand (row-gap column-gap)", () => { + const node = makeFrame({ + layoutMode: "HORIZONTAL", + layoutWrap: "WRAP", + itemSpacing: 10, + counterAxisSpacing: 24, + }); + // row layout: counterAxisSpacing=row-gap, itemSpacing=column-gap + expect(buildSimplifiedLayout(node).gap).toBe("24px 10px"); + }); + + test("wrapped column: both gaps emit CSS shorthand (row-gap column-gap)", () => { + const node = makeFrame({ + layoutMode: "VERTICAL", + layoutWrap: "WRAP", + itemSpacing: 10, + counterAxisSpacing: 24, + }); + // column layout: itemSpacing=row-gap, counterAxisSpacing=column-gap + expect(buildSimplifiedLayout(node).gap).toBe("10px 24px"); + }); + + test("wrapped: equal gaps collapse to single value", () => { + const node = makeFrame({ + layoutWrap: "WRAP", + itemSpacing: 16, + counterAxisSpacing: 16, + }); + expect(buildSimplifiedLayout(node).gap).toBe("16px"); + }); + + test("counterAxisSpacing ignored for non-wrapped layouts", () => { + const node = makeFrame({ + layoutWrap: "NO_WRAP", + itemSpacing: 10, + counterAxisSpacing: 24, + }); + expect(buildSimplifiedLayout(node).gap).toBe("10px"); + }); + }); + + describe("alignItems stretch detection", () => { + test("row: all children fill cross axis → stretch", () => { + const node = makeFrame({ + layoutMode: "HORIZONTAL", + children: [ + makeChild({ layoutSizingVertical: "FILL" }), + makeChild({ layoutSizingVertical: "FILL" }), + ], + }); + expect(buildSimplifiedLayout(node).alignItems).toBe("stretch"); + }); + + test("column: all children fill cross axis → stretch", () => { + const node = makeFrame({ + layoutMode: "VERTICAL", + children: [ + makeChild({ layoutSizingHorizontal: "FILL" }), + makeChild({ layoutSizingHorizontal: "FILL" }), + ], + }); + expect(buildSimplifiedLayout(node).alignItems).toBe("stretch"); + }); + + test("row: mixed children → falls back to enum value", () => { + const node = makeFrame({ + layoutMode: "HORIZONTAL", + counterAxisAlignItems: "CENTER", + children: [ + makeChild({ layoutSizingVertical: "FILL" }), + makeChild({ layoutSizingVertical: "FIXED" }), + ], + }); + expect(buildSimplifiedLayout(node).alignItems).toBe("center"); + }); + + test("column: mixed children → falls back to enum value", () => { + const node = makeFrame({ + layoutMode: "VERTICAL", + counterAxisAlignItems: "MAX", + children: [ + makeChild({ layoutSizingHorizontal: "FILL" }), + makeChild({ layoutSizingHorizontal: "FIXED" }), + ], + }); + expect(buildSimplifiedLayout(node).alignItems).toBe("flex-end"); + }); + + test("absolute children are excluded from stretch check", () => { + const node = makeFrame({ + layoutMode: "HORIZONTAL", + children: [ + makeChild({ layoutSizingVertical: "FILL" }), + makeChild({ layoutPositioning: "ABSOLUTE", layoutSizingVertical: "FIXED" }), + ], + }); + expect(buildSimplifiedLayout(node).alignItems).toBe("stretch"); + }); + + test("no children → no stretch, uses enum value", () => { + const node = makeFrame({ + layoutMode: "HORIZONTAL", + counterAxisAlignItems: "CENTER", + children: [], + }); + expect(buildSimplifiedLayout(node).alignItems).toBe("center"); + }); + + // These two tests verify correct cross-axis detection — the bug PR #232 addressed. + // With the old bug, row mode checked layoutSizingHorizontal (main axis) instead of + // layoutSizingVertical (cross axis), so children filling main-only would false-positive. + test("row: children fill main axis only → no stretch", () => { + const node = makeFrame({ + layoutMode: "HORIZONTAL", + counterAxisAlignItems: "CENTER", + children: [ + makeChild({ layoutSizingHorizontal: "FILL", layoutSizingVertical: "FIXED" }), + makeChild({ layoutSizingHorizontal: "FILL", layoutSizingVertical: "FIXED" }), + ], + }); + expect(buildSimplifiedLayout(node).alignItems).toBe("center"); + }); + + test("column: children fill main axis only → no stretch", () => { + const node = makeFrame({ + layoutMode: "VERTICAL", + counterAxisAlignItems: "CENTER", + children: [ + makeChild({ layoutSizingVertical: "FILL", layoutSizingHorizontal: "FIXED" }), + makeChild({ layoutSizingVertical: "FILL", layoutSizingHorizontal: "FIXED" }), + ], + }); + expect(buildSimplifiedLayout(node).alignItems).toBe("center"); + }); + }); + + describe("dimensions in parent auto layout", () => { + test("keeps fixed height when a row child stretches across a column parent", () => { + const parent = makeFrame({ + layoutMode: "VERTICAL", + absoluteBoundingBox: { x: 0, y: 0, width: 536, height: 158 }, + }); + const child = makeFrame({ + layoutMode: "HORIZONTAL", + absoluteBoundingBox: { x: 0, y: 80, width: 536, height: 78 }, + layoutAlign: "STRETCH", + layoutGrow: 0, + layoutSizingHorizontal: "FILL", + layoutSizingVertical: "FIXED", + }); + + expect(buildSimplifiedLayout(child, parent).dimensions).toEqual({ height: 78 }); + }); + }); + + // The requested root has no parent in the payload, so Figma reports it as + // FIXED at its artboard size — an artifact of being top-level. Honoring that + // as a hard width/height would pin the whole design and kill responsiveness, + // so the root marks such axes "contextual" and keeps the size as a non-binding + // designed* reference. Descendants (real parent) keep their fixed semantics. + describe("root node contextual sizing", () => { + test("rewrites FIXED axes as contextual + designed reference on a top-level node", () => { + const root = makeFrame({ + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + absoluteBoundingBox: { x: 0, y: 0, width: 1440, height: 900 }, + }); + const layout = buildSimplifiedLayout(root); + expect(layout.sizing).toEqual({ horizontal: "contextual", vertical: "contextual" }); + expect(layout.designedWidth).toBe("1440px"); + expect(layout.designedHeight).toBe("900px"); + // No binding dimensions on the root. + expect(layout.dimensions).toBeUndefined(); + }); + + test("keeps the same FIXED sizing and dimensions on a non-root child", () => { + const parent = makeFrame({ + layoutMode: "NONE", + absoluteBoundingBox: { x: 0, y: 0, width: 1440, height: 900 }, + }); + const child = makeFrame({ + layoutMode: "NONE", + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + }); + const layout = buildSimplifiedLayout(child, parent); + expect(layout.sizing).toEqual({ horizontal: "fixed", vertical: "fixed" }); + expect(layout.dimensions).toEqual({ width: 200, height: 100 }); + expect(layout.designedWidth).toBeUndefined(); + }); + + test("only the FIXED axis becomes contextual; a real HUG axis is left alone", () => { + const root = makeFrame({ + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "HUG", + absoluteBoundingBox: { x: 0, y: 0, width: 1440, height: 900 }, + }); + const layout = buildSimplifiedLayout(root); + // Canonical desktop root: fluid width, content-sized height. + expect(layout.sizing).toEqual({ horizontal: "contextual", vertical: "hug" }); + expect(layout.designedWidth).toBe("1440px"); + // HUG needs no anchor — height is determined by content. + expect(layout.designedHeight).toBeUndefined(); + }); + }); + + describe("locationRelativeToParent", () => { + // SECTION holds children but has no frame properties (no clipsContent, no + // layoutMode), so it can never auto-layout — children are always positioned + // absolutely within it. Regression guard: a stricter `isFrame(parent)` gate + // previously dropped positions for SECTION children entirely. + test("emits position for children of a SECTION parent", () => { + const section = { + type: "SECTION", + absoluteBoundingBox: { x: 100, y: 200, width: 708, height: 245 }, + } as unknown as FigmaDocumentNode; + const child = makeFrame({ + layoutMode: "NONE", + absoluteBoundingBox: { x: 120, y: 210, width: 50, height: 50 }, + }); + + expect(buildSimplifiedLayout(child, section).locationRelativeToParent).toEqual({ + x: 20, + y: 10, + }); + }); + + test("omits position for top-level nodes (no parent)", () => { + const node = makeFrame({ + absoluteBoundingBox: { x: 100, y: 200, width: 50, height: 50 }, + }); + expect(buildSimplifiedLayout(node).locationRelativeToParent).toBeUndefined(); + }); + + test("omits position for in-flow children of an auto-layout parent", () => { + const parent = makeFrame({ + layoutMode: "HORIZONTAL", + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + }); + const child = makeFrame({ + absoluteBoundingBox: { x: 10, y: 10, width: 50, height: 50 }, + }); + expect(buildSimplifiedLayout(child, parent).locationRelativeToParent).toBeUndefined(); + }); + + test("emits position for ABSOLUTE children inside an auto-layout parent", () => { + const parent = makeFrame({ + layoutMode: "HORIZONTAL", + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + }); + const child = makeFrame({ + layoutPositioning: "ABSOLUTE", + absoluteBoundingBox: { x: 30, y: 40, width: 50, height: 50 }, + }); + const result = buildSimplifiedLayout(child, parent); + expect(result.position).toBe("absolute"); + expect(result.locationRelativeToParent).toEqual({ x: 30, y: 40 }); + }); + }); +}); + +describe("grid layout", () => { + function makeGridParent(overrides: Record = {}) { + return makeFrame({ + layoutMode: "GRID", + gridColumnsSizing: "repeat(3,minmax(0,1fr))", + gridRowsSizing: "auto", + children: [], + ...overrides, + }); + } + + function makeGridChild(overrides: Record = {}) { + return { + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 50 }, + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 0, + gridColumnSpan: 1, + gridRowSpan: 1, + gridChildHorizontalAlign: "AUTO", + gridChildVerticalAlign: "AUTO", + ...overrides, + } as unknown as FigmaDocumentNode; + } + + describe("grid container", () => { + test("basic grid container output", () => { + const node = makeFrame({ + layoutMode: "GRID", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 200 }, + gridColumnsSizing: "repeat(3,minmax(0,1fr))", + gridRowsSizing: "auto", + gridRowGap: 10, + gridColumnGap: 10, + }); + const result = buildSimplifiedLayout(node); + expect(result.mode).toBe("grid"); + expect(result.gridTemplateColumns).toBe("repeat(3,minmax(0,1fr))"); + expect(result.gridTemplateRows).toBe("auto"); + expect(result.gap).toBe("10px"); + // Flex-specific props should NOT be present + expect(result.justifyContent).toBeUndefined(); + expect(result.alignItems).toBeUndefined(); + expect(result.wrap).toBeUndefined(); + }); + + test("trims whitespace from grid template strings", () => { + const node = makeFrame({ + layoutMode: "GRID", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 200 }, + gridColumnsSizing: " 100px 200px ", + gridRowsSizing: " auto ", + }); + const result = buildSimplifiedLayout(node); + expect(result.gridTemplateColumns).toBe("100px 200px"); + expect(result.gridTemplateRows).toBe("auto"); + }); + + test("unequal row/column gaps produce CSS shorthand", () => { + const node = makeFrame({ + layoutMode: "GRID", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 200 }, + gridRowGap: 10, + gridColumnGap: 20, + }); + expect(buildSimplifiedLayout(node).gap).toBe("10px 20px"); + }); + + test("grid container with padding", () => { + const node = makeFrame({ + layoutMode: "GRID", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 200 }, + paddingTop: 8, + paddingRight: 16, + paddingBottom: 8, + paddingLeft: 16, + }); + expect(buildSimplifiedLayout(node).padding).toBe("8px 16px"); + }); + }); + + describe("grid child properties", () => { + test("default grid child (span 1, AUTO align, packed) produces no grid props", () => { + const child = makeGridChild(); + const parent = makeGridParent({ children: [child] }); + const result = buildSimplifiedLayout(child, parent); + expect(result.gridColumn).toBeUndefined(); + expect(result.gridRow).toBeUndefined(); + expect(result.justifySelf).toBeUndefined(); + expect(result.alignSelf).toBeUndefined(); + }); + + test("packed grid: column span > 1 emits span shorthand", () => { + const child = makeGridChild({ gridColumnSpan: 2 }); + const parent = makeGridParent({ children: [child] }); + const result = buildSimplifiedLayout(child, parent); + expect(result.gridColumn).toBe("span 2"); + expect(result.gridRow).toBeUndefined(); + }); + + test("packed grid: row span > 1 emits span shorthand", () => { + const child = makeGridChild({ gridRowSpan: 3 }); + const parent = makeGridParent({ children: [child] }); + const result = buildSimplifiedLayout(child, parent); + expect(result.gridRow).toBe("span 3"); + }); + + test("non-AUTO horizontal alignment emits justifySelf", () => { + const child = makeGridChild({ gridChildHorizontalAlign: "CENTER" }); + const parent = makeGridParent({ children: [child] }); + expect(buildSimplifiedLayout(child, parent).justifySelf).toBe("center"); + }); + + test("non-AUTO vertical alignment emits alignSelf", () => { + const child = makeGridChild({ gridChildVerticalAlign: "MAX" }); + const parent = makeGridParent({ children: [child] }); + expect(buildSimplifiedLayout(child, parent).alignSelf).toBe("end"); + }); + + test("MIN alignment maps to start", () => { + const child = makeGridChild({ + gridChildHorizontalAlign: "MIN", + gridChildVerticalAlign: "MIN", + }); + const parent = makeGridParent({ children: [child] }); + const result = buildSimplifiedLayout(child, parent); + expect(result.justifySelf).toBe("start"); + expect(result.alignSelf).toBe("start"); + }); + }); + + describe("packed vs gapped grid positions", () => { + test("packed grid: no explicit positions emitted", () => { + // 3 children filling a 3-column grid sequentially + const c1 = makeGridChild({ gridColumnAnchorIndex: 0, gridRowAnchorIndex: 0 }); + const c2 = makeGridChild({ gridColumnAnchorIndex: 1, gridRowAnchorIndex: 0 }); + const c3 = makeGridChild({ gridColumnAnchorIndex: 2, gridRowAnchorIndex: 0 }); + const parent = makeGridParent({ children: [c1, c2, c3] }); + + expect(buildSimplifiedLayout(c1, parent).gridColumn).toBeUndefined(); + expect(buildSimplifiedLayout(c2, parent).gridColumn).toBeUndefined(); + expect(buildSimplifiedLayout(c3, parent).gridColumn).toBeUndefined(); + }); + + test("gapped grid: explicit positions on all children", () => { + // 2 children in a 3-column grid with a gap (cell at 0,1 is empty) + const c1 = makeGridChild({ gridColumnAnchorIndex: 0, gridRowAnchorIndex: 0 }); + const c2 = makeGridChild({ gridColumnAnchorIndex: 2, gridRowAnchorIndex: 0 }); + const parent = makeGridParent({ children: [c1, c2] }); + + // CSS is 1-based + expect(buildSimplifiedLayout(c1, parent).gridColumn).toBe("1"); + expect(buildSimplifiedLayout(c1, parent).gridRow).toBe("1"); + expect(buildSimplifiedLayout(c2, parent).gridColumn).toBe("3"); + expect(buildSimplifiedLayout(c2, parent).gridRow).toBe("1"); + }); + + test("gapped grid with spans: position includes span", () => { + // Child spans 2 columns in a gapped grid + const c1 = makeGridChild({ + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 0, + gridColumnSpan: 2, + }); + const c2 = makeGridChild({ gridColumnAnchorIndex: 0, gridRowAnchorIndex: 1 }); + // c1 occupies (0,0) and (0,1), c2 occupies (1,0) — gapped because (1,1) is empty + const parent = makeGridParent({ children: [c1, c2] }); + + expect(buildSimplifiedLayout(c1, parent).gridColumn).toBe("1 / span 2"); + expect(buildSimplifiedLayout(c1, parent).gridRow).toBe("1"); + }); + }); + + describe("z-order vs grid-flow order", () => { + test("children already in anchor order: no sort, no zIndex", () => { + const c1 = makeGridChild({ gridColumnAnchorIndex: 0, gridRowAnchorIndex: 0 }); + const c2 = makeGridChild({ gridColumnAnchorIndex: 1, gridRowAnchorIndex: 0 }); + const c3 = makeGridChild({ gridColumnAnchorIndex: 2, gridRowAnchorIndex: 0 }); + const parent = makeGridParent({ children: [c1, c2, c3] }); + + expect(computeGridChildOrder(parent)).toBeNull(); + expect(buildSimplifiedLayout(c1, parent).zIndex).toBeUndefined(); + expect(buildSimplifiedLayout(c2, parent).zIndex).toBeUndefined(); + expect(buildSimplifiedLayout(c3, parent).zIndex).toBeUndefined(); + }); + + test("z-order differs from anchor order with overlap: sort, emit zIndex on moved children", () => { + // Mirrors the "Dynamic - FR" case from the Figma file: 6 children in a 3x2 + // packed grid, where the 100x100 cell-spanning child is z-order-last but + // belongs at row 2 col 0 in grid flow. Children are placed with overlapping + // bboxes to verify zIndex is emitted when stacking matters. + const overlapBox = { x: 0, y: 0, width: 100, height: 100 }; + const c0 = makeGridChild({ + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 0, + absoluteBoundingBox: overlapBox, + }); + const c1 = makeGridChild({ + gridColumnAnchorIndex: 1, + gridRowAnchorIndex: 0, + absoluteBoundingBox: overlapBox, + }); + const c2 = makeGridChild({ + gridColumnAnchorIndex: 2, + gridRowAnchorIndex: 0, + absoluteBoundingBox: overlapBox, + }); + const c3 = makeGridChild({ + gridColumnAnchorIndex: 1, + gridRowAnchorIndex: 1, + absoluteBoundingBox: overlapBox, + }); + const c4 = makeGridChild({ + gridColumnAnchorIndex: 2, + gridRowAnchorIndex: 1, + absoluteBoundingBox: overlapBox, + }); + const cBig = makeGridChild({ + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 1, + absoluteBoundingBox: overlapBox, + }); + const parent = makeGridParent({ children: [c0, c1, c2, c3, c4, cBig] }); + + // Sorted order is [c0, c1, c2, cBig, c3, c4] → indices [0, 1, 2, 5, 3, 4] + expect(computeGridChildOrder(parent)).toEqual([0, 1, 2, 5, 3, 4]); + + // Unmoved children: no zIndex + expect(buildSimplifiedLayout(c0, parent).zIndex).toBeUndefined(); + expect(buildSimplifiedLayout(c1, parent).zIndex).toBeUndefined(); + expect(buildSimplifiedLayout(c2, parent).zIndex).toBeUndefined(); + + // Moved children: zIndex = original index (Figma z-order) + expect(buildSimplifiedLayout(c3, parent).zIndex).toBe(3); + expect(buildSimplifiedLayout(c4, parent).zIndex).toBe(4); + expect(buildSimplifiedLayout(cBig, parent).zIndex).toBe(5); + + // Packed grid → no explicit grid positions emitted; sort handles placement + expect(buildSimplifiedLayout(cBig, parent).gridColumn).toBeUndefined(); + expect(buildSimplifiedLayout(cBig, parent).gridRow).toBeUndefined(); + }); + + test("sort reorders, but no overlap: skip zIndex (stacking can't affect rendering)", () => { + // Array order ≠ anchor order, so sort reorders, but children sit in + // distinct screen positions with no overlap — stacking is invisible + // and zIndex would be noise. + const c0 = makeGridChild({ + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 0, + absoluteBoundingBox: { x: 0, y: 0, width: 50, height: 50 }, + }); + // Second in z-order but anchored at (1, 0) — sorts last. + const cBottom = makeGridChild({ + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 1, + absoluteBoundingBox: { x: 0, y: 60, width: 50, height: 50 }, + }); + // Third in z-order but anchored at (0, 1) — sorts second. + const cRight = makeGridChild({ + gridColumnAnchorIndex: 1, + gridRowAnchorIndex: 0, + absoluteBoundingBox: { x: 60, y: 0, width: 50, height: 50 }, + }); + const parent = makeGridParent({ children: [c0, cBottom, cRight] }); + + // Sort still reorders: c0 stays, cRight (idx 2) moves to slot 1, cBottom (idx 1) to slot 2. + expect(computeGridChildOrder(parent)).toEqual([0, 2, 1]); + + // But no overlap → no zIndex on anyone, including the moved children. + expect(buildSimplifiedLayout(c0, parent).zIndex).toBeUndefined(); + expect(buildSimplifiedLayout(cBottom, parent).zIndex).toBeUndefined(); + expect(buildSimplifiedLayout(cRight, parent).zIndex).toBeUndefined(); + }); + + test("adjacent cells touching (gap = 0) are not overlap", () => { + // c0 at x=0..50 and cRight at x=50..100 share an edge but don't overlap. + // The reordered child still shouldn't get a zIndex. + const c0 = makeGridChild({ + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 0, + absoluteBoundingBox: { x: 0, y: 0, width: 50, height: 50 }, + }); + const cBottom = makeGridChild({ + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 1, + absoluteBoundingBox: { x: 0, y: 50, width: 50, height: 50 }, + }); + const cRight = makeGridChild({ + gridColumnAnchorIndex: 1, + gridRowAnchorIndex: 0, + absoluteBoundingBox: { x: 50, y: 0, width: 50, height: 50 }, + }); + const parent = makeGridParent({ children: [c0, cBottom, cRight] }); + + expect(buildSimplifiedLayout(cBottom, parent).zIndex).toBeUndefined(); + expect(buildSimplifiedLayout(cRight, parent).zIndex).toBeUndefined(); + }); + + test("ABSOLUTE children keep their slot; in-flow siblings still sort", () => { + const c0 = makeGridChild({ gridColumnAnchorIndex: 0, gridRowAnchorIndex: 0 }); + const cAbs = makeGridChild({ + layoutPositioning: "ABSOLUTE", + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 0, + }); + const c1 = makeGridChild({ gridColumnAnchorIndex: 0, gridRowAnchorIndex: 1 }); + const c2 = makeGridChild({ gridColumnAnchorIndex: 1, gridRowAnchorIndex: 0 }); + const parent = makeGridParent({ children: [c0, cAbs, c1, c2] }); + + // In-flow indices [0, 2, 3] sort by anchor → [0, 3, 2]; absolute keeps slot 1. + expect(computeGridChildOrder(parent)).toEqual([0, 1, 3, 2]); + }); + }); + + describe("gap shorthand zero handling", () => { + test("zero row gap with non-zero column gap", () => { + const node = makeFrame({ + layoutMode: "GRID", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 200 }, + gridRowGap: 0, + gridColumnGap: 16, + }); + expect(buildSimplifiedLayout(node).gap).toBe("0px 16px"); + }); + + test("both gaps zero is omitted (CSS default)", () => { + const node = makeFrame({ + layoutMode: "GRID", + absoluteBoundingBox: { x: 0, y: 0, width: 300, height: 200 }, + gridRowGap: 0, + gridColumnGap: 0, + }); + expect(buildSimplifiedLayout(node).gap).toBeUndefined(); + }); + }); + + describe("cross-layout nesting", () => { + test("grid container inside flex parent retains alignSelf", () => { + const gridContainer = makeFrame({ + layoutMode: "GRID", + layoutAlign: "CENTER", + gridColumnsSizing: "1fr 1fr", + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + }); + const flexParent = makeFrame({ + layoutMode: "HORIZONTAL", + children: [gridContainer], + }); + const result = buildSimplifiedLayout(gridContainer, flexParent); + // Container should be grid mode + expect(result.mode).toBe("grid"); + expect(result.gridTemplateColumns).toBe("1fr 1fr"); + // But should NOT have flex alignment from parent + expect(result.justifyContent).toBeUndefined(); + // alignSelf comes from the container's own layoutAlign + expect(result.alignSelf).toBe("center"); + }); + + test("flex container inside grid parent gets grid child props", () => { + // A child that is itself a flex row, but sits inside a grid + const flexChild = makeFrame({ + layoutMode: "HORIZONTAL", + children: [], + gridColumnSpan: 2, + gridChildHorizontalAlign: "CENTER", + gridColumnAnchorIndex: 0, + gridRowAnchorIndex: 0, + absoluteBoundingBox: { x: 0, y: 0, width: 200, height: 100 }, + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + }); + const gridParent = makeFrame({ + layoutMode: "GRID", + gridColumnsSizing: "1fr 1fr 1fr", + children: [flexChild], + }); + const result = buildSimplifiedLayout(flexChild, gridParent); + // Own layout mode drives the mode + expect(result.mode).toBe("row"); + // Grid child props come from grid parent relationship + expect(result.gridColumn).toBe("span 2"); + expect(result.justifySelf).toBe("center"); + }); + }); +}); + +describe("aspectRatio zero-height guard", () => { + // A zero-height column child would divide width/0 and emit aspectRatio:Infinity + // into the LLM-facing output. The guard drops the field rather than emit a + // value no consumer can use. + // A non-auto-layout parent so the child's own column mode drives the axis. + const parent = makeFrame({ + layoutMode: "NONE", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height: 100 }, + }); + const columnChild = (height: number) => + makeFrame({ + layoutMode: "VERTICAL", + preserveRatio: true, + layoutSizingHorizontal: "FIXED", + layoutSizingVertical: "FIXED", + absoluteBoundingBox: { x: 0, y: 0, width: 100, height }, + }); + + test("does not emit aspectRatio for a column child with zero height", () => { + const result = buildSimplifiedLayout(columnChild(0), parent); + + expect(result.dimensions?.aspectRatio).toBeUndefined(); + }); + + test("still emits aspectRatio for a column child with non-zero height", () => { + const result = buildSimplifiedLayout(columnChild(50), parent); + + expect(result.dimensions?.aspectRatio).toBe(2); + }); +}); diff --git a/src/tests/path-validation.test.ts b/src/tests/path-validation.test.ts new file mode 100644 index 0000000..e5827eb --- /dev/null +++ b/src/tests/path-validation.test.ts @@ -0,0 +1,268 @@ +import path from "path"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("~/telemetry/index.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureValidationReject: vi.fn(), + }; +}); + +import { downloadFigmaImagesTool } from "~/mcp/tools/download-figma-images-tool.js"; +import { downloadFigmaImage } from "~/utils/common.js"; +import { resolveLocalPath, isWithin } from "~/utils/local-path.js"; +import type { ToolExtra } from "~/mcp/progress.js"; +import * as telemetry from "~/telemetry/index.js"; + +const stubFigmaService = { + downloadImages: () => Promise.resolve([]), +} as unknown as Parameters[1]; + +const stubExtra = { + sendNotification: () => Promise.resolve(), + signal: AbortSignal.timeout(30_000), +} as unknown as ToolExtra; + +const validParams = { + fileKey: "abc123", + nodes: [{ nodeId: "1:2", fileName: "test.png" }], + pngScale: 2, +}; + +describe("download path validation (handler)", () => { + const imageDir = path.resolve("/project/root"); + + beforeEach(() => { + vi.mocked(telemetry.captureValidationReject).mockClear(); + }); + + it("captures path-traversal attempts as validation rejects", async () => { + const result = await downloadFigmaImagesTool.handler( + { ...validParams, localPath: "../../etc" }, + stubFigmaService, + imageDir, + "stdio", + "api_key", + undefined, + stubExtra, + ); + + expect(result.isError).toBe(true); + + const captureSpy = vi.mocked(telemetry.captureValidationReject); + expect(captureSpy).toHaveBeenCalledOnce(); + const [input, context] = captureSpy.mock.calls[0]; + expect(input.tool).toBe("download_figma_images"); + expect(input.field).toBe("localPath"); + expect(input.rule).toBe("outside_image_dir"); + expect(context.transport).toBe("stdio"); + expect(context.authMode).toBe("api_key"); + }); + + it("rejects localPath that traverses outside imageDir", async () => { + const result = await downloadFigmaImagesTool.handler( + { ...validParams, localPath: "../../etc" }, + stubFigmaService, + imageDir, + "stdio", + "api_key", + undefined, + stubExtra, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("resolves outside the allowed image directory"); + expect(result.content[0].text).toContain(imageDir); + }); + + it("hints at the relative form when a leading slash takes the path outside imageDir", async () => { + const result = await downloadFigmaImagesTool.handler( + { ...validParams, localPath: "/some/elsewhere" }, + stubFigmaService, + imageDir, + "stdio", + "api_key", + undefined, + stubExtra, + ); + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain("drop the leading slash"); + expect(result.content[0].text).toContain('"some/elsewhere"'); + }); + + it("accepts valid relative path within imageDir", async () => { + const result = await downloadFigmaImagesTool.handler( + { ...validParams, localPath: "public/images" }, + stubFigmaService, + imageDir, + "stdio", + "api_key", + undefined, + stubExtra, + ); + + expect(result.isError).toBeUndefined(); + }); + + it("accepts valid path when imageDir is a drive root", async () => { + // Drive roots already end with a separator. The unified containment check + // uses path.relative, so the trailing separator no longer causes false + // rejection (the previous startsWith(base + sep) check did). + const driveRoot = path.resolve("/"); + const result = await downloadFigmaImagesTool.handler( + { ...validParams, localPath: "project/src/static/images/test" }, + stubFigmaService, + driveRoot, + "stdio", + "api_key", + undefined, + stubExtra, + ); + + expect(result.isError).toBeUndefined(); + }); +}); + +describe("resolveLocalPath", () => { + describe("with POSIX semantics (path.posix)", () => { + const base = "/project/root"; + const posix = path.posix; + + it("resolves a relative path under base", () => { + const result = resolveLocalPath("public/images", base, posix); + expect(result).toEqual({ ok: true, resolvedPath: "/project/root/public/images" }); + }); + + it("rejects a relative path that traverses out", () => { + const result = resolveLocalPath("../etc", base, posix); + expect(result).toEqual({ ok: false, reason: "outside_image_dir" }); + }); + + it("accepts an absolute path inside base", () => { + const inside = "/project/root/public/images"; + const result = resolveLocalPath(inside, base, posix); + expect(result).toEqual({ ok: true, resolvedPath: inside }); + }); + + it("rejects an absolute path outside base", () => { + const result = resolveLocalPath("/some/other/place", base, posix); + expect(result).toEqual({ ok: false, reason: "outside_image_dir" }); + }); + + it("rejects a leading-slash path that would have doubled under the legacy join hack", () => { + // Used to be silently accepted as "/Users/xl/Desktop/figma/public/images". + const result = resolveLocalPath("/Users/xl/Desktop/figma/public/images", base, posix); + expect(result).toEqual({ ok: false, reason: "outside_image_dir" }); + }); + + it("rejects backslash drive-letter paths on POSIX", () => { + const result = resolveLocalPath("C:\\Users\\xl\\Desktop\\figma\\public", base, posix); + expect(result).toEqual({ ok: false, reason: "drive_letter_on_posix" }); + }); + + it("rejects forward-slash drive-letter paths on POSIX", () => { + // path.posix.isAbsolute("C:/Users/...") returns false, so without an + // explicit check this would resolve to "/C:/Users/..." and miswrite. + const result = resolveLocalPath("C:/Users/xl/Desktop/figma/public", base, posix); + expect(result).toEqual({ ok: false, reason: "drive_letter_on_posix" }); + }); + + it("normalizes backslashes in relative paths to forward slashes on POSIX", () => { + // LLMs frequently send Windows-style separators regardless of host OS. + // Drive-letter paths still reject (above) — only pure separator + // mismatches normalize. + const result = resolveLocalPath("local\\nested\\dir", base, posix); + expect(result).toEqual({ ok: true, resolvedPath: "/project/root/local/nested/dir" }); + }); + }); + + describe("with Windows semantics (path.win32)", () => { + const base = "C:\\Users\\xl\\Desktop\\figma"; + const win32 = path.win32; + + it("accepts an absolute Windows path inside base", () => { + const inside = "C:\\Users\\xl\\Desktop\\figma\\public\\figma-assets\\retry-final"; + const result = resolveLocalPath(inside, base, win32); + expect(result).toEqual({ ok: true, resolvedPath: inside }); + }); + + it("rejects an absolute Windows path on a different drive", () => { + const result = resolveLocalPath("D:\\elsewhere\\images", base, win32); + expect(result).toEqual({ ok: false, reason: "outside_image_dir" }); + }); + + // Note on the "LLM stripped the drive letter" scenario from issue #364: + // path.win32.resolve("/Users/...") drive-roots the path on the runtime's + // *current* Windows drive, which doesn't exist on POSIX hosts running + // path.win32. So that specific scenario can't be deterministically + // unit-tested without a Windows runner. The drive-letter-inside-base + // test below covers the primary issue #364 input shape. + + it("accepts a relative path under base", () => { + const result = resolveLocalPath("public\\images", base, win32); + expect(result).toEqual({ + ok: true, + resolvedPath: "C:\\Users\\xl\\Desktop\\figma\\public\\images", + }); + }); + + it("normalizes forward slashes from the LLM into Windows separators", () => { + // LLMs frequently emit forward slashes regardless of host OS. + // path.win32.resolve normalizes them — verify the joined path is + // backslash-canonical and lands inside base. + const result = resolveLocalPath("public/images/icons", base, win32); + expect(result).toEqual({ + ok: true, + resolvedPath: "C:\\Users\\xl\\Desktop\\figma\\public\\images\\icons", + }); + }); + + it("rejects '..' traversal", () => { + const result = resolveLocalPath("..\\..\\Windows\\System32", base, win32); + expect(result).toEqual({ ok: false, reason: "outside_image_dir" }); + }); + + it("does not apply the POSIX backslash rule", () => { + // On Windows backslashes are valid separators, so this is just a + // relative path (assuming it's within base). + const result = resolveLocalPath("public\\nested\\images", base, win32); + expect(result.ok).toBe(true); + }); + }); + + describe("isWithin (containment helper)", () => { + it("treats base itself as within", () => { + expect(isWithin("/project/root", "/project/root", path.posix)).toBe(true); + }); + + it("accepts descendants", () => { + expect(isWithin("/project/root", "/project/root/public/images", path.posix)).toBe(true); + }); + + it("rejects siblings", () => { + expect(isWithin("/project/root", "/project/sibling", path.posix)).toBe(false); + }); + + it("rejects ancestors", () => { + expect(isWithin("/project/root", "/project", path.posix)).toBe(false); + }); + + it("works for Windows drive roots without double-counting separators", () => { + // The previous startsWith(base + sep) check would reject this because + // "C:\\" + "\\" produces "C:\\\\" which doesn't prefix "C:\\public". + expect(isWithin("C:\\", "C:\\public\\images", path.win32)).toBe(true); + }); + }); +}); + +describe("downloadFigmaImage filename validation", () => { + it("rejects fileName with directory traversal", async () => { + const localPath = path.join(process.cwd(), "test-images"); + + await expect( + downloadFigmaImage("../../../etc/evil.png", localPath, "https://example.com/img.png"), + ).rejects.toThrow("File path escapes target directory"); + }); +}); diff --git a/src/tests/rich-text.test.ts b/src/tests/rich-text.test.ts new file mode 100644 index 0000000..9d26d60 --- /dev/null +++ b/src/tests/rich-text.test.ts @@ -0,0 +1,656 @@ +import { describe, expect, it } from "vitest"; +import type { Node as FigmaNode, TypeStyle } from "@figma/rest-api-spec"; +import { extractFromDesign } from "~/extractors/node-walker.js"; +import { allExtractors } from "~/extractors/built-in.js"; +import type { SimplifiedTextStyle } from "~/transformers/text.js"; + +/** + * Minimal Figma TEXT node factory. Tests only need the fields the text + * extractor reads — the full Figma union is deeply discriminated, so we cast + * through `unknown` to avoid inventing thousands of irrelevant fields. + */ +function makeText(opts: { + id?: string; + name?: string; + characters: string; + style?: Partial; + characterStyleOverrides?: number[]; + styleOverrideTable?: Record>; + lineTypes?: Array<"NONE" | "ORDERED" | "UNORDERED">; + lineIndentations?: number[]; +}): FigmaNode { + return { + id: opts.id ?? "text:1", + name: opts.name ?? "Text", + type: "TEXT", + visible: true, + characters: opts.characters, + style: opts.style ?? { fontFamily: "Inter", fontWeight: 400, fontSize: 16 }, + characterStyleOverrides: opts.characterStyleOverrides ?? [], + styleOverrideTable: opts.styleOverrideTable ?? {}, + lineTypes: opts.lineTypes ?? [], + lineIndentations: opts.lineIndentations ?? [], + } as unknown as FigmaNode; +} + +async function extract(nodes: FigmaNode[]) { + return extractFromDesign(nodes, allExtractors); +} + +describe("buildFormattedText — plain text passthrough", () => { + it("emits raw text with no boldWeight when there are no overrides", async () => { + const { nodes, globalVars } = await extract([makeText({ characters: "Hello world" })]); + expect(nodes[0].text).toBe("Hello world"); + expect(nodes[0].boldWeight).toBeUndefined(); + // No ts refs should appear in globalVars when there are no overrides. + expect(Object.keys(globalVars.styles).some((k) => k.startsWith("ts"))).toBe(false); + }); + + it("escapes markdown special chars in plain text", async () => { + const { nodes } = await extract([ + makeText({ characters: "Use *stars* and _underscores_ and [brackets]" }), + ]); + expect(nodes[0].text).toBe("Use \\*stars\\* and \\_underscores\\_ and \\[brackets\\]"); + }); +}); + +describe("buildFormattedText — markdown-expressible overrides", () => { + it("bold override produces **text** and emits boldWeight", async () => { + const { nodes } = await extract([ + makeText({ + // "bold" spans chars 4–8 + characters: "abc bold def", + style: { fontFamily: "Inter", fontWeight: 400, fontSize: 16 }, + characterStyleOverrides: [0, 0, 0, 0, 1, 1, 1, 1], + styleOverrideTable: { "1": { fontWeight: 700 } }, + }), + ]); + expect(nodes[0].text).toBe("abc **bold** def"); + expect(nodes[0].boldWeight).toBe(700); + }); + + it("italic override produces *text*", async () => { + const { nodes } = await extract([ + makeText({ + characters: "a b c", + characterStyleOverrides: [0, 0, 0, 0, 1], + styleOverrideTable: { "1": { italic: true } }, + }), + ]); + expect(nodes[0].text).toBe("a b *c*"); + }); + + it("strikethrough override produces ~~text~~", async () => { + const { nodes } = await extract([ + makeText({ + characters: "ab", + characterStyleOverrides: [1, 1], + styleOverrideTable: { "1": { textDecoration: "STRIKETHROUGH" } }, + }), + ]); + expect(nodes[0].text).toBe("~~ab~~"); + }); + + it("URL hyperlink produces [text](url)", async () => { + const { nodes } = await extract([ + makeText({ + characters: "see link", + characterStyleOverrides: [0, 0, 0, 0, 1, 1, 1, 1], + styleOverrideTable: { + "1": { hyperlink: { type: "URL", url: "https://example.com" } }, + }, + }), + ]); + expect(nodes[0].text).toBe("see [link](https://example.com)"); + }); + + it("combines bold + italic + strike into ~~***text***~~", async () => { + const { nodes } = await extract([ + makeText({ + characters: "wow", + characterStyleOverrides: [1, 1, 1], + styleOverrideTable: { + "1": { fontWeight: 700, italic: true, textDecoration: "STRIKETHROUGH" }, + }, + }), + ]); + expect(nodes[0].text).toBe("~~***wow***~~"); + }); +}); + +describe("buildFormattedText — style-ref overrides", () => { + it("color (fills) override emits a ts ref with a fills delta", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "red", + characterStyleOverrides: [1, 1, 1], + styleOverrideTable: { + "1": { + fills: [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 } } as never], + }, + }, + }), + ]); + expect(nodes[0].text).toMatch(/^\{ts1\}red\{\/ts1\}$/); + const delta = globalVars.styles["ts1"] as SimplifiedTextStyle; + expect(delta.fills).toEqual(["#FF0000"]); + }); + + it("fontSize override emits a ts ref with fontSize delta", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "big", + style: { fontFamily: "Inter", fontWeight: 400, fontSize: 16 }, + characterStyleOverrides: [1, 1, 1], + styleOverrideTable: { "1": { fontSize: 24 } }, + }), + ]); + expect(nodes[0].text).toBe("{ts1}big{/ts1}"); + expect(globalVars.styles["ts1"]).toEqual({ fontSize: 24 }); + }); + + it("mixed bold + color nests style ref outside markdown", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "hot", + characterStyleOverrides: [1, 1, 1], + styleOverrideTable: { + "1": { + fontWeight: 700, + fills: [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 } } as never], + }, + }, + }), + ]); + expect(nodes[0].text).toBe("{ts1}**hot**{/ts1}"); + expect(nodes[0].boldWeight).toBe(700); + // The ts ref carries only fills — the bold lives in markdown, not the ref. + expect(globalVars.styles["ts1"]).toEqual({ fills: ["#FF0000"] }); + }); + + it("NODE-type hyperlink falls through to a style ref", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "ref", + characterStyleOverrides: [1, 1, 1], + styleOverrideTable: { + "1": { hyperlink: { type: "NODE", nodeID: "42:1" } }, + }, + }), + ]); + expect(nodes[0].text).toBe("{ts1}ref{/ts1}"); + expect(globalVars.styles["ts1"]).toEqual({ + hyperlink: { type: "NODE", nodeID: "42:1" }, + }); + }); +}); + +describe("buildFormattedText — run merging and weight detection", () => { + it("merges adjacent runs with identical deltas from different override IDs", async () => { + // Two override entries with visually identical deltas should collapse. + const { nodes, globalVars } = await extract([ + makeText({ + characters: "abcd", + characterStyleOverrides: [1, 1, 2, 2], + styleOverrideTable: { + "1": { fontSize: 24 }, + "2": { fontSize: 24 }, + }, + }), + ]); + expect(nodes[0].text).toBe("{ts1}abcd{/ts1}"); + // Only one ref registered in globalVars — no ts2. + expect(globalVars.styles["ts1"]).toEqual({ fontSize: 24 }); + expect(globalVars.styles["ts2"]).toBeUndefined(); + }); + + it("trailing-zero omission in characterStyleOverrides is handled", async () => { + // Override array shorter than text → trailing chars default to base (0). + const { nodes } = await extract([ + makeText({ + characters: "bold then plain", + characterStyleOverrides: [1, 1, 1, 1], + styleOverrideTable: { "1": { fontWeight: 700 } }, + }), + ]); + expect(nodes[0].text).toBe("**bold** then plain"); + }); + + it("picks the most-frequent heavier weight as boldWeight", async () => { + // 6 chars at weight 800, 3 chars at weight 600 → boldWeight = 800. + // The 600 run also gets `**` but carries an explicit fontWeight in its ref. + const { nodes, globalVars } = await extract([ + makeText({ + characters: "AAAAAA BBB", + characterStyleOverrides: [1, 1, 1, 1, 1, 1, 0, 2, 2, 2], + styleOverrideTable: { + "1": { fontWeight: 800 }, + "2": { fontWeight: 600 }, + }, + }), + ]); + expect(nodes[0].boldWeight).toBe(800); + // "AAAAAA" renders as plain **, "BBB" renders as {ts1}**BBB**{/ts1}. + expect(nodes[0].text).toBe("**AAAAAA** {ts1}**BBB**{/ts1}"); + expect(globalVars.styles["ts1"]).toEqual({ fontWeight: 600 }); + }); + + it("inverse override (lighter than base) becomes a style ref, not markdown", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "ab", + style: { fontFamily: "Inter", fontWeight: 700, fontSize: 16 }, + characterStyleOverrides: [0, 1], + styleOverrideTable: { "1": { fontWeight: 400 } }, + }), + ]); + expect(nodes[0].text).toBe("a{ts1}b{/ts1}"); + expect(nodes[0].boldWeight).toBeUndefined(); + expect(globalVars.styles["ts1"]).toEqual({ fontWeight: 400 }); + }); +}); + +describe("buildFormattedText — cross-node dedup and edge cases", () => { + it("shares a ts ref across different text nodes with the same delta", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + id: "t1", + name: "One", + characters: "ab", + characterStyleOverrides: [1, 1], + styleOverrideTable: { "1": { fontSize: 24 } }, + }), + makeText({ + id: "t2", + name: "Two", + characters: "cd", + characterStyleOverrides: [1, 1], + styleOverrideTable: { "1": { fontSize: 24 } }, + }), + ]); + expect(nodes[0].text).toBe("{ts1}ab{/ts1}"); + expect(nodes[1].text).toBe("{ts1}cd{/ts1}"); + // Only one ts entry registered — deduped via the globalVars style cache. + const tsKeys = Object.keys(globalVars.styles).filter((k) => k.startsWith("ts")); + expect(tsKeys).toEqual(["ts1"]); + }); + + it("drops no-op overrides that match the base style", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "x", + style: { fontFamily: "Inter", fontWeight: 400, fontSize: 16 }, + characterStyleOverrides: [1], + // Override declares fontWeight: 400 — same as base, so it's a no-op. + styleOverrideTable: { "1": { fontWeight: 400 } }, + }), + ]); + expect(nodes[0].text).toBe("x"); + expect(nodes[0].boldWeight).toBeUndefined(); + expect(Object.keys(globalVars.styles).some((k) => k.startsWith("ts"))).toBe(false); + }); + + it("handles an empty text node", async () => { + const { nodes } = await extract([makeText({ characters: "" })]); + // Empty text: no `text` field is set on the result. + expect(nodes[0].text).toBeUndefined(); + expect(nodes[0].boldWeight).toBeUndefined(); + }); +}); + +describe("buildFormattedText — reviewer regression coverage", () => { + it("clears inherited underline when a run switches to strikethrough", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "ab", + style: { + fontFamily: "Inter", + fontWeight: 400, + fontSize: 16, + textDecoration: "UNDERLINE", + }, + characterStyleOverrides: [0, 1], + styleOverrideTable: { "1": { textDecoration: "STRIKETHROUGH" } }, + }), + ]); + expect(nodes[0].text).toBe("a{ts1}~~b~~{/ts1}"); + expect(globalVars.styles["ts1"]).toEqual({ textDecoration: "STRIKETHROUGH" }); + }); + + it("emits an inverse-decoration delta when a run clears the base decoration", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "ab", + style: { + fontFamily: "Inter", + fontWeight: 400, + fontSize: 16, + textDecoration: "UNDERLINE", + }, + characterStyleOverrides: [0, 1], + styleOverrideTable: { "1": { textDecoration: "NONE" } }, + }), + ]); + expect(nodes[0].text).toBe("a{ts1}b{/ts1}"); + expect(globalVars.styles["ts1"]).toEqual({ textDecoration: "NONE" }); + }); + + it("pulls whitespace outside markdown emphasis markers", async () => { + const { nodes } = await extract([ + makeText({ + characters: "a bold ", + characterStyleOverrides: [0, 0, 1, 1, 1, 1, 1], + styleOverrideTable: { "1": { fontWeight: 700 } }, + }), + ]); + // The trailing space lives OUTSIDE the `**` so markdown renders correctly. + expect(nodes[0].text).toBe("a **bold** "); + }); + + it("escapes URL destinations that contain parens or whitespace", async () => { + const { nodes } = await extract([ + makeText({ + characters: "link", + characterStyleOverrides: [1, 1, 1, 1], + styleOverrideTable: { + "1": { hyperlink: { type: "URL", url: "https://a.com/(x)" } }, + }, + }), + ]); + // `(` and `)` are percent-encoded so they don't close the destination. + expect(nodes[0].text).toBe("[link](https://a.com/%28x%29)"); + }); + + it("merges runs whose deltas differ only in key order", async () => { + // Base uses Roboto/16 so both fontFamily AND fontSize overrides survive + // computeDelta's no-op filter and actually end up in the two runs' + // delta objects in different property orders. + const { nodes, globalVars } = await extract([ + makeText({ + characters: "ab", + style: { fontFamily: "Roboto", fontWeight: 400, fontSize: 16 }, + characterStyleOverrides: [1, 2], + styleOverrideTable: { + "1": { fontSize: 24, fontFamily: "Inter" }, + "2": { fontFamily: "Inter", fontSize: 24 }, + }, + }), + ]); + expect(nodes[0].text).toBe("{ts1}ab{/ts1}"); + const tsKeys = Object.keys(globalVars.styles).filter((k) => k.startsWith("ts")); + expect(tsKeys).toEqual(["ts1"]); + }); + + it("keeps inline ts refs in their own namespace even if a base style shares the shape", async () => { + // Node A has a base textStyle with only { fontSize: 24 } — the exact + // shape some inline deltas produce. Node B uses an inline delta with the + // same shape. The inline ref must still be a `ts*` ID, not the base + // style's ID. Without a separate cache namespace the second caller would + // get back the first caller's style_* ID. + const { nodes, globalVars } = await extract([ + makeText({ + id: "t1", + name: "base only", + characters: "x", + style: { fontSize: 24 }, + }), + makeText({ + id: "t2", + name: "with inline", + characters: "ab", + style: { fontFamily: "Inter", fontWeight: 400, fontSize: 16 }, + characterStyleOverrides: [1, 1], + styleOverrideTable: { "1": { fontSize: 24 } }, + }), + ]); + // Base-only node: textStyle is a `style_*` ID (or a named style). + expect(nodes[0].textStyle).toMatch(/^style_/); + // Inline node: text uses a `ts*` ID and its globalVars entry matches. + expect(nodes[1].text).toBe("{ts1}ab{/ts1}"); + expect(globalVars.styles["ts1"]).toEqual({ fontSize: 24 }); + // The two IDs do NOT collide. + expect(nodes[0].textStyle).not.toBe("ts1"); + }); +}); + +describe("buildFormattedText — newline escaping", () => { + it("escapes real newlines as literal backslash-n in plain text", async () => { + const { nodes } = await extract([makeText({ characters: "line one\nline two" })]); + // Literal `\n` (two chars) — prevents YAML block-scalar emission. + expect(nodes[0].text).toBe("line one\\nline two"); + }); + + it("escapes paragraph separator U+2029 the same way", async () => { + const { nodes } = await extract([makeText({ characters: "a\u2029b" })]); + expect(nodes[0].text).toBe("a\\nb"); + }); + + it("escapes newlines in styled runs too", async () => { + const { nodes } = await extract([ + makeText({ + // "bold" chars 0–3, newline char 4, "tail" chars 5–8. + characters: "bold\ntail", + characterStyleOverrides: [1, 1, 1, 1], + styleOverrideTable: { "1": { fontWeight: 700 } }, + }), + ]); + expect(nodes[0].text).toBe("**bold**\\ntail"); + }); +}); + +describe("buildFormattedText — list formatting", () => { + it("produces 1. / 2. / 3. prefixes for ordered lists", async () => { + const { nodes } = await extract([ + makeText({ + characters: "one\ntwo\nthree", + lineTypes: ["ORDERED", "ORDERED", "ORDERED"], + lineIndentations: [0, 0, 0], + }), + ]); + expect(nodes[0].text).toBe("1. one\\n2. two\\n3. three"); + }); + + it("produces - prefixes for unordered lists", async () => { + const { nodes } = await extract([ + makeText({ + characters: "a\nb", + lineTypes: ["UNORDERED", "UNORDERED"], + lineIndentations: [0, 0], + }), + ]); + expect(nodes[0].text).toBe("- a\\n- b"); + }); + + it("nests list levels with 2-space CommonMark indentation", async () => { + const { nodes } = await extract([ + makeText({ + characters: "outer\ninner\nback", + lineTypes: ["ORDERED", "ORDERED", "ORDERED"], + lineIndentations: [0, 1, 0], + }), + ]); + // Outer ordered list continues across the nested level. + expect(nodes[0].text).toBe("1. outer\\n 1. inner\\n2. back"); + }); + + it("resets nested counters when the outer list advances", async () => { + // Verifies that moving back up and then down again restarts the deeper + // counter rather than resuming where it left off. + const { nodes } = await extract([ + makeText({ + characters: "a\nx\ny\nb\nz", + lineTypes: ["ORDERED", "ORDERED", "ORDERED", "ORDERED", "ORDERED"], + lineIndentations: [0, 1, 1, 0, 1], + }), + ]); + expect(nodes[0].text).toBe("1. a\\n 1. x\\n 2. y\\n2. b\\n 1. z"); + }); + + it("preserves inline markdown inside list items", async () => { + const { nodes } = await extract([ + makeText({ + // Chars: "a" "\n" "b" "o" "l" "d" — overrides length 6 (with newline). + characters: "a\nbold", + characterStyleOverrides: [0, 0, 1, 1, 1, 1], + styleOverrideTable: { "1": { fontWeight: 700 } }, + lineTypes: ["UNORDERED", "UNORDERED"], + lineIndentations: [0, 0], + }), + ]); + expect(nodes[0].text).toBe("- a\\n- **bold**"); + expect(nodes[0].boldWeight).toBe(700); + }); + + it("handles mixed ordered and unordered list types", async () => { + const { nodes } = await extract([ + makeText({ + characters: "one\ntwo\nbullet", + lineTypes: ["ORDERED", "ORDERED", "UNORDERED"], + lineIndentations: [0, 0, 0], + }), + ]); + expect(nodes[0].text).toBe("1. one\\n2. two\\n- bullet"); + }); + + it("renders NONE lines between list items as plain paragraphs and resets ordering", async () => { + const { nodes } = await extract([ + makeText({ + characters: "item one\nbreak\nitem two", + lineTypes: ["ORDERED", "NONE", "ORDERED"], + lineIndentations: [0, 0, 0], + }), + ]); + // A non-ORDERED line at the same depth breaks the list — the next + // ORDERED item restarts at 1. + expect(nodes[0].text).toBe("1. item one\\nbreak\\n1. item two"); + }); + + it("preserves empty lines", async () => { + const { nodes } = await extract([ + makeText({ + characters: "a\n\nb", + lineTypes: ["NONE", "NONE", "NONE"], + lineIndentations: [0, 0, 0], + }), + ]); + expect(nodes[0].text).toBe("a\\n\\nb"); + }); + + it("detects boldWeight across all lines of a list", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + // "a" "\n" "big" — "big" is bold 800. + characters: "a\nbig", + characterStyleOverrides: [0, 0, 1, 1, 1], + styleOverrideTable: { "1": { fontWeight: 800 } }, + lineTypes: ["UNORDERED", "UNORDERED"], + lineIndentations: [0, 0], + }), + ]); + expect(nodes[0].text).toBe("- a\\n- **big**"); + expect(nodes[0].boldWeight).toBe(800); + // The bold run matches the canonical boldWeight, so no ts ref is needed. + expect(Object.keys(globalVars.styles).some((k) => k.startsWith("ts"))).toBe(false); + }); +}); + +describe("extractTextStyle — line height", () => { + it("omits lineHeight when the node uses Figma's auto (INTRINSIC_%) mode", async () => { + // Real Figma shape: auto line height still reports a `lineHeightPx` (the + // computed intrinsic value for the current font). Before the fix this + // leaked out as an em string like "1.2102272851126534em". + const { nodes, globalVars } = await extract([ + makeText({ + characters: "auto", + style: { + fontFamily: "Inter", + fontWeight: 400, + fontSize: 14, + lineHeightPx: 16.94318199157715, + lineHeightPercent: 100, + lineHeightUnit: "INTRINSIC_%", + } as never, + }), + ]); + const styleRef = nodes[0].textStyle as string; + const style = globalVars.styles[styleRef] as SimplifiedTextStyle; + expect(style.lineHeight).toBeUndefined(); + }); + + it("emits explicit pixel line heights as px, rounded", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "explicit", + style: { + fontFamily: "Inter", + fontWeight: 400, + fontSize: 14, + lineHeightPx: 16.94318199157715, + lineHeightUnit: "PIXELS", + } as never, + }), + ]); + const styleRef = nodes[0].textStyle as string; + const style = globalVars.styles[styleRef] as SimplifiedTextStyle; + expect(style.lineHeight).toBe("16.94px"); + }); + + it("emits font-size-relative line heights as em (one canonical relative form)", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "pct", + style: { + fontFamily: "Inter", + fontWeight: 400, + fontSize: 14, + lineHeightPx: 21, + lineHeightPercentFontSize: 150, + lineHeightUnit: "FONT_SIZE_%", + } as never, + }), + ]); + const styleRef = nodes[0].textStyle as string; + const style = globalVars.styles[styleRef] as SimplifiedTextStyle; + expect(style.lineHeight).toBe("1.5em"); + }); + + it("emits letterSpacing as em so it pastes straight into CSS", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "tracking", + style: { + fontFamily: "Inter", + fontWeight: 400, + fontSize: 16, + letterSpacing: -0.32, // -0.32px / 16px = -0.02em + } as never, + }), + ]); + const styleRef = nodes[0].textStyle as string; + const style = globalVars.styles[styleRef] as SimplifiedTextStyle; + expect(style.letterSpacing).toBe("-0.02em"); + }); +}); + +describe("extractTextStyle — broadened base style capture", () => { + it("includes italic / textDecoration / hyperlink on a fully-styled text node", async () => { + const { nodes, globalVars } = await extract([ + makeText({ + characters: "fully styled", + style: { + fontFamily: "Inter", + fontWeight: 400, + fontSize: 16, + italic: true, + textDecoration: "UNDERLINE", + hyperlink: { type: "URL", url: "https://framelink.ai" }, + }, + }), + ]); + const styleRef = nodes[0].textStyle as string; + const style = globalVars.styles[styleRef] as SimplifiedTextStyle; + expect(style.italic).toBe(true); + expect(style.textDecoration).toBe("UNDERLINE"); + expect(style.hyperlink).toEqual({ type: "URL", url: "https://framelink.ai" }); + }); +}); diff --git a/src/tests/serialization.test.ts b/src/tests/serialization.test.ts new file mode 100644 index 0000000..cf31e57 --- /dev/null +++ b/src/tests/serialization.test.ts @@ -0,0 +1,306 @@ +import yaml from "js-yaml"; +import { serializeResult } from "~/utils/serialize.js"; +import { wrapForSerialization } from "~/utils/serializable-design.js"; +import type { SimplifiedDesign, SimplifiedNode } from "~/extractors/types.js"; + +describe("result serialization", () => { + describe("YAML format", () => { + it("keeps long strings on a single line", () => { + const longString = "a".repeat(200); + const data = { description: longString }; + + const output = serializeResult(data, "yaml"); + const bare = yaml.dump(data); + + // Bare yaml.dump folds at 80 chars, producing multi-line output + expect(bare.split("\n").length).toBeGreaterThan(2); + // With lineWidth: -1, the value stays on one line (plus trailing newline) + expect(output).toBe(`description: ${longString}\n`); + }); + + it("serializes duplicate references independently instead of using anchors", () => { + const shared = { color: "#ff0000", opacity: 1 }; + const data = { fill: shared, stroke: shared }; + + const output = serializeResult(data, "yaml"); + const bare = yaml.dump(data); + + // Bare yaml.dump detects the shared reference and emits anchors/aliases + expect(bare).toMatch(/&ref_0/); + expect(bare).toMatch(/\*ref_0/); + + // With noRefs: true, each occurrence is serialized independently + expect(output).not.toMatch(/&ref/); + expect(output).not.toMatch(/\*ref/); + // Both occurrences appear fully expanded + const colorMatches = output.match(/color: '#ff0000'/g); + expect(colorMatches).toHaveLength(2); + }); + + it("skips unnecessary quoting for strings ambiguous under default YAML schema", () => { + const data = { answer: "yes", date: "2024-01-01" }; + + const output = serializeResult(data, "yaml"); + const bare = yaml.dump(data); + + // Default schema quotes "yes" and "2024-01-01" to prevent + // boolean/timestamp interpretation on load. + expect(bare).toContain("'yes'"); + expect(bare).toContain("'2024-01-01'"); + + // JSON_SCHEMA only recognizes true/false as booleans and has no + // timestamp type, so these strings don't need protective quoting. + expect(output).not.toContain("'yes'"); + expect(output).not.toContain("'2024-01-01'"); + }); + + it("round-trips through parse without data loss", () => { + const data = { + name: "Frame 1", + width: 320, + visible: true, + children: [{ name: "Text", content: "hello" }], + }; + + const output = serializeResult(data, "yaml"); + const parsed = yaml.load(output); + + expect(parsed).toEqual(data); + }); + }); + + describe("JSON format", () => { + it("pretty-prints with 2-space indentation", () => { + const data = { name: "Frame", width: 100 }; + + const output = serializeResult(data, "json"); + + const lines = output.split("\n"); + // Second line should be indented with exactly 2 spaces + expect(lines[1]).toMatch(/^ {2}"/); + }); + + it("round-trips through parse without data loss", () => { + const data = { + name: "Frame 1", + width: 320, + visible: true, + children: [{ name: "Text", content: "hello" }], + }; + + const output = serializeResult(data, "json"); + const parsed = JSON.parse(output); + + expect(parsed).toEqual(data); + }); + }); + + describe("tree format", () => { + // The production pipeline wraps the SimplifiedDesign before calling + // serializeResult. The tree renderer must read from that wrapped shape; + // a regression here silently broke the production --format=tree path. + it("renders the wrapped design shape produced by getFigmaData", () => { + const design: SimplifiedDesign = { + name: "Test File", + components: {}, + componentSets: {}, + globalVars: { styles: {} }, + elements: {}, + nodes: [ + { + id: "1:1", + name: "Card", + type: "FRAME", + borderRadius: "12px", + }, + ], + }; + + const output = serializeResult(wrapForSerialization(design), "tree"); + + expect(output).toContain('NAME: "Test File"'); + expect(output).toContain("NODES:"); + expect(output).toMatch(/\[FRAME\] "Card" #1:1 borderRadius=12px/); + }); + + // Figma allows free-form component property names like "On Sale". The + // value must serialize as readable JSON — earlier attempts escaped + // whitespace to \uXXXX, which is hostile to the LLM consumer. + it("emits componentProperties as readable JSON even when keys contain whitespace", () => { + const design: SimplifiedDesign = { + name: "Test", + components: {}, + componentSets: {}, + globalVars: { styles: {} }, + elements: {}, + nodes: [ + { + id: "1:1", + name: "Btn", + type: "INSTANCE", + componentId: "abc", + componentProperties: { "On Sale": true, Size: "md" }, + }, + ], + }; + + const output = serializeResult(wrapForSerialization(design), "tree"); + + expect(output).toContain('componentProperties={"On Sale":true,"Size":"md"}'); + }); + + // After count-gating, single-use style values live inline on the node rather + // than as a globalVars ref. The tree renderer must emit them as compact JSON. + it("renders inline (non-reference) style values as JSON", () => { + const design: SimplifiedDesign = { + name: "Test", + components: {}, + componentSets: {}, + globalVars: { styles: {} }, + elements: {}, + nodes: [ + { + id: "1:1", + name: "Box", + type: "FRAME", + fills: ["#FF0000"], + }, + ], + }; + + const output = serializeResult(wrapForSerialization(design), "tree"); + + expect(output).toContain('fills=["#FF0000"]'); + }); + + // Deduplicated nodes carry only id/name/template/children; the type and + // styling live in the ELEMENTS block. The renderer resolves the type label + // from the element so the line keeps its `[TYPE] "name" #id` shape. + it("renders an ELEMENTS block and template-reference nodes", () => { + const design: SimplifiedDesign = { + name: "Test", + components: {}, + componentSets: {}, + globalVars: { styles: { fill_red: ["#FF0000"] } }, + elements: { + "EL-abc12345": { type: "FRAME", fills: "fill_red" }, + }, + nodes: [ + { id: "1:1", name: "Card A", template: "EL-abc12345" }, + { id: "1:2", name: "Card B", template: "EL-abc12345" }, + ], + }; + + const output = serializeResult(wrapForSerialization(design), "tree"); + + expect(output).toContain("ELEMENTS:"); + expect(output).toContain('[FRAME] "Card A" #1:1 template=EL-abc12345'); + expect(output).toContain('[FRAME] "Card B" #1:2 template=EL-abc12345'); + }); + }); + + // Figma auto-names TEXT layers after their content (often leaving stale copies) + // and auto-names shapes after their tool (`Rectangle 12`). Both are noise the + // serialization pass drops across every format. Mirrors framelink-app's + // design-parser, which strips these during parsing. + describe("noise name omission", () => { + function design(node: SimplifiedNode): SimplifiedDesign { + return { + name: "Test", + components: {}, + componentSets: {}, + globalVars: { styles: {} }, + elements: {}, + nodes: [node], + }; + } + + it("drops every TEXT node name, even one that differs from the text", () => { + // Text names are dropped wholesale — a name that disagrees with the content + // is a stale leftover from an edit and is more misleading than helpful. + for (const [name, text] of [ + ["About", "About"], // identical + ["Old heading", "The perfect partner for your plunge"], // stale + ] as const) { + const output = serializeResult( + wrapForSerialization(design({ id: "1:1", name, type: "TEXT", text })), + "tree", + ); + // No name token sits between the `[TEXT]` label and the `#id`. + expect(output).toContain("[TEXT] #1:1"); + expect(output).toContain(`text=${quote(text)}`); + } + }); + + it("drops auto-generated shape names (Word + number)", () => { + for (const name of ["Rectangle 123", "Frame 8372211", "Ellipse 6", "Group 5", "Arrow 2"]) { + const output = serializeResult( + wrapForSerialization(design({ id: "1:1", name, type: "FRAME" })), + "tree", + ); + expect(output).toContain("[FRAME] #1:1"); + expect(output).not.toContain(quote(name)); + } + }); + + it("preserves a bare type word with no number (likely a deliberate name)", () => { + // `Vector`/`Line`/`Star` without a trailing number are just as likely to be + // an intentional designer name, so the number is required to call it noise. + for (const name of ["Vector", "Line", "Star"]) { + const output = serializeResult( + wrapForSerialization(design({ id: "1:1", name, type: "FRAME" })), + "tree", + ); + expect(output).toContain(`[FRAME] ${quote(name)} #1:1`); + } + }); + + it("preserves a meaningful name on a non-text node", () => { + const output = serializeResult( + wrapForSerialization(design({ id: "1:1", name: "Submit Button", type: "FRAME" })), + "tree", + ); + + expect(output).toContain('[FRAME] "Submit Button" #1:1'); + }); + + it("drops a templated TEXT node name by resolving its type from the element", () => { + // A template-ref node carries no `type`; without resolving it from the + // element, a stale templated-text name would leak through. + const wrapped = wrapForSerialization({ + name: "Test", + components: {}, + componentSets: {}, + globalVars: { styles: {} }, + elements: { "EL-abc12345": { type: "TEXT", text: "Buy now" } }, + nodes: [{ id: "1:1", name: "Stale label", template: "EL-abc12345" }], + }); + const output = serializeResult(wrapped, "tree"); + + expect(output).toContain("[TEXT] #1:1 template=EL-abc12345"); + expect(output).not.toContain("Stale label"); + }); + + it("applies the same omission in yaml and json", () => { + for (const format of ["yaml", "json"] as const) { + const dropped = serializeResult( + wrapForSerialization(design({ id: "1:1", name: "About", type: "TEXT", text: "About" })), + format, + ); + // The text-node name key is gone, but the text content remains. + expect(dropped).not.toMatch(/name['"]?:\s*['"]?About/); + expect(dropped).toContain("About"); + + const kept = serializeResult( + wrapForSerialization(design({ id: "1:1", name: "Submit Button", type: "FRAME" })), + format, + ); + expect(kept).toContain("Submit Button"); + } + }); + }); +}); + +function quote(s: string): string { + return JSON.stringify(s); +} diff --git a/src/tests/server.test.ts b/src/tests/server.test.ts new file mode 100644 index 0000000..ccac188 --- /dev/null +++ b/src/tests/server.test.ts @@ -0,0 +1,245 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { startHttpServer, stopHttpServer } from "../server.js"; +import type { AddressInfo } from "net"; +import type { FigmaAuthOptions } from "../services/figma.js"; +import { spawn, type ChildProcess } from "child_process"; + +const dummyAuth: FigmaAuthOptions = { + figmaApiKey: "test-key-not-used", + figmaOAuthToken: "", + useOAuth: false, +}; + +describe("StreamableHTTP transport", () => { + let port: number; + + beforeAll(async () => { + const httpServer = await startHttpServer("127.0.0.1", 0, dummyAuth, {}); + port = (httpServer.address() as AddressInfo).port; + }, 15_000); + + afterAll(async () => { + try { + await stopHttpServer(); + } catch { + // Server may not have started + } + }); + + it("connects, initializes, and lists tools via /mcp", async () => { + const client = new Client({ name: "test-streamable", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); + + await client.connect(transport); + + const { tools } = await client.listTools(); + const toolNames = tools.map((t) => t.name); + + expect(toolNames).toContain("get_figma_data"); + expect(toolNames).toContain("download_figma_images"); + + await client.close(); + }, 15_000); + + it("connects, initializes, and lists tools via /sse (backward compat)", async () => { + const client = new Client({ name: "test-sse-compat", version: "1.0.0" }); + const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/sse`)); + + await client.connect(transport); + + const { tools } = await client.listTools(); + const toolNames = tools.map((t) => t.name); + + expect(toolNames).toContain("get_figma_data"); + expect(toolNames).toContain("download_figma_images"); + + await client.close(); + }, 15_000); + + it("responses contain no mcp-session-id header", async () => { + const res = await fetch(`http://127.0.0.1:${port}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + id: 1, + }), + }); + expect(res.headers.get("mcp-session-id")).toBeNull(); + }, 15_000); +}); + +describe("Method not allowed", () => { + let port: number; + + beforeAll(async () => { + const httpServer = await startHttpServer("127.0.0.1", 0, dummyAuth, {}); + port = (httpServer.address() as AddressInfo).port; + }, 15_000); + + afterAll(async () => { + try { + await stopHttpServer(); + } catch { + // Server may not have started + } + }); + + it("GET /mcp returns 405", async () => { + const res = await fetch(`http://127.0.0.1:${port}/mcp`, { method: "GET" }); + expect(res.status).toBe(405); + }); + + it("DELETE /mcp returns 405", async () => { + const res = await fetch(`http://127.0.0.1:${port}/mcp`, { method: "DELETE" }); + expect(res.status).toBe(405); + }); + + it("GET /sse returns 405", async () => { + const res = await fetch(`http://127.0.0.1:${port}/sse`, { method: "GET" }); + expect(res.status).toBe(405); + }); + + it("DELETE /sse returns 405", async () => { + const res = await fetch(`http://127.0.0.1:${port}/sse`, { method: "DELETE" }); + expect(res.status).toBe(405); + }); +}); + +describe("Multi-client test", () => { + let port: number; + + beforeAll(async () => { + const httpServer = await startHttpServer("127.0.0.1", 0, dummyAuth, {}); + port = (httpServer.address() as AddressInfo).port; + }, 15_000); + + afterAll(async () => { + try { + await stopHttpServer(); + } catch { + // Server may not have started + } + }); + + it("multiple StreamableHTTP clients work concurrently", async () => { + const clientA = new Client({ name: "test-a", version: "1.0.0" }); + const transportA = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`)); + + const clientB = new Client({ name: "test-b", version: "1.0.0" }); + const transportB = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/sse`)); + + await Promise.all([clientA.connect(transportA), clientB.connect(transportB)]); + + const [toolsA, toolsB] = await Promise.all([clientA.listTools(), clientB.listTools()]); + + expect(toolsA.tools.map((t) => t.name)).toContain("get_figma_data"); + expect(toolsB.tools.map((t) => t.name)).toContain("get_figma_data"); + + await Promise.all([clientA.close(), clientB.close()]); + }, 15_000); +}); + +describe("Server lifecycle", () => { + it("starts and listens on assigned port", async () => { + const httpServer = await startHttpServer("127.0.0.1", 0, dummyAuth, {}); + const port = (httpServer.address() as AddressInfo).port; + + expect(port).toBeGreaterThan(0); + + await stopHttpServer(); + }, 15_000); + + it("stopHttpServer shuts down cleanly without hanging", async () => { + await startHttpServer("127.0.0.1", 0, dummyAuth, {}); + + const timeout = new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), 5_000).unref(), + ); + const result = await Promise.race([stopHttpServer().then(() => "stopped" as const), timeout]); + + expect(result).toBe("stopped"); + }, 15_000); +}); + +describe("Process-level HTTP startup", () => { + const TEST_PORT = 19876; + let child: ChildProcess; + + afterEach(() => { + if (child?.pid && !child.killed) { + child.kill("SIGTERM"); + } + }); + + /** Spawn bin.ts and resolve once the server logs that it's listening. */ + function spawnAndWaitForReady(): Promise { + return new Promise((resolve, reject) => { + child = spawn("tsx", ["src/bin.ts", `--figma-api-key=test-key`, `--port=${TEST_PORT}`], { + stdio: ["pipe", "pipe", "pipe"], + }); + + const timeout = setTimeout(() => { + reject(new Error("Server did not become ready within 15 seconds")); + }, 15_000); + + // Logger.isHTTP is not set until the first request, so startup logs + // go to stderr. The config block logs to stdout via console.log. + // Watch both streams to catch the "listening" message regardless. + const onData = (chunk: Buffer) => { + if (chunk.toString().includes(`HTTP server listening on port ${TEST_PORT}`)) { + clearTimeout(timeout); + resolve(); + } + }; + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + + child.on("error", (err) => { + clearTimeout(timeout); + reject(err); + }); + + child.on("exit", (code) => { + clearTimeout(timeout); + reject(new Error(`Process exited unexpectedly with code ${code}`)); + }); + }); + } + + it("starts HTTP server and accepts MCP initialize request", async () => { + await spawnAndWaitForReady(); + + const res = await fetch(`http://127.0.0.1:${TEST_PORT}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test", version: "1.0.0" }, + }, + id: 1, + }), + }); + + expect(res.ok).toBe(true); + const text = await res.text(); + expect(text).toContain("serverInfo"); + }, 30_000); +}); diff --git a/src/tests/stdio.test.ts b/src/tests/stdio.test.ts new file mode 100644 index 0000000..c4e6362 --- /dev/null +++ b/src/tests/stdio.test.ts @@ -0,0 +1,48 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +describe("stdio transport", () => { + let client: Client; + let transport: StdioClientTransport; + + afterEach(async () => { + try { + await client?.close(); + } catch { + // Best-effort cleanup + } + }); + + it("starts, completes MCP handshake, and lists tools", async () => { + transport = new StdioClientTransport({ + command: "tsx", + args: ["src/bin.ts", "--stdio", "--figma-api-key=test-key"], + }); + client = new Client({ name: "stdio-test", version: "1.0.0" }); + + await client.connect(transport); + + const { tools } = await client.listTools(); + const toolNames = tools.map((t) => t.name); + + expect(toolNames).toContain("get_figma_data"); + expect(toolNames).toContain("download_figma_images"); + }, 30_000); + + it("starts stdio mode via NODE_ENV=cli", async () => { + transport = new StdioClientTransport({ + command: "tsx", + args: ["src/bin.ts", "--figma-api-key=test-key"], + env: { ...process.env, NODE_ENV: "cli" }, + }); + client = new Client({ name: "stdio-env-test", version: "1.0.0" }); + + await client.connect(transport); + + const { tools } = await client.listTools(); + const toolNames = tools.map((t) => t.name); + + expect(toolNames).toContain("get_figma_data"); + expect(toolNames).toContain("download_figma_images"); + }, 30_000); +}); diff --git a/src/tests/telemetry-redaction.test.ts b/src/tests/telemetry-redaction.test.ts new file mode 100644 index 0000000..60e8c2e --- /dev/null +++ b/src/tests/telemetry-redaction.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock posthog-node so we can observe what the telemetry client sends without +// hitting the network. We're testing OUR code (withRequestSecrets, ALS +// propagation, redactErrorMessage merge logic) end-to-end — only the system +// boundary is mocked. +const captureSpy = vi.fn(); +const shutdownSpy = vi.fn(async () => {}); +vi.mock("posthog-node", () => ({ + PostHog: class { + capture = captureSpy; + shutdown = shutdownSpy; + }, +})); + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { Server } from "http"; +import type { AddressInfo } from "net"; +import { startHttpServer, stopHttpServer } from "~/server.js"; +import { initTelemetry, shutdown as shutdownTelemetry } from "~/telemetry/index.js"; + +const PER_REQUEST_KEY = "figd_TENANT_SECRET_xyz789"; + +describe("per-request telemetry redaction", () => { + let client: Client; + let httpServer: Server | undefined; + + beforeEach(() => { + captureSpy.mockClear(); + // Init with NO global redaction secrets so the assertion proves the + // per-request AsyncLocalStorage path is what's doing the scrubbing. + initTelemetry({ optOut: false, immediateFlush: true, redactFromErrors: [] }); + + // Stub fetch to fail with the per-request token embedded in the error + // message. FigmaService wraps the original message into a new Error, so + // the secret survives into `outcome.error.message` and reaches captureEvent. + const realFetch = globalThis.fetch; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).startsWith("https://api.figma.com")) { + throw new Error(`upstream failure (token=${PER_REQUEST_KEY})`); + } + return realFetch(input, init); + }), + ); + }); + + afterEach(async () => { + await client?.close(); + if (httpServer) { + await stopHttpServer(); + httpServer = undefined; + } + await shutdownTelemetry(); + vi.unstubAllGlobals(); + }); + + it("scrubs per-request X-Figma-Token from telemetry error_message", async () => { + httpServer = await startHttpServer( + "127.0.0.1", + 0, + { figmaApiKey: "", figmaOAuthToken: "", useOAuth: false }, + {}, + ); + const port = (httpServer.address() as AddressInfo).port; + + client = new Client({ name: "redaction-test", version: "1.0.0" }); + await client.connect( + new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${port}/mcp`), { + requestInit: { headers: { "X-Figma-Token": PER_REQUEST_KEY } }, + }), + ); + + const result = await client.request( + { + method: "tools/call", + params: { name: "get_figma_data", arguments: { fileKey: "abc123" } }, + }, + CallToolResultSchema, + ); + // Sanity: the tool call should fail (fetch threw), so we know the error + // path actually fired. + expect(result.isError).toBe(true); + + const errorEvents = captureSpy.mock.calls + .map(([args]) => args) + .filter((args) => args?.properties?.is_error === true); + expect(errorEvents.length).toBeGreaterThan(0); + + for (const event of errorEvents) { + const message = String(event.properties.error_message ?? ""); + expect(message, `event ${event.event} leaked the per-request token`).not.toContain( + PER_REQUEST_KEY, + ); + expect(message).toContain("[REDACTED]"); + } + }); +}); diff --git a/src/tests/tree-walker.test.ts b/src/tests/tree-walker.test.ts new file mode 100644 index 0000000..adfb405 --- /dev/null +++ b/src/tests/tree-walker.test.ts @@ -0,0 +1,751 @@ +import { describe, expect, it } from "vitest"; +import { extractFromDesign } from "~/extractors/node-walker.js"; +import { allExtractors, collapseSvgContainers } from "~/extractors/built-in.js"; +import { simplifyRawFigmaObject } from "~/extractors/design-extractor.js"; +import type { GetFileResponse, Style } from "@figma/rest-api-spec"; +import type { Node as FigmaNode } from "@figma/rest-api-spec"; + +// Minimal Figma node factory — only the fields the walker actually reads. +// The Figma types are deeply discriminated unions; we cast through unknown +// because tests only need the subset of fields the walker touches. +function makeNode(overrides: Record): FigmaNode { + return { visible: true, ...overrides } as unknown as FigmaNode; +} + +// A small but representative node tree: +// Page +// ├── Frame "Header" (visible) +// │ ├── Text "Title" +// │ └── Rectangle "Bg" (invisible) +// ├── Frame "Body" +// │ └── Frame "Card" +// │ └── Text "Label" +// └── Vector "Icon" (becomes IMAGE-SVG) +const fixtureNodes: FigmaNode[] = [ + makeNode({ + id: "1:1", + name: "Header", + type: "FRAME", + children: [ + makeNode({ id: "1:2", name: "Title", type: "TEXT", characters: "Hello" }), + makeNode({ id: "1:3", name: "Bg", type: "RECTANGLE", visible: false }), + ], + }), + makeNode({ + id: "2:1", + name: "Body", + type: "FRAME", + children: [ + makeNode({ + id: "2:2", + name: "Card", + type: "FRAME", + children: [makeNode({ id: "2:3", name: "Label", type: "TEXT", characters: "World" })], + }), + ], + }), + makeNode({ id: "3:1", name: "Icon", type: "VECTOR" }), +]; + +describe("extractFromDesign", () => { + it("produces correct node structure from a nested tree", async () => { + const { nodes } = await extractFromDesign(fixtureNodes, allExtractors); + + // Top-level: Header, Body, Icon (3 nodes — Bg is invisible, filtered out) + expect(nodes).toHaveLength(3); + expect(nodes.map((n) => n.name)).toEqual(["Header", "Body", "Icon"]); + + // Header has 1 child (Title only — Bg is invisible) + const header = nodes[0]; + expect(header.children).toHaveLength(1); + expect(header.children![0].name).toBe("Title"); + expect(header.children![0].text).toBe("Hello"); + + // Body > Card > Label + const body = nodes[1]; + expect(body.children).toHaveLength(1); + expect(body.children![0].name).toBe("Card"); + expect(body.children![0].children).toHaveLength(1); + expect(body.children![0].children![0].name).toBe("Label"); + expect(body.children![0].children![0].text).toBe("World"); + + // Vector becomes IMAGE-SVG + const icon = nodes[2]; + expect(icon.type).toBe("IMAGE-SVG"); + expect(icon.children).toBeUndefined(); + }); + + it("respects maxDepth option", async () => { + const { nodes } = await extractFromDesign(fixtureNodes, allExtractors, { maxDepth: 1 }); + + // At depth 0 we get top-level nodes, depth 1 gets their direct children, no deeper + const header = nodes.find((n) => n.name === "Header")!; + expect(header.children).toHaveLength(1); + expect(header.children![0].name).toBe("Title"); + + // Body's child "Card" is at depth 1 — it should exist but have no children + const body = nodes.find((n) => n.name === "Body")!; + expect(body.children).toHaveLength(1); + expect(body.children![0].name).toBe("Card"); + expect(body.children![0].children).toBeUndefined(); + }); + + it("accumulates global style variables across nodes", async () => { + const styledNode = makeNode({ + id: "4:1", + name: "Styled", + type: "FRAME", + fills: [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 }, visible: true }], + }); + + const { globalVars } = await extractFromDesign([styledNode], allExtractors); + + // The fill should be extracted into a global variable + expect(Object.keys(globalVars.styles).length).toBeGreaterThan(0); + }); + + it("deduplicates identical styles across nodes into a single global variable", async () => { + const sharedFill = [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 }, visible: true }]; + + const nodeA = makeNode({ id: "5:1", name: "A", type: "FRAME", fills: sharedFill }); + const nodeB = makeNode({ id: "5:2", name: "B", type: "FRAME", fills: sharedFill }); + + const { nodes, globalVars } = await extractFromDesign([nodeA, nodeB], allExtractors); + + // Both nodes should reference the same fill variable + expect(nodes[0].fills).toBeDefined(); + expect(nodes[0].fills).toBe(nodes[1].fills); + + // Only one fill entry should exist in globalVars + const fillEntries = Object.entries(globalVars.styles).filter(([key]) => key.startsWith("fill")); + expect(fillEntries).toHaveLength(1); + }); + + it("deduplicates identical colors used as both fill and stroke", async () => { + const sharedColor = [{ type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 }, visible: true }]; + + // Stroke node first — if strokes used a different prefix, the var would + // be named stroke_* and the fill would reuse it under the wrong prefix. + const strokeNode = makeNode({ + id: "8:1", + name: "A", + type: "FRAME", + strokes: sharedColor, + strokeWeight: 1, + }); + const fillNode = makeNode({ id: "8:2", name: "B", type: "FRAME", fills: sharedColor }); + + const { nodes, globalVars } = await extractFromDesign([strokeNode, fillNode], allExtractors); + + expect(nodes[0].strokes).toBeDefined(); + expect(nodes[1].fills).toBeDefined(); + expect(nodes[0].strokes).toBe(nodes[1].fills); + + // The shared var should use the fill prefix since stroke colors are + // structurally identical to fill colors in Figma (both are FILL-type styles). + const colorEntries = Object.entries(globalVars.styles).filter( + ([, value]) => JSON.stringify(value) === JSON.stringify(["#FF0000"]), + ); + expect(colorEntries).toHaveLength(1); + expect(colorEntries[0][0]).toMatch(/^fill_/); + }); + + it("preserves non-default stroke alignment on the simplified node", async () => { + const node = makeNode({ + id: "9:1", + name: "Card", + type: "FRAME", + strokes: [{ type: "SOLID", color: { r: 0.89, g: 0.9, b: 0.9, a: 1 }, visible: true }], + strokeWeight: 2, + strokeAlign: "OUTSIDE", + }); + + const { nodes } = await extractFromDesign([node], allExtractors); + + expect(nodes[0].strokeAlign).toBe("OUTSIDE"); + expect(nodes[0].strokeWeight).toBe("2px"); + }); + + it("omits INSIDE stroke alignment, the CSS-border default consumers assume", async () => { + const node = makeNode({ + id: "9:2", + name: "Card", + type: "FRAME", + strokes: [{ type: "SOLID", color: { r: 0.89, g: 0.9, b: 0.9, a: 1 }, visible: true }], + strokeWeight: 2, + strokeAlign: "INSIDE", + }); + + const { nodes } = await extractFromDesign([node], allExtractors); + + expect(nodes[0].strokeAlign).toBeUndefined(); + expect(nodes[0].strokeWeight).toBe("2px"); + }); + + it("disambiguates named styles when style names collide", async () => { + const nodeA = makeNode({ + id: "7:1", + name: "Text A", + type: "TEXT", + characters: "Hello", + style: { fontFamily: "Inter", fontWeight: 400, fontSize: 12 }, + styles: { text: "13:77" }, + }); + + const nodeB = makeNode({ + id: "7:2", + name: "Text B", + type: "TEXT", + characters: "World", + style: { fontFamily: "Inter", fontWeight: 600, fontSize: 14 }, + styles: { text: "161:300" }, + }); + + const extraStyles: Record = { + "13:77": { name: "Heading / Large" } as Style, + "161:300": { name: "Heading / Large" } as Style, + }; + + const { nodes, globalVars: resultVars } = await extractFromDesign( + [nodeA, nodeB], + allExtractors, + {}, + { styles: {} }, + extraStyles, + ); + + expect(nodes[0].textStyle).toBe("Heading / Large"); + expect(nodes[1].textStyle).toBe("Heading / Large (161:300)"); + + const styleKeys = Object.keys(resultVars.styles).filter((key) => + key.startsWith("Heading / Large"), + ); + expect(styleKeys).toHaveLength(2); + }); +}); + +describe("fill flattening", () => { + // Resolve a node's registered fills var back to its concrete value. + type Extracted = Awaited>; + function fillsValue(nodes: Extracted["nodes"], globalVars: Extracted["globalVars"]) { + return globalVars.styles[nodes[0].fills as string]; + } + + // Figma orders the fills array bottom-first, so index 0 is the backdrop and + // the last entry is the topmost layer. + it("composites an all-solid stack into a single resolved color", async () => { + const node = makeNode({ + id: "f:1", + name: "Swatch", + type: "FRAME", + fills: [ + { type: "SOLID", color: { r: 1, g: 1, b: 1, a: 1 }, visible: true }, // white backdrop + { type: "SOLID", color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 0.2, visible: true }, // black @ 20% + ], + }); + + const { nodes, globalVars } = await extractFromDesign([node], allExtractors); + + expect(fillsValue(nodes, globalVars)).toEqual(["#CCCCCC"]); + }); + + it("culls layers fully occluded by an opaque paint above them", async () => { + const node = makeNode({ + id: "f:2", + name: "Swatch", + type: "FRAME", + fills: [ + { type: "SOLID", color: { r: 0, g: 0, b: 1, a: 1 }, visible: true }, // blue backdrop + { type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 }, visible: true }, // opaque red on top + ], + }); + + const { nodes, globalVars } = await extractFromDesign([node], allExtractors); + + // Only the opaque top color survives; the blue beneath contributes nothing. + expect(fillsValue(nodes, globalVars)).toEqual(["#FF0000"]); + }); + + it("folds both color.a and paint.opacity into the effective alpha", async () => { + const node = makeNode({ + id: "f:6", + name: "Swatch", + type: "FRAME", + fills: [ + { type: "SOLID", color: { r: 1, g: 1, b: 1, a: 1 }, visible: true }, // white backdrop + // black at color.a 0.5 × opacity 0.5 = 0.25 effective → 0.75 of white shows through + { type: "SOLID", color: { r: 0, g: 0, b: 0, a: 0.5 }, opacity: 0.5, visible: true }, + ], + }); + + const { nodes, globalVars } = await extractFromDesign([node], allExtractors); + + expect(fillsValue(nodes, globalVars)).toEqual(["#BFBFBF"]); + }); + + it("culls everything below a fully-opaque mid-stack paint, compositing only what's above", async () => { + const node = makeNode({ + id: "f:7", + name: "Swatch", + type: "FRAME", + fills: [ + { type: "SOLID", color: { r: 1, g: 0, b: 0, a: 1 }, visible: true }, // red (culled) + { type: "SOLID", color: { r: 0, g: 1, b: 0, a: 1 }, visible: true }, // opaque green + { type: "SOLID", color: { r: 0, g: 0, b: 1, a: 1 }, opacity: 0.5, visible: true }, // blue @ 50% on top + ], + }); + + const { nodes, globalVars } = await extractFromDesign([node], allExtractors); + + // Red contributes nothing (opaque green above it); blue@50% blends over green → teal. + expect(fillsValue(nodes, globalVars)).toEqual(["#008080"]); + }); + + it("treats PASS_THROUGH blend as flattenable", async () => { + const node = makeNode({ + id: "f:8", + name: "Swatch", + type: "FRAME", + fills: [ + { + type: "SOLID", + color: { r: 1, g: 1, b: 1, a: 1 }, + blendMode: "PASS_THROUGH", + visible: true, + }, + { + type: "SOLID", + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 0.2, + blendMode: "PASS_THROUGH", + visible: true, + }, + ], + }); + + const { nodes, globalVars } = await extractFromDesign([node], allExtractors); + + expect(fillsValue(nodes, globalVars)).toEqual(["#CCCCCC"]); + }); + + it("emits rgba() when the composited stack is still translucent", async () => { + const node = makeNode({ + id: "f:3", + name: "Swatch", + type: "FRAME", + fills: [ + { type: "SOLID", color: { r: 1, g: 1, b: 1, a: 0.5 }, visible: true }, + { type: "SOLID", color: { r: 0, g: 0, b: 0, a: 0.5 }, visible: true }, + ], + }); + + const { nodes, globalVars } = await extractFromDesign([node], allExtractors); + + expect(fillsValue(nodes, globalVars)).toEqual(["rgba(85, 85, 85, 0.75)"]); + }); + + it("leaves a stack untouched when it contains a gradient", async () => { + const node = makeNode({ + id: "f:4", + name: "Swatch", + type: "FRAME", + fills: [ + { + type: "GRADIENT_LINEAR", + visible: true, + gradientHandlePositions: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + { x: 0, y: 1 }, + ], + gradientStops: [ + { position: 0, color: { r: 1, g: 0, b: 0, a: 1 } }, + { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }, + ], + }, + { type: "SOLID", color: { r: 0, g: 0, b: 0, a: 1 }, opacity: 0.2, visible: true }, + ], + }); + + const { nodes, globalVars } = await extractFromDesign([node], allExtractors); + + // Both layers survive, reversed into CSS top-first order: solid first, gradient last. + const value = fillsValue(nodes, globalVars) as unknown[]; + expect(value).toHaveLength(2); + expect(value[0]).toBe("rgba(0, 0, 0, 0.2)"); + expect((value[1] as { type: string }).type).toBe("GRADIENT_LINEAR"); + }); + + it("leaves a stack untouched when any solid uses a non-normal blend mode", async () => { + const node = makeNode({ + id: "f:5", + name: "Swatch", + type: "FRAME", + fills: [ + { type: "SOLID", color: { r: 1, g: 1, b: 1, a: 1 }, visible: true }, + { + type: "SOLID", + color: { r: 0, g: 0, b: 0, a: 0.2 }, + blendMode: "MULTIPLY", + visible: true, + }, + ], + }); + + const { nodes, globalVars } = await extractFromDesign([node], allExtractors); + + expect(fillsValue(nodes, globalVars)).toHaveLength(2); + }); +}); + +describe("collapseSvgContainers", () => { + it("collapses BOOLEAN_OPERATION nodes to IMAGE-SVG", async () => { + const booleanOpNode = makeNode({ + id: "5:1", + name: "Combined Shape", + type: "BOOLEAN_OPERATION", + booleanOperation: "UNION", + children: [ + makeNode({ id: "5:2", name: "Circle", type: "ELLIPSE" }), + makeNode({ id: "5:3", name: "Square", type: "RECTANGLE" }), + ], + }); + + const { nodes } = await extractFromDesign([booleanOpNode], allExtractors, { + afterChildren: collapseSvgContainers, + }); + + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("IMAGE-SVG"); + expect(nodes[0].children).toBeUndefined(); + }); + + it("collapses a frame containing a BOOLEAN_OPERATION to IMAGE-SVG", async () => { + const frameWithBoolOp = makeNode({ + id: "6:1", + name: "Icon Frame", + type: "FRAME", + children: [ + makeNode({ + id: "6:2", + name: "Union", + type: "BOOLEAN_OPERATION", + booleanOperation: "UNION", + children: [ + makeNode({ id: "6:3", name: "A", type: "RECTANGLE" }), + makeNode({ id: "6:4", name: "B", type: "ELLIPSE" }), + ], + }), + ], + }); + + const { nodes } = await extractFromDesign([frameWithBoolOp], allExtractors, { + afterChildren: collapseSvgContainers, + }); + + // The BOOLEAN_OPERATION collapses to IMAGE-SVG first (bottom-up), + // then the FRAME sees all children are SVG-eligible and collapses too. + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("IMAGE-SVG"); + expect(nodes[0].children).toBeUndefined(); + }); + + // Auto-layout signals authored structure — the spacing between children is + // intentional, so we should preserve the container even when its children + // are all SVG-eligible (e.g., bar charts, button rows, layout test frames). + it("does not collapse an auto-layout frame whose children are all SVG-eligible", async () => { + const autoLayoutRow = makeNode({ + id: "7:1", + name: "Bar Chart", + type: "FRAME", + clipsContent: false, + layoutMode: "HORIZONTAL", + itemSpacing: 8, + children: [ + makeNode({ id: "7:2", name: "Bar 1", type: "RECTANGLE" }), + makeNode({ id: "7:3", name: "Bar 2", type: "RECTANGLE" }), + makeNode({ id: "7:4", name: "Bar 3", type: "RECTANGLE" }), + ], + }); + + const { nodes } = await extractFromDesign([autoLayoutRow], allExtractors, { + afterChildren: collapseSvgContainers, + }); + + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("FRAME"); + expect(nodes[0].children).toHaveLength(3); + }); + + // Escape hatch for decorative patterns: enough leaf primitives that the + // payload cost outweighs the structural value (e.g., dotted backgrounds + // built from grids of ellipses). + it("collapses an auto-layout frame with many SVG-eligible children", async () => { + const dotRow = makeNode({ + id: "8:1", + name: "Dot Row", + type: "FRAME", + clipsContent: false, + layoutMode: "HORIZONTAL", + itemSpacing: 4, + children: Array.from({ length: 20 }, (_, i) => + makeNode({ id: `8:${i + 2}`, name: `Dot ${i}`, type: "ELLIPSE" }), + ), + }); + + const { nodes } = await extractFromDesign([dotRow], allExtractors, { + afterChildren: collapseSvgContainers, + }); + + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("IMAGE-SVG"); + expect(nodes[0].children).toBeUndefined(); + }); + + // Non-auto-layout container with shape children is the original target case: + // hand-drawn icons made of vector primitives. Must keep collapsing. + it("still collapses a non-auto-layout frame whose children are all SVG-eligible", async () => { + const iconFrame = makeNode({ + id: "9:1", + name: "Icon", + type: "FRAME", + clipsContent: false, + children: [ + makeNode({ id: "9:2", name: "Circle", type: "ELLIPSE" }), + makeNode({ id: "9:3", name: "Rect", type: "RECTANGLE" }), + ], + }); + + const { nodes } = await extractFromDesign([iconFrame], allExtractors, { + afterChildren: collapseSvgContainers, + }); + + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("IMAGE-SVG"); + expect(nodes[0].children).toBeUndefined(); + }); +}); + +describe("component property support", () => { + it("rescues hidden nodes with componentPropertyReferences.visible inside components", async () => { + const componentNode = makeNode({ + id: "10:1", + name: "Card", + type: "COMPONENT", + children: [ + makeNode({ id: "10:2", name: "Title", type: "TEXT", characters: "Card Title" }), + makeNode({ + id: "10:3", + name: "Badge", + type: "FRAME", + visible: false, + componentPropertyReferences: { visible: "Show Badge#341:0" }, + children: [makeNode({ id: "10:4", name: "Badge Text", type: "TEXT", characters: "NEW" })], + }), + ], + }); + + const { nodes } = await extractFromDesign([componentNode], allExtractors); + + const card = nodes[0]; + expect(card.children).toHaveLength(2); + + const badge = card.children!.find((c) => c.name === "Badge")!; + expect(badge).toBeDefined(); + expect(badge.componentPropertyReferences).toEqual({ visible: "Show Badge" }); + }); + + it("strips hidden nodes normally inside instances", async () => { + const instanceNode = makeNode({ + id: "11:1", + name: "Card Instance", + type: "INSTANCE", + componentId: "10:1", + componentProperties: { + "Show Badge": { type: "BOOLEAN", value: false }, + }, + children: [ + makeNode({ id: "11:2", name: "Title", type: "TEXT", characters: "My Card" }), + makeNode({ id: "11:3", name: "Badge", type: "FRAME", visible: false }), + ], + }); + + const { nodes } = await extractFromDesign([instanceNode], allExtractors); + + const instance = nodes[0]; + expect(instance.children).toHaveLength(1); + expect(instance.children![0].name).toBe("Title"); + }); + + it("collects componentPropertyDefinitions during traversal", async () => { + const componentNode = makeNode({ + id: "12:1", + name: "Product Card", + type: "COMPONENT", + componentPropertyDefinitions: { + "On Sale#341:0": { type: "BOOLEAN", defaultValue: true }, + "Title#341:1": { type: "TEXT", defaultValue: "Product Name" }, + "Icon#341:2": { type: "INSTANCE_SWAP", defaultValue: "999:1" }, + }, + children: [makeNode({ id: "12:2", name: "Title", type: "TEXT", characters: "Product Name" })], + }); + + const { traversalState } = await extractFromDesign([componentNode], allExtractors); + + expect(traversalState.componentPropertyDefinitions["12:1"]).toEqual({ + "On Sale": { type: "boolean", defaultValue: true }, + Title: { type: "text", defaultValue: "Product Name" }, + }); + expect(traversalState.componentPropertyDefinitions["12:1"]).not.toHaveProperty("Icon"); + }); + + it("annotates componentPropertyReferences with characters→text rename", async () => { + const componentNode = makeNode({ + id: "13:1", + name: "Button", + type: "COMPONENT", + children: [ + makeNode({ + id: "13:2", + name: "Label", + type: "TEXT", + characters: "Click me", + componentPropertyReferences: { characters: "Button Label#100:0" }, + }), + ], + }); + + const { nodes } = await extractFromDesign([componentNode], allExtractors); + + const label = nodes[0].children![0]; + expect(label.componentPropertyReferences).toEqual({ text: "Button Label" }); + }); + + it("simplifies instance componentProperties to Record format", async () => { + const instanceNode = makeNode({ + id: "14:1", + name: "Card Instance", + type: "INSTANCE", + componentId: "10:1", + componentProperties: { + "On Sale": { type: "BOOLEAN", value: true }, + Title: { type: "TEXT", value: "My Product" }, + }, + children: [makeNode({ id: "14:2", name: "Content", type: "FRAME" })], + }); + + const { nodes } = await extractFromDesign([instanceNode], allExtractors); + + expect(nodes[0].componentProperties).toEqual({ + "On Sale": true, + Title: "My Product", + }); + }); + + it("strips hidden children inside nested instances within components", async () => { + const componentNode = makeNode({ + id: "15:1", + name: "Wrapper", + type: "COMPONENT", + children: [ + makeNode({ + id: "15:2", + name: "Nested Instance", + type: "INSTANCE", + componentId: "99:1", + children: [ + makeNode({ id: "15:3", name: "Visible Child", type: "FRAME" }), + makeNode({ id: "15:4", name: "Hidden Child", type: "FRAME", visible: false }), + ], + }), + ], + }); + + const { nodes } = await extractFromDesign([componentNode], allExtractors); + + const nestedInstance = nodes[0].children![0]; + expect(nestedInstance).toBeDefined(); + expect(nestedInstance.name).toBe("Nested Instance"); + expect(nestedInstance.children).toHaveLength(1); + expect(nestedInstance.children![0].name).toBe("Visible Child"); + }); +}); + +describe("simplifyRawFigmaObject", () => { + it("produces a complete SimplifiedDesign from a mock API response", async () => { + const mockResponse = { + name: "Test File", + document: { + id: "0:0", + name: "Document", + type: "DOCUMENT", + children: fixtureNodes, + visible: true, + }, + components: {}, + componentSets: {}, + styles: {}, + schemaVersion: 0, + version: "1", + role: "owner", + lastModified: "2024-01-01", + thumbnailUrl: "", + editorType: "figma", + } as unknown as GetFileResponse; + + const result = await simplifyRawFigmaObject(mockResponse, allExtractors, { + afterChildren: collapseSvgContainers, + }); + + expect(result.name).toBe("Test File"); + expect(result.nodes).toHaveLength(3); + expect(result.nodes.map((n) => n.name)).toEqual(["Header", "Body", "Icon"]); + + // Verify full depth traversal happened + const label = result.nodes[1].children![0].children![0]; + expect(label.name).toBe("Label"); + expect(label.text).toBe("World"); + }); + + it("flows property definitions from tree traversal into component metadata", async () => { + const componentNode = makeNode({ + id: "20:1", + name: "Product Card", + type: "COMPONENT", + componentPropertyDefinitions: { + "On Sale#341:0": { type: "BOOLEAN", defaultValue: true }, + "Title#341:1": { type: "TEXT", defaultValue: "Product Name" }, + }, + children: [makeNode({ id: "20:2", name: "Content", type: "FRAME" })], + }); + + const mockResponse = { + name: "Test File", + document: { + id: "0:0", + name: "Document", + type: "DOCUMENT", + children: [componentNode], + visible: true, + }, + components: { + "20:1": { key: "abc123", name: "Product Card", componentSetId: undefined }, + }, + componentSets: {}, + styles: {}, + schemaVersion: 0, + version: "1", + role: "owner", + lastModified: "2024-01-01", + thumbnailUrl: "", + editorType: "figma", + } as unknown as GetFileResponse; + + const result = await simplifyRawFigmaObject(mockResponse, allExtractors); + + expect(result.components["20:1"].propertyDefinitions).toEqual({ + "On Sale": { type: "boolean", defaultValue: true }, + Title: { type: "text", defaultValue: "Product Name" }, + }); + }); +}); diff --git a/src/tests/validation-reject.test.ts b/src/tests/validation-reject.test.ts new file mode 100644 index 0000000..e2dae15 --- /dev/null +++ b/src/tests/validation-reject.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/telemetry/index.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + captureValidationReject: vi.fn(), + captureGetFigmaDataCall: vi.fn(), + captureDownloadImagesCall: vi.fn(), + }; +}); + +import { createServer } from "~/mcp/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import * as telemetry from "~/telemetry/index.js"; + +/** + * These tests cover the McpServer.validateToolInput monkey patch in + * src/mcp/index.ts. They use a real Client/Server pair over InMemoryTransport + * so the SDK validation path runs end-to-end — anything less wouldn't catch + * SDK upgrades that change the validation signature. + */ +describe("validation reject capture (monkey patch)", () => { + let client: Client; + let server: McpServer; + + beforeEach(async () => { + vi.mocked(telemetry.captureValidationReject).mockClear(); + + server = createServer( + { figmaApiKey: "test-key", figmaOAuthToken: "", useOAuth: false }, + { transport: "stdio" }, + ); + client = new Client({ name: "validation-test-client", version: "1.0.0" }); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientT), server.connect(serverT)]); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + }); + + it("captures structured field/rule when get_figma_data fileKey fails regex", async () => { + // McpServer catches the validation McpError and turns it into a tool + // result with isError=true (rather than rejecting the JSON-RPC request). + // Our monkey patch fires before that conversion. + const result = await client.request( + { + method: "tools/call", + params: { + name: "get_figma_data", + arguments: { fileKey: "invalid-key!" }, + }, + }, + CallToolResultSchema, + ); + expect(result.isError).toBe(true); + + const captureSpy = vi.mocked(telemetry.captureValidationReject); + expect(captureSpy).toHaveBeenCalledOnce(); + const [input] = captureSpy.mock.calls[0]; + expect(input.tool).toBe("get_figma_data"); + expect(input.field).toBe("fileKey"); + // Zod regex failures emit invalid_string in v3 and invalid_format in v4 — + // accept either so the test doesn't break across SDK upgrades. + expect(input.rule).toMatch(/invalid_string|invalid_format/); + }); + + it("normalizes array indexes to [] in nested validation_field", async () => { + // download_figma_images.nodes[0].nodeId fails the nodeId regex. + const result = await client.request( + { + method: "tools/call", + params: { + name: "download_figma_images", + arguments: { + fileKey: "abc123", + nodes: [{ nodeId: "BAD!!!", fileName: "x.png" }], + localPath: "images", + }, + }, + }, + CallToolResultSchema, + ); + expect(result.isError).toBe(true); + + const captureSpy = vi.mocked(telemetry.captureValidationReject); + expect(captureSpy).toHaveBeenCalledOnce(); + const [input] = captureSpy.mock.calls[0]; + expect(input.tool).toBe("download_figma_images"); + // The literal index 0 should be collapsed so `nodes.0.nodeId` doesn't + // appear with high cardinality in PostHog. + expect(input.field).toBe("nodes[].nodeId"); + }); + + it("does not capture validation rejects on successful tool calls", async () => { + // We don't actually want this to run the full pipeline — we only need to + // confirm the monkey patch doesn't fire on the success path. Use a + // syntactically valid fileKey; the call will fail later (no real API), but + // SDK validation will pass and captureValidationReject must not be called. + await client + .request( + { + method: "tools/call", + params: { + name: "get_figma_data", + arguments: { fileKey: "abc123" }, + }, + }, + CallToolResultSchema, + ) + .catch(() => { + // Network failure or tool-level error is fine — we don't care about the + // outcome, only that the validation hook stayed quiet. + }); + + expect(vi.mocked(telemetry.captureValidationReject)).not.toHaveBeenCalled(); + }); +}); diff --git a/src/transformers/component.ts b/src/transformers/component.ts new file mode 100644 index 0000000..faa5388 --- /dev/null +++ b/src/transformers/component.ts @@ -0,0 +1,132 @@ +import type { Component, ComponentSet } from "@figma/rest-api-spec"; + +export interface SimplifiedPropertyDefinition { + type: string; + defaultValue: boolean | string; +} + +export interface SimplifiedComponentDefinition { + id: string; + key: string; + name: string; + componentSetId?: string; + propertyDefinitions?: Record; +} + +export interface SimplifiedComponentSetDefinition { + id: string; + key: string; + name: string; + description?: string; + propertyDefinitions?: Record; +} + +/** + * Strip the #nodeId suffix from Figma property names. + * "On Sale#341:0" → "On Sale" + */ +export function stripPropertyNameSuffix(name: string): string { + const hashIndex = name.indexOf("#"); + return hashIndex === -1 ? name : name.substring(0, hashIndex); +} + +/** + * Simplify componentPropertyDefinitions from the raw Figma format to a flat + * Record of property name → default value. Only extracts BOOLEAN and TEXT + * properties for Phase 1. + */ +export function simplifyPropertyDefinitions( + definitions: Record, +): Record { + const result: Record = {}; + for (const [name, def] of Object.entries(definitions)) { + if (def.type === "BOOLEAN" || def.type === "TEXT") { + result[stripPropertyNameSuffix(name)] = { + type: def.type.toLowerCase(), + defaultValue: def.defaultValue, + }; + } + } + return result; +} + +/** + * Simplify componentPropertyReferences from the raw Figma format. + * Strips #nodeId suffixes from property names and renames "characters" key to "text" + * to match SimplifiedNode's text field. + * Only handles "visible" (BOOLEAN) and "characters" (TEXT) references for Phase 1. + */ +export function simplifyPropertyReferences( + references: Record, +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(references)) { + if (key === "visible" || key === "characters") { + const outputKey = key === "characters" ? "text" : key; + result[outputKey] = stripPropertyNameSuffix(value); + } + } + return result; +} + +/** + * Simplify instance componentProperties from the verbose Figma format to a flat + * Record of property name → value. Only extracts BOOLEAN and TEXT properties for Phase 1. + */ +export function simplifyComponentProperties( + properties: Record, +): Record { + const result: Record = {}; + for (const [name, prop] of Object.entries(properties)) { + if (prop.type === "BOOLEAN" || prop.type === "TEXT") { + result[stripPropertyNameSuffix(name)] = prop.value; + } + } + return result; +} + +/** + * Remove unnecessary component properties and convert to simplified format. + */ +export function simplifyComponents( + aggregatedComponents: Record, + propertyDefinitions?: Record>, +): Record { + return Object.fromEntries( + Object.entries(aggregatedComponents).map(([id, comp]) => [ + id, + { + id, + key: comp.key, + name: comp.name, + componentSetId: comp.componentSetId, + ...(propertyDefinitions?.[id] && { + propertyDefinitions: propertyDefinitions[id], + }), + }, + ]), + ); +} + +/** + * Remove unnecessary component set properties and convert to simplified format. + */ +export function simplifyComponentSets( + aggregatedComponentSets: Record, + propertyDefinitions?: Record>, +): Record { + return Object.fromEntries( + Object.entries(aggregatedComponentSets).map(([id, set]) => [ + id, + { + id, + key: set.key, + name: set.name, + description: set.description, + ...(propertyDefinitions?.[id] && { + propertyDefinitions: propertyDefinitions[id], + }), + }, + ]), + ); +} diff --git a/src/transformers/effects.ts b/src/transformers/effects.ts new file mode 100644 index 0000000..edc0255 --- /dev/null +++ b/src/transformers/effects.ts @@ -0,0 +1,75 @@ +import type { + DropShadowEffect, + InnerShadowEffect, + BlurEffect, + Node as FigmaDocumentNode, +} from "@figma/rest-api-spec"; +import { formatRGBAColor } from "~/transformers/style.js"; +import { hasValue } from "~/utils/identity.js"; +import { pixelRound } from "~/utils/common.js"; + +export type SimplifiedEffects = { + boxShadow?: string; + filter?: string; + backdropFilter?: string; + textShadow?: string; +}; + +export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects { + if (!hasValue("effects", n)) return {}; + const effects = n.effects.filter((e) => e.visible); + + // Handle drop and inner shadows (both go into CSS box-shadow) + const dropShadows = effects + .filter((e): e is DropShadowEffect => e.type === "DROP_SHADOW") + .map(simplifyDropShadow); + + const innerShadows = effects + .filter((e): e is InnerShadowEffect => e.type === "INNER_SHADOW") + .map(simplifyInnerShadow); + + const boxShadow = [...dropShadows, ...innerShadows].join(", "); + + // Handle blur effects - separate by CSS property. A zero-radius blur is a + // no-op, so drop it entirely rather than emit a dead `blur(0px)`. + // Layer blurs use the CSS 'filter' property + const filterBlurValues = effects + .filter((e): e is BlurEffect => e.type === "LAYER_BLUR" && e.radius > 0) + .map(simplifyBlur) + .join(" "); + + // Background blurs use the CSS 'backdrop-filter' property + const backdropFilterValues = effects + .filter((e): e is BlurEffect => e.type === "BACKGROUND_BLUR" && e.radius > 0) + .map(simplifyBlur) + .join(" "); + + const result: SimplifiedEffects = {}; + + if (boxShadow) { + if (n.type === "TEXT") { + result.textShadow = boxShadow; + } else { + result.boxShadow = boxShadow; + } + } + if (filterBlurValues) result.filter = filterBlurValues; + if (backdropFilterValues) result.backdropFilter = backdropFilterValues; + + return result; +} + +function simplifyDropShadow(effect: DropShadowEffect) { + return `${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`; +} + +function simplifyInnerShadow(effect: InnerShadowEffect) { + return `inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`; +} + +function simplifyBlur(effect: BlurEffect) { + // Figma's blur radius is ~2x the CSS blur() radius — verified by direct CSS + // test and corroborated by Figma's own Dev Mode output (a Figma blur of 32 + // renders as CSS blur(16px)). Halve it so the emitted value matches CSS. + return `blur(${pixelRound(effect.radius / 2)}px)`; +} diff --git a/src/transformers/layout.ts b/src/transformers/layout.ts new file mode 100644 index 0000000..dae3d18 --- /dev/null +++ b/src/transformers/layout.ts @@ -0,0 +1,182 @@ +import { isInAutoLayoutFlow, isFrame, isLayout, isRectangle } from "~/utils/identity.js"; +import type { Node as FigmaDocumentNode, HasLayoutTrait } from "@figma/rest-api-spec"; +import { generateCSSShorthand, pixelRound } from "~/utils/common.js"; +import { + convertSelfAlign, + convertSizing, + gapShorthand, + getChildStretch, + layoutModeToSchema, + resolveChildAxis, + shouldEmitFixedDimension, +} from "./layout/common.js"; +import { buildFlexGap, convertAlignItems, convertJustifyContent } from "./layout/flex.js"; +import { buildGridChildPositioning, isPackedGrid } from "./layout/grid.js"; +import type { SimplifiedLayout } from "./layout/common.js"; + +export type { SimplifiedLayout } from "./layout/common.js"; +export { computeGridChildOrder } from "./layout/grid.js"; + +// Convert Figma's layout config into a more typical flex-like schema +export function buildSimplifiedLayout( + n: FigmaDocumentNode, + parent?: FigmaDocumentNode, +): SimplifiedLayout { + const frameValues = buildSimplifiedFrameValues(n); + const parentGridPacked = + isFrame(parent) && parent.layoutMode === "GRID" && "children" in parent + ? isPackedGrid(parent.children as FigmaDocumentNode[]) + : undefined; + const layoutValues = + buildSimplifiedLayoutValues(n, parent, frameValues.mode, parentGridPacked) || {}; + + return { ...frameValues, ...layoutValues }; +} + +function buildSimplifiedFrameValues(n: FigmaDocumentNode): SimplifiedLayout | { mode: "none" } { + if (!isFrame(n)) { + return { mode: "none" }; + } + + const frameValues: SimplifiedLayout = { + mode: layoutModeToSchema(n.layoutMode), + }; + + const overflowScroll: SimplifiedLayout["overflowScroll"] = []; + if (n.overflowDirection?.includes("HORIZONTAL")) overflowScroll.push("x"); + if (n.overflowDirection?.includes("VERTICAL")) overflowScroll.push("y"); + if (overflowScroll.length > 0) frameValues.overflowScroll = overflowScroll; + + const { mode } = frameValues; + if (mode === "none") { + return frameValues; + } + + // Shared across grid and flex containers + frameValues.alignSelf = convertSelfAlign(n.layoutAlign); + if (n.paddingTop || n.paddingBottom || n.paddingLeft || n.paddingRight) { + frameValues.padding = generateCSSShorthand({ + top: n.paddingTop ?? 0, + right: n.paddingRight ?? 0, + bottom: n.paddingBottom ?? 0, + left: n.paddingLeft ?? 0, + }); + } + + if (mode === "grid") { + // Grid template/gap properties live on HasLayoutTrait; GRID frames always + // carry both traits, so the cast is safe. + const ln = n as unknown as HasLayoutTrait; + const cols = ln.gridColumnsSizing?.trim(); + if (cols) frameValues.gridTemplateColumns = cols; + + const rows = ln.gridRowsSizing?.trim(); + if (rows) frameValues.gridTemplateRows = rows; + + frameValues.gap = gapShorthand(ln.gridRowGap, ln.gridColumnGap); + return frameValues; + } + + // Flex-specific — mode is narrowed to "row" | "column" after grid early-return + frameValues.justifyContent = convertJustifyContent(n.primaryAxisAlignItems ?? "MIN"); + frameValues.alignItems = convertAlignItems(n.counterAxisAlignItems ?? "MIN", n.children, mode); + frameValues.wrap = n.layoutWrap === "WRAP" ? true : undefined; + frameValues.gap = buildFlexGap(n, mode); + + return frameValues; +} + +function buildSimplifiedLayoutValues( + n: FigmaDocumentNode, + parent: FigmaDocumentNode | undefined, + mode: SimplifiedLayout["mode"], + parentGridPacked?: boolean, +): SimplifiedLayout | undefined { + if (!isLayout(n)) return undefined; + + // The requested root has no parent in the payload, so Figma reports its sizing + // FIXED relative to an absent container — an artifact of being top-level, not + // design intent. Honoring it as a hard width/height pins the whole design to + // its artboard and kills responsiveness. Descendants (which have a real parent) + // keep their fill/hug/fixed semantics. See fig-ovmi. + const isRoot = parent === undefined; + + const layoutValues: SimplifiedLayout = { mode }; + + layoutValues.sizing = { + horizontal: convertSizing(n.layoutSizingHorizontal), + vertical: convertSizing(n.layoutSizingVertical), + }; + + // For the root, rewrite each spurious FIXED axis as "contextual" (it fills + // whatever it's placed in) and surface the designed size as a non-binding + // reference — absolutely-positioned children and the fill-chain still need a + // concrete size to anchor against. Real FILL/HUG axes are intent; leave them. + if (isRoot && n.absoluteBoundingBox) { + if (layoutValues.sizing.horizontal === "fixed") { + layoutValues.sizing.horizontal = "contextual"; + layoutValues.designedWidth = `${pixelRound(n.absoluteBoundingBox.width)}px`; + } + if (layoutValues.sizing.vertical === "fixed") { + layoutValues.sizing.vertical = "contextual"; + layoutValues.designedHeight = `${pixelRound(n.absoluteBoundingBox.height)}px`; + } + } + + // Emit positioning relative to parent unless the parent's auto-layout already + // places this child. `isLayout(parent)` also screens out top-level nodes + // (no parent) and parents without bounding boxes (e.g. CANVAS), where + // coordinates would be meaningless. + if (isLayout(parent) && !isInAutoLayoutFlow(n, parent)) { + if (n.layoutPositioning === "ABSOLUTE") { + layoutValues.position = "absolute"; + } + if (n.absoluteBoundingBox && parent.absoluteBoundingBox) { + layoutValues.locationRelativeToParent = { + x: pixelRound(n.absoluteBoundingBox.x - parent.absoluteBoundingBox.x), + y: pixelRound(n.absoluteBoundingBox.y - parent.absoluteBoundingBox.y), + }; + } + } + + // Grid child properties: positioning, spans, alignment, and z-order + const parentIsGrid = parentGridPacked !== undefined; + if (parentIsGrid && parent && n.layoutPositioning !== "ABSOLUTE") { + Object.assign(layoutValues, buildGridChildPositioning(n, parent, parentGridPacked)); + } + + // Emit a dimension only when the child isn't stretching that axis and the + // sizing flag permits it. Stretch detection and the "is FIXED?" rule both + // depend on whether the parent is flex, grid, or non-auto-layout — see the + // helpers in ./layout/common.ts for the per-axis vocabulary mapping. + if (!isRoot && isRectangle("absoluteBoundingBox", n)) { + const dimensions: { width?: number; height?: number; aspectRatio?: number } = {}; + const axis = resolveChildAxis(n, parent, mode, parentIsGrid); + const stretch = getChildStretch(n, axis); + + if (!stretch.horizontal && shouldEmitFixedDimension(n.layoutSizingHorizontal, axis)) { + dimensions.width = n.absoluteBoundingBox.width; + } + if (!stretch.vertical && shouldEmitFixedDimension(n.layoutSizingVertical, axis)) { + dimensions.height = n.absoluteBoundingBox.height; + } + + // Preserves historical behavior: aspectRatio is emitted only for + // column-parent children. Likely should apply more broadly — pre-existing. + if (axis === "column" && n.preserveRatio && n.absoluteBoundingBox.height !== 0) { + dimensions.aspectRatio = n.absoluteBoundingBox.width / n.absoluteBoundingBox.height; + } + + if (Object.keys(dimensions).length > 0) { + if (dimensions.width) { + dimensions.width = pixelRound(dimensions.width); + } + if (dimensions.height) { + dimensions.height = pixelRound(dimensions.height); + } + layoutValues.dimensions = dimensions; + } + } + + return layoutValues; +} diff --git a/src/transformers/layout/common.ts b/src/transformers/layout/common.ts new file mode 100644 index 0000000..703fdc5 --- /dev/null +++ b/src/transformers/layout/common.ts @@ -0,0 +1,200 @@ +import type { + Node as FigmaDocumentNode, + HasFramePropertiesTrait, + HasLayoutTrait, +} from "@figma/rest-api-spec"; +import { exhaustiveCheck } from "~/utils/common.js"; +import { isFrame, isInAutoLayoutFlow } from "~/utils/identity.js"; + +export interface SimplifiedLayout { + mode: "none" | "row" | "column" | "grid"; + justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch"; + alignItems?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch"; + alignSelf?: "flex-start" | "flex-end" | "center" | "stretch" | "start" | "end"; + wrap?: boolean; + gap?: string; + gridTemplateColumns?: string; + gridTemplateRows?: string; + gridColumn?: string; + gridRow?: string; + justifySelf?: "start" | "end" | "center"; + // Emitted on a grid child only when the parent's child array order (Figma z-order, + // back-to-front) doesn't match grid-anchor / reading order. The MCP reorders such + // children into anchor order so the AI generates idiomatic flowing-grid CSS, then + // surfaces the original z-order here so stacking can be preserved with `z-index` + // when children overlap. Value is the child's original index in `parent.children` + // (higher = drawn on top). + zIndex?: number; + locationRelativeToParent?: { + x: number; + y: number; + }; + dimensions?: { + width?: number; + height?: number; + aspectRatio?: number; + }; + // The size the requested root was designed at, surfaced as a non-binding + // reference (not a hard width/height). The root's own dimensions are + // "contextual" — it fills whatever it's placed in — but absolutely-positioned + // children and the fill-chain still need a concrete size to anchor against, so + // we keep the designed value here, named so it can't be mistaken for a pin. + designedWidth?: string; + designedHeight?: string; + padding?: string; + sizing?: { + // "contextual": size is determined by wherever the element is placed (used + // for the requested root, whose FIXED size is an artifact of being top-level). + horizontal?: "fixed" | "fill" | "hug" | "contextual"; + vertical?: "fixed" | "fill" | "hug" | "contextual"; + }; + overflowScroll?: ("x" | "y")[]; + position?: "absolute"; +} + +export function convertSizing( + s?: HasLayoutTrait["layoutSizingHorizontal"] | HasLayoutTrait["layoutSizingVertical"], +) { + if (s === "FIXED") return "fixed"; + if (s === "FILL") return "fill"; + if (s === "HUG") return "hug"; + return undefined; +} + +export function convertSelfAlign(align?: HasLayoutTrait["layoutAlign"]) { + switch (align) { + case "MIN": + // MIN, AKA flex-start, is the default alignment + return undefined; + case "MAX": + return "flex-end"; + case "CENTER": + return "center"; + case "STRETCH": + return "stretch"; + default: + return undefined; + } +} + +// Centralized mapping of Figma's layoutMode to our schema's mode tag. +// Exhaustive switch — if @figma/rest-api-spec ever adds a new layoutMode value, +// exhaustiveCheck fails the build until we decide how to map it. +export function layoutModeToSchema( + layoutMode: HasFramePropertiesTrait["layoutMode"], +): SimplifiedLayout["mode"] { + switch (layoutMode) { + case "HORIZONTAL": + return "row"; + case "VERTICAL": + return "column"; + case "GRID": + return "grid"; + case "NONE": + case undefined: + return "none"; + default: + return exhaustiveCheck(layoutMode); + } +} + +export function getParentAutoLayoutMode(parent?: FigmaDocumentNode): "row" | "column" | undefined { + if (!isFrame(parent)) return undefined; + if (parent.layoutMode === "HORIZONTAL") return "row"; + if (parent.layoutMode === "VERTICAL") return "column"; + return undefined; +} + +/** + * The axis a child's layout flags should be interpreted against. + * + * Figma encodes "is this child stretching?" with different properties depending + * on the parent's layout mode, and `layoutGrow` / `layoutAlign` are keyed to the + * parent's main/cross axes rather than literal horizontal/vertical. Resolving + * this once up front means the dimension logic doesn't have to re-derive it. + */ +export type ChildAxis = "row" | "column" | "grid" | "none"; + +export type StretchFlags = { horizontal: boolean; vertical: boolean }; + +/** + * Determines the axis context for interpreting `n`'s sizing/stretch flags. + * + * For flex children, `layoutGrow` is "stretch along main axis" and + * `layoutAlign === "STRETCH"` is "stretch along cross axis" — both keyed to the + * *parent's* axis, not the child's own layout. A row child inside a column + * parent has its main axis aligned with the column. Picking the wrong axis here + * silently mis-emits dimensions (see fix #379). + */ +export function resolveChildAxis( + n: FigmaDocumentNode, + parent: FigmaDocumentNode | undefined, + ownMode: SimplifiedLayout["mode"], + parentIsGrid: boolean, +): ChildAxis { + if (parentIsGrid) return "grid"; + // When in an auto-layout parent, prefer the parent's axis (fix #379). + // Outside it, fall back to the node's own mode so a top-level row/column + // frame still threads through the row/column dimension logic. Per the + // Figma spec, layoutGrow/layoutAlign only apply to direct auto-layout + // children, so consulting them outside that context is arguably wrong — + // but this preserves the pre-refactor behavior. + const parentAxis = isInAutoLayoutFlow(n, parent) ? getParentAutoLayoutMode(parent) : undefined; + if (parentAxis) return parentAxis; + return ownMode === "row" || ownMode === "column" ? ownMode : "none"; +} + +/** + * Per-axis "is this child stretching to fill the parent?" flags, normalizing + * Figma's flex vs grid vocabularies into the same shape. + * + * - Flex children use `layoutGrow` (main axis, numeric 0/1) and + * `layoutAlign === "STRETCH"` (cross axis, enum). + * - Grid children use `layoutSizing{Horizontal,Vertical} === "FILL"` (no + * main/cross — properties are axis-named directly). + */ +export function getChildStretch(n: HasLayoutTrait, axis: ChildAxis): StretchFlags { + switch (axis) { + case "grid": + return { + horizontal: n.layoutSizingHorizontal === "FILL", + vertical: n.layoutSizingVertical === "FILL", + }; + case "row": + return { horizontal: !!n.layoutGrow, vertical: n.layoutAlign === "STRETCH" }; + case "column": + return { horizontal: n.layoutAlign === "STRETCH", vertical: !!n.layoutGrow }; + case "none": + return { horizontal: false, vertical: false }; + default: + return exhaustiveCheck(axis); + } +} + +/** + * Whether an axis should emit its bounding-box dimension. + * + * Flex children are strict: `layoutSizing*` is reliably populated by Figma and + * only `FIXED` should emit. Grid children and non-auto-layout nodes also allow + * absent sizing — for non-auto-layout nodes the property may not exist at all, + * and the historical grid path treated absent as fixed for symmetry. + */ +export function shouldEmitFixedDimension( + sizing: HasLayoutTrait["layoutSizingHorizontal"] | undefined, + axis: ChildAxis, +): boolean { + if (axis === "row" || axis === "column") return sizing === "FIXED"; + return !sizing || sizing === "FIXED"; +} + +// Zero is only meaningful as one half of a two-value shorthand (e.g. "0px 16px"). +// As a single value it's the CSS default — omit to match the project's convention. +export function gapShorthand(row?: number, col?: number): string | undefined { + if (row === undefined && col === undefined) return undefined; + if (row !== undefined && col !== undefined) { + if (row === 0 && col === 0) return undefined; + return row === col ? `${row}px` : `${row}px ${col}px`; + } + const single = (row ?? col)!; + return single ? `${single}px` : undefined; +} diff --git a/src/transformers/layout/flex.ts b/src/transformers/layout/flex.ts new file mode 100644 index 0000000..b9e07d7 --- /dev/null +++ b/src/transformers/layout/flex.ts @@ -0,0 +1,66 @@ +import type { Node as FigmaDocumentNode, HasFramePropertiesTrait } from "@figma/rest-api-spec"; +import { gapShorthand } from "./common.js"; + +export function convertJustifyContent(align?: HasFramePropertiesTrait["primaryAxisAlignItems"]) { + switch (align) { + case "MIN": + return undefined; + case "MAX": + return "flex-end"; + case "CENTER": + return "center"; + case "SPACE_BETWEEN": + return "space-between"; + default: + return undefined; + } +} + +export function convertAlignItems( + align: HasFramePropertiesTrait["counterAxisAlignItems"] | undefined, + children: FigmaDocumentNode[], + mode: "row" | "column", +) { + // Row cross-axis is vertical; column cross-axis is horizontal + const crossSizing = mode === "row" ? "layoutSizingVertical" : "layoutSizingHorizontal"; + const allStretch = + children.length > 0 && + children.every( + (c) => + ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") || + (crossSizing in c && (c as Record)[crossSizing] === "FILL"), + ); + if (allStretch) return "stretch"; + + switch (align) { + case "MIN": + return undefined; + case "MAX": + return "flex-end"; + case "CENTER": + return "center"; + case "BASELINE": + return "baseline"; + default: + return undefined; + } +} + +// SPACE_BETWEEN computes gaps dynamically — the API returns stale spacing +// values, but Figma's UI shows "Auto". Suppress the affected axis. +export function buildFlexGap( + n: HasFramePropertiesTrait, + mode: "row" | "column", +): string | undefined { + const primaryGap = n.primaryAxisAlignItems === "SPACE_BETWEEN" ? undefined : n.itemSpacing; + const counterGap = + n.layoutWrap !== "WRAP" || n.counterAxisAlignContent === "SPACE_BETWEEN" + ? undefined + : n.counterAxisSpacing; + + // Map Figma's primary/counter axes to CSS's row/column axes + const rowGap = mode === "row" ? counterGap : primaryGap; + const colGap = mode === "row" ? primaryGap : counterGap; + + return gapShorthand(rowGap, colGap); +} diff --git a/src/transformers/layout/grid.ts b/src/transformers/layout/grid.ts new file mode 100644 index 0000000..eaa5edb --- /dev/null +++ b/src/transformers/layout/grid.ts @@ -0,0 +1,196 @@ +import type { Node as FigmaDocumentNode, HasLayoutTrait } from "@figma/rest-api-spec"; +import { hasGridLayout, hasValue, isLayout } from "~/utils/identity.js"; +import type { SimplifiedLayout } from "./common.js"; + +/** + * Compute the order in which a grid container's children should appear so that + * array position matches grid-flow (reading) order. + * + * Why: Figma returns children in z-order (back-to-front), which can differ + * from the order their grid anchors place them in. CSS auto-placement uses + * DOM order, so emitting children in Figma's z-order lands them in the wrong + * cells. Sorting into anchor order lets us emit idiomatic flowing-grid CSS + * (no explicit `grid-column` / `grid-row` per child) while keeping rendering + * correct. The original z-order is surfaced via {@link SimplifiedLayout.zIndex} + * on children whose position changed. + * + * ABSOLUTE-positioned children don't participate in grid flow, so they keep + * their original slot in the array — only in-flow children are reordered + * relative to each other. + * + * Returns null when the parent isn't a grid, has no children, or when the + * existing order already matches anchor order (no work to do). + */ +export function computeGridChildOrder(parent: FigmaDocumentNode): number[] | null { + if (!hasGridLayout(parent) || !hasValue("children", parent)) return null; + const children = parent.children as FigmaDocumentNode[]; + if (children.length < 2) return null; + + const isAbsolute = (c: FigmaDocumentNode) => isLayout(c) && c.layoutPositioning === "ABSOLUTE"; + + const inFlow = children + .map((_, i) => i) + .filter((i) => !isAbsolute(children[i])) + .sort((a, b) => { + const ca = children[a] as HasLayoutTrait; + const cb = children[b] as HasLayoutTrait; + const ar = ca.gridRowAnchorIndex ?? 0; + const br = cb.gridRowAnchorIndex ?? 0; + if (ar !== br) return ar - br; + const ac = ca.gridColumnAnchorIndex ?? 0; + const bc = cb.gridColumnAnchorIndex ?? 0; + if (ac !== bc) return ac - bc; + return a - b; // stable on equal anchors + }); + + // Slot absolute children back into their original positions, and fill the + // remaining slots with the sorted in-flow indices. + const result: number[] = []; + let cursor = 0; + for (let i = 0; i < children.length; i++) { + if (isAbsolute(children[i])) { + result.push(i); + } else { + result.push(inFlow[cursor++]); + } + } + + return result.every((idx, i) => idx === i) ? null : result; +} + +/** Check whether a grid's children fill a packed sequence with no empty cells. */ +export function isPackedGrid(children: FigmaDocumentNode[]): boolean { + const occupied = new Set(); + + for (const child of children) { + if (!isLayout(child) || child.layoutPositioning === "ABSOLUTE") continue; + + const colAnchor = child.gridColumnAnchorIndex ?? 0; + const rowAnchor = child.gridRowAnchorIndex ?? 0; + const colSpan = child.gridColumnSpan ?? 1; + const rowSpan = child.gridRowSpan ?? 1; + + for (let r = rowAnchor; r < rowAnchor + rowSpan; r++) { + for (let c = colAnchor; c < colAnchor + colSpan; c++) { + occupied.add(`${r},${c}`); + } + } + } + + if (occupied.size === 0) return true; + + let maxRow = 0; + let maxCol = 0; + for (const key of occupied) { + const [r, c] = key.split(",").map(Number); + maxRow = Math.max(maxRow, r); + maxCol = Math.max(maxCol, c); + } + + // Packed means every cell in the bounding rectangle is occupied + return occupied.size === (maxRow + 1) * (maxCol + 1); +} + +/** + * Whether any pair of a grid's in-flow children overlap (AABB intersection on + * `absoluteBoundingBox`). + * + * Used to gate `zIndex` emission: when children don't overlap, their CSS + * stacking can't affect what the user sees, so the z-order annotation is + * noise. ABSOLUTE-positioned children are excluded — they manage their own + * stacking and don't participate in grid flow. + * + * Edges that merely touch (e.g., adjacent cells with gap = 0) are NOT + * overlap; strict inequalities below handle that. + */ +function gridChildrenOverlap(parent: FigmaDocumentNode): boolean { + if (!hasValue("children", parent)) return false; + const boxes = (parent.children as FigmaDocumentNode[]) + .filter((c) => isLayout(c) && c.layoutPositioning !== "ABSOLUTE") + .map((c) => (c as HasLayoutTrait).absoluteBoundingBox) + .filter((b): b is NonNullable => b != null); + + for (let i = 0; i < boxes.length; i++) { + const a = boxes[i]; + for (let j = i + 1; j < boxes.length; j++) { + const b = boxes[j]; + if ( + a.x < b.x + b.width && + a.x + a.width > b.x && + a.y < b.y + b.height && + a.y + a.height > b.y + ) { + return true; + } + } + } + return false; +} + +function convertGridAlign(align: "MIN" | "CENTER" | "MAX"): "start" | "end" | "center" { + switch (align) { + case "MIN": + return "start"; + case "MAX": + return "end"; + case "CENTER": + return "center"; + } +} + +/** + * Build the grid-child-specific positioning fields for a node whose parent is a + * GRID frame: `gridColumn` / `gridRow` (only when needed), self-alignment, and + * the `zIndex` annotation that preserves Figma z-order when overlap matters. + * + * The caller is responsible for confirming the parent is a grid and the child + * is in-flow (not ABSOLUTE). + */ +export function buildGridChildPositioning( + n: HasLayoutTrait, + parent: FigmaDocumentNode, + packed: boolean, +): Partial { + const out: Partial = {}; + + const colSpan = n.gridColumnSpan ?? 1; + const rowSpan = n.gridRowSpan ?? 1; + + if (!packed) { + const col = (n.gridColumnAnchorIndex ?? 0) + 1; // CSS grid is 1-based + const row = (n.gridRowAnchorIndex ?? 0) + 1; + out.gridColumn = colSpan > 1 ? `${col} / span ${colSpan}` : `${col}`; + out.gridRow = rowSpan > 1 ? `${row} / span ${rowSpan}` : `${row}`; + } else { + if (colSpan > 1) out.gridColumn = `span ${colSpan}`; + if (rowSpan > 1) out.gridRow = `span ${rowSpan}`; + } + + const hAlign = n.gridChildHorizontalAlign; + if (hAlign && hAlign !== "AUTO") { + out.justifySelf = convertGridAlign(hAlign); + } + + const vAlign = n.gridChildVerticalAlign; + if (vAlign && vAlign !== "AUTO") { + out.alignSelf = convertGridAlign(vAlign); + } + + // When sorting moves this child AND siblings actually overlap, surface its + // original Figma stacking position so CSS can preserve z-order. Skipped when: + // - sort is a no-op (no reorder happened) + // - this child's slot didn't move + // - no in-flow siblings overlap (stacking can't affect rendering) + const order = computeGridChildOrder(parent); + if (order && gridChildrenOverlap(parent)) { + const originalIndex = (parent as { children: FigmaDocumentNode[] }).children.indexOf( + n as FigmaDocumentNode, + ); + const newIndex = order.indexOf(originalIndex); + if (originalIndex !== newIndex) { + out.zIndex = originalIndex; + } + } + + return out; +} diff --git a/src/transformers/style.ts b/src/transformers/style.ts new file mode 100644 index 0000000..a58a116 --- /dev/null +++ b/src/transformers/style.ts @@ -0,0 +1,153 @@ +import type { Node as FigmaDocumentNode, Paint } from "@figma/rest-api-spec"; +import { generateCSSShorthand, isVisible } from "~/utils/common.js"; +import { tagError } from "~/utils/error-meta.js"; +import { hasValue, isStrokeWeights } from "~/utils/identity.js"; + +import { convertColor, formatRGBAColor } from "./style/color.js"; +import { translateScaleMode, handleImageTransform, parsePatternPaint } from "./style/image.js"; +import type { SimplifiedImageFill } from "./style/image.js"; +import { convertGradientToCss } from "./style/gradient.js"; + +// The style transformer is split across a style/ subdirectory (color, image, +// gradient) because gradient geometry alone is ~290 lines. This module stays the +// single public entry point — `~/transformers/style.js` — so the re-exports below +// preserve the import surface every caller already uses. +export type { CSSRGBAColor, CSSHexColor, ColorValue } from "./style/color.js"; +export { convertColor, formatRGBAColor, flattenSolidFills } from "./style/color.js"; +export type { SimplifiedImageFill, SimplifiedPatternFill } from "./style/image.js"; +export type { SimplifiedGradientFill } from "./style/gradient.js"; + +import type { CSSRGBAColor, CSSHexColor } from "./style/color.js"; +import type { SimplifiedPatternFill } from "./style/image.js"; +import type { SimplifiedGradientFill } from "./style/gradient.js"; + +export type SimplifiedFill = + | SimplifiedImageFill + | SimplifiedGradientFill + | SimplifiedPatternFill + | CSSRGBAColor + | CSSHexColor; + +export type SimplifiedStroke = { + colors: SimplifiedFill[]; + strokeWeight?: string; + strokeDashes?: number[]; + strokeWeights?: string; + strokeAlign?: "INSIDE" | "OUTSIDE" | "CENTER"; +}; + +/** + * Build simplified stroke information from a Figma node + * + * @param n - The Figma node to extract stroke information from + * @param hasChildren - Whether the node has children (affects paint processing) + * @returns Simplified stroke object with colors and properties + */ +export function buildSimplifiedStrokes( + n: FigmaDocumentNode, + hasChildren: boolean = false, +): SimplifiedStroke { + let strokes: SimplifiedStroke = { colors: [] }; + if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) { + // Reverse to match CSS stacking order (Figma layers bottom-to-top, CSS top-to-bottom) + strokes.colors = n.strokes + .filter(isVisible) + .map((stroke) => parsePaint(stroke, hasChildren)) + .reverse(); + } + + if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) { + strokes.strokeWeight = `${n.strokeWeight}px`; + } + + if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) { + strokes.strokeDashes = n.strokeDashes; + } + + if (hasValue("strokeAlign", n) && (n.strokeAlign === "OUTSIDE" || n.strokeAlign === "CENTER")) { + strokes.strokeAlign = n.strokeAlign; + } + + if (hasValue("individualStrokeWeights", n, isStrokeWeights)) { + strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights); + } + + return strokes; +} + +/** + * Convert a Figma paint (solid, image, gradient) to a SimplifiedFill + * @param raw - The Figma paint to convert + * @param hasChildren - Whether the node has children (determines CSS properties) + * @returns The converted SimplifiedFill + */ +export function parsePaint(raw: Paint, hasChildren: boolean = false): SimplifiedFill { + if (raw.type === "IMAGE") { + // Figma's spec types imageRef as a required string, but in practice it can + // come back null for IMAGE paints whose asset lives in another file (e.g. + // pasted from a file you don't own). Omit the field in that case so the + // LLM doesn't pass a null/"null" through to download_figma_images — the + // downloader will fall back to rendering the containing node by nodeId. + const baseImageFill: SimplifiedImageFill = { + type: "IMAGE", + ...(raw.imageRef ? { imageRef: raw.imageRef } : {}), + ...(raw.gifRef ? { gifRef: raw.gifRef } : {}), + scaleMode: raw.scaleMode as "FILL" | "FIT" | "TILE" | "STRETCH", + scalingFactor: raw.scalingFactor, + }; + + // Get CSS properties and processing metadata from scale mode + // TILE mode always needs to be treated as background image (can't tile an tag) + const isBackground = hasChildren || baseImageFill.scaleMode === "TILE"; + const { css, processing } = translateScaleMode( + baseImageFill.scaleMode, + isBackground, + raw.scalingFactor, + ); + + // Combine scale mode processing with transform processing if needed + // Transform processing (cropping) takes precedence over scale mode processing + let finalProcessing = processing; + if (raw.imageTransform) { + const transformProcessing = handleImageTransform(raw.imageTransform); + finalProcessing = { + ...processing, + ...transformProcessing, + // Keep requiresImageDimensions from scale mode (needed for TILE) + requiresImageDimensions: + processing.requiresImageDimensions || transformProcessing.requiresImageDimensions, + }; + } + + return { + ...baseImageFill, + ...css, + imageDownloadArguments: finalProcessing, + }; + } else if (raw.type === "SOLID") { + // treat as SOLID + const { hex, opacity } = convertColor(raw.color!, raw.opacity); + if (opacity === 1) { + return hex; + } else { + return formatRGBAColor(raw.color!, opacity); + } + } else if (raw.type === "PATTERN") { + return parsePatternPaint(raw); + } else if ( + ["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes( + raw.type, + ) + ) { + return { + type: raw.type as + | "GRADIENT_LINEAR" + | "GRADIENT_RADIAL" + | "GRADIENT_ANGULAR" + | "GRADIENT_DIAMOND", + gradient: convertGradientToCss(raw), + }; + } else { + tagError(new Error(`Unknown paint type: ${raw.type}`), { category: "internal" }); + } +} diff --git a/src/transformers/style/color.ts b/src/transformers/style/color.ts new file mode 100644 index 0000000..f0a2829 --- /dev/null +++ b/src/transformers/style/color.ts @@ -0,0 +1,111 @@ +import type { Paint, RGBA } from "@figma/rest-api-spec"; + +export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`; +export type CSSHexColor = `#${string}`; +export interface ColorValue { + hex: CSSHexColor; + opacity: number; +} + +/** + * Convert color from RGBA to { hex, opacity } + * + * @param color - The color to convert, including alpha channel + * @param opacity - The opacity of the color, if not included in alpha channel + * @returns The converted color + **/ +export function convertColor(color: RGBA, opacity = 1): ColorValue { + const r = Math.round(color.r * 255); + const g = Math.round(color.g * 255); + const b = Math.round(color.b * 255); + + // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative + const a = Math.round(opacity * color.a * 100) / 100; + + const hex = ("#" + + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor; + + return { hex, opacity: a }; +} + +/** + * Convert color from Figma RGBA to rgba(#, #, #, #) CSS format + * + * @param color - The color to convert, including alpha channel + * @param opacity - The opacity of the color, if not included in alpha channel + * @returns The converted color + **/ +export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor { + const r = Math.round(color.r * 255); + const g = Math.round(color.g * 255); + const b = Math.round(color.b * 255); + // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative + const a = Math.round(opacity * color.a * 100) / 100; + + return `rgba(${r}, ${g}, ${b}, ${a})`; +} + +/** + * A solid paint participates in flattening only when it blends normally with + * what's behind it. PASS_THROUGH (groups) and NORMAL both composite with plain + * source-over; every other blend mode (MULTIPLY, SCREEN, …) needs the backdrop + * to compute, which a flattened stack has thrown away — so its presence anywhere + * disqualifies the whole stack. A missing blendMode is treated as NORMAL (its + * Figma default). + */ +function isFlattenableSolid(paint: Paint): paint is Extract { + return ( + paint.type === "SOLID" && + (paint.blendMode === undefined || + paint.blendMode === "NORMAL" || + paint.blendMode === "PASS_THROUGH") + ); +} + +type StraightColor = { r: number; g: number; b: number; a: number }; + +/** Source-over composite of `top` (foreground) onto `bottom` (backdrop), straight alpha, channels 0..1. */ +function compositeOver(top: StraightColor, bottom: StraightColor): StraightColor { + const a = top.a + bottom.a * (1 - top.a); + if (a === 0) return { r: 0, g: 0, b: 0, a: 0 }; + const blend = (cT: number, cB: number) => (cT * top.a + cB * bottom.a * (1 - top.a)) / a; + return { r: blend(top.r, bottom.r), g: blend(top.g, bottom.g), b: blend(top.b, bottom.b), a }; +} + +/** + * Collapse an all-solid paint stack into the single color a viewer actually sees. + * + * The emitted fills array carries layer order as a silent positional contract, + * and LLM consumers misread it — picking the wrong layer's color, or emitting a + * layer that's fully occluded by an opaque paint above it. When every visible + * paint is a normally-blended SOLID we can resolve that ambiguity outright: + * source-over composite the stack into one resolved color (hex when the result + * is fully opaque, else rgba()). Compositing inherently culls dead layers — + * anything beneath a fully-opaque paint contributes nothing to the result. + * + * Returns null when the stack contains a gradient, image, pattern, or any + * non-normal blend mode: those have their own output syntax and can't be folded, + * so the caller falls back to emitting the per-paint array untouched. + * + * `paints` must be in Figma order (index 0 = bottom layer). + */ +export function flattenSolidFills(paints: Paint[]): CSSHexColor | CSSRGBAColor | null { + if (!paints.length || !paints.every(isFlattenableSolid)) return null; + + // Fold effective alpha (color.a * paint.opacity) before compositing, bottom to top. + const toStraight = (p: Extract): StraightColor => ({ + r: p.color.r, + g: p.color.g, + b: p.color.b, + a: p.color.a * (p.opacity ?? 1), + }); + + let acc = toStraight(paints[0]); + for (let i = 1; i < paints.length; i++) { + acc = compositeOver(toStraight(paints[i]), acc); + } + + const composited: RGBA = acc; + const { hex, opacity } = convertColor(composited); + return opacity === 1 ? hex : formatRGBAColor(composited); +} diff --git a/src/transformers/style/gradient.ts b/src/transformers/style/gradient.ts new file mode 100644 index 0000000..9b2fc77 --- /dev/null +++ b/src/transformers/style/gradient.ts @@ -0,0 +1,264 @@ +import type { Paint, RGBA, Vector } from "@figma/rest-api-spec"; +import { formatRGBAColor } from "./color.js"; + +export type SimplifiedGradientFill = { + type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND"; + gradient: string; +}; + +type GradientPaint = Extract< + Paint, + { + type: "GRADIENT_LINEAR" | "GRADIENT_RADIAL" | "GRADIENT_ANGULAR" | "GRADIENT_DIAMOND"; + } +>; + +type GradientStop = { position: number; color: RGBA }; + +type GradientGeometry = { stops: string; cssGeometry: string }; + +type GradientMapper = ( + gradientStops: GradientStop[], + handles: Vector[], + paintOpacity: number, +) => GradientGeometry; + +/** + * Format stops as CSS ` %` segments at their original positions. + * `paintOpacity` multiplies each stop's `color.a` (via `formatRGBAColor`). + * Mappers that remap positions (e.g. linear's extended-line case) format inline. + */ +function formatStops(stops: GradientStop[], paintOpacity: number): string { + return stops + .map( + ({ position, color }) => + `${formatRGBAColor(color, paintOpacity)} ${Math.round(position * 100)}%`, + ) + .join(", "); +} + +/** + * Map linear gradient from Figma handles to CSS + */ +function mapLinearGradient( + gradientStops: GradientStop[], + handles: Vector[], + paintOpacity: number, +): GradientGeometry { + const [start, end] = handles; + + // Calculate the gradient line in element space + const dx = end.x - start.x; + const dy = end.y - start.y; + const gradientLength = Math.sqrt(dx * dx + dy * dy); + + // Handle degenerate case + if (gradientLength === 0) { + return { + stops: formatStops(gradientStops, paintOpacity), + cssGeometry: "0deg", + }; + } + + // Calculate angle for CSS + const angle = Math.atan2(dy, dx) * (180 / Math.PI) + 90; + + // Find where the extended gradient line intersects the element boundaries + const extendedIntersections = findExtendedLineIntersections(start, end); + + if (extendedIntersections.length >= 2) { + // The gradient line extended to fill the element + const fullLineStart = Math.min(extendedIntersections[0], extendedIntersections[1]); + const fullLineEnd = Math.max(extendedIntersections[0], extendedIntersections[1]); + // Map gradient stops from the Figma line segment to the full CSS line + const mappedStops = gradientStops.map(({ position, color }) => { + const cssColor = formatRGBAColor(color, paintOpacity); + + // Position along the Figma gradient line (0 = start handle, 1 = end handle) + const figmaLinePosition = position; + + // The Figma line spans from t=0 to t=1 + // The full extended line spans from fullLineStart to fullLineEnd + // Map the figma position to the extended line + const tOnExtendedLine = figmaLinePosition * (1 - 0) + 0; // This is just figmaLinePosition + const extendedPosition = (tOnExtendedLine - fullLineStart) / (fullLineEnd - fullLineStart); + const clampedPosition = Math.max(0, Math.min(1, extendedPosition)); + + return `${cssColor} ${Math.round(clampedPosition * 100)}%`; + }); + + return { + stops: mappedStops.join(", "), + cssGeometry: `${Math.round(angle)}deg`, + }; + } + + // Fallback to simple gradient if intersection calculation fails + return { + stops: formatStops(gradientStops, paintOpacity), + cssGeometry: `${Math.round(angle)}deg`, + }; +} + +/** + * Find where the extended gradient line intersects with the element boundaries + */ +function findExtendedLineIntersections(start: Vector, end: Vector): number[] { + const dx = end.x - start.x; + const dy = end.y - start.y; + + // Handle degenerate case + if (Math.abs(dx) < 1e-10 && Math.abs(dy) < 1e-10) { + return []; + } + + const intersections: number[] = []; + + // Check intersection with each edge of the unit square [0,1] x [0,1] + // Top edge (y = 0) + if (Math.abs(dy) > 1e-10) { + const t = -start.y / dy; + const x = start.x + t * dx; + if (x >= 0 && x <= 1) { + intersections.push(t); + } + } + + // Bottom edge (y = 1) + if (Math.abs(dy) > 1e-10) { + const t = (1 - start.y) / dy; + const x = start.x + t * dx; + if (x >= 0 && x <= 1) { + intersections.push(t); + } + } + + // Left edge (x = 0) + if (Math.abs(dx) > 1e-10) { + const t = -start.x / dx; + const y = start.y + t * dy; + if (y >= 0 && y <= 1) { + intersections.push(t); + } + } + + // Right edge (x = 1) + if (Math.abs(dx) > 1e-10) { + const t = (1 - start.x) / dx; + const y = start.y + t * dy; + if (y >= 0 && y <= 1) { + intersections.push(t); + } + } + + // Remove duplicates and sort + const uniqueIntersections = [ + ...new Set(intersections.map((t) => Math.round(t * 1000000) / 1000000)), + ]; + return uniqueIntersections.sort((a, b) => a - b); +} + +/** + * Map radial gradient from Figma handles to CSS + */ +function mapRadialGradient( + gradientStops: GradientStop[], + handles: Vector[], + paintOpacity: number, +): GradientGeometry { + const [center] = handles; + const centerX = Math.round(center.x * 100); + const centerY = Math.round(center.y * 100); + + return { + stops: formatStops(gradientStops, paintOpacity), + cssGeometry: `circle at ${centerX}% ${centerY}%`, + }; +} + +/** + * Map angular gradient from Figma handles to CSS + */ +function mapAngularGradient( + gradientStops: GradientStop[], + handles: Vector[], + paintOpacity: number, +): GradientGeometry { + const [center, angleHandle] = handles; + const centerX = Math.round(center.x * 100); + const centerY = Math.round(center.y * 100); + + const angle = + Math.atan2(angleHandle.y - center.y, angleHandle.x - center.x) * (180 / Math.PI) + 90; + + return { + stops: formatStops(gradientStops, paintOpacity), + cssGeometry: `from ${Math.round(angle)}deg at ${centerX}% ${centerY}%`, + }; +} + +/** + * Map diamond gradient from Figma handles to CSS (approximate with ellipse) + */ +function mapDiamondGradient( + gradientStops: GradientStop[], + handles: Vector[], + paintOpacity: number, +): GradientGeometry { + const [center] = handles; + const centerX = Math.round(center.x * 100); + const centerY = Math.round(center.y * 100); + + return { + stops: formatStops(gradientStops, paintOpacity), + cssGeometry: `ellipse at ${centerX}% ${centerY}%`, + }; +} + +/** + * Per-type dispatch: how to compute each gradient's geometry + stops, and which + * CSS function wraps the result. Keying both halves off `gradient.type` in one + * table keeps the geometry mapper and its CSS wrapper from drifting apart — they + * were previously two separate switches that had to be kept in sync by hand. + */ +const GRADIENT_RENDERERS: Record< + GradientPaint["type"], + { map: GradientMapper; wrap: (geometry: string, stops: string) => string } +> = { + GRADIENT_LINEAR: { + map: mapLinearGradient, + wrap: (g, s) => `linear-gradient(${g}, ${s})`, + }, + GRADIENT_RADIAL: { + map: mapRadialGradient, + wrap: (g, s) => `radial-gradient(${g}, ${s})`, + }, + GRADIENT_ANGULAR: { + map: mapAngularGradient, + wrap: (g, s) => `conic-gradient(${g}, ${s})`, + }, + GRADIENT_DIAMOND: { + map: mapDiamondGradient, + wrap: (g, s) => `radial-gradient(${g}, ${s})`, + }, +}; + +/** + * Convert a Figma gradient to CSS gradient syntax. + */ +export function convertGradientToCss(gradient: GradientPaint): string { + // The paint's overall opacity multiplies into each stop's own alpha (the two stack). + const paintOpacity = gradient.opacity ?? 1; + const sortedStops = [...gradient.gradientStops].sort((a, b) => a.position - b.position); + const { map, wrap } = GRADIENT_RENDERERS[gradient.type]; + + // Without two handles there's no gradient line to map; emit stops at their raw + // positions and let the per-type wrapper supply a neutral "0deg" geometry. + const handles = gradient.gradientHandlePositions; + if (!handles || handles.length < 2) { + return wrap("0deg", formatStops(sortedStops, paintOpacity)); + } + + const { stops, cssGeometry } = map(sortedStops, handles, paintOpacity); + return wrap(cssGeometry, stops); +} diff --git a/src/transformers/style/image.ts b/src/transformers/style/image.ts new file mode 100644 index 0000000..350edbf --- /dev/null +++ b/src/transformers/style/image.ts @@ -0,0 +1,251 @@ +import type { Paint, Transform } from "@figma/rest-api-spec"; + +/** + * Simplified image fill with CSS properties and processing metadata + * + * This type represents an image fill that can be used as either: + * - background-image (when parent node has children) + * - tag (when parent node has no children) + * + * The CSS properties are mutually exclusive based on usage context. + */ +export type SimplifiedImageFill = { + type: "IMAGE"; + /** + * Reference to the embedded image asset. Omitted when Figma returns a null + * imageRef — typically images pasted in from a file you don't own, where the + * asset still renders in the editor (it's reachable from the source file) + * but isn't registered against your file. In that case download_figma_images + * falls back to rendering the containing node as a PNG via its nodeId. + */ + imageRef?: string; + /** + * Present when the fill is an animated GIF. Use this ref (instead of imageRef) when calling + * download_figma_images to retrieve the animated GIF file; imageRef only points to a static + * snapshot frame. + */ + gifRef?: string; + scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH"; + /** + * For TILE mode, the scaling factor relative to original image size + */ + scalingFactor?: number; + + // CSS properties for background-image usage (when node has children) + backgroundSize?: string; + backgroundRepeat?: string; + + // CSS properties for tag usage (when node has no children) + isBackground?: boolean; + objectFit?: string; + + // Image processing metadata (NOT for CSS translation) + // Used by download tools to determine post-processing needs + imageDownloadArguments?: { + /** + * Whether image needs cropping based on transform + */ + needsCropping: boolean; + /** + * Whether CSS variables for dimensions are needed to calculate the background size for TILE mode + * + * Figma bases scalingFactor on the image's original size. In CSS, background size (as a percentage) + * is calculated based on the size of the container. We need to pass back the original dimensions + * after processing to calculate the intended background size when translated to code. + */ + requiresImageDimensions: boolean; + /** + * Figma's transform matrix for Sharp processing + */ + cropTransform?: Transform; + /** + * Suggested filename suffix to make cropped images unique + * When the same imageRef is used multiple times with different crops, + * this helps avoid overwriting conflicts + */ + filenameSuffix?: string; + }; +}; + +export type SimplifiedPatternFill = { + type: "PATTERN"; + patternSource: { + /** + * Hardcode to expect PNG for now, consider SVG detection in the future. + * + * SVG detection is a bit challenging because the nodeId in question isn't + * guaranteed to be included in the response we're working with. No guaranteed + * way to look into it and see if it's only composed of vector shapes. + */ + type: "IMAGE-PNG"; + nodeId: string; + }; + backgroundRepeat: string; + backgroundSize: string; + backgroundPosition: string; +}; + +/** + * Translate Figma scale modes to CSS properties based on usage context + * + * @param scaleMode - The Figma scale mode (FILL, FIT, TILE, STRETCH) + * @param isBackground - Whether this image will be used as background-image (true) or tag (false) + * @param scalingFactor - For TILE mode, the scaling factor relative to original image size + * @returns Object containing CSS properties and processing metadata + */ +export function translateScaleMode( + scaleMode: "FILL" | "FIT" | "TILE" | "STRETCH", + hasChildren: boolean, + scalingFactor?: number, +): { + css: Partial; + processing: NonNullable; +} { + const isBackground = hasChildren; + + switch (scaleMode) { + case "FILL": + // Image covers entire container, may be cropped + return { + css: isBackground + ? { backgroundSize: "cover", backgroundRepeat: "no-repeat", isBackground: true } + : { objectFit: "cover", isBackground: false }, + processing: { needsCropping: false, requiresImageDimensions: false }, + }; + + case "FIT": + // Image fits entirely within container, may have empty space + return { + css: isBackground + ? { backgroundSize: "contain", backgroundRepeat: "no-repeat", isBackground: true } + : { objectFit: "contain", isBackground: false }, + processing: { needsCropping: false, requiresImageDimensions: false }, + }; + + case "TILE": + // Image repeats to fill container at specified scale + // Always treat as background image (can't tile an tag) + return { + css: { + backgroundRepeat: "repeat", + backgroundSize: scalingFactor + ? `calc(var(--original-width) * ${scalingFactor}) calc(var(--original-height) * ${scalingFactor})` + : "auto", + isBackground: true, + }, + processing: { needsCropping: false, requiresImageDimensions: true }, + }; + + case "STRETCH": + // Figma calls crop "STRETCH" in its API. + return { + css: isBackground + ? { backgroundSize: "100% 100%", backgroundRepeat: "no-repeat", isBackground: true } + : { objectFit: "fill", isBackground: false }, + processing: { needsCropping: false, requiresImageDimensions: false }, + }; + + default: + return { + css: {}, + processing: { needsCropping: false, requiresImageDimensions: false }, + }; + } +} + +/** + * Generate a short hash from a transform matrix to create unique filenames + * @param transform - The transform matrix to hash + * @returns Short hash string for filename suffix + */ +function generateTransformHash(transform: Transform): string { + const values = transform.flat(); + const hash = values.reduce((acc, val) => { + // Simple hash function - convert to string and create checksum + const str = val.toString(); + for (let i = 0; i < str.length; i++) { + acc = ((acc << 5) - acc + str.charCodeAt(i)) & 0xffffffff; + } + return acc; + }, 0); + + // Convert to positive hex string, take first 6 chars + return Math.abs(hash).toString(16).substring(0, 6); +} + +/** + * Handle imageTransform for post-processing (not CSS translation) + * + * When Figma includes an imageTransform matrix, it means the image is cropped/transformed. + * This function converts the transform into processing instructions for Sharp. + * + * @param imageTransform - Figma's 2x3 transform matrix [[scaleX, skewX, translateX], [skewY, scaleY, translateY]] + * @returns Processing metadata for image cropping + */ +export function handleImageTransform( + imageTransform: Transform, +): NonNullable { + const transformHash = generateTransformHash(imageTransform); + return { + needsCropping: true, + requiresImageDimensions: false, + cropTransform: imageTransform, + filenameSuffix: `${transformHash}`, + }; +} + +/** + * Convert a Figma PatternPaint to a CSS-like pattern fill. + * + * Ignores `tileType` and `spacing` from the Figma API currently as there's + * no great way to translate them to CSS. + * + * @param raw - The Figma PatternPaint to convert + * @returns The converted pattern SimplifiedFill + */ +export function parsePatternPaint(raw: Extract): SimplifiedPatternFill { + /** + * The only CSS-like repeat value supported by Figma is repeat. + * + * They also have hexagonal horizontal and vertical repeats, but + * those aren't easy to pull off in CSS, so we just use repeat. + */ + let backgroundRepeat = "repeat"; + + let horizontal = "left"; + switch (raw.horizontalAlignment) { + case "START": + horizontal = "left"; + break; + case "CENTER": + horizontal = "center"; + break; + case "END": + horizontal = "right"; + break; + } + + let vertical = "top"; + switch (raw.verticalAlignment) { + case "START": + vertical = "top"; + break; + case "CENTER": + vertical = "center"; + break; + case "END": + vertical = "bottom"; + break; + } + + return { + type: raw.type, + patternSource: { + type: "IMAGE-PNG", + nodeId: raw.sourceNodeId, + }, + backgroundRepeat, + backgroundSize: `${Math.round(raw.scalingFactor * 100)}%`, + backgroundPosition: `${horizontal} ${vertical}`, + }; +} diff --git a/src/transformers/text.ts b/src/transformers/text.ts new file mode 100644 index 0000000..13f3e71 --- /dev/null +++ b/src/transformers/text.ts @@ -0,0 +1,800 @@ +import type { Hyperlink, Node as FigmaDocumentNode, TypeStyle, Paint } from "@figma/rest-api-spec"; +import { isVisible, pixelRound, stableStringify } from "~/utils/common.js"; +import { hasValue } from "~/utils/identity.js"; +import { parsePaint, type SimplifiedFill } from "~/transformers/style.js"; + +export type SimplifiedTextStyle = Partial<{ + fontFamily: string; + fontStyle: string; + fontWeight: number; + fontSize: number; + lineHeight: string; + letterSpacing: string; + textCase: string; + textAlignHorizontal: string; + textAlignVertical: string; + italic: boolean; + // "NONE" appears on inline deltas only — it represents an inverse override + // where the base style has underline/strike and a per-character run clears + // it. The base textStyle never emits NONE (defaults drop out). + textDecoration: "STRIKETHROUGH" | "UNDERLINE" | "NONE"; + hyperlink: Hyperlink; + // Only non-zero flags are emitted; defaults stay out of the ref so two nodes + // that differ only in default flag values still dedupe. + opentypeFlags: Record; + paragraphSpacing: number; + paragraphIndent: number; + listSpacing: number; + // Text color overrides — only used on inline style-ref deltas, not the base + // textStyle (the node's `fills` handles color for the whole text node via + // visualsExtractor). Inline deltas need their own fills field because a + // styled run can override text color within a single node. + fills: SimplifiedFill[]; +}>; + +export function isTextNode( + n: FigmaDocumentNode, +): n is Extract { + return n.type === "TEXT"; +} + +export function hasTextStyle( + n: FigmaDocumentNode, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `any` needed to extract the style variant from the union +): n is FigmaDocumentNode & { style: Extract["style"] } { + return hasValue("style", n) && Object.keys(n.style).length > 0; +} + +// Letter-spacing is emitted as a font-size-relative `em` so it pastes straight +// into CSS and lets native targets (iOS/Flutter) resolve to absolute units by +// multiplying the fontSize in the same textStyle. We round to 4 decimals rather +// than reuse `pixelRound` (2 dp): letter-spacing fractions are O(0.01), where +// 0.01em is ~0.16px at a 16px font — coarse enough to visibly shift tracking. +// 4 dp preserves the precision the old "%" form carried (0.01% = 0.0001em). +function emRound(value: number): number { + return Number(value.toFixed(4)); +} + +export function extractTextStyle(n: FigmaDocumentNode) { + if (hasTextStyle(n)) { + const style = n.style; + const textStyle: SimplifiedTextStyle = { + fontFamily: style.fontFamily, + fontStyle: "fontStyle" in style && style.fontStyle ? style.fontStyle : undefined, + fontWeight: style.fontWeight, + fontSize: style.fontSize, + lineHeight: formatLineHeight(style as LineHeightSource, style.fontSize), + letterSpacing: + style.letterSpacing && style.letterSpacing !== 0 && style.fontSize + ? `${emRound(style.letterSpacing / style.fontSize)}em` + : undefined, + textCase: style.textCase, + textAlignHorizontal: style.textAlignHorizontal, + textAlignVertical: style.textAlignVertical, + italic: "italic" in style && style.italic ? true : undefined, + textDecoration: + "textDecoration" in style && + (style.textDecoration === "STRIKETHROUGH" || style.textDecoration === "UNDERLINE") + ? style.textDecoration + : undefined, + hyperlink: "hyperlink" in style && style.hyperlink ? style.hyperlink : undefined, + opentypeFlags: pickNonZeroFlags("opentypeFlags" in style ? style.opentypeFlags : undefined), + paragraphSpacing: + "paragraphSpacing" in style && style.paragraphSpacing && style.paragraphSpacing > 0 + ? style.paragraphSpacing + : undefined, + paragraphIndent: + "paragraphIndent" in style && style.paragraphIndent && style.paragraphIndent > 0 + ? style.paragraphIndent + : undefined, + listSpacing: + "listSpacing" in style && style.listSpacing && style.listSpacing > 0 + ? style.listSpacing + : undefined, + }; + return textStyle; + } +} + +function pickNonZeroFlags( + flags: Record | undefined, +): Record | undefined { + if (!flags) return undefined; + const nonZero: Record = {}; + for (const [k, v] of Object.entries(flags)) { + if (v) nonZero[k] = v; + } + return Object.keys(nonZero).length ? nonZero : undefined; +} + +/** + * Format Figma's line-height fields into a CSS string that preserves the + * unit the designer actually specified, or omit when the text uses "auto". + * + * INTRINSIC_% → undefined (auto — follows the font's intrinsic metrics; + * `lineHeightPx` is just the computed value for whichever + * font the node happens to use, not user intent) + * PIXELS → "24px" from `lineHeightPx` + * FONT_SIZE_% → "1.5em" from `lineHeightPercentFontSize` + * + * Relative line-height is emitted as `em` (not `%`) to match letterSpacing and + * give one canonical font-size-relative form: it pastes straight into CSS and + * native targets resolve it by multiplying the fontSize in the same textStyle. + * Absolute line-height stays `px`. Falls back to an em conversion when the unit + * is missing (older API responses) so the output is never worse than before. + * All numeric values are rounded via `pixelRound` so floating-point noise like + * Figma's `16.94318199157715` doesn't leak through. + */ +type LineHeightSource = { + lineHeightPx?: number; + lineHeightUnit?: string; + lineHeightPercentFontSize?: number; +}; + +function formatLineHeight( + source: LineHeightSource, + fontSize: number | undefined, +): string | undefined { + const { lineHeightUnit, lineHeightPx, lineHeightPercentFontSize } = source; + + if (lineHeightUnit === "INTRINSIC_%") return undefined; + + if (lineHeightUnit === "PIXELS" && lineHeightPx) { + return `${pixelRound(lineHeightPx)}px`; + } + + if (lineHeightUnit === "FONT_SIZE_%" && lineHeightPercentFontSize) { + return `${pixelRound(lineHeightPercentFontSize / 100)}em`; + } + + if (lineHeightPx && fontSize) { + return `${pixelRound(lineHeightPx / fontSize)}em`; + } + + return undefined; +} + +// --------------------------------------------------------------------------- +// Rich text (inline formatting) +// --------------------------------------------------------------------------- + +/** + * Callback used by `buildFormattedText` to register a style-ref delta for a + * styled run and receive the inline ID (e.g. `ts1`) that should wrap the run + * in the output. Keeping the side effects (ID generation, globalVars mutation, + * dedup) in the caller lets this module stay a near-pure transformer. + */ +type RegisterInlineTextStyle = (delta: SimplifiedTextStyle) => string; + +type BuildFormattedTextResult = { + text: string; + /** + * Numeric font weight that `**` maps to in `text`. Only present when the + * node has per-character bold overrides, so the consumer knows what weight + * the markdown bold represents. + */ + boldWeight?: number; +}; + +type Run = { + /** + * Raw character range for this run — not yet escaped or wrapped. + * Stored as an array of code points so we can slice the characters string + * without splitting surrogate pairs when handling emoji / astral chars. + */ + text: string; + /** Deduped delta against the base style (only properties that actually differ). */ + delta: Partial; +}; + +type Classification = { + isBold: boolean; + isItalic: boolean; + isStrike: boolean; + /** URL for `[text](url)` rendering — only set for `type: "URL"` hyperlinks. */ + urlLink?: string; + /** Delta to wrap in `{tsN}...{/tsN}` — undefined when no style-ref props remain. */ + refDelta?: SimplifiedTextStyle; +}; + +/** + * Fields ignored by the delta computation. These are either book-keeping + * (semanticWeight, semanticItalic, isOverrideOverTextStyle) that don't affect + * visual output, or fields we explicitly don't carry into the simplified + * representation (fontPostScriptName, boundVariables). + */ +const IGNORED_TYPE_STYLE_FIELDS = new Set([ + "semanticWeight", + "semanticItalic", + "isOverrideOverTextStyle", + "fontPostScriptName", + "boundVariables", +]); + +/** + * Build a formatted markdown + inline style-ref representation of a text + * node's mixed character formatting. + * + * Algorithm (matches `docs/plans/2026-04-08-feat-rich-text-styling-plan.md`): + * 1. Split characters by newline / paragraph-separator into lines, carrying + * a per-line slice of `characterStyleOverrides`. + * 2. For each line: split into runs based on the line's overrides, compute + * each run's delta against the base `style` (dropping no-op overrides), + * and merge adjacent identical runs. + * 3. Determine the canonical `boldWeight` — the heavier fontWeight that + * appears across the most characters across all lines. This is what + * plain `**` maps to. + * 4. Classify each run's delta into markdown (bold/italic/strike/URL link) + * + residual style-ref properties, then render: escape raw text, wrap + * style-ref deltas on the outside, markdown markers on the inside. + * 5. Prepend a list marker to each line based on `lineTypes` / + * `lineIndentations` (ordered `1.`, unordered `-`, nested with 2-space + * CommonMark indent). + * 6. Join lines with a literal `\n` (two characters, backslash + n). Real + * newlines in the output would cause the YAML serializer to emit a + * block scalar with per-line indentation — one indent level per nesting + * depth, multiplied by every line. Literal `\n` keeps the entire string + * on a single YAML plain scalar. + * + * Why markdown on the inside: `{ts1}**text**{/ts1}` keeps markdown markers + * contiguous and lets the style ref describe a visual region decorated by + * markdown within it. The reverse nesting would fragment markdown across + * every style boundary. + */ +export function buildFormattedText( + node: FigmaDocumentNode, + registerStyle: RegisterInlineTextStyle, +): BuildFormattedTextResult { + if (!isTextNode(node)) { + return { text: "" }; + } + const characters = node.characters ?? ""; + if (characters.length === 0) { + return { text: "" }; + } + + const overrides = node.characterStyleOverrides ?? []; + const lineTypes: Array<"NONE" | "ORDERED" | "UNORDERED"> = + "lineTypes" in node && Array.isArray(node.lineTypes) ? node.lineTypes : []; + const lineIndentations: number[] = + "lineIndentations" in node && Array.isArray(node.lineIndentations) ? node.lineIndentations : []; + + // Defensive: synthetic test fixtures sometimes lack a base style. The + // delta pipeline can't run without one, so emit characters as-is. + // (Real Figma TEXT nodes always have `style` per the API spec.) + if (!node.style) { + return { text: escapeMarkdown(characters) }; + } + + const hasOverrides = overrides.some((id) => id !== 0); + const hasList = lineTypes.some((t) => t === "ORDERED" || t === "UNORDERED"); + + // Fast path: nothing to format. `escapeMarkdown` still rewrites real + // newlines to a literal `\n` so multi-line plain text stays compact in + // YAML output. + if (!hasOverrides && !hasList) { + return { text: escapeMarkdown(characters) }; + } + + // Split into code points so a surrogate pair stays with its run. + const codePoints = Array.from(characters); + const overrideTable = (node.styleOverrideTable ?? {}) as Record; + const baseStyle: TypeStyle = node.style; + + const lines = splitLines(codePoints, overrides); + const perLineRuns: Run[][] = lines.map((line) => + computeRunsForLine(line.codePoints, line.overrides, overrideTable, baseStyle), + ); + + // boldWeight is detected once across every run in every line — `**` maps + // to a single canonical weight for the whole text node, not per-line. + const boldWeight = detectBoldWeight(perLineRuns.flat(), baseStyle); + + const listState = new ListState(); + const renderedLines: string[] = []; + for (let i = 0; i < lines.length; i++) { + let lineOutput = ""; + for (const run of perLineRuns[i]) { + const classification = classifyRun(run.delta, baseStyle, boldWeight); + lineOutput += renderRun(run.text, classification, registerStyle); + } + const type = lineTypes[i] ?? "NONE"; + const depth = lineIndentations[i] ?? 0; + renderedLines.push(listState.advance(type, depth) + lineOutput); + } + + // Join with a literal `\n` (backslash + n, two chars). See the + // `escapeMarkdown` comment for why real newlines aren't used. + const text = renderedLines.join("\\n"); + return boldWeight !== undefined ? { text, boldWeight } : { text }; +} + +/** + * Split characters into lines at `\n` / `\u2029` (paragraph separator — the + * Figma API allows both). Each line carries its slice of the overrides + * array. The newline character itself is discarded along with its override. + * + * The returned line count matches what Figma's `lineTypes` / + * `lineIndentations` arrays expect: a trailing newline produces an empty + * final line. + */ +function splitLines( + codePoints: string[], + overrides: number[], +): Array<{ codePoints: string[]; overrides: number[] }> { + const lines: Array<{ codePoints: string[]; overrides: number[] }> = []; + let lineStart = 0; + for (let i = 0; i <= codePoints.length; i++) { + const ch = i < codePoints.length ? codePoints[i] : null; + if (ch === "\n" || ch === "\u2029" || ch === null) { + lines.push({ + codePoints: codePoints.slice(lineStart, i), + overrides: overrides.slice(lineStart, i), + }); + lineStart = i + 1; + } + } + return lines; +} + +/** + * Compute the merged run list for a single line — split by override + * boundaries, diff each range against the base style, merge adjacent runs + * with equal deltas. + * + * Kept separate from `buildFormattedText` so the list-aware caller can run + * the run pipeline once per line without duplicating the merge logic, and + * so the trailing-zero handling (`overrides[i] ?? 0`) operates on the + * caller's per-line slice of the overrides array rather than the whole. + */ +function computeRunsForLine( + codePoints: string[], + overrides: number[], + overrideTable: Record, + baseStyle: TypeStyle, +): Run[] { + if (codePoints.length === 0) return []; + + const rawRuns: Run[] = []; + let runStart = 0; + for (let i = 0; i <= codePoints.length; i++) { + // Trailing entries of characterStyleOverrides can be omitted, in which + // case they implicitly mean override ID 0 (base style). Past-end sentinel + // is -1 so we always close the final run on the last iteration. + const currentId = i < codePoints.length ? (overrides[i] ?? 0) : -1; + const startId = runStart < codePoints.length ? (overrides[runStart] ?? 0) : 0; + if ((i === codePoints.length || currentId !== startId) && i > runStart) { + rawRuns.push({ + text: codePoints.slice(runStart, i).join(""), + delta: computeDelta(startId, overrideTable, baseStyle), + }); + runStart = i; + } + } + + const runs: Run[] = []; + for (const run of rawRuns) { + const prev = runs[runs.length - 1]; + if (prev && deltasEqual(prev.delta, run.delta)) { + prev.text += run.text; + } else { + runs.push({ ...run }); + } + } + return runs; +} + +/** + * Tracks ordered-list counters across lines so `1. / 2. / 3.` numbering is + * correct even with nested lists and non-list interruptions. + * + * Counter scope rules: + * - Counters at depths *deeper* than the current line are cleared — moving + * shallower closes any nested lists below. + * - A non-ORDERED line at depth d (UNORDERED or NONE) clears depth d's + * counter, so a subsequent ORDERED line there restarts at 1. + * - Counters at depths *shallower* than the current line are untouched — + * an outer ordered list continues across nested interruptions. + */ +class ListState { + private counters = new Map(); + + /** + * Mutates the counter state and returns the list prefix for the next line. + * Named `advance` (not `nextPrefix`) to telegraph the side effect — two + * calls with the same arguments return different strings as the counter + * increments. + */ + advance(type: "NONE" | "ORDERED" | "UNORDERED", depth: number): string { + for (const k of Array.from(this.counters.keys())) { + if (k > depth) this.counters.delete(k); + } + + // CommonMark nests lists with 2-space indentation per level. Plain + // (NONE) lines are not indented — list-adjacent prose sits at column 0. + const indent = " ".repeat(depth); + + if (type === "ORDERED") { + const n = (this.counters.get(depth) ?? 0) + 1; + this.counters.set(depth, n); + return `${indent}${n}. `; + } + + if (type === "UNORDERED") { + this.counters.delete(depth); + return `${indent}- `; + } + + this.counters.delete(depth); + return ""; + } +} + +/** + * Compute the delta for an override ID against the base style. + * + * Returns only the properties that differ from the base. Override ID 0 and + * missing entries both mean "no delta". We filter out no-op overrides — e.g. + * a leftover `fontWeight: 400` in the override table when the base is already + * 400 — because they would otherwise produce empty style refs. + */ +function computeDelta( + overrideId: number, + overrideTable: Record, + baseStyle: TypeStyle, +): Partial { + if (overrideId === 0) return {}; + const override = overrideTable[String(overrideId)]; + if (!override) return {}; + + const delta: Record = {}; + for (const [key, value] of Object.entries(override)) { + if (IGNORED_TYPE_STYLE_FIELDS.has(key)) continue; + if (value === undefined) continue; + const baseValue = (baseStyle as Record)[key]; + if (JSON.stringify(baseValue) === JSON.stringify(value)) continue; + delta[key] = value; + } + return delta as Partial; +} + +function deltasEqual(a: Partial, b: Partial): boolean { + return stableStringify(a) === stableStringify(b); +} + +/** + * Pick the numeric weight that `**` should map to when a node has bold + * overrides: the heavier-than-base weight that covers the most characters. + * Ties break toward the heavier weight so `600 vs 800` at equal usage picks + * `800`. + */ +function detectBoldWeight(runs: Run[], baseStyle: TypeStyle): number | undefined { + const baseWeight = baseStyle.fontWeight ?? 400; + const counts = new Map(); + for (const run of runs) { + const w = run.delta.fontWeight; + if (typeof w === "number" && w > baseWeight) { + counts.set(w, (counts.get(w) ?? 0) + run.text.length); + } + } + if (counts.size === 0) return undefined; + let bestWeight: number | undefined; + let bestCount = -1; + for (const [weight, count] of counts) { + if (count > bestCount || (count === bestCount && weight > (bestWeight ?? 0))) { + bestWeight = weight; + bestCount = count; + } + } + return bestWeight; +} + +/** + * Split a run's delta into markdown decorations and residual style-ref props. + * + * Markdown handles: bold (fontWeight > base), italic (italic:true when base is + * not italic), strikethrough, URL hyperlinks. A run can fall into *both* + * buckets — bold + red text produces `{ts1}**text**{/ts1}` where `ts1` carries + * the fills delta. + * + * Inverse overrides (regular on a bold base, non-italic on an italic base) + * can't be expressed in markdown, so they fall through into the style-ref + * delta as explicit `fontWeight` / `italic` properties. + */ +function classifyRun( + delta: Partial, + baseStyle: TypeStyle, + boldWeight: number | undefined, +): Classification { + const c: Classification = { isBold: false, isItalic: false, isStrike: false }; + const refDelta: SimplifiedTextStyle = {}; + let hasRefProps = false; + + const baseWeight = baseStyle.fontWeight ?? 400; + const baseItalic = baseStyle.italic === true; + + // Effective fontSize for unit conversions (em, %) — the override may carry + // its own fontSize, otherwise fall back to the base. + const effectiveFontSize = + typeof delta.fontSize === "number" ? delta.fontSize : (baseStyle.fontSize ?? 0); + + for (const [key, rawValue] of Object.entries(delta)) { + const value = rawValue as unknown; + switch (key) { + case "fontWeight": { + const w = value as number; + if (w > baseWeight) { + c.isBold = true; + // A heavy weight that doesn't match the canonical bold weight still + // renders as `**`, but also carries an explicit fontWeight in the + // ref so the consumer can realize the actual weight. + if (boldWeight !== undefined && w !== boldWeight) { + refDelta.fontWeight = w; + hasRefProps = true; + } + } else { + // Lighter-than-base override — markdown can't remove bold, so emit + // as a style ref with the explicit weight. + refDelta.fontWeight = w; + hasRefProps = true; + } + break; + } + case "italic": { + const italic = value as boolean; + if (italic && !baseItalic) { + c.isItalic = true; + } else if (!italic && baseItalic) { + refDelta.italic = false; + hasRefProps = true; + } + break; + } + case "textDecoration": { + const td = value as "NONE" | "STRIKETHROUGH" | "UNDERLINE"; + const baseDecoration = (baseStyle as { textDecoration?: string }).textDecoration; + if (td === "STRIKETHROUGH") { + c.isStrike = true; + // If the base had UNDERLINE, the inherited underline still applies + // on top of our `~~` — clear it explicitly so the run is only + // strikethrough, matching the designer's intent. + if (baseDecoration === "UNDERLINE") { + refDelta.textDecoration = "STRIKETHROUGH"; + hasRefProps = true; + } + } else if (td === "UNDERLINE") { + refDelta.textDecoration = "UNDERLINE"; + hasRefProps = true; + } else if (td === "NONE" && baseDecoration) { + // Inverse override: the base had decoration and this run removes + // it. Markdown can't express decoration removal, so emit an + // explicit `NONE` delta that the consumer can use to suppress the + // inherited base. + refDelta.textDecoration = "NONE"; + hasRefProps = true; + } + break; + } + case "hyperlink": { + const link = value as Hyperlink; + if (link.type === "URL" && link.url) { + c.urlLink = link.url; + } else { + // NODE hyperlinks have no markdown equivalent — carry through as a + // style-ref property so the consumer can at least see the link. + refDelta.hyperlink = link; + hasRefProps = true; + } + break; + } + case "fills": { + const paints = value as Paint[]; + const fills = paints + .filter(isVisible) + .map((p) => parsePaint(p, false)) + .reverse(); + if (fills.length) { + refDelta.fills = fills; + hasRefProps = true; + } + break; + } + case "fontFamily": { + refDelta.fontFamily = value as string; + hasRefProps = true; + break; + } + case "fontStyle": { + // Figma's fontStyle is the named variant like "Bold Italic". It's + // informational — italic/fontWeight carry the actual visual data. + // Pass through so the consumer sees the exact variant name. + refDelta.fontStyle = value as string; + hasRefProps = true; + break; + } + case "fontSize": { + refDelta.fontSize = value as number; + hasRefProps = true; + break; + } + case "letterSpacing": { + const ls = value as number; + if (ls && effectiveFontSize) { + refDelta.letterSpacing = `${emRound(ls / effectiveFontSize)}em`; + hasRefProps = true; + } + break; + } + case "lineHeightPx": + case "lineHeightUnit": + case "lineHeightPercent": + case "lineHeightPercentFontSize": { + // Line-height is a multi-field concept (px/unit/%). Any of the four + // landing in the delta means the run's effective line-height + // differs from the base. Resolve the merged shape (override wins + // per field) and format once — running `formatLineHeight` on each + // case would emit duplicate refs for the same logical change. + if (refDelta.lineHeight !== undefined) break; + const merged: LineHeightSource = { + lineHeightPx: delta.lineHeightPx ?? (baseStyle as LineHeightSource).lineHeightPx, + lineHeightUnit: + (delta as LineHeightSource).lineHeightUnit ?? + (baseStyle as LineHeightSource).lineHeightUnit, + lineHeightPercentFontSize: + (delta as LineHeightSource).lineHeightPercentFontSize ?? + (baseStyle as LineHeightSource).lineHeightPercentFontSize, + }; + const formatted = formatLineHeight(merged, effectiveFontSize); + if (formatted) { + refDelta.lineHeight = formatted; + hasRefProps = true; + } + break; + } + case "textCase": { + refDelta.textCase = value as string; + hasRefProps = true; + break; + } + case "textAlignHorizontal": { + refDelta.textAlignHorizontal = value as string; + hasRefProps = true; + break; + } + case "textAlignVertical": { + refDelta.textAlignVertical = value as string; + hasRefProps = true; + break; + } + case "opentypeFlags": { + const nonZero = pickNonZeroFlags(value as Record); + if (nonZero) { + refDelta.opentypeFlags = nonZero; + hasRefProps = true; + } + break; + } + // paragraphSpacing / paragraphIndent / listSpacing are passed through + // to the textStyle ref but intentionally NOT consumed during the + // markdown rendering itself. Markdown has no representation for + // pixel-valued vertical whitespace, and conflating `paragraphIndent` + // with the list-nesting indent we already use for `lineIndentations` + // would corrupt list structure. Consumers that need these values read + // them off the ref directly. + case "paragraphSpacing": { + if (typeof value === "number" && value > 0) { + refDelta.paragraphSpacing = value; + hasRefProps = true; + } + break; + } + case "paragraphIndent": { + if (typeof value === "number" && value > 0) { + refDelta.paragraphIndent = value; + hasRefProps = true; + } + break; + } + case "listSpacing": { + if (typeof value === "number" && value > 0) { + refDelta.listSpacing = value; + hasRefProps = true; + } + break; + } + // Unknown / unmapped TypeStyle fields are ignored — they either don't + // have a visual effect we preserve today (e.g. textAutoResize) or + // don't appear as per-run overrides in practice. + default: + break; + } + } + + if (hasRefProps) c.refDelta = refDelta; + return c; +} + +/** + * Characters that must be escaped to avoid being interpreted as markdown + * (or as the inline style-ref delimiter). + * + * Escaping happens BEFORE wrappers are inserted — otherwise a literal `*` + * from user text would become an accidental italic marker once wrapped. + * Backslash is included so `\` in user text doesn't merge with our own + * escapes. + * + * Real newlines and paragraph separators get rewritten to a literal `\n` + * (two characters, backslash + n). Emitting real newlines would force the + * YAML serializer to wrap the value in a block scalar whose every line + * inherits the parent's indentation — for a text node 4 levels deep + * that's 8+ wasted spaces per line. The literal form keeps the whole + * string on a single YAML plain scalar regardless of nesting depth. + * Backslash is already escaped by the first pass so user content that + * literally contains `\n` stays unambiguous (it becomes `\\n`). + */ +const MARKDOWN_ESCAPE_CHARS = /[\\*_~[\](){}]/g; + +function escapeMarkdown(text: string): string { + return text.replace(MARKDOWN_ESCAPE_CHARS, "\\$&").replace(/[\n\u2029]/g, "\\n"); +} + +/** + * CommonMark emphasis markers can't have whitespace flanking the inner text + * (e.g. `** bold**` isn't bold). Pull any leading/trailing whitespace out of + * the run before applying markdown wrappers so the markers hug the text. + */ +function splitEdgeWhitespace(text: string): { leading: string; core: string; trailing: string } { + const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(text); + if (!match) return { leading: "", core: text, trailing: "" }; + return { leading: match[1], core: match[2], trailing: match[3] }; +} + +/** + * Escape a URL for use as a markdown link destination. Parens would close the + * destination early, and whitespace ends the URL in CommonMark — percent- + * encode both. Note `encodeURIComponent` itself leaves `(` and `)` alone, so + * we map them explicitly. + */ +function escapeLinkUrl(url: string): string { + return url.replace(/[()\s]/g, (ch) => { + if (ch === "(") return "%28"; + if (ch === ")") return "%29"; + return encodeURIComponent(ch); + }); +} + +/** + * Render a single run with wrappers applied outer-to-inner: + * {tsN} → [...]( ) → ~~ → ** → * + * + * This ordering ensures that when two decorations collide on one run, the + * broader visual region (the style ref) surrounds the narrower markdown + * decoration, and the link text contains the formatted content. + */ +function renderRun( + rawText: string, + c: Classification, + registerStyle: RegisterInlineTextStyle, +): string { + const hasMarkdownWrap = c.isItalic || c.isBold || c.isStrike || c.urlLink !== undefined; + // Pull whitespace outside any markdown wrappers so `**bold** ` instead of + // `**bold **`. Skip the split when there's no wrapping — escaping the raw + // string directly is all we need. + const { leading, core, trailing } = hasMarkdownWrap + ? splitEdgeWhitespace(rawText) + : { leading: "", core: rawText, trailing: "" }; + + let inner = escapeMarkdown(core); + if (c.isItalic) inner = `*${inner}*`; + if (c.isBold) inner = `**${inner}**`; + if (c.isStrike) inner = `~~${inner}~~`; + if (c.urlLink) inner = `[${inner}](${escapeLinkUrl(c.urlLink)})`; + + let output = `${escapeMarkdown(leading)}${inner}${escapeMarkdown(trailing)}`; + + if (c.refDelta) { + const id = registerStyle(c.refDelta); + output = `{${id}}${output}{/${id}}`; + } + return output; +} diff --git a/src/utils/common.ts b/src/utils/common.ts new file mode 100644 index 0000000..4854974 --- /dev/null +++ b/src/utils/common.ts @@ -0,0 +1,237 @@ +import fs from "fs"; +import path from "path"; +import { tagError } from "~/utils/error-meta.js"; +import { isWithin } from "~/utils/local-path.js"; + +/** + * Download Figma image and save it locally + * @param fileName - The filename to save as + * @param localPath - The local path to save to + * @param imageUrl - Image URL (images[nodeId]) + * @returns A Promise that resolves to the full file path where the image was saved + * @throws Error if download fails + */ +export async function downloadFigmaImage( + fileName: string, + localPath: string, + imageUrl: string, +): Promise { + try { + // Ensure local path exists + if (!fs.existsSync(localPath)) { + fs.mkdirSync(localPath, { recursive: true }); + } + + const fullPath = path.resolve(path.join(localPath, fileName)); + if (!isWithin(localPath, fullPath)) { + tagError(new Error(`File path escapes target directory: ${fileName}`), { + category: "invalid_input", + }); + } + + // Use fetch to download the image + const response = await fetch(imageUrl, { + method: "GET", + }); + + if (!response.ok) { + tagError(new Error(`Failed to download image: ${response.statusText}`), { + category: "image_download", + }); + } + + // Create write stream + const writer = fs.createWriteStream(fullPath); + + // Get the response as a readable stream and pipe it to the file + const reader = response.body?.getReader(); + if (!reader) { + tagError(new Error("Failed to get response body"), { category: "image_download" }); + } + + return new Promise((resolve, reject) => { + // Process stream + const processStream = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + writer.end(); + break; + } + writer.write(value); + } + } catch (err) { + writer.end(); + fs.unlink(fullPath, () => {}); + reject(err); + } + }; + + // Resolve only when the stream is fully written + writer.on("finish", () => { + resolve(fullPath); + }); + + writer.on("error", (err) => { + reader.cancel(); + fs.unlink(fullPath, () => {}); + reject(new Error(`Failed to write image: ${err.message}`)); + }); + + processStream(); + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + throw new Error(`Error downloading image: ${errorMessage}`, { cause: error }); + } +} + +/** + * Remove keys with empty arrays or empty objects from an object. + * @param input - The input object or value. + * @returns The processed object or the original value. + */ +export function removeEmptyKeys(input: T): T { + // If not an object type or null, return directly + if (typeof input !== "object" || input === null) { + return input; + } + + // Handle array type + if (Array.isArray(input)) { + return input.map((item) => removeEmptyKeys(item)) as T; + } + + // Handle object type + const result = {} as T; + for (const key in input) { + if (Object.prototype.hasOwnProperty.call(input, key)) { + const value = input[key]; + + // Recursively process nested objects + const cleanedValue = removeEmptyKeys(value); + + // Skip empty arrays and empty objects + if ( + cleanedValue !== undefined && + !(Array.isArray(cleanedValue) && cleanedValue.length === 0) && + !( + typeof cleanedValue === "object" && + cleanedValue !== null && + Object.keys(cleanedValue).length === 0 + ) + ) { + result[key] = cleanedValue; + } + } + } + + return result; +} + +/** + * Generate a CSS shorthand for values that come with top, right, bottom, and left + * + * input: { top: 10, right: 10, bottom: 10, left: 10 } + * output: "10px" + * + * input: { top: 10, right: 20, bottom: 10, left: 20 } + * output: "10px 20px" + * + * input: { top: 10, right: 20, bottom: 30, left: 40 } + * output: "10px 20px 30px 40px" + * + * @param values - The values to generate the shorthand for + * @returns The generated shorthand + */ +export function generateCSSShorthand( + values: { + top: number; + right: number; + bottom: number; + left: number; + }, + { + ignoreZero = true, + suffix = "px", + }: { + /** + * If true and all values are 0, return undefined. Defaults to true. + */ + ignoreZero?: boolean; + /** + * The suffix to add to the shorthand. Defaults to "px". + */ + suffix?: string; + } = {}, +) { + const { top, right, bottom, left } = values; + if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) { + return undefined; + } + if (top === right && right === bottom && bottom === left) { + return `${top}${suffix}`; + } + if (right === left) { + if (top === bottom) { + return `${top}${suffix} ${right}${suffix}`; + } + return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`; + } + return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`; +} + +/** + * Check if an element is visible + * @param element - The item to check + * @returns True if the item is visible, false otherwise + */ +export function isVisible(element: { visible?: boolean }): boolean { + return element.visible ?? true; +} + +/** + * Rounds a number to two decimal places, suitable for pixel value processing. + * @param num The number to be rounded. + * @returns The rounded number with two decimal places. + * @throws TypeError If the input is not a valid number + */ +export function pixelRound(num: number): number { + if (isNaN(num)) { + throw new TypeError(`Input must be a valid number`); + } + return Number(Number(num).toFixed(2)); +} + +/** + * Compile-time exhaustiveness guard for discriminated unions. + * + * Place in the default branch of a switch over a union: the `value: never` parameter + * forces TS to error here if any union member was missed, and the `never` return type + * tells control-flow analysis that execution doesn't continue (so callers don't need a + * trailing return). Throws at runtime as a defense against type-system bypasses + * (`as`, JSON inputs, etc.) — should never actually fire in well-typed code. + */ +export function exhaustiveCheck(value: never): never { + throw new Error(`Unhandled discriminant: ${String(value)}`); +} + +/** + * Serialize a value to JSON with sorted object keys so two equal-but- + * differently-ordered objects produce the same string. Used for cache keys + * and deep-equality checks where property order isn't a stable guarantee + * (e.g. partial TypeStyle entries from Figma's styleOverrideTable). + */ +export function stableStringify(value: unknown): string { + return JSON.stringify(value, (_key, v) => { + if (v && typeof v === "object" && !Array.isArray(v)) { + const sorted: Record = {}; + for (const k of Object.keys(v as Record).sort()) { + sorted[k] = (v as Record)[k]; + } + return sorted; + } + return v; + }); +} diff --git a/src/utils/error-meta.ts b/src/utils/error-meta.ts new file mode 100644 index 0000000..f8dff38 --- /dev/null +++ b/src/utils/error-meta.ts @@ -0,0 +1,83 @@ +/** + * Structured metadata attached to thrown errors so telemetry can categorize + * failures without parsing error messages. Producers tag errors via `tagError`; + * consumers read merged meta from the error chain via `getErrorMeta`. + * + * Why a Symbol key: it never collides with caller-defined error fields and + * doesn't show up in `Object.keys` or `JSON.stringify`, so attaching meta is + * invisible to downstream code that doesn't know about it. + * + * Why mutate instead of wrapping: re-throwing the original error preserves + * the stack trace and instance identity. A subclass tree would drown the + * useful debugging info in synthetic frames. Wrapping is reserved for the + * cases where we want to add context to the message itself (e.g. figma.ts + * annotates the endpoint), and those wrappers chain via `cause` so the + * original meta is still discoverable. + */ + +export type ErrorPhase = + | "validate" + | "fetch" + | "simplify" + | "serialize" + | "download" + | "format_response"; + +export type ErrorCategory = + | "rate_limit" + | "auth" + | "not_found" + | "invalid_input" + | "network" + | "figma_api" + | "image_download" + | "internal"; + +export type ErrorMeta = { + phase?: ErrorPhase; + category?: ErrorCategory; + http_status?: number; + network_code?: string; + fs_code?: string; + is_retryable?: boolean; +}; + +const META = Symbol.for("framelink.errorMeta"); + +type WithMeta = { [META]?: ErrorMeta }; + +/** + * Attach metadata to an error and re-throw it. Existing meta on the same + * error is preserved; new fields override matching keys. + */ +export function tagError(error: unknown, meta: ErrorMeta): never { + if (error && typeof error === "object") { + const existing = (error as WithMeta)[META] ?? {}; + (error as WithMeta)[META] = { ...existing, ...meta }; + } + throw error; +} + +/** + * Walk the error → cause chain and merge any attached meta. Outer errors win + * for overlapping keys — a `phase` tagged at the pipeline level should not be + * overridden by an inner wrapper that doesn't know which phase it's running in. + */ +export function getErrorMeta(error: unknown): ErrorMeta { + const merged: ErrorMeta = {}; + let current: unknown = error; + const seen = new Set(); + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current); + const meta = (current as WithMeta)[META]; + if (meta) { + for (const [key, value] of Object.entries(meta) as Array<[keyof ErrorMeta, unknown]>) { + if (merged[key] === undefined && value !== undefined) { + (merged as Record)[key] = value; + } + } + } + current = (current as { cause?: unknown }).cause; + } + return merged; +} diff --git a/src/utils/fetch-json.ts b/src/utils/fetch-json.ts new file mode 100644 index 0000000..4382888 --- /dev/null +++ b/src/utils/fetch-json.ts @@ -0,0 +1,144 @@ +import { tagError, type ErrorCategory } from "~/utils/error-meta.js"; + +type RequestOptions = RequestInit & { + /** + * Force format of headers to be a record of strings, e.g. { "Authorization": "Bearer 123" } + * + * Avoids complexity of needing to deal with `instanceof Headers`, which is not supported in some environments. + */ + headers?: Record; + /** + * Secrets to scrub from the response body before it's attached to a thrown + * HttpError. Defense in depth: api.figma.com never echoes credentials, but + * HTTP intermediaries (corporate proxies, MITM filters) sometimes mirror + * request metadata into error pages. + */ + redactFromResponseBody?: string[]; +}; + +/** + * Error thrown on HTTP failures. Carries the response headers so callers can + * read rate-limit metadata without needing the original Response object, plus + * the (whitespace-collapsed, secret-redacted, truncated) response body so + * callers can distinguish Figma errors from proxy/intermediary errors. + * + * Modeled as a class rather than a structural Error extension so consumers + * get a real `instanceof HttpError` check instead of an unsafe cast. + */ +export class HttpError extends Error { + readonly responseHeaders: Record; + readonly responseBody: string | undefined; + + constructor( + message: string, + opts: { responseHeaders: Record; responseBody: string | undefined }, + ) { + super(message); + this.name = "HttpError"; + this.responseHeaders = opts.responseHeaders; + this.responseBody = opts.responseBody; + } +} + +const CONNECTION_ERROR_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", + "ENOTFOUND", + "UND_ERR_CONNECT_TIMEOUT", +]); + +// HTTP statuses where retrying might succeed: rate limits and transient +// server-side failures. 4xx other than 429 are caller errors and not retryable. +const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); + +// Cap the attached response body. Corp-firewall HTML blocks can be 50KB+; +// we only need enough to identify the origin ("Blocked by Zscaler", etc.). +const MAX_RESPONSE_BODY_CHARS = 500; + +export async function fetchJSON( + url: string, + options: RequestOptions = {}, +): Promise<{ data: T; rawSize: number }> { + const { redactFromResponseBody = [], ...fetchOptions } = options; + try { + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + const responseBody = await readResponseBody(response, redactFromResponseBody.filter(Boolean)); + const bodySuffix = responseBody ? `\nResponse body: ${responseBody}` : ""; + const httpError = new HttpError( + `Fetch failed with status ${response.status}: ${response.statusText}${bodySuffix}`, + { responseHeaders, responseBody }, + ); + tagError(httpError, { + http_status: response.status, + category: httpStatusCategory(response.status), + is_retryable: RETRYABLE_STATUSES.has(response.status), + }); + } + // Read as text first so we can measure the raw body size for telemetry, + // then parse. This is the same work response.json() does internally, just + // split so we can observe the byte count before parsing. + const text = await response.text(); + const rawSize = Buffer.byteLength(text, "utf8"); + const data = JSON.parse(text) as T; + return { data, rawSize }; + } catch (error: unknown) { + const networkCode = getConnectionErrorCode(error); + if (networkCode) { + const message = error instanceof Error ? error.message : String(error); + const wrapped = new Error( + `${message}\n\nCould not connect to the Figma API. If your network requires a proxy, ` + + `set the --proxy flag in your MCP server config or the FIGMA_PROXY environment variable ` + + `to your proxy URL (e.g. http://proxy:8080).`, + { cause: error }, + ); + tagError(wrapped, { network_code: networkCode, category: "network", is_retryable: true }); + } + throw error; + } +} + +function getConnectionErrorCode(error: unknown): string | undefined { + if (!(error instanceof Error)) return undefined; + const cause = (error as { cause?: { code?: string } }).cause; + if (cause?.code && CONNECTION_ERROR_CODES.has(cause.code)) return cause.code; + return undefined; +} + +function httpStatusCategory(status: number): ErrorCategory { + if (status === 429) return "rate_limit"; + if (status === 401 || status === 403) return "auth"; + return "figma_api"; +} + +async function readResponseBody( + response: Response, + redactSecrets: string[], +): Promise { + // Body read can fail if the connection is killed mid-response; we'd rather + // surface the status/headers we already have than mask it with a body-read + // error. + let text: string; + try { + text = await response.text(); + } catch { + return undefined; + } + if (!text) return undefined; + + // Collapse whitespace so HTML error pages read as one line in error messages. + let result = text.replace(/\s+/g, " ").trim(); + for (const secret of redactSecrets) { + result = result.replaceAll(secret, "[REDACTED]"); + } + if (result.length > MAX_RESPONSE_BODY_CHARS) { + result = result.slice(0, MAX_RESPONSE_BODY_CHARS) + "… [truncated]"; + } + return result; +} diff --git a/src/utils/figma-url.ts b/src/utils/figma-url.ts new file mode 100644 index 0000000..557f86b --- /dev/null +++ b/src/utils/figma-url.ts @@ -0,0 +1,31 @@ +import { tagError } from "~/utils/error-meta.js"; + +export interface FigmaUrlParts { + fileKey: string; + nodeId: string | undefined; +} + +const FIGMA_PATH_PATTERN = /^\/(file|design)\/([a-zA-Z0-9]+)/; + +export function parseFigmaUrl(input: string): FigmaUrlParts { + const url = new URL(input); + + if (url.hostname !== "figma.com" && !url.hostname.endsWith(".figma.com")) { + tagError(new Error(`Not a Figma URL: ${input}`), { category: "invalid_input" }); + } + + const match = url.pathname.match(FIGMA_PATH_PATTERN); + if (!match) { + tagError(new Error(`Could not extract file key from Figma URL: ${input}`), { + category: "invalid_input", + }); + } + + const fileKey = match[2]; + + // Figma URLs encode node IDs with dashes (1-2), but the API expects colons (1:2) + const rawNodeId = url.searchParams.get("node-id"); + const nodeId = rawNodeId ? rawNodeId.replace(/-/g, ":") : undefined; + + return { fileKey, nodeId }; +} diff --git a/src/utils/identity.ts b/src/utils/identity.ts new file mode 100644 index 0000000..7ec4505 --- /dev/null +++ b/src/utils/identity.ts @@ -0,0 +1,137 @@ +import type { + Rectangle, + HasLayoutTrait, + StrokeWeights, + HasFramePropertiesTrait, +} from "@figma/rest-api-spec"; +import { isTruthy } from "remeda"; +import type { CSSHexColor, CSSRGBAColor } from "~/transformers/style.js"; + +export { isTruthy }; + +export function hasValue( + key: K, + obj: unknown, + typeGuard?: (val: unknown) => val is T, +): obj is Record { + const isObject = typeof obj === "object" && obj !== null; + if (!isObject || !(key in obj)) return false; + const val = (obj as Record)[key]; + return typeGuard ? typeGuard(val) : val !== undefined; +} + +// Checks for `HasFramePropertiesTrait`, not node type. This is the FRAME family +// — FRAME, GROUP, COMPONENT, COMPONENT_SET, INSTANCE — i.e. the nodes that can +// carry auto-layout properties like `layoutMode`, `paddingTop`, etc. NOT a +// general "is container" check: SECTION, BOOLEAN_OPERATION, and TABLE all hold +// children but do not have frame properties. Structural checking via +// `clipsContent` covers the FRAME family without maintaining a type-string list. +export function isFrame(val: unknown): val is HasFramePropertiesTrait { + return ( + typeof val === "object" && + !!val && + "clipsContent" in val && + typeof val.clipsContent === "boolean" + ); +} + +export function isLayout(val: unknown): val is HasLayoutTrait { + return ( + typeof val === "object" && + !!val && + "absoluteBoundingBox" in val && + typeof val.absoluteBoundingBox === "object" && + !!val.absoluteBoundingBox && + "x" in val.absoluteBoundingBox && + "y" in val.absoluteBoundingBox && + "width" in val.absoluteBoundingBox && + "height" in val.absoluteBoundingBox + ); +} + +/** + * Whether a node uses flex-style auto-layout (HORIZONTAL or VERTICAL layoutMode). + * + * Narrower than the general "auto-layout" concept — does NOT match `layoutMode: "GRID"`. + * GRID has a different positioning model (gridRowAnchorIndex etc.) and a different schema + * mapping (CSS Grid rather than flex), so callers that care about row/column flex + * semantics specifically should use this; callers that want "any non-NONE auto-layout" + * should use {@link hasAutoLayout}. + */ +export function hasFlexLayout(val: unknown): val is HasFramePropertiesTrait { + return isFrame(val) && (val.layoutMode === "HORIZONTAL" || val.layoutMode === "VERTICAL"); +} + +/** + * Whether a node uses CSS-grid-style auto-layout (`layoutMode: "GRID"`). + * + * Children of grid frames are positioned via gridRow/ColumnAnchorIndex + gridRow/ColumnSpan + * rather than flex flow or absolute coordinates. + */ +export function hasGridLayout(val: unknown): val is HasFramePropertiesTrait { + return isFrame(val) && val.layoutMode === "GRID"; +} + +/** + * Whether a node uses any form of Figma auto-layout — flex (HORIZONTAL/VERTICAL) or GRID. + * + * Use this when the question is "did the designer hand-position children, or did they let + * Figma's layout engine do it?" — e.g., deciding whether to skip absolute-positioning + * emission, or whether to preserve authored structure when collapsing SVG containers. + * + * When the answer matters per-mode (e.g., emitting flex vs grid CSS), branch on + * `layoutMode` directly or use the narrower {@link hasFlexLayout} / {@link hasGridLayout}. + */ +export function hasAutoLayout(val: unknown): val is HasFramePropertiesTrait { + return hasFlexLayout(val) || hasGridLayout(val); +} + +/** + * Checks if: + * 1. A node is a child to an auto-layout frame (flex or grid) + * 2. The child adheres to the auto-layout rules—i.e. it's not absolutely positioned + * + * @param node - The node to check. + * @param parent - The parent node. + * @returns True if the node is a child of an auto-layout frame, false otherwise. + */ +export function isInAutoLayoutFlow(node: unknown, parent: unknown): boolean { + return hasAutoLayout(parent) && isLayout(node) && node.layoutPositioning !== "ABSOLUTE"; +} + +export function isStrokeWeights(val: unknown): val is StrokeWeights { + return ( + typeof val === "object" && + val !== null && + "top" in val && + "right" in val && + "bottom" in val && + "left" in val + ); +} + +export function isRectangle( + key: K, + obj: T, +): obj is T & { [P in K]: Rectangle } { + const recordObj = obj as Record; + return ( + typeof obj === "object" && + !!obj && + key in recordObj && + typeof recordObj[key] === "object" && + !!recordObj[key] && + "x" in recordObj[key] && + "y" in recordObj[key] && + "width" in recordObj[key] && + "height" in recordObj[key] + ); +} + +export function isRectangleCornerRadii(val: unknown): val is number[] { + return Array.isArray(val) && val.length === 4 && val.every((v) => typeof v === "number"); +} + +export function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor { + return typeof val === "string" && (val.startsWith("#") || val.startsWith("rgba")); +} diff --git a/src/utils/image-processing.ts b/src/utils/image-processing.ts new file mode 100644 index 0000000..a091006 --- /dev/null +++ b/src/utils/image-processing.ts @@ -0,0 +1,249 @@ +import { createJimp } from "@jimp/core"; +import png from "@jimp/js-png"; +import jpeg from "@jimp/js-jpeg"; +import gif from "@jimp/js-gif"; +import * as crop from "@jimp/plugin-crop"; +import type { Transform } from "@figma/rest-api-spec"; + +const Jimp = createJimp({ formats: [png, jpeg, gif], plugins: [crop.methods] }); + +/** + * Apply crop transform to an image based on Figma's transformation matrix + * @param imagePath - Path to the original image file + * @param cropTransform - Figma transform matrix [[scaleX, skewX, translateX], [skewY, scaleY, translateY]] + * @returns Promise - Path to the cropped image + */ +export async function applyCropTransform( + imagePath: string, + cropTransform: Transform, +): Promise { + const { Logger } = await import("./logger.js"); + + try { + // Extract transform values (skew values intentionally unused for now) + const scaleX = cropTransform[0]?.[0] ?? 1; + const translateX = cropTransform[0]?.[2] ?? 0; + const scaleY = cropTransform[1]?.[1] ?? 1; + const translateY = cropTransform[1]?.[2] ?? 0; + + const image = await Jimp.read(imagePath); + const { width, height } = image; + + // Calculate crop region based on transform matrix + // Figma's transform matrix represents how the image is positioned within its container + // We need to extract the visible portion based on the scaling and translation + + // The transform matrix defines the visible area as: + // - scaleX/scaleY: how much of the original image is visible (0-1) + // - translateX/translateY: offset of the visible area (0-1, relative to image size) + + const cropLeft = Math.max(0, Math.round(translateX * width)); + const cropTop = Math.max(0, Math.round(translateY * height)); + const cropWidth = Math.min(width - cropLeft, Math.round(scaleX * width)); + const cropHeight = Math.min(height - cropTop, Math.round(scaleY * height)); + + if (cropWidth <= 0 || cropHeight <= 0) { + Logger.log(`Invalid crop dimensions for ${imagePath}, using original image`); + return imagePath; + } + + image.crop({ x: cropLeft, y: cropTop, w: cropWidth, h: cropHeight }); + await image.write(imagePath as `${string}.${string}`); + + Logger.log(`Cropped image saved (overwritten): ${imagePath}`); + Logger.log( + `Crop region: ${cropLeft}, ${cropTop}, ${cropWidth}x${cropHeight} from ${width}x${height}`, + ); + + return imagePath; + } catch (error) { + Logger.error(`Error cropping image ${imagePath}:`, error); + return imagePath; + } +} + +/** + * Get image dimensions from a file + * @param imagePath - Path to the image file + * @returns Promise<{width: number, height: number}> + */ +export async function getImageDimensions(imagePath: string): Promise<{ + width: number; + height: number; +}> { + const image = await Jimp.read(imagePath); + return { width: image.width, height: image.height }; +} + +/** + * Read an SVG's intrinsic dimensions from its markup. + * + * jimp only decodes rasters, so the SVG branch can't measure files the way the + * raster path does — but an SVG already declares its own size as text. Prefer the + * `width`/`height` attributes (Figma exports them as plain user units, e.g. + * `width="52"`); fall back to the viewBox's width/height when they're missing or + * non-absolute (percentages aren't an intrinsic pixel size). Returns {0,0} only + * when the markup carries no usable size at all. + */ +export function parseSvgDimensions(svg: string): { width: number; height: number } { + const openTag = svg.match(/]*>/i)?.[0] ?? ""; + + const attr = (name: string): string | undefined => + openTag.match(new RegExp(`\\b${name}\\s*=\\s*["']([^"']*)["']`, "i"))?.[1]; + + const parseLength = (raw: string | undefined): number | undefined => { + // Percentages (and other relative units) aren't an intrinsic pixel size. + if (!raw || raw.includes("%")) return undefined; + const n = parseFloat(raw); + return Number.isFinite(n) && n > 0 ? n : undefined; + }; + + const width = parseLength(attr("width")); + const height = parseLength(attr("height")); + if (width !== undefined && height !== undefined) { + return { width, height }; + } + + // viewBox is "min-x min-y width height" — the last two are the intrinsic size. + const viewBox = attr("viewBox"); + if (viewBox) { + const [, , vbWidth, vbHeight] = viewBox + .trim() + .split(/[\s,]+/) + .map(Number); + if (vbWidth > 0 && vbHeight > 0) { + return { width: vbWidth, height: vbHeight }; + } + } + + return { width: 0, height: 0 }; +} + +export type ImageProcessingResult = { + filePath: string; + originalDimensions: { width: number; height: number }; + finalDimensions: { width: number; height: number }; + wasCropped: boolean; + cropRegion?: { left: number; top: number; width: number; height: number }; + cssVariables?: string; + processingLog: string[]; +}; + +/** + * Enhanced image download with post-processing + * @param fileName - The filename to save as + * @param localPath - The local path to save to + * @param imageUrl - Image URL + * @param needsCropping - Whether to apply crop transform + * @param cropTransform - Transform matrix for cropping + * @param requiresImageDimensions - Whether to generate dimension metadata + * @returns Promise - Detailed processing information + */ +export async function downloadAndProcessImage( + fileName: string, + localPath: string, + imageUrl: string, + needsCropping: boolean = false, + cropTransform?: Transform, + requiresImageDimensions: boolean = false, +): Promise { + const { Logger } = await import("./logger.js"); + const processingLog: string[] = []; + + // First download the original image + const { downloadFigmaImage } = await import("./common.js"); + const originalPath = await downloadFigmaImage(fileName, localPath, imageUrl); + Logger.log(`Downloaded original image: ${originalPath}`); + + // SVGs are vector — jimp can't read them and cropping doesn't apply. Their + // intrinsic size is declared in the markup, so read it from there rather than + // reporting a misleading 0x0 (which reads like a download failure). + const isSvg = fileName.toLowerCase().endsWith(".svg"); + if (isSvg) { + const { readFile } = await import("node:fs/promises"); + const dimensions = parseSvgDimensions(await readFile(originalPath, "utf-8")); + Logger.log(`SVG dimensions: ${dimensions.width}x${dimensions.height}`); + return { + filePath: originalPath, + originalDimensions: dimensions, + finalDimensions: dimensions, + wasCropped: false, + cssVariables: requiresImageDimensions ? generateImageCSSVariables(dimensions) : undefined, + processingLog, + }; + } + + // Get original dimensions before any processing + const originalDimensions = await getImageDimensions(originalPath); + Logger.log(`Original dimensions: ${originalDimensions.width}x${originalDimensions.height}`); + + let finalPath = originalPath; + let wasCropped = false; + let cropRegion: { left: number; top: number; width: number; height: number } | undefined; + + // Apply crop transform if needed (skip for GIFs — cropping destroys animation frames) + if (needsCropping && cropTransform && !fileName.toLowerCase().endsWith(".gif")) { + Logger.log("Applying crop transform..."); + + // Extract crop region info before applying transform + const scaleX = cropTransform[0]?.[0] ?? 1; + const scaleY = cropTransform[1]?.[1] ?? 1; + const translateX = cropTransform[0]?.[2] ?? 0; + const translateY = cropTransform[1]?.[2] ?? 0; + + const cropLeft = Math.max(0, Math.round(translateX * originalDimensions.width)); + const cropTop = Math.max(0, Math.round(translateY * originalDimensions.height)); + const cropWidth = Math.min( + originalDimensions.width - cropLeft, + Math.round(scaleX * originalDimensions.width), + ); + const cropHeight = Math.min( + originalDimensions.height - cropTop, + Math.round(scaleY * originalDimensions.height), + ); + + if (cropWidth > 0 && cropHeight > 0) { + cropRegion = { left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight }; + finalPath = await applyCropTransform(originalPath, cropTransform); + wasCropped = true; + Logger.log(`Cropped to region: ${cropLeft}, ${cropTop}, ${cropWidth}x${cropHeight}`); + } else { + Logger.log("Invalid crop dimensions, keeping original image"); + } + } + + // Get final dimensions after processing + const finalDimensions = await getImageDimensions(finalPath); + Logger.log(`Final dimensions: ${finalDimensions.width}x${finalDimensions.height}`); + + // Generate CSS variables if required (for TILE mode) + let cssVariables: string | undefined; + if (requiresImageDimensions) { + cssVariables = generateImageCSSVariables(finalDimensions); + } + + return { + filePath: finalPath, + originalDimensions, + finalDimensions, + wasCropped, + cropRegion, + cssVariables, + processingLog, + }; +} + +/** + * Create CSS custom properties for image dimensions + * @param imagePath - Path to the image file + * @returns Promise - CSS custom properties + */ +export function generateImageCSSVariables({ + width, + height, +}: { + width: number; + height: number; +}): string { + return `--original-width: ${width}px; --original-height: ${height}px;`; +} diff --git a/src/utils/local-path.ts b/src/utils/local-path.ts new file mode 100644 index 0000000..603bd02 --- /dev/null +++ b/src/utils/local-path.ts @@ -0,0 +1,86 @@ +import path from "path"; + +export type ResolveLocalPathFailureReason = "outside_image_dir" | "drive_letter_on_posix"; +export type ResolveLocalPathResult = + | { ok: true; resolvedPath: string } + | { ok: false; reason: ResolveLocalPathFailureReason }; + +// Subset of the `path` module we use. Both `path.posix` and `path.win32` +// satisfy this shape, so tests can exercise cross-platform behavior on a +// single host. +type PathImpl = Pick; + +/** + * Resolve a user-supplied path against an allowed base directory, accepting + * absolute inputs only when they lexically resolve inside the base. + * + * The previous implementation used `path.join(base, raw)` so that a leading + * slash from the LLM ("/public/images") would be silently treated as a + * relative path. That hack hid a class of cross-OS bugs: an absolute Windows + * path mangled by the LLM into POSIX shape (e.g. drive letter stripped) was + * also "silently treated as relative," concatenated onto the base, and + * resulted in a doubled-up directory structure under imageDir. The tool + * reported success and the file was never where the user expected. + * + * This resolver makes ambiguous inputs loud: + * - On POSIX servers, backslashes are normalized to forward slashes so an + * LLM can use either separator. Drive-letter prefixes ("C:/..." or + * "C:\\...") still reject loudly though — they aren't recognized as + * absolute by path.posix.isAbsolute, so they'd otherwise resolve to + * "/C:/..." and silently miswrite. + * - Absolute paths must lexically resolve inside the base directory. + * Naturally accepts "user pasted a full path that happens to be inside + * the project" while rejecting absolute paths pointing elsewhere. + * - Containment is computed via path.relative — a single source of truth + * replacing ad-hoc startsWith(base + sep) checks that mishandle drive + * roots on Windows. + * + * Not a defense against symlinks/junctions: the check is lexical, so a + * symlink under the base that points outside is not detected. If a real + * filesystem boundary is needed later, compare `realpath` of base and + * candidate's existing parent. + */ +export function resolveLocalPath( + rawPath: string, + baseDir: string, + pathImpl: PathImpl = path, +): ResolveLocalPathResult { + let normalized = rawPath; + if (pathImpl.sep === "/") { + // Drive-letter prefixes aren't recognized as absolute by path.posix, so + // "C:\\Users\\..." or "C:/Users/..." would resolve to "/C:/Users/..." + // and silently miswrite. The path is meaningless on POSIX regardless. + if (/^[A-Za-z]:[/\\]/.test(rawPath)) { + return { ok: false, reason: "drive_letter_on_posix" }; + } + // Otherwise normalize backslashes to forward slashes so an LLM can use + // either separator without thinking about host OS. Backslashes are valid + // filename characters on POSIX in theory, but real-world filenames + // virtually never contain them and the common case (LLM mixing styles) + // is far more important. + normalized = rawPath.replace(/\\/g, "/"); + } + + const base = pathImpl.resolve(baseDir); + const candidate = pathImpl.isAbsolute(normalized) + ? pathImpl.resolve(normalized) + : pathImpl.resolve(base, normalized); + + if (!isWithin(base, candidate, pathImpl)) { + return { ok: false, reason: "outside_image_dir" }; + } + + return { ok: true, resolvedPath: candidate }; +} + +/** + * True when `candidate` is `base` itself or a descendant of `base`. + * + * Uses path.relative rather than `startsWith(base + sep)` because the latter + * mishandles drive roots on Windows (where `base` already ends with a + * separator and concatenating another would double it). + */ +export function isWithin(base: string, candidate: string, pathImpl: PathImpl = path): boolean { + const rel = pathImpl.relative(base, candidate); + return rel === "" || (!rel.startsWith("..") && !pathImpl.isAbsolute(rel)); +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..8c7b077 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,41 @@ +import fs from "fs"; + +/* eslint-disable @typescript-eslint/no-explicit-any -- logging accepts arbitrary values */ +export const Logger = { + isHTTP: false, + log: (...args: any[]) => { + if (Logger.isHTTP) { + console.log("[INFO]", ...args); + } else { + console.error("[INFO]", ...args); + } + }, + error: (...args: any[]) => { + console.error("[ERROR]", ...args); + }, +}; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- writes arbitrary debug data +export function writeLogs(name: string, value: any): void { + if (process.env.NODE_ENV !== "development") return; + + try { + const logsDir = "logs"; + const logPath = `${logsDir}/${name}`; + + // Check if we can write to the current directory + fs.accessSync(process.cwd(), fs.constants.W_OK); + + // Create logs directory if it doesn't exist + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + fs.writeFileSync(logPath, JSON.stringify(value, null, 2)); + Logger.log(`Debug log written to: ${logPath}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + Logger.log(`Failed to write logs to ${name}: ${errorMessage}`); + } +} diff --git a/src/utils/node-names.ts b/src/utils/node-names.ts new file mode 100644 index 0000000..ea7ade0 --- /dev/null +++ b/src/utils/node-names.ts @@ -0,0 +1,31 @@ +/** + * Figma auto-names new layers after the tool that created them: `Rectangle 12`, + * `Frame 5`, `Ellipse 6`, `Group 3`. These names carry no design intent — the + * serialized line already states the node `[TYPE]` and its `#id`, so the name is + * pure context-budget noise. + * + * The trailing ` \d+` is REQUIRED, not optional: a bare word like `Line`, `Star`, + * `Group`, or `Section` is just as likely to be a deliberate designer-given name, + * and stripping it would lose real signal. Only the unmistakable `Word 123` form + * is auto-generated. (Mirrors framelink-app's design-parser AUTO_GENERATED_NAME_PATTERN.) + */ +const AUTO_GENERATED_NAME = + /^(?:Frame|Rectangle|Ellipse|Line|Vector|Group|Component|Instance|Polygon|Star|Text|Image|Slice|Section|Boolean|Union|Subtract|Intersect|Exclude|Arrow)\s+\d+$/; + +export function isAutoGeneratedName(name: string): boolean { + return AUTO_GENERATED_NAME.test(name); +} + +/** + * True when a node's `name` adds no signal over the rest of its serialized line + * and should be dropped. Shared by all output formats so tree/yaml/json stay + * consistent. + * + * TEXT layers are dropped wholesale: Figma auto-names them after their content, + * so the name is nearly always a duplicate of — or, worse, a stale copy left + * behind after the content was edited — the `text` value we already emit. A stale + * name actively misleads the LLM consumer, so it's never worth keeping. + */ +export function isNoiseName(name: string, type: string | undefined): boolean { + return type === "TEXT" || isAutoGeneratedName(name); +} diff --git a/src/utils/proxy-env.ts b/src/utils/proxy-env.ts new file mode 100644 index 0000000..a36ddac --- /dev/null +++ b/src/utils/proxy-env.ts @@ -0,0 +1,28 @@ +/** + * Which dispatcher is installed on the global fetch. `env` means + * EnvHttpProxyAgent is routing, but a specific request may still go direct + * when NO_PROXY matches — treat this as configuration state, not as + * "was this request proxied." + */ +export type ProxyMode = "none" | "explicit" | "env"; + +const PROXY_ENV_VARS = ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"]; + +/** + * Whether the host environment has any HTTP proxy var set (case-insensitive). + * Shared so server.ts, telemetry/client.ts, and figma.ts agree on what counts + * as "proxy env" — don't inline this check. + */ +export function hasProxyEnv(): boolean { + return PROXY_ENV_VARS.some((n) => process.env[n] || process.env[n.toLowerCase()]); +} + +let currentMode: ProxyMode = "none"; + +export function setProxyMode(mode: ProxyMode): void { + currentMode = mode; +} + +export function proxyMode(): ProxyMode { + return currentMode; +} diff --git a/src/utils/serializable-design.ts b/src/utils/serializable-design.ts new file mode 100644 index 0000000..71049fc --- /dev/null +++ b/src/utils/serializable-design.ts @@ -0,0 +1,36 @@ +import type { SimplifiedDesign, SimplifiedNode } from "~/extractors/types.js"; +import { isNoiseName } from "./node-names.js"; + +export function wrapForSerialization(design: SimplifiedDesign) { + const { nodes, globalVars, elements, ...metadata } = design; + return { + metadata, + nodes: nodes.map((node) => stripNoiseName(node, elements)), + globalVars, + elements, + }; +} + +/** + * Drop noise node names (auto-generated, or any TEXT layer name) once, here, + * before any serializer runs — so tree/yaml/json all emit the same names without + * each format re-deriving the rule. Returns a shallow copy rather than mutating + * the source, since the simplified design may be read again (e.g. logs/metrics). + * + * A template-ref node carries no `type` of its own (it lives in the shared + * element), so resolve it from `elements` to catch templated TEXT nodes too. + */ +function stripNoiseName( + node: SimplifiedNode, + elements: SimplifiedDesign["elements"], +): SimplifiedNode { + const children = node.children?.map((child) => stripNoiseName(child, elements)); + const next: SimplifiedNode = children ? { ...node, children } : { ...node }; + const type = node.type ?? (node.template ? elements[node.template]?.type : undefined); + if (next.name !== undefined && isNoiseName(next.name, type)) { + delete next.name; + } + return next; +} + +export type SerializableDesign = ReturnType; diff --git a/src/utils/serialize-tree.ts b/src/utils/serialize-tree.ts new file mode 100644 index 0000000..15c6821 --- /dev/null +++ b/src/utils/serialize-tree.ts @@ -0,0 +1,123 @@ +import type { SimplifiedNode } from "~/extractors/types.js"; +import type { SerializableDesign } from "./serializable-design.js"; +import { dumpYaml } from "./yaml-dump.js"; + +/** + * Render the simplified design as a token-efficient indented tree. + * + * Structural keys (id, name, type, children) are encoded positionally on each + * node line, eliminating the YAML/JSON overhead of repeating those keys for + * every node. Style values stay deduplicated in a globalVars block at the top, + * so identical styling across many nodes still pays once — the win over + * inline-only formats grows with how much style reuse the design has. + * + * Node line format: + * [TYPE] "name" #id key=value key=value ... + * + * All SimplifiedNode fields are preserved; this is a serialization change only. + */ +export function serializeAsTree(design: SerializableDesign): string { + const sections: string[] = []; + + // Quote the design name — designers can use anything, including ":" or + // whitespace, which would otherwise produce a malformed `NAME: foo: bar` line. + sections.push(`NAME: ${quote(design.metadata.name)}`); + + if (Object.keys(design.globalVars.styles).length > 0) { + sections.push(`\nGLOBAL_VARS:\n${dumpYaml(design.globalVars.styles)}`); + } + + // Deduplicated element bodies referenced by node `template=` fields below. + if (design.elements && Object.keys(design.elements).length > 0) { + sections.push(`ELEMENTS:\n${dumpYaml(design.elements)}`); + } + + if (Object.keys(design.metadata.components).length > 0) { + sections.push(`COMPONENTS:\n${dumpYaml(design.metadata.components)}`); + } + + if (Object.keys(design.metadata.componentSets).length > 0) { + sections.push(`COMPONENT_SETS:\n${dumpYaml(design.metadata.componentSets)}`); + } + + const lines: string[] = ["NODES:"]; + for (const node of design.nodes) { + renderNode(node, 0, lines, design.elements); + } + sections.push(lines.join("\n")); + + return sections.join("\n"); +} + +function renderNode( + node: SimplifiedNode, + depth: number, + out: string[], + elements: SerializableDesign["elements"], +): void { + const indent = " ".repeat(depth); + const parts: string[] = []; + + // A template reference carries no body of its own — its type and styling live + // in the shared element. Render the type label from there so the line keeps the + // familiar `[TYPE] "name" #id` shape, then point at the template. + const element = node.template ? elements?.[node.template] : undefined; + parts.push(`[${element?.type ?? node.type}]`); + // Name is dropped upstream (wrapForSerialization) when it is noise, so the + // token is conditional and the line collapses to `[TYPE] #id ...`. + if (node.name !== undefined) parts.push(quote(node.name)); + parts.push(`#${node.id}`); + if (node.template !== undefined) parts.push(`template=${node.template}`); + + // Order chosen to put high-signal properties first + if (node.layout !== undefined) parts.push(`layout=${renderStyleValue(node.layout)}`); + if (node.fills !== undefined) parts.push(`fills=${renderStyleValue(node.fills)}`); + if (node.strokes !== undefined) parts.push(`strokes=${renderStyleValue(node.strokes)}`); + if (node.strokeWeight !== undefined) parts.push(`strokeWeight=${maybeQuote(node.strokeWeight)}`); + if (node.strokeWeights !== undefined) { + parts.push(`strokeWeights=${maybeQuote(node.strokeWeights)}`); + } + if (node.strokeDashes !== undefined) parts.push(`strokeDashes=${node.strokeDashes.join(",")}`); + if (node.effects !== undefined) parts.push(`effects=${renderStyleValue(node.effects)}`); + if (node.opacity !== undefined) parts.push(`opacity=${node.opacity}`); + if (node.borderRadius !== undefined) parts.push(`borderRadius=${maybeQuote(node.borderRadius)}`); + if (node.styles !== undefined) parts.push(`styles=${maybeQuote(node.styles)}`); + if (node.componentId !== undefined) parts.push(`componentId=${node.componentId}`); + if (node.componentProperties !== undefined) { + parts.push(`componentProperties=${JSON.stringify(node.componentProperties)}`); + } + if (node.componentPropertyReferences !== undefined) { + parts.push(`componentPropertyReferences=${JSON.stringify(node.componentPropertyReferences)}`); + } + if (node.textStyle !== undefined) parts.push(`textStyle=${renderStyleValue(node.textStyle)}`); + if (node.boldWeight !== undefined) parts.push(`boldWeight=${node.boldWeight}`); + if (node.text !== undefined) parts.push(`text=${quote(node.text)}`); + + out.push(indent + parts.join(" ")); + + if (node.children) { + for (const child of node.children) { + renderNode(child, depth + 1, out, elements); + } + } +} + +// Style fields hold either a globalVars reference (a short scalar id) or, for +// single-use values after the finalize pass, the inline value itself. Refs render +// bare; inline objects/arrays render as compact JSON (consistent with how +// componentProperties is rendered), keeping the whole node on one line. +function renderStyleValue(value: unknown): string { + return typeof value === "string" ? maybeQuote(value) : JSON.stringify(value); +} + +// Always JSON-quote name and text so embedded whitespace, quotes, or newlines +// can't break the line-per-node parse contract. +function quote(s: string): string { + return JSON.stringify(s); +} + +// Quote only when the value would otherwise break the space-separated +// `key=value` parse — keeps short scalar refs (`layout_ABC`, `12px`) unquoted. +function maybeQuote(s: string): string { + return /[\s"]/.test(s) ? JSON.stringify(s) : s; +} diff --git a/src/utils/serialize.ts b/src/utils/serialize.ts new file mode 100644 index 0000000..3ecab17 --- /dev/null +++ b/src/utils/serialize.ts @@ -0,0 +1,21 @@ +import { serializeAsTree } from "./serialize-tree.js"; +import type { SerializableDesign } from "./serializable-design.js"; +import { dumpYaml } from "./yaml-dump.js"; + +export type OutputFormat = "yaml" | "json" | "tree"; + +export const VALID_OUTPUT_FORMATS: readonly OutputFormat[] = ["yaml", "json", "tree"]; + +export function isOutputFormat(value: string): value is OutputFormat { + return (VALID_OUTPUT_FORMATS as readonly string[]).includes(value); +} + +// Accepts `unknown` so YAML/JSON callers can pass arbitrary structures (tests, +// debug dumps, partial fixtures); the tree path requires the design wrapper +// shape and casts at its boundary. Production callers go through +// `wrapForSerialization` which enforces the contract at compile time. +export function serializeResult(result: unknown, format: OutputFormat): string { + if (format === "json") return JSON.stringify(result, null, 2); + if (format === "tree") return serializeAsTree(result as SerializableDesign); + return dumpYaml(result); +} diff --git a/src/utils/yaml-dump.ts b/src/utils/yaml-dump.ts new file mode 100644 index 0000000..60025a7 --- /dev/null +++ b/src/utils/yaml-dump.ts @@ -0,0 +1,13 @@ +import yaml from "js-yaml"; + +// Output goes to LLMs, not human editors — optimize for speed over readability. +// noRefs skips O(n²) reference detection; lineWidth:-1 skips line-folding; +// JSON_SCHEMA reduces per-string implicit type checks. +export function dumpYaml(value: unknown): string { + return yaml.dump(value, { + noRefs: true, + lineWidth: -1, + noCompatMode: true, + schema: yaml.JSON_SCHEMA, + }); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1cfed5c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "rootDir": "src", + "paths": { + "~/*": ["./src/*"] + }, + + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "verbatimModuleSyntax": true, + "allowJs": true, + "checkJs": true, + + /* EMIT RULES */ + "outDir": "./dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "removeComments": true, + + "types": ["vitest/globals"], + + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..dda15ac --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "tsup"; + +const isDev = process.env.npm_lifecycle_event === "dev"; +const packageVersion = process.env.npm_package_version; + +export default defineConfig({ + clean: true, + entry: ["src/index.ts", "src/bin.ts", "src/mcp-server.ts"], + format: ["esm"], + minify: !isDev, + target: "esnext", + outDir: "dist", + outExtension: ({ format: _format }) => ({ + js: ".js", + }), + onSuccess: isDev ? "node dist/bin.js" : undefined, + define: { + "process.env.NPM_PACKAGE_VERSION": JSON.stringify(packageVersion), + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..18dd92b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + resolve: { + alias: { + "~": path.resolve(__dirname, "src"), + }, + }, + test: { + globals: true, + testTimeout: 30_000, + }, +});