diff --git a/.changeset/fuzzy-numbers-marry.md b/.changeset/fuzzy-numbers-marry.md deleted file mode 100644 index 3e2453e..0000000 --- a/.changeset/fuzzy-numbers-marry.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@shopify/dev-mcp": patch ---- - -Added get_started tool diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d317421 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - uses: actions/setup-node@v3 + name: Setup node.js + with: + cache: npm + node-version-file: ".nvmrc" + + - name: Install dependencies + run: npm ci + shell: bash + + - name: Build + run: npm run build + + - name: Create Release Pull Request or Publish to npm + id: changesets + uses: changesets/action@v1 + with: + publish: npm run release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4f9fd6b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# @shopify/dev-mcp + +## 1.2.0 + +### Minor Changes + +- 4c3c9fb: ## Major Improvements + + ### Build System Overhaul (PR #88) + + - Migrated from TypeScript compiler (tsc) to Vite for faster builds and better bundling + - Implemented automatic tool loading from directories, improving modularity + - Switched to env-paths for cross-platform cache directory support + - Renamed output to index.js and made it executable for better npm bin support + + ### Theme Validation Enhancements + + - **Full theme validation** (PR #70): Added `validate_theme` tool to check entire theme directories + - **Theme file validation** (PR #69): Added validation for individual theme files and codeblocks + - **Improved validation granularity** (PR #84): Separated partial and full theme validation capabilities + - **Smart validation** (PR #83): Automatically run liquid validation on files modified by LLM + - **Enhanced error reporting** (PR #82): Added support for schema and doc errors in liquid tools + - **Updated dependencies** (PR #79): Updated to latest theme-check packages + + ### Tool Improvements + + - **Multi-turn conversations** (PR #90): Enhanced learn_shopify_api tool to better support conversations where API surfaces change + - **Modular architecture** (PR #87): Reorganized tools into separate files for better maintainability + - **Search enhancements** (PR #67): Added max_num_results parameter to search_docs_chunks for better control + - **Tool renaming** (PR #63): Renamed tools for clarity: + - get_started → learn_shopify_api + - search_dev_docs → search_docs_chunks + - fetch_docs_by_path → fetch_full_docs + - **GraphQL validation** (PR #51): Added validate_graphql_codeblocks tool for Admin API GraphQL validation + + ### GraphQL Schema Support + + - **Multiple schema support** (PR #73): Added support for multiple MCP GraphQL schemas + - Improved schema fetching and local caching mechanisms + - Better version management for GraphQL schemas + + ### Bug Fixes & Maintenance + + - Fixed Vite configuration for theme-check-node package (PR #95) + - Fixed flaky tests in theme validation (PR #77) + - Added deploy workflow (PR #40) + - Various tool description updates and improvements diff --git a/README.md b/README.md index 332f36c..1bd9084 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ This project implements a Model Context Protocol (MCP) server that interacts wit - Admin GraphQL API - Functions - (Optional) Polaris Web Components +- (Optional) Liquid/Theme validation ## Setup @@ -84,10 +85,11 @@ If you want Cursor or Claude Desktop to surface Polaris Web Components documenta } ``` - -[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=shopify-dev-mcp&config=eyJjb21tYW5kIjoibnB4IC15IEBzaG9waWZ5L2Rldi1tY3BAbGF0ZXN0IiwiZW52Ijp7IlBPTEFSSVNfVU5JRklFRCI6InRydWUifX0%3D) +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=shopify-dev-mcp&config=eyJjb21tYW5kIjoibnB4IC15IEBzaG9waWZ5L2Rldi1tY3BAbGF0ZXN0IiwiZW52Ijp7IkxJUVVJRCI6InRydWUifX0%3D) + +You can also control the validation mode by setting `LIQUID_VALIDATION_MODE`: + +- `"full"` (default): Enables the `validate_theme` tool for validating entire theme directories +- `"partial"`: Enables the `validate_theme_codeblocks` tool for validating individual codeblocks + +```json +{ + "mcpServers": { + "shopify-dev-mcp": { + "command": "npx", + "args": ["-y", "@shopify/dev-mcp@latest"], + "env": { + "LIQUID": "true", + "LIQUID_VALIDATION_MODE": "partial" + } + } + } +} +``` + +[![Install MCP Server](https://cursor.com/deeplink/mcp-install-dark.svg)](https://cursor.com/install-mcp?name=shopify-dev-mcp&config=eyJjb21tYW5kIjoibnB4IC15IEBzaG9waWZ5L2Rldi1tY3BAbGF0ZXN0IiwiZW52Ijp7IkxJUVVJRCI6InRydWUiLCJMSVFVSURfVkFMSURBVElPTl9NT0RFIjoicGFydGlhbCJ9fQ%3D%3D) ## Available tools @@ -117,10 +140,12 @@ This MCP server provides the following tools: | fetch_full_docs | Retrieve complete documentation for specific paths from shopify.dev. Provides full context without chunking loss, but requires knowing the exact path. Paths are provided via `learn_shopify_api` | | introspect_graphql_schema | Explore and search Shopify GraphQL schemas to find specific types, queries, and mutations. Returns schema elements filtered by search terms, helping developers discover available fields, operations, and data structures for building GraphQL operations | | validate_graphql_codeblocks | Validate GraphQL code blocks against a specific GraphQL schema to ensure they don't contain hallucinated fields or operations | +| validate_theme_codeblocks | (When `LIQUID=true` and `LIQUID_VALIDATION_MODE=partial`) Validates individual Liquid codeblocks and supporting theme files (JSON, CSS, JS, SVG) to ensure correct syntax and references | +| validate_theme | (When `LIQUID=true` and `LIQUID_VALIDATION_MODE=full`) Validates entire theme directories using Shopify's Theme Check to detect errors in Liquid syntax, missing references, and other theme issues | ## Tool Usage Guidelines -### When to use each documentation tool: +### When to use each documentation tool - **`learn_shopify_api`**: Always call this first when working with Shopify APIs. It provides essential context about supported APIs and generates a conversation ID for tracking usage across tool calls. @@ -128,6 +153,12 @@ This MCP server provides the following tools: - **`fetch_full_docs`**: Use when you need complete documentation for a specific API resource and know the exact path (e.g., `/docs/api/admin-rest/resources/product`). This provides full context without any information loss from chunking. +### When to use theme validation tools (requires `LIQUID=true`) + +- **`validate_theme_codeblocks`**: Use when generating or modifying individual Liquid files or codeblocks. This tool validates syntax, checks for undefined objects/filters, and ensures references to other files exist. Perfect for incremental development and quick validation of code snippets. + +- **`validate_theme`**: Use when working with complete theme directories to validate all files at once. This comprehensive validation catches cross-file issues, ensures consistency across the theme, and applies all Theme Check rules. + ## Available prompts This MCP server provides the following prompts: diff --git a/__mocks__/fs.cjs b/__mocks__/fs.cjs deleted file mode 100644 index b054994..0000000 --- a/__mocks__/fs.cjs +++ /dev/null @@ -1,2 +0,0 @@ -const { fs } = require("memfs"); -module.exports = fs; diff --git a/__mocks__/fs/promises.cjs b/__mocks__/fs/promises.cjs deleted file mode 100644 index 584c3ef..0000000 --- a/__mocks__/fs/promises.cjs +++ /dev/null @@ -1,2 +0,0 @@ -const { fs } = require("memfs"); -module.exports = fs.promises; diff --git a/mock-schemas/admin_2025-01-mock.json b/mock-schemas/admin_2025-01-mock.json new file mode 100644 index 0000000..1861a60 --- /dev/null +++ b/mock-schemas/admin_2025-01-mock.json @@ -0,0 +1,141 @@ +{ + "data": { + "__schema": { + "types": [ + { + "kind": "OBJECT", + "name": "Product", + "description": "A product in the shop", + "fields": [ + { + "name": "id", + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false + }, + { + "name": "title", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false + } + ] + }, + { + "kind": "INPUT_OBJECT", + "name": "ProductInput", + "description": "Input for a product", + "fields": null, + "inputFields": [ + { + "name": "title", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ] + }, + { + "kind": "OBJECT", + "name": "Order", + "description": "An order in the shop", + "fields": [ + { + "name": "id", + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false + } + ] + }, + { + "kind": "OBJECT", + "name": "QueryRoot", + "fields": [ + { + "name": "product", + "description": "Get a product by ID", + "args": [ + { + "name": "id", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Product", + "ofType": null + } + }, + { + "name": "order", + "description": "Get an order by ID", + "args": [ + { + "name": "id", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Order", + "ofType": null + } + } + ] + }, + { + "kind": "OBJECT", + "name": "Mutation", + "fields": [ + { + "name": "productCreate", + "description": "Create a product", + "args": [ + { + "name": "input", + "type": { + "kind": "INPUT_OBJECT", + "name": "ProductInput", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Product", + "ofType": null + } + } + ] + } + ] + } + } +} diff --git a/mock-schemas/admin_2025-01-mock2.json b/mock-schemas/admin_2025-01-mock2.json new file mode 100644 index 0000000..518453f --- /dev/null +++ b/mock-schemas/admin_2025-01-mock2.json @@ -0,0 +1,706 @@ +{ + "data": { + "__schema": { + "queryType": { + "name": "QueryRoot" + }, + "mutationType": { + "name": "Mutation" + }, + "subscriptionType": null, + "types": [ + { + "kind": "OBJECT", + "name": "QueryRoot", + "description": "The schema's entry-point for queries.", + "fields": [ + { + "name": "products", + "description": "List of the shop's products.", + "args": [ + { + "name": "first", + "description": "Returns up to the first `n` elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "after", + "description": "Returns the elements that come after the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "last", + "description": "Returns up to the last `n` elements from the list.", + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "before", + "description": "Returns the elements that come before the specified cursor.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "reverse", + "description": "Reverse the order of the underlying list.", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false" + }, + { + "name": "sortKey", + "description": "Sort the underlying list by the given key.", + "type": { + "kind": "ENUM", + "name": "ProductSortKeys", + "ofType": null + }, + "defaultValue": "ID" + }, + { + "name": "query", + "description": "Supported filter parameters.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProductConnection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "product", + "description": "Returns a Product resource by ID.", + "args": [ + { + "name": "id", + "description": "The ID of the Product to return.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Product", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Mutation", + "description": "The schema's entry-point for mutations.", + "fields": [ + { + "name": "productCreate", + "description": "Creates a product.", + "args": [ + { + "name": "product", + "description": "The properties for the new product.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ProductInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ProductCreatePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProductConnection", + "description": "An auto-generated type for paginating through multiple Products.", + "fields": [ + { + "name": "edges", + "description": "A list of edges.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProductEdge", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nodes", + "description": "A list of the nodes contained in ProductEdge.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Product", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pageInfo", + "description": "Information to aid in pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PageInfo", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProductEdge", + "description": "An auto-generated type which holds one Product and a cursor during pagination.", + "fields": [ + { + "name": "cursor", + "description": "A cursor for use in pagination.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "node", + "description": "The item at the end of ProductEdge.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Product", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Product", + "description": "A product represents an individual item for sale in a Shopify store.", + "fields": [ + { + "name": "id", + "description": "A globally-unique identifier.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "title", + "description": "The title of the product.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "handle", + "description": "A human-friendly unique string for the Product automatically generated from its title.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": "The date and time when the product was created.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Node", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ProductInput", + "description": "The input fields for a product.", + "fields": null, + "inputFields": [ + { + "name": "title", + "description": "The title of the product.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "handle", + "description": "A human-friendly unique string for the Product automatically generated from its title.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProductCreatePayload", + "description": "Return type for `productCreate` mutation.", + "fields": [ + { + "name": "product", + "description": "The product object.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "Product", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userErrors", + "description": "The list of errors that occurred from executing the mutation.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UserError", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PageInfo", + "description": "Information about pagination in a connection.", + "fields": [ + { + "name": "hasNextPage", + "description": "Indicates if there are more pages to fetch.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasPreviousPage", + "description": "Indicates if there are any pages prior to the current page.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startCursor", + "description": "The cursor corresponding to the first node in edges.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endCursor", + "description": "The cursor corresponding to the last node in edges.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INTERFACE", + "name": "Node", + "description": "An object with an ID field to support global identification, in accordance with the Relay specification.", + "fields": [ + { + "name": "id", + "description": "A globally-unique identifier.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Product", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "UserError", + "description": "An error that occurred during a mutation.", + "fields": [ + { + "name": "field", + "description": "The path to the input field that caused the error.", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "message", + "description": "The error message.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ProductSortKeys", + "description": "The set of valid sort keys for the Product query.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "CREATED_AT", + "description": "Sort by the `created_at` value.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ID", + "description": "Sort by the `id` value.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PRODUCT_TYPE", + "description": "Sort by the `product_type` value.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RELEVANCE", + "description": "Sort by relevance to the search terms when the `query` parameter is specified on the connection.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TITLE", + "description": "Sort by the `title` value.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UPDATED_AT", + "description": "Sort by the `updated_at` value.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VENDOR", + "description": "Sort by the `vendor` value.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "Represents textual data as UTF-8 character sequences.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Boolean", + "description": "Represents `true` or `false` values.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "Represents non-fractional signed whole numeric values.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ID", + "description": "Represents a unique identifier that is Base64 obfuscated.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "DateTime", + "description": "'An ISO-8601 encoded UTC date time string. Example value: `\"2019-07-03T20:47:55Z\"`.',", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + } + ], + "directives": [] + } + } +} diff --git a/package-lock.json b/package-lock.json index 4400a43..809fe84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,26 @@ { "name": "@shopify/dev-mcp", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@shopify/dev-mcp", - "version": "1.1.0", + "version": "1.2.0", "license": "ISC", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", + "@shopify/app-bridge-ui-types": "^0.2.0", "@shopify/theme-check-common": "^3.20.0", "@shopify/theme-check-docs-updater": "^3.20.0", "@shopify/theme-check-node": "^3.20.0", + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "acorn-typescript": "^1.4.13", + "acorn-walk": "^8.3.4", + "env-paths": "^3.0.0", "graphql": "^16.11.0", - "zod": "^3.24.2" + "zod": "^3.25.76" }, "bin": { "shopify-dev-mcp": "dist/index.js" @@ -27,9 +33,11 @@ "@types/graphql": "^14.2.3", "@types/node": "^22.13.10", "@vitest/coverage-v8": "^3.0.9", + "jsdom": "^26.1.0", "memfs": "^4.17.0", "prettier": "^3.5.3", - "typescript": "^5.8.2", + "typescript": "^5.8.3", + "vite": "^6.3.5", "vitest": "^3.0.9" } }, @@ -58,6 +66,27 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -889,384 +918,125 @@ "node": ">=12" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz", - "integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz", - "integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz", - "integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz", - "integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", - "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz", - "integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz", - "integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz", - "integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz", - "integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz", - "integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz", - "integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz", - "integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz", - "integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz", - "integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==", - "cpu": [ - "ppc64" - ], + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT-0", "engines": { "node": ">=18" } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz", - "integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==", - "cpu": [ - "riscv64" - ], + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz", - "integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==", - "cpu": [ - "s390x" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz", - "integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==", - "cpu": [ - "x64" - ], + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz", - "integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==", - "cpu": [ - "arm64" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz", - "integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==", - "cpu": [ - "x64" - ], + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz", - "integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==", - "cpu": [ - "arm64" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], "engines": { "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz", - "integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==", - "cpu": [ - "x64" - ], + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz", - "integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==", - "cpu": [ - "x64" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/win32-arm64": { + "node_modules/@esbuild/darwin-arm64": { "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz", - "integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz", + "integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==", "cpu": [ "arm64" ], @@ -1274,41 +1044,7 @@ "license": "MIT", "optional": true, "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz", - "integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz", - "integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" + "darwin" ], "engines": { "node": ">=18" @@ -1696,10 +1432,44 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz", + "integrity": "sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1714,16 +1484,39 @@ } }, "node_modules/@jsonjoy.com/json-pack": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.2.0.tgz", - "integrity": "sha512-io1zEbbYcElht3tdlqEOFxZ0dMTYrHz9iMf0gqn1pPjZFTCgM5R4R5IMA20Chb2UPYYsxjzs8CgZ7Nb5n2K2rA==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.11.0.tgz", + "integrity": "sha512-nLqSTAYwpk+5ZQIoVp7pfd/oSKNWlEdvTq2LzVA4r2wtWZg6v+5u0VgBOaDJuUfNOuw/4Ysq6glN5QKSrOCgrA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/base64": "^1.1.1", - "@jsonjoy.com/util": "^1.1.2", + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.1", + "@jsonjoy.com/util": "^1.9.0", "hyperdyperid": "^1.2.0", - "thingies": "^1.20.0" + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.1.tgz", + "integrity": "sha512-tJpwQfuBuxqZlyoJOSZcqf7OUmiYQ6MiPNmOv4KbZdXE/DdvBSSAwhos0zIlJU/AXxC8XpuO8p08bh2fIl+RKA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "^1.3.0" }, "engines": { "node": ">=10.0" @@ -1737,11 +1530,15 @@ } }, "node_modules/@jsonjoy.com/util": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.5.0.tgz", - "integrity": "sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, "engines": { "node": ">=10.0" }, @@ -2825,38 +2622,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.36.0.tgz", - "integrity": "sha512-jgrXjjcEwN6XpZXL0HUeOVGfjXhPyxAbbhD0BlXUB+abTOpbPiN5Wb3kOT7yb+uEtATNYF5x5gIfwutmuBA26w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.36.0.tgz", - "integrity": "sha512-NyfuLvdPdNUfUNeYKUwPwKsE5SXa2J6bCt2LdB/N+AxShnkpiczi3tcLJrm5mA+eqpy0HmaIY9F6XCa32N5yzg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.36.0.tgz", - "integrity": "sha512-JQ1Jk5G4bGrD4pWJQzWsD8I1n1mgPXq33+/vP4sk8j/z/C2siRuxZtaUA7yMTf71TCZTZl/4e1bfzwUmFb3+rw==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.2.tgz", + "integrity": "sha512-HV7bW2Fb/F5KPdM/9bApunQh68YVDU8sO8BvcW9OngQVN3HHHkw99wFupuUJfGR9pYLLAjcAOA6iO+evsbBaPQ==", "cpu": [ "arm64" ], @@ -2867,229 +2636,10 @@ "darwin" ] }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.36.0.tgz", - "integrity": "sha512-6c6wMZa1lrtiRsbDziCmjE53YbTkxMYhhnWnSW8R/yqsM7a6mSJ3uAVT0t8Y/DGt7gxUWYuFM4bwWk9XCJrFKA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.36.0.tgz", - "integrity": "sha512-KXVsijKeJXOl8QzXTsA+sHVDsFOmMCdBRgFmBb+mfEb/7geR7+C8ypAml4fquUt14ZyVXaw2o1FWhqAfOvA4sg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.36.0.tgz", - "integrity": "sha512-dVeWq1ebbvByI+ndz4IJcD4a09RJgRYmLccwlQ8bPd4olz3Y213uf1iwvc7ZaxNn2ab7bjc08PrtBgMu6nb4pQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.36.0.tgz", - "integrity": "sha512-bvXVU42mOVcF4le6XSjscdXjqx8okv4n5vmwgzcmtvFdifQ5U4dXFYaCB87namDRKlUL9ybVtLQ9ztnawaSzvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.36.0.tgz", - "integrity": "sha512-JFIQrDJYrxOnyDQGYkqnNBtjDwTgbasdbUiQvcU8JmGDfValfH1lNpng+4FWlhaVIR4KPkeddYjsVVbmJYvDcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.36.0.tgz", - "integrity": "sha512-KqjYVh3oM1bj//5X7k79PSCZ6CvaVzb7Qs7VMWS+SlWB5M8p3FqufLP9VNp4CazJ0CsPDLwVD9r3vX7Ci4J56A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.36.0.tgz", - "integrity": "sha512-QiGnhScND+mAAtfHqeT+cB1S9yFnNQ/EwCg5yE3MzoaZZnIV0RV9O5alJAoJKX/sBONVKeZdMfO8QSaWEygMhw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.36.0.tgz", - "integrity": "sha512-1ZPyEDWF8phd4FQtTzMh8FQwqzvIjLsl6/84gzUxnMNFBtExBtpL51H67mV9xipuxl1AEAerRBgBwFNpkw8+Lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.36.0.tgz", - "integrity": "sha512-VMPMEIUpPFKpPI9GZMhJrtu8rxnp6mJR3ZzQPykq4xc2GmdHj3Q4cA+7avMyegXy4n1v+Qynr9fR88BmyO74tg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.36.0.tgz", - "integrity": "sha512-ttE6ayb/kHwNRJGYLpuAvB7SMtOeQnVXEIpMtAvx3kepFQeowVED0n1K9nAdraHUPJ5hydEMxBpIR7o4nrm8uA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.36.0.tgz", - "integrity": "sha512-4a5gf2jpS0AIe7uBjxDeUMNcFmaRTbNv7NxI5xOCs4lhzsVyGR/0qBXduPnoWf6dGC365saTiwag8hP1imTgag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.36.0.tgz", - "integrity": "sha512-5KtoW8UWmwFKQ96aQL3LlRXX16IMwyzMq/jSSVIIyAANiE1doaQsx/KRyhAvpHlPjPiSU/AYX/8m+lQ9VToxFQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.36.0.tgz", - "integrity": "sha512-sycrYZPrv2ag4OCvaN5js+f01eoZ2U+RmT5as8vhxiFz+kxwlHrsxOwKPSA8WyS+Wc6Epid9QeI/IkQ9NkgYyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.36.0.tgz", - "integrity": "sha512-qbqt4N7tokFwwSVlWDsjfoHgviS3n/vZ8LK0h1uLG9TYIRuUTJC88E1xb3LM2iqZ/WTqNQjYrtmtGmrmmawB6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.36.0.tgz", - "integrity": "sha512-t+RY0JuRamIocMuQcfwYSOkmdX9dtkr1PbhKW42AMvaDQa+jOdpUYysroTF/nuPpAaQMWp7ye+ndlmmthieJrQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.36.0.tgz", - "integrity": "sha512-aRXd7tRZkWLqGbChgcMMDEHjOKudo1kChb1Jt1IfR8cY/KIpgNviLeJy5FUb9IpSuQj8dU2fAYNMPW/hLKOSTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "node_modules/@shopify/app-bridge-ui-types": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@shopify/app-bridge-ui-types/-/app-bridge-ui-types-0.2.1.tgz", + "integrity": "sha512-sqYd27NwpvcPZZyphKyPySOkv86hh1JJJhSIfe7qKy4Gsfg6k2ngSw7WuvEfGG7I4zvNUif7cc36QlJNrpmSaA==" }, "node_modules/@shopify/liquid-html-parser": { "version": "2.8.2", @@ -3155,6 +2705,15 @@ "theme-docs": "scripts/cli.js" } }, + "node_modules/@shopify/theme-check-docs-updater/node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/@shopify/theme-check-node": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/@shopify/theme-check-node/-/theme-check-node-3.20.0.tgz", @@ -3265,9 +2824,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -3613,10 +3172,9 @@ } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", - "dev": true, + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3625,11 +3183,28 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-typescript": { + "version": "1.4.13", + "resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz", + "integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==", + "license": "MIT", + "peerDependencies": { + "acorn": ">=8.9.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -3638,6 +3213,16 @@ "node": ">=0.4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4253,37 +3838,102 @@ "vary": "^1" }, "engines": { - "node": ">= 0.10" + "node": ">= 0.10" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "node_modules/data-urls/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, - "license": "MIT" - }, - "node_modules/cross-fetch": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", - "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", "license": "MIT", "dependencies": { - "node-fetch": "^2.7.0" + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">= 8" + "node": ">=18" } }, "node_modules/dataloader": { @@ -4310,6 +3960,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -4496,13 +4153,29 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "license": "MIT", "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/es-define-property": { @@ -5160,6 +4833,19 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -5192,6 +4878,34 @@ "node": ">= 0.8" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-id": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.1.tgz", @@ -5348,6 +5062,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5713,6 +5434,83 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5911,15 +5709,16 @@ } }, "node_modules/memfs": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.17.0.tgz", - "integrity": "sha512-4eirfZ7thblFmqFjywlTmuWVSvccHAJbn1r8qQLzmTO11qcqpohOjmY2mFce6x7x7WtskzRqApPD0hv+Oa74jg==", + "version": "4.36.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.36.3.tgz", + "integrity": "sha512-rZIVsNPGdZDPls/ckWhIsod2zRNsI2f2kEru0gMldkrEve+fPn7CVBTvfKLNyHQ9rZDWwzVBF8tPsZivzDPiZQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@jsonjoy.com/json-pack": "^1.0.3", - "@jsonjoy.com/util": "^1.3.0", - "tree-dump": "^1.0.1", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", "tslib": "^2.0.0" }, "engines": { @@ -6112,6 +5911,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6278,6 +6084,19 @@ "quansync": "^0.2.7" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -6434,9 +6253,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -6454,7 +6273,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6780,13 +6599,13 @@ } }, "node_modules/rollup": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.36.0.tgz", - "integrity": "sha512-zwATAXNQxUcd40zgtQG0ZafcRK4g004WtEl7kbuhTWPvf07PsfohXl39jVUvPF7jvNAIkKPQ2XrsDlWuxBd++Q==", + "version": "4.46.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.2.tgz", + "integrity": "sha512-WMmLFI+Boh6xbop+OAGo9cQ3OgX9MIg7xOQjn+pTCwOkk+FNDAeAemXkJ3HzDJrVXleLOFVa1ipuc1AmEx1Dwg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -6796,25 +6615,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.36.0", - "@rollup/rollup-android-arm64": "4.36.0", - "@rollup/rollup-darwin-arm64": "4.36.0", - "@rollup/rollup-darwin-x64": "4.36.0", - "@rollup/rollup-freebsd-arm64": "4.36.0", - "@rollup/rollup-freebsd-x64": "4.36.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.36.0", - "@rollup/rollup-linux-arm-musleabihf": "4.36.0", - "@rollup/rollup-linux-arm64-gnu": "4.36.0", - "@rollup/rollup-linux-arm64-musl": "4.36.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.36.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.36.0", - "@rollup/rollup-linux-riscv64-gnu": "4.36.0", - "@rollup/rollup-linux-s390x-gnu": "4.36.0", - "@rollup/rollup-linux-x64-gnu": "4.36.0", - "@rollup/rollup-linux-x64-musl": "4.36.0", - "@rollup/rollup-win32-arm64-msvc": "4.36.0", - "@rollup/rollup-win32-ia32-msvc": "4.36.0", - "@rollup/rollup-win32-x64-msvc": "4.36.0", + "@rollup/rollup-android-arm-eabi": "4.46.2", + "@rollup/rollup-android-arm64": "4.46.2", + "@rollup/rollup-darwin-arm64": "4.46.2", + "@rollup/rollup-darwin-x64": "4.46.2", + "@rollup/rollup-freebsd-arm64": "4.46.2", + "@rollup/rollup-freebsd-x64": "4.46.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.2", + "@rollup/rollup-linux-arm-musleabihf": "4.46.2", + "@rollup/rollup-linux-arm64-gnu": "4.46.2", + "@rollup/rollup-linux-arm64-musl": "4.46.2", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.2", + "@rollup/rollup-linux-ppc64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-gnu": "4.46.2", + "@rollup/rollup-linux-riscv64-musl": "4.46.2", + "@rollup/rollup-linux-s390x-gnu": "4.46.2", + "@rollup/rollup-linux-x64-gnu": "4.46.2", + "@rollup/rollup-linux-x64-musl": "4.46.2", + "@rollup/rollup-win32-arm64-msvc": "4.46.2", + "@rollup/rollup-win32-ia32-msvc": "4.46.2", + "@rollup/rollup-win32-x64-msvc": "4.46.2", "fsevents": "~2.3.2" } }, @@ -6834,6 +6654,13 @@ "node": ">= 18" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-applescript": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", @@ -6907,6 +6734,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -7344,6 +7184,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", @@ -7402,14 +7249,18 @@ } }, "node_modules/thingies": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.21.0.tgz", - "integrity": "sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", + "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", "dev": true, - "license": "Unlicense", + "license": "MIT", "engines": { "node": ">=10.18" }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, "peerDependencies": { "tslib": "^2" } @@ -7428,6 +7279,51 @@ "dev": true, "license": "MIT" }, + "node_modules/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/tinypool": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", @@ -7458,6 +7354,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -7500,6 +7416,19 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -7507,9 +7436,9 @@ "license": "MIT" }, "node_modules/tree-dump": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.2.tgz", - "integrity": "sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.0.3.tgz", + "integrity": "sha512-il+Cv80yVHFBwokQSfd4bldvr1Md951DpgAGfmhydt04L+YzHgubm2tQ7zueWDcGENKHq0ZvGFR/hjvNXilHEg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -7609,9 +7538,9 @@ } }, "node_modules/typescript": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", - "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7750,15 +7679,18 @@ } }, "node_modules/vite": { - "version": "6.2.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz", - "integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -7844,6 +7776,37 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.9.tgz", @@ -7945,6 +7908,19 @@ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -7961,6 +7937,29 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -8105,6 +8104,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -8174,9 +8190,9 @@ } }, "node_modules/zod": { - "version": "3.24.2", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", - "integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 9c9d1eb..9d4d353 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "@shopify/dev-mcp", - "version": "1.1.0", + "version": "1.2.0", "main": "dist/index.js", "scripts": { - "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"", - "build:watch": "tsc --watch", + "build": "vite build && node -e \"require('fs').chmodSync('dist/index.js', '755')\"", + "build:watch": "vite build --watch", "changeset": "changeset", "test": "vitest run", "test:watch": "vitest", @@ -18,11 +18,17 @@ "description": "A command line tool for setting up Shopify Dev MCP server", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", + "@shopify/app-bridge-ui-types": "^0.2.0", "@shopify/theme-check-common": "^3.20.0", "@shopify/theme-check-docs-updater": "^3.20.0", "@shopify/theme-check-node": "^3.20.0", + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "acorn-typescript": "^1.4.13", + "acorn-walk": "^8.3.4", + "env-paths": "^3.0.0", "graphql": "^16.11.0", - "zod": "^3.24.2" + "zod": "^3.25.76" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", @@ -32,9 +38,11 @@ "@types/graphql": "^14.2.3", "@types/node": "^22.13.10", "@vitest/coverage-v8": "^3.0.9", + "jsdom": "^26.1.0", "memfs": "^4.17.0", "prettier": "^3.5.3", - "typescript": "^5.8.2", + "typescript": "^5.8.3", + "vite": "^6.3.5", "vitest": "^3.0.9" }, "type": "module", diff --git a/src/data/typescriptSchemas/appHome.ts b/src/data/typescriptSchemas/appHome.ts new file mode 100644 index 0000000..756e68d --- /dev/null +++ b/src/data/typescriptSchemas/appHome.ts @@ -0,0 +1,804 @@ +import { z } from "zod"; + +// Generated schemas for component types +// This file is auto-generated - do not edit manually + +// Tag name to TypeScript type mapping +/* eslint-disable @typescript-eslint/naming-convention */ +export const TAG_TO_TYPE_MAPPING = { + "s-badge": "Badge", + "s-banner": "Banner", + "s-box": "Box", + "s-button": "Button", + "s-checkbox": "Checkbox", + "s-choice": "ChoiceList", + "s-choice-list": "ChoiceList", + "s-clickable": "Clickable", + "s-date-picker": "DatePicker", + "s-divider": "Divider", + "s-email-field": "EmailField", + "s-grid": "Grid", + "s-grid-item": "Grid", + "s-heading": "Heading", + "s-icon": "Icon", + "s-image": "Image", + "s-link": "Link", + "s-money-field": "MoneyField", + "s-number-field": "NumberField", + "s-option": "Select", + "s-page": "Page", + "s-paragraph": "Paragraph", + "s-password-field": "PasswordField", + "s-query-container": "QueryContainer", + "s-search-field": "SearchField", + "s-section": "Page", + "s-select": "Select", + "s-spinner": "Spinner", + "s-stack": "Stack", + "s-switch": "Switch", + "s-table": "Table", + "s-table-body": "Table", + "s-table-cell": "Table", + "s-table-header": "Table", + "s-table-header-row": "Table", + "s-table-row": "Table", + "s-text": "Page", + "s-text-area": "TextArea", + "s-text-field": "TextField", + "s-url-field": "URLField", +} as const; +/* eslint-enable @typescript-eslint/naming-convention */ + +export const _UIModalAttributesSchema = z.object({ + children: z.string().optional(), + id: z.string().optional(), + src: z.string().optional(), + variant: z.string().optional(), +}); + +export const UITitleBarAttributesSchema = z.object({ + children: z.string().optional(), + title: z.string().optional(), +}); + +export const _UIModalElementSchema = z.object({ + addEventListener: z.function().optional(), + content: z.string().optional(), + contentWindow: z.string().optional(), + hide: z.function().optional(), + removeEventListener: z.function().optional(), + show: z.function().optional(), + src: z.string().optional(), + toggle: z.function().optional(), + variant: z.string().optional(), +}); + +export const _UINavMenuAttributesSchema = z.object({ + children: z.string().optional(), +}); + +export const UINavMenuFirstChildSchema = z.object({ + a: z.string(), +}); + +export const UINavMenuChildrenSchema = z.object({ + a: z.string().optional(), +}); + +export const _UISaveBarAttributesSchema = z.object({ + children: z.string().optional(), + discardConfirmation: z.boolean().optional(), + id: z.string().optional(), +}); + +export const UISaveBarChildrenSchema = z.object({ + button: z.string().optional(), +}); + +export const _UISaveBarElementSchema = z.object({ + addEventListener: z.function().optional(), + discardConfirmation: z.boolean().optional(), + hide: z.function().optional(), + removeEventListener: z.function().optional(), + show: z.function().optional(), + showing: z.boolean().optional(), + toggle: z.function().optional(), +}); + +export const _UITitleBarAttributesSchema = z.object({ + children: z.string().optional(), + title: z.string().optional(), +}); + +export const UITitleBarChildrenSchema = z.object({ + a: z.string().optional(), + button: z.string().optional(), + section: z.string().optional(), +}); + +export const BaseElementAttributesSchema = z.object({ + children: z.string().optional(), + class: z.string().optional(), + href: z.string().optional(), + id: z.string().optional(), + name: z.string().optional(), + onclick: z.string().optional(), + rel: z.string().optional(), + target: z.string().optional(), +}); + +export const BadgeSchema = z.object({ + color: z.string().optional(), + icon: z.string().optional(), + size: z.string().optional(), + tone: z.string().optional(), +}); + +export const ClickOptionsSchema = z.object({ + sourceEvent: z.string().optional(), +}); + +export const ActivationEventEsqueSchema = z.object({ + button: z.number().optional(), + ctrlKey: z.boolean().optional(), + metaKey: z.boolean().optional(), + shiftKey: z.boolean().optional(), +}); + +export const BannerSchema = z.object({ + dismissible: z.boolean().optional(), + heading: z.string().optional(), + hidden: z.boolean().optional(), + tone: z.string().optional(), +}); + +export const BannerEventsSchema = z.object({ + afterhide: z.string().optional(), + dismiss: z.string().optional(), +}); + +export const BannerSlotsSchema = z.object({ + secondaryActions: z.string().optional(), +}); + +export const BoxSchema = z.object({ + accessibilityLabel: z.string().optional(), + accessibilityRole: z.string().optional(), + accessibilityVisibility: z.string().optional(), + background: z.string().optional(), + blockSize: z.string().optional(), + border: z.string().optional(), + borderColor: z.string().optional(), + borderRadius: z.string().optional(), + borderStyle: z.string().optional(), + borderWidth: z.string().optional(), + display: z.string().optional(), + inlineSize: z.string().optional(), + maxBlockSize: z.string().optional(), + maxInlineSize: z.string().optional(), + minBlockSize: z.string().optional(), + minInlineSize: z.string().optional(), + overflow: z.string().optional(), + padding: z.string().optional(), + paddingBlock: z.string().optional(), + paddingBlockEnd: z.string().optional(), + paddingBlockStart: z.string().optional(), + paddingInline: z.string().optional(), + paddingInlineEnd: z.string().optional(), + paddingInlineStart: z.string().optional(), +}); + +export const ButtonSchema = z.object({ + accessibilityLabel: z.string().optional(), + command: z.string().optional(), + commandFor: z.string().optional(), + disabled: z.boolean().optional(), + download: z.string().optional(), + href: z.string().optional(), + icon: z.string().optional(), + loading: z.boolean().optional(), + target: z.string().optional(), + tone: z.string().optional(), + type: z.string().optional(), + variant: z.string().optional(), +}); + +export const ButtonEventsSchema = z.object({ + blur: z.string().optional(), + click: z.string().optional(), + focus: z.string().optional(), +}); + +export const CheckboxSchema = z.object({ + accessibilityLabel: z.string().optional(), + checked: z.boolean().optional(), + defaultChecked: z.boolean().optional(), + defaultIndeterminate: z.boolean().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + indeterminate: z.boolean().optional(), + label: z.string().optional(), + name: z.string().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const CheckboxEventsSchema = z.object({ + change: z.string().optional(), + input: z.string().optional(), +}); + +export const ChoiceListSchema = z.object({ + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + multiple: z.boolean().optional(), + name: z.string().optional(), + values: z.string().optional(), +}); + +export const ChoiceSchema = z.object({ + accessibilityLabel: z.string().optional(), + defaultSelected: z.boolean().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + label: z.string().optional(), + selected: z.boolean().optional(), + value: z.string().optional(), +}); + +export const ChoiceListEventsSchema = z.object({ + change: z.string().optional(), + input: z.string().optional(), +}); + +export const ClickableSchema = z.object({ + accessibilityLabel: z.string().optional(), + accessibilityRole: z.string().optional(), + accessibilityVisibility: z.string().optional(), + background: z.string().optional(), + blockSize: z.string().optional(), + border: z.string().optional(), + borderColor: z.string().optional(), + borderRadius: z.string().optional(), + borderStyle: z.string().optional(), + borderWidth: z.string().optional(), + command: z.string().optional(), + commandFor: z.string().optional(), + disabled: z.boolean().optional(), + display: z.string().optional(), + download: z.string().optional(), + href: z.string().optional(), + inlineSize: z.string().optional(), + loading: z.boolean().optional(), + maxBlockSize: z.string().optional(), + maxInlineSize: z.string().optional(), + minBlockSize: z.string().optional(), + minInlineSize: z.string().optional(), + overflow: z.string().optional(), + padding: z.string().optional(), + paddingBlock: z.string().optional(), + paddingBlockEnd: z.string().optional(), + paddingBlockStart: z.string().optional(), + paddingInline: z.string().optional(), + paddingInlineEnd: z.string().optional(), + paddingInlineStart: z.string().optional(), + target: z.string().optional(), + type: z.string().optional(), +}); + +export const ClickableEventsSchema = z.object({ + blur: z.string().optional(), + click: z.string().optional(), + focus: z.string().optional(), +}); + +export const DatePickerSchema = z.object({ + allow: z.string().optional(), + allowDays: z.string().optional(), + defaultValue: z.string().optional(), + defaultView: z.string().optional(), + disallow: z.string().optional(), + disallowDays: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), + value: z.string().optional(), + view: z.string().optional(), +}); + +export const DatePickerEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), + viewchange: z.string().optional(), +}); + +export const DividerSchema = z.object({ + color: z.string().optional(), + direction: z.string().optional(), +}); + +export const EmailFieldSchema = z.object({ + autocomplete: z.string().optional(), + defaultValue: z.string().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + maxLength: z.number().optional(), + minLength: z.number().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + readOnly: z.boolean().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const EmailFieldEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), +}); + +export const GridSchema = z.object({ + accessibilityLabel: z.string().optional(), + accessibilityRole: z.string().optional(), + accessibilityVisibility: z.string().optional(), + alignContent: z.string().optional(), + alignItems: z.string().optional(), + background: z.string().optional(), + blockSize: z.string().optional(), + border: z.string().optional(), + borderColor: z.string().optional(), + borderRadius: z.string().optional(), + borderStyle: z.string().optional(), + borderWidth: z.string().optional(), + columnGap: z.string().optional(), + display: z.string().optional(), + gap: z.string().optional(), + gridTemplateColumns: z.string().optional(), + gridTemplateRows: z.string().optional(), + inlineSize: z.string().optional(), + justifyContent: z.string().optional(), + justifyItems: z.string().optional(), + maxBlockSize: z.string().optional(), + maxInlineSize: z.string().optional(), + minBlockSize: z.string().optional(), + minInlineSize: z.string().optional(), + overflow: z.string().optional(), + padding: z.string().optional(), + paddingBlock: z.string().optional(), + paddingBlockEnd: z.string().optional(), + paddingBlockStart: z.string().optional(), + paddingInline: z.string().optional(), + paddingInlineEnd: z.string().optional(), + paddingInlineStart: z.string().optional(), + placeContent: z.string().optional(), + placeItems: z.string().optional(), + rowGap: z.string().optional(), +}); + +export const GridItemSchema = z.object({ + accessibilityLabel: z.string().optional(), + accessibilityRole: z.string().optional(), + accessibilityVisibility: z.string().optional(), + background: z.string().optional(), + blockSize: z.string().optional(), + border: z.string().optional(), + borderColor: z.string().optional(), + borderRadius: z.string().optional(), + borderStyle: z.string().optional(), + borderWidth: z.string().optional(), + display: z.string().optional(), + gridColumn: z.string().optional(), + gridRow: z.string().optional(), + inlineSize: z.string().optional(), + maxBlockSize: z.string().optional(), + maxInlineSize: z.string().optional(), + minBlockSize: z.string().optional(), + minInlineSize: z.string().optional(), + overflow: z.string().optional(), + padding: z.string().optional(), + paddingBlock: z.string().optional(), + paddingBlockEnd: z.string().optional(), + paddingBlockStart: z.string().optional(), + paddingInline: z.string().optional(), + paddingInlineEnd: z.string().optional(), + paddingInlineStart: z.string().optional(), +}); + +export const HeadingSchema = z.object({ + accessibilityRole: z.string().optional(), + accessibilityVisibility: z.string().optional(), + lineClamp: z.number().optional(), +}); + +export const IconSchema = z.object({ + color: z.string().optional(), + size: z.string().optional(), + tone: z.string().optional(), + type: z.string().optional(), +}); + +export const ImageSchema = z.object({ + accessibilityRole: z.string().optional(), + alt: z.string().optional(), + aspectRatio: z.string().optional(), + border: z.string().optional(), + borderColor: z.string().optional(), + borderRadius: z.string().optional(), + borderStyle: z.string().optional(), + borderWidth: z.string().optional(), + inlineSize: z.string().optional(), + loading: z.string().optional(), + objectFit: z.string().optional(), + sizes: z.string().optional(), + src: z.string().optional(), + srcSet: z.string().optional(), +}); + +export const ImageEventsSchema = z.object({ + error: z.string().optional(), + load: z.string().optional(), +}); + +export const LinkSchema = z.object({ + accessibilityLabel: z.string().optional(), + command: z.string().optional(), + commandFor: z.string().optional(), + download: z.string().optional(), + href: z.string().optional(), + lang: z.string().optional(), + target: z.string().optional(), + tone: z.string().optional(), +}); + +export const LinkEventsSchema = z.object({ + click: z.string().optional(), +}); + +export const MoneyFieldSchema = z.object({ + autocomplete: z.string().optional(), + currencyCode: z.string().optional(), + defaultValue: z.string().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + max: z.number().optional(), + min: z.number().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + readOnly: z.boolean().optional(), + required: z.boolean().optional(), + step: z.number().optional(), + value: z.string().optional(), +}); + +export const MoneyFieldEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), +}); + +export const NumberFieldSchema = z.object({ + autocomplete: z.string().optional(), + defaultValue: z.string().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + inputMode: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + max: z.number().optional(), + min: z.number().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + prefix: z.string().optional(), + readOnly: z.boolean().optional(), + required: z.boolean().optional(), + step: z.number().optional(), + suffix: z.string().optional(), + value: z.string().optional(), +}); + +export const NumberFieldEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), +}); + +export const PageSchema = z.object({ + connectedCallback: z.function().optional(), + disconnectedCallback: z.function().optional(), + inlineSize: z.string().optional(), +}); + +export const PageSlotsSchema = z.object({ + aside: z.string().optional(), +}); + +export const ParagraphSchema = z.object({ + accessibilityVisibility: z.string().optional(), + color: z.string().optional(), + dir: z.string().optional(), + fontVariantNumeric: z.string().optional(), + lineClamp: z.number().optional(), + tone: z.string().optional(), +}); + +export const PasswordFieldSchema = z.object({ + autocomplete: z.string().optional(), + defaultValue: z.string().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + maxLength: z.number().optional(), + minLength: z.number().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + readOnly: z.boolean().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const PasswordFieldEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), +}); + +export const QueryContainerSchema = z.object({ + containerName: z.string().optional(), +}); + +export const SearchFieldSchema = z.object({ + autocomplete: z.string().optional(), + defaultValue: z.string().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + maxLength: z.number().optional(), + minLength: z.number().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + readOnly: z.boolean().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const SearchFieldEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), +}); + +export const SectionSchema = z.object({ + accessibilityLabel: z.string().optional(), + heading: z.string().optional(), + padding: z.string().optional(), +}); + +export const SelectSchema = z.object({ + details: z.string().optional(), + disabled: z.boolean().optional(), + disconnectedCallback: z.function().optional(), + error: z.string().optional(), + icon: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const SelectEventsSchema = z.object({ + change: z.string().optional(), + input: z.string().optional(), +}); + +export const SpinnerSchema = z.object({ + accessibilityLabel: z.string().optional(), + size: z.string().optional(), +}); + +export const StackSchema = z.object({ + accessibilityLabel: z.string().optional(), + accessibilityRole: z.string().optional(), + accessibilityVisibility: z.string().optional(), + alignContent: z.string().optional(), + alignItems: z.string().optional(), + background: z.string().optional(), + blockSize: z.string().optional(), + border: z.string().optional(), + borderColor: z.string().optional(), + borderRadius: z.string().optional(), + borderStyle: z.string().optional(), + borderWidth: z.string().optional(), + columnGap: z.string().optional(), + direction: z.string().optional(), + display: z.string().optional(), + gap: z.string().optional(), + inlineSize: z.string().optional(), + justifyContent: z.string().optional(), + maxBlockSize: z.string().optional(), + maxInlineSize: z.string().optional(), + minBlockSize: z.string().optional(), + minInlineSize: z.string().optional(), + overflow: z.string().optional(), + padding: z.string().optional(), + paddingBlock: z.string().optional(), + paddingBlockEnd: z.string().optional(), + paddingBlockStart: z.string().optional(), + paddingInline: z.string().optional(), + paddingInlineEnd: z.string().optional(), + paddingInlineStart: z.string().optional(), + rowGap: z.string().optional(), +}); + +export const SwitchSchema = z.object({ + accessibilityLabel: z.string().optional(), + checked: z.boolean().optional(), + defaultChecked: z.boolean().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + name: z.string().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const SwitchEventsSchema = z.object({ + change: z.string().optional(), + input: z.string().optional(), +}); + +export const TableSchema = z.object({ + hasNextPage: z.boolean().optional(), + hasPreviousPage: z.boolean().optional(), + loading: z.boolean().optional(), + paginate: z.boolean().optional(), + variant: z.string().optional(), +}); + +export const AddedContextSchema = z.object({ + addEventListener: z.function().optional(), + dispatchEvent: z.function().optional(), + removeEventListener: z.function().optional(), + value: z.string().optional(), +}); + +export const TableSlotsSchema = z.object({ + filters: z.string().optional(), +}); + +export const TableEventsSchema = z.object({ + nextpage: z.string().optional(), + previouspage: z.string().optional(), +}); + +export const TableHeaderSchema = z.object({ + listSlot: z.string().optional(), +}); + +export const TableHeaderRowSchema = z.object({ + disconnectedCallback: z.function().optional(), +}); + +export const TextSchema = z.object({ + accessibilityVisibility: z.string().optional(), + color: z.string().optional(), + dir: z.string().optional(), + fontVariantNumeric: z.string().optional(), + tone: z.string().optional(), + type: z.string().optional(), +}); + +export const TextAreaSchema = z.object({ + autocomplete: z.string().optional(), + defaultValue: z.string().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + maxLength: z.number().optional(), + minLength: z.number().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + readOnly: z.boolean().optional(), + required: z.boolean().optional(), + rows: z.number().optional(), + value: z.string().optional(), +}); + +export const TextAreaEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), +}); + +export const TextFieldSchema = z.object({ + autocomplete: z.string().optional(), + defaultValue: z.string().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + icon: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + maxLength: z.number().optional(), + minLength: z.number().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + prefix: z.string().optional(), + readOnly: z.boolean().optional(), + required: z.boolean().optional(), + suffix: z.string().optional(), + value: z.string().optional(), +}); + +export const TextFieldSlotsSchema = z.object({ + accessory: z.string().optional(), +}); + +export const TextFieldEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), +}); + +export const URLFieldSchema = z.object({ + autocomplete: z.string().optional(), + defaultValue: z.string().optional(), + details: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + id: z.string().optional(), + label: z.string().optional(), + labelAccessibilityVisibility: z.string().optional(), + maxLength: z.number().optional(), + minLength: z.number().optional(), + name: z.string().optional(), + placeholder: z.string().optional(), + readOnly: z.boolean().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const URLFieldEventsSchema = z.object({ + blur: z.string().optional(), + change: z.string().optional(), + focus: z.string().optional(), + input: z.string().optional(), +}); diff --git a/src/data/typescriptSchemas/pos.ts b/src/data/typescriptSchemas/pos.ts new file mode 100644 index 0000000..a6aaf9e --- /dev/null +++ b/src/data/typescriptSchemas/pos.ts @@ -0,0 +1,1117 @@ +import { z } from "zod"; + +// Generated schemas for component types +// This file is auto-generated - do not edit manually + +export const ActionApiContentSchema = z.object({ + presentModal: z.function(), +}); + +export const CartApiContentSchema = z.object({ + addAddress: z.function(), + addCartCodeDiscount: z.function(), + addCartProperties: z.function(), + addCustomSale: z.function(), + addLineItem: z.function(), + addLineItemProperties: z.function(), + applyCartDiscount: z.function(), + bulkAddLineItemProperties: z.function(), + bulkCartUpdate: z.function(), + bulkSetLineItemDiscounts: z.function(), + clearCart: z.function(), + deleteAddress: z.function(), + removeAllDiscounts: z.function(), + removeCartDiscount: z.function(), + removeCartProperties: z.function(), + removeCustomer: z.function(), + removeLineItem: z.function(), + removeLineItemDiscount: z.function(), + removeLineItemProperties: z.function(), + setAttributedStaff: z.function(), + setAttributedStaffToLineItem: z.function(), + setCustomer: z.function(), + setLineItemDiscount: z.function(), + subscribable: z.string(), + updateDefaultAddress: z.function(), +}); + +export const AddressSchema = z.object({ + address1: z.string().optional(), + address2: z.string().optional(), + city: z.string().optional(), + company: z.string().optional(), + country: z.string().optional(), + countryCode: z.string().optional(), + firstName: z.string().optional(), + lastName: z.string().optional(), + name: z.string().optional(), + phone: z.string().optional(), + province: z.string().optional(), + provinceCode: z.string().optional(), + zip: z.string().optional(), +}); + +export const CountryCodeSchema = z.object({ + AF: z.string(), + AX: z.string(), + AL: z.string(), + DZ: z.string(), + AD: z.string(), + AO: z.string(), + AI: z.string(), + AG: z.string(), + AR: z.string(), + AM: z.string(), + AW: z.string(), + AC: z.string(), + AU: z.string(), + AT: z.string(), + AZ: z.string(), + BS: z.string(), + BH: z.string(), + BD: z.string(), + BB: z.string(), + BY: z.string(), + BE: z.string(), + BZ: z.string(), + BJ: z.string(), + BM: z.string(), + BT: z.string(), + BO: z.string(), + BA: z.string(), + BW: z.string(), + BV: z.string(), + BR: z.string(), + IO: z.string(), + BN: z.string(), + BG: z.string(), + BF: z.string(), + BI: z.string(), + KH: z.string(), + CA: z.string(), + CV: z.string(), + BQ: z.string(), + KY: z.string(), + CF: z.string(), + TD: z.string(), + CL: z.string(), + CN: z.string(), + CX: z.string(), + CC: z.string(), + CO: z.string(), + KM: z.string(), + CG: z.string(), + CD: z.string(), + CK: z.string(), + CR: z.string(), + HR: z.string(), + CU: z.string(), + CW: z.string(), + CY: z.string(), + CZ: z.string(), + CI: z.string(), + DK: z.string(), + DJ: z.string(), + DM: z.string(), + DO: z.string(), + EC: z.string(), + EG: z.string(), + SV: z.string(), + GQ: z.string(), + ER: z.string(), + EE: z.string(), + SZ: z.string(), + ET: z.string(), + FK: z.string(), + FO: z.string(), + FJ: z.string(), + FI: z.string(), + FR: z.string(), + GF: z.string(), + PF: z.string(), + TF: z.string(), + GA: z.string(), + GM: z.string(), + GE: z.string(), + DE: z.string(), + GH: z.string(), + GI: z.string(), + GR: z.string(), + GL: z.string(), + GD: z.string(), + GP: z.string(), + GT: z.string(), + GG: z.string(), + GN: z.string(), + GW: z.string(), + GY: z.string(), + HT: z.string(), + HM: z.string(), + VA: z.string(), + HN: z.string(), + HK: z.string(), + HU: z.string(), + IS: z.string(), + IN: z.string(), + ID: z.string(), + IR: z.string(), + IQ: z.string(), + IE: z.string(), + IM: z.string(), + IL: z.string(), + IT: z.string(), + JM: z.string(), + JP: z.string(), + JE: z.string(), + JO: z.string(), + KZ: z.string(), + KE: z.string(), + KI: z.string(), + KP: z.string(), + XK: z.string(), + KW: z.string(), + KG: z.string(), + LA: z.string(), + LV: z.string(), + LB: z.string(), + LS: z.string(), + LR: z.string(), + LY: z.string(), + LI: z.string(), + LT: z.string(), + LU: z.string(), + MO: z.string(), + MG: z.string(), + MW: z.string(), + MY: z.string(), + MV: z.string(), + ML: z.string(), + MT: z.string(), + MQ: z.string(), + MR: z.string(), + MU: z.string(), + YT: z.string(), + MX: z.string(), + MD: z.string(), + MC: z.string(), + MN: z.string(), + ME: z.string(), + MS: z.string(), + MA: z.string(), + MZ: z.string(), + MM: z.string(), + NA: z.string(), + NR: z.string(), + NP: z.string(), + NL: z.string(), + AN: z.string(), + NC: z.string(), + NZ: z.string(), + NI: z.string(), + NE: z.string(), + NG: z.string(), + NU: z.string(), + NF: z.string(), + MK: z.string(), + NO: z.string(), + OM: z.string(), + PK: z.string(), + PS: z.string(), + PA: z.string(), + PG: z.string(), + PY: z.string(), + PE: z.string(), + PH: z.string(), + PN: z.string(), + PL: z.string(), + PT: z.string(), + QA: z.string(), + CM: z.string(), + RE: z.string(), + RO: z.string(), + RU: z.string(), + RW: z.string(), + BL: z.string(), + SH: z.string(), + KN: z.string(), + LC: z.string(), + MF: z.string(), + PM: z.string(), + WS: z.string(), + SM: z.string(), + ST: z.string(), + SA: z.string(), + SN: z.string(), + RS: z.string(), + SC: z.string(), + SL: z.string(), + SG: z.string(), + SX: z.string(), + SK: z.string(), + SI: z.string(), + SB: z.string(), + SO: z.string(), + ZA: z.string(), + GS: z.string(), + KR: z.string(), + SS: z.string(), + ES: z.string(), + LK: z.string(), + VC: z.string(), + SD: z.string(), + SR: z.string(), + SJ: z.string(), + SE: z.string(), + CH: z.string(), + SY: z.string(), + TW: z.string(), + TJ: z.string(), + TZ: z.string(), + TH: z.string(), + TL: z.string(), + TG: z.string(), + TK: z.string(), + TO: z.string(), + TT: z.string(), + TA: z.string(), + TN: z.string(), + TR: z.string(), + TM: z.string(), + TC: z.string(), + TV: z.string(), + UG: z.string(), + UA: z.string(), + AE: z.string(), + GB: z.string(), + US: z.string(), + UM: z.string(), + UY: z.string(), + UZ: z.string(), + VU: z.string(), + VE: z.string(), + VN: z.string(), + VG: z.string(), + WF: z.string(), + EH: z.string(), + YE: z.string(), + ZM: z.string(), + ZW: z.string(), + ZZ: z.string(), +}); + +export const CustomSaleSchema = z.object({ + price: z.string(), + quantity: z.number(), + taxable: z.boolean(), + title: z.string(), +}); + +export const SetLineItemPropertiesInputSchema = z.object({ + lineItemUuid: z.string(), + properties: z.string(), +}); + +export const CartUpdateInputSchema = z.object({ + cartDiscount: z.string().optional(), + cartDiscounts: z.string(), + customer: z.string().optional(), + lineItems: z.string(), + note: z.string().optional(), + properties: z.string(), +}); + +export const DiscountSchema = z.object({ + amount: z.number(), + currency: z.string().optional(), + discountDescription: z.string().optional(), + type: z.string().optional(), +}); + +export const CustomerSchema = z.object({ + id: z.number(), +}); + +export const LineItemSchema = z.object({ + attributedUserId: z.number().optional(), + discounts: z.string(), + isGiftCard: z.boolean(), + price: z.number().optional(), + productId: z.number().optional(), + properties: z.string(), + quantity: z.number(), + sku: z.string().optional(), + taxable: z.boolean(), + taxLines: z.string(), + title: z.string().optional(), + uuid: z.string(), + variantId: z.number().optional(), + vendor: z.string().optional(), +}); + +export const TaxLineSchema = z.object({ + enabled: z.boolean().optional(), + price: z.string(), + rate: z.number(), + rateRange: z.string().optional(), + title: z.string(), + uuid: z.string().optional(), +}); + +export const MoneySchema = z.object({ + amount: z.number(), + currency: z.string(), +}); + +export const CartSchema = z.object({ + cartDiscount: z.string().optional(), + cartDiscounts: z.string(), + customer: z.string().optional(), + editable: z.boolean().optional(), + grandTotal: z.string(), + lineItems: z.string(), + note: z.string().optional(), + properties: z.string(), + subtotal: z.string(), + taxTotal: z.string(), +}); + +export const SetLineItemDiscountInputSchema = z.object({ + lineItemDiscount: z.string(), + lineItemUuid: z.string(), +}); + +export const LineItemDiscountSchema = z.object({ + amount: z.string(), + title: z.string(), + type: z.enum(["Percentage", "FixedAmount"]), +}); + +export const CartLineItemApiSchema = z.object({ + cartLineItem: z.string(), +}); + +export const ConnectivityApiContentSchema = z.object({ + subscribable: z.string(), +}); + +export const ConnectivityStateSchema = z.object({ + internetConnected: z.string(), +}); + +export const CustomerApiContentSchema = z.object({ + id: z.number(), +}); + +export const DeviceApiContentSchema = z.object({ + getDeviceId: z.function(), + isTablet: z.function(), + name: z.string(), +}); + +export const DraftOrderApiContentSchema = z.object({ + customerId: z.number().optional(), + id: z.number(), + name: z.string(), +}); + +export const LocaleApiContentSchema = z.object({ + subscribable: z.string(), +}); + +export const NavigationApiContentSchema = z.object({ + dismiss: z.function(), + navigate: z.function(), + pop: z.function(), +}); + +export const OrderApiContentSchema = z.object({ + customerId: z.number().optional(), + id: z.number(), + name: z.string(), +}); + +export const PrintApiContentSchema = z.object({ + print: z.function(), +}); + +export const ProductApiContentSchema = z.object({ + id: z.number(), + variantId: z.number(), +}); + +export const ProductSearchApiContentSchema = z.object({ + fetchPaginatedProductVariantsWithProductId: z.function(), + fetchProductsWithIds: z.function(), + fetchProductVariantsWithIds: z.function(), + fetchProductVariantsWithProductId: z.function(), + fetchProductVariantWithId: z.function(), + fetchProductWithId: z.function(), + searchProducts: z.function(), +}); + +export const PaginationParamsSchema = z.object({ + afterCursor: z.string().optional(), + first: z.number().optional(), +}); + +export const PaginatedResultSchema = z.object({ + hasNextPage: z.boolean(), + items: z.string(), + lastCursor: z.string().optional(), +}); + +export const ProductVariantSchema = z.object({ + barcode: z.string().optional(), + compareAtPrice: z.string().optional(), + createdAt: z.string(), + displayName: z.string(), + hasInStockVariants: z.boolean().optional(), + id: z.number(), + image: z.string().optional(), + inventoryAtAllLocations: z.number().optional(), + inventoryAtLocation: z.number().optional(), + inventoryIsTracked: z.boolean(), + inventoryPolicy: z.string(), + options: z.string().optional(), + position: z.number(), + price: z.string(), + product: z.string().optional(), + productId: z.number(), + sku: z.string().optional(), + taxable: z.boolean(), + title: z.string(), + updatedAt: z.string(), +}); + +export const ProductVariantOptionSchema = z.object({ + name: z.string(), + value: z.string(), +}); + +export const ProductSchema = z.object({ + createdAt: z.string(), + description: z.string(), + descriptionHtml: z.string(), + featuredImage: z.string().optional(), + hasInStockVariants: z.boolean().optional(), + hasOnlyDefaultVariant: z.boolean(), + id: z.number(), + isGiftCard: z.boolean(), + maxVariantPrice: z.string(), + minVariantPrice: z.string(), + numVariants: z.number(), + onlineStoreUrl: z.string().optional(), + options: z.string(), + productCategory: z.string(), + productType: z.string(), + tags: z.string(), + title: z.string(), + totalAvailableInventory: z.number().optional(), + totalInventory: z.number(), + tracksInventory: z.boolean(), + updatedAt: z.string(), + variants: z.string(), + vendor: z.string(), +}); + +export const ProductOptionSchema = z.object({ + id: z.number(), + name: z.string(), + optionValues: z.string(), + productId: z.number(), +}); + +export const MultipleResourceResultSchema = z.object({ + fetchedResources: z.string(), + idsForResourcesNotFound: z.string(), +}); + +export const ProductSearchParamsSchema = z.object({ + afterCursor: z.string().optional(), + first: z.number().optional(), + queryString: z.string().optional(), + sortType: z.string().optional(), +}); + +export const ScannerApiContentSchema = z.object({ + scannerDataSubscribable: z.string(), + scannerSourcesSubscribable: z.string(), +}); + +export const ScannerSubscriptionResultSchema = z.object({ + data: z.string().optional(), + source: z.string().optional(), +}); + +export const SessionApiContentSchema = z.object({ + currentSession: z.string(), + getSessionToken: z.function(), +}); + +export const SessionSchema = z.object({ + currency: z.string(), + locationId: z.number(), + posVersion: z.string(), + shopDomain: z.string(), + shopId: z.number(), + staffMemberId: z.number().optional(), + userId: z.number(), +}); + +export const StorageSchema = z.object({ + clear: z.function(), + delete: z.function(), + entries: z.function(), + get: z.function(), + set: z.function(), +}); + +export const ToastApiContentSchema = z.object({ + show: z.function(), +}); + +export const ShowToastOptionsSchema = z.object({ + duration: z.number().optional(), +}); + +export const BadgePropsSchema = z.object({ + status: z.string().optional(), + text: z.string(), + variant: z.string(), +}); + +export const BannerPropsSchema = z.object({ + action: z.string().optional(), + hideAction: z.boolean().optional(), + onPress: z.function().optional(), + title: z.string(), + variant: z.string(), + visible: z.boolean(), +}); + +export const BoxPropsSchema = z.object({ + blockSize: z.string().optional(), + inlineSize: z.string().optional(), + maxBlockSize: z.string().optional(), + maxInlineSize: z.string().optional(), + minBlockSize: z.string().optional(), + minInlineSize: z.string().optional(), + padding: z.string().optional(), + paddingBlock: z.string().optional(), + paddingBlockEnd: z.string().optional(), + paddingBlockStart: z.string().optional(), + paddingInline: z.string().optional(), + paddingInlineEnd: z.string().optional(), + paddingInlineStart: z.string().optional(), +}); + +export const ButtonPropsSchema = z.object({ + isDisabled: z.boolean().optional(), + isLoading: z.boolean().optional(), + onPress: z.function().optional(), + title: z.string().optional(), + type: z.string().optional(), +}); + +export const CameraScannerPropsSchema = z.object({ + bannerProps: z.string().optional(), +}); + +export const CameraScannerBannerPropsSchema = z.object({ + title: z.string(), + variant: z.string(), + visible: z.boolean(), +}); + +export const DateFieldPropsSchema = z.object({ + action: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + helpText: z.string().optional(), + label: z.string(), + onBlur: z.function().optional(), + onChange: z.function().optional(), + onFocus: z.function().optional(), + value: z.string().optional(), +}); + +export const InputActionSchema = z.object({ + disabled: z.boolean().optional(), + label: z.string(), + onPress: z.function(), +}); + +export const DatePickerPropsSchema = z.object({ + inputMode: z.enum(["inline", "spinner"]).optional(), + onChange: z.function().optional(), + selected: z.string().optional(), + visibleState: z.function(), +}); + +export const DialogPropsSchema = z.object({ + actionText: z.string(), + content: z.string().optional(), + isVisible: z.boolean(), + onAction: z.function(), + onSecondaryAction: z.function().optional(), + secondaryActionText: z.string().optional(), + showSecondaryAction: z.boolean().optional(), + title: z.string(), + type: z.string().optional(), +}); + +export const EmailFieldPropsSchema = z.object({ + action: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + helpText: z.string().optional(), + label: z.string(), + maxLength: z.number().optional(), + onBlur: z.function().optional(), + onChange: z.function().optional(), + onFocus: z.function().optional(), + onInput: z.function().optional(), + placeholder: z.string().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const IconPropsSchema = z.object({ + name: z.string(), + size: z.string().optional(), + tone: z.string().optional(), +}); + +export const ImagePropsSchema = z.object({ + size: z.string().optional(), + src: z.string().optional(), +}); + +export const ListPropsSchema = z.object({ + data: z.string(), + imageDisplayStrategy: z.enum(["automatic", "always", "never"]).optional(), + isLoadingMore: z.boolean().optional(), + listHeaderComponent: z.string().optional(), + onEndReached: z.function().optional(), + title: z.string().optional(), +}); + +export const ListRowSchema = z.object({ + id: z.string(), + leftSide: z.string(), + onPress: z.function().optional(), + rightSide: z.string().optional(), +}); + +export const ListRowLeftSideSchema = z.object({ + badges: z.string().optional(), + image: z.string().optional(), + label: z.string(), + subtitle: z.string().optional(), +}); + +export const SubtitleTypeSchema = z.object({ + color: z.string().optional(), + content: z.string(), +}); + +export const ListRowRightSideSchema = z.object({ + label: z.string().optional(), + showChevron: z.boolean().optional(), + toggleSwitch: z.string().optional(), +}); + +export const ToggleSwitchSchema = z.object({ + disabled: z.boolean().optional(), + value: z.boolean().optional(), +}); + +export const NavigatorPropsSchema = z.object({ + initialScreenName: z.string().optional(), +}); + +export const NumberFieldPropsSchema = z.object({ + action: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + helpText: z.string().optional(), + inputMode: z.enum(["decimal", "numeric"]).optional(), + label: z.string(), + max: z.number().optional(), + maxLength: z.number().optional(), + min: z.number().optional(), + onBlur: z.function().optional(), + onChange: z.function().optional(), + onFocus: z.function().optional(), + onInput: z.function().optional(), + placeholder: z.string().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const POSBlockPropsSchema = z.object({ + action: z.function().optional(), +}); + +export const POSBlockRowPropsSchema = z.object({ + onPress: z.function().optional(), +}); + +export const PinPadPropsSchema = z.object({ + label: z.string().optional(), + masked: z.boolean().optional(), + maxPinLength: z.string().optional(), + minPinLength: z.string().optional(), + onPinEntry: z.function().optional(), + onSubmit: z.function(), + pinPadAction: z.string().optional(), +}); + +export const PinPadActionTypeSchema = z.object({ + label: z.string(), + onPress: z.function(), +}); + +export const PrintPreviewPropsSchema = z.object({ + src: z.string(), +}); + +export const QRCodePropsSchema = z.object({ + value: z.string(), +}); + +export const RadioButtonListPropsSchema = z.object({ + initialOffsetToShowSelectedItem: z.boolean().optional(), + initialSelectedItem: z.string().optional(), + items: z.string(), + onItemSelected: z.function(), +}); + +export const ScreenPropsSchema = z.object({ + isLoading: z.boolean().optional(), + name: z.string(), + onNavigate: z.function().optional(), + onNavigateBack: z.function().optional(), + onReceiveParams: z.function().optional(), + overrideNavigateBack: z.function().optional(), + presentation: z.string().optional(), + secondaryAction: z.string().optional(), + title: z.string(), +}); + +export const ScreenPresentationPropsSchema = z.object({ + sheet: z.boolean().optional(), +}); + +export const SecondaryActionPropsSchema = z.object({ + isEnabled: z.boolean().optional(), + onPress: z.function(), + text: z.string(), +}); + +export const SearchBarPropsSchema = z.object({ + editable: z.boolean().optional(), + initialValue: z.string().optional(), + onBlur: z.function().optional(), + onFocus: z.function().optional(), + onSearch: z.function(), + onTextChange: z.function().optional(), + placeholder: z.string().optional(), +}); + +export const SectionPropsSchema = z.object({ + action: z.string().optional(), + title: z.string().optional(), +}); + +export const SectionHeaderActionSchema = z.object({ + onPress: z.function(), + title: z.string(), +}); + +export const SectionHeaderPropsSchema = z.object({ + action: z.function().optional(), + hideDivider: z.boolean().optional(), + title: z.string(), +}); + +export const SegmentedControlPropsSchema = z.object({ + onSelect: z.function(), + segments: z.string(), + selected: z.string(), +}); + +export const SegmentSchema = z.object({ + disabled: z.boolean(), + id: z.string(), + label: z.string(), +}); + +export const SelectablePropsSchema = z.object({ + disabled: z.boolean().optional(), + onPress: z.function(), +}); + +export const StackPropsSchema = z.object({ + alignContent: z.enum(["stretch"]).optional(), + alignItems: z.enum(["stretch", "baseline"]).optional(), + alignment: z.string().optional(), + blockSize: z.string().optional(), + columnGap: z.string().optional(), + direction: z.enum(["inline", "block"]).optional(), + flex: z.number().optional(), + flexChildren: z.boolean().optional(), + flexWrap: z.enum(["wrap", "nowrap", "wrap-reverse"]).optional(), + gap: z.string().optional(), + inlineSize: z.string().optional(), + justifyContent: z.string().optional(), + maxBlockSize: z.string().optional(), + maxInlineSize: z.string().optional(), + minBlockSize: z.string().optional(), + minInlineSize: z.string().optional(), + padding: z.string().optional(), + paddingBlock: z.string().optional(), + paddingBlockEnd: z.string().optional(), + paddingBlockStart: z.string().optional(), + paddingHorizontal: z.string().optional(), + paddingInline: z.string().optional(), + paddingInlineEnd: z.string().optional(), + paddingInlineStart: z.string().optional(), + paddingVertical: z.string().optional(), + rowGap: z.string().optional(), + spacing: z.string().optional(), +}); + +export const StepperPropsSchema = z.object({ + disabled: z.boolean().optional(), + initialValue: z.number(), + maximumValue: z.number().optional(), + minimumValue: z.number().optional(), + onValueChanged: z.function(), + value: z.number().optional(), +}); + +export const TextPropsSchema = z.object({ + color: z.string().optional(), + variant: z.string().optional(), +}); + +export const TextAreaPropsSchema = z.object({ + action: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + helpText: z.string().optional(), + label: z.string(), + maxLength: z.number().optional(), + onBlur: z.function().optional(), + onChange: z.function().optional(), + onFocus: z.function().optional(), + onInput: z.function().optional(), + placeholder: z.string().optional(), + required: z.boolean().optional(), + rows: z.number().optional(), + value: z.string().optional(), +}); + +export const NewTextFieldPropsSchema = z.object({ + action: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + helpText: z.string().optional(), + label: z.string(), + maxLength: z.number().optional(), + onBlur: z.function().optional(), + onChange: z.function().optional(), + onFocus: z.function().optional(), + onInput: z.function().optional(), + placeholder: z.string().optional(), + required: z.boolean().optional(), + value: z.string().optional(), +}); + +export const TilePropsSchema = z.object({ + badgeValue: z.number().optional(), + destructive: z.boolean().optional(), + enabled: z.boolean().optional(), + onPress: z.function().optional(), + subtitle: z.string().optional(), + title: z.string(), +}); + +export const TimeFieldPropsSchema = z.object({ + action: z.string().optional(), + disabled: z.boolean().optional(), + error: z.string().optional(), + helpText: z.string().optional(), + is24Hour: z.boolean().optional(), + label: z.string(), + onBlur: z.function().optional(), + onChange: z.function().optional(), + onFocus: z.function().optional(), + value: z.string().optional(), +}); + +export const TimePickerPropsSchema = z.object({ + inputMode: z.enum(["inline", "spinner"]).optional(), + is24Hour: z.boolean().optional(), + onChange: z.function().optional(), + selected: z.string().optional(), + visibleState: z.function(), +}); + +export const CartUpdateEventDataSchema = z.object({ + cart: z.string(), + connectivity: z.string(), + device: z.string(), + locale: z.string(), + session: z.string(), + storage: z.string(), +}); + +export const DeviceSchema = z.object({ + deviceId: z.number(), + isTablet: z.boolean(), + name: z.string(), +}); + +export const CashTrackingSessionCompleteDataSchema = z.object({ + cashTrackingSessionComplete: z.string(), + connectivity: z.string(), + device: z.string(), + locale: z.string(), + session: z.string(), + storage: z.string(), +}); + +export const CashTrackingSessionStartDataSchema = z.object({ + cashTrackingSessionStart: z.string(), + connectivity: z.string(), + device: z.string(), + locale: z.string(), + session: z.string(), + storage: z.string(), +}); + +export const TransactionCompleteWithReprintDataSchema = z.object({ + connectivity: z.string(), + device: z.string(), + locale: z.string(), + session: z.string(), + storage: z.string(), + transaction: z.string(), +}); + +export const SaleTransactionDataSchema = z.object({ + balanceDue: z.string(), + customer: z.string().optional(), + discounts: z.string().optional(), + draftCheckoutUuid: z.string().optional(), + executedAt: z.string(), + grandTotal: z.string(), + lineItems: z.string(), + orderId: z.number().optional(), + paymentMethods: z.string(), + shippingLines: z.string().optional(), + subtotal: z.string(), + taxLines: z.string().optional(), + taxTotal: z.string(), + transactionType: z.string(), +}); + +export const PaymentSchema = z.object({ + amount: z.number(), + currency: z.string(), + type: z.string(), +}); + +export const ShippingLineSchema = z.object({ + handle: z.string().optional(), + price: z.string(), + taxLines: z.string().optional(), + title: z.string().optional(), +}); + +export const ReturnTransactionDataSchema = z.object({ + balanceDue: z.string(), + customer: z.string().optional(), + discounts: z.string().optional(), + exchangeId: z.number().optional(), + executedAt: z.string(), + grandTotal: z.string(), + lineItems: z.string(), + orderId: z.number().optional(), + paymentMethods: z.string(), + returnId: z.number().optional(), + shippingLines: z.string().optional(), + subtotal: z.string(), + taxLines: z.string().optional(), + taxTotal: z.string(), + transactionType: z.string(), +}); + +export const ExchangeTransactionDataSchema = z.object({ + balanceDue: z.string(), + customer: z.string().optional(), + discounts: z.string().optional(), + exchangeId: z.number().optional(), + executedAt: z.string(), + grandTotal: z.string(), + lineItemsAdded: z.string(), + lineItemsRemoved: z.string(), + orderId: z.number().optional(), + paymentMethods: z.string(), + shippingLines: z.string().optional(), + subtotal: z.string(), + taxLines: z.string().optional(), + taxTotal: z.string(), + transactionType: z.string(), +}); + +export const ReprintReceiptDataSchema = z.object({ + balanceDue: z.string(), + customer: z.string().optional(), + discounts: z.string().optional(), + executedAt: z.string(), + grandTotal: z.string(), + lineItems: z.string(), + orderId: z.number().optional(), + paymentMethods: z.string(), + shippingLines: z.string().optional(), + subtotal: z.string(), + taxLines: z.string().optional(), + taxTotal: z.string(), + transactionType: z.string(), +}); + +export const OrderLineItemSchema = z.object({ + attributedUserId: z.number().optional(), + currentQuantity: z.number(), + discounts: z.string(), + isGiftCard: z.boolean(), + price: z.number().optional(), + productId: z.number().optional(), + properties: z.string(), + quantity: z.number(), + refunds: z.string().optional(), + sku: z.string().optional(), + taxable: z.boolean(), + taxLines: z.string(), + title: z.string().optional(), + uuid: z.string(), + variantId: z.number().optional(), + vendor: z.string().optional(), +}); + +export const LineItemRefundSchema = z.object({ + createdAt: z.string(), + quantity: z.number(), +}); + +export const TransactionCompleteDataSchema = z.object({ + connectivity: z.string(), + device: z.string(), + locale: z.string(), + session: z.string(), + storage: z.string(), + transaction: z.string(), +}); diff --git a/src/esm-dependencies.test.ts b/src/esm-dependencies.test.ts new file mode 100644 index 0000000..1b04c92 --- /dev/null +++ b/src/esm-dependencies.test.ts @@ -0,0 +1,230 @@ +import { readFile } from "fs/promises"; +import { builtinModules } from "module"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, it } from "vitest"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Packages that are known to work fine despite not being pure ESM +// Add packages here that you explicitly want to allow +const ALLOWED_NON_ESM_PACKAGES: string[] = ["typescript", "env-paths"]; + +// Get external packages from vite config +async function getViteExternalPackages(): Promise { + try { + // Import vite config dynamically + // @ts-expect-error vite.config.js doesn't have type declarations + const viteConfigModule = await import("../vite.config.js"); + const viteConfig = viteConfigModule.default; + + // Get external packages from the config + const externals = viteConfig?.build?.rollupOptions?.external || []; + + // Filter out builtin modules and their node: prefixed versions + return externals.filter( + (pkg: string) => + !builtinModules.includes(pkg) && !pkg.startsWith("node:"), + ); + } catch (error) { + console.warn("Could not load vite config for external packages:", error); + } + return []; +} + +describe("ESM Dependencies Check", () => { + it("should ensure all dependencies are ESM modules", async () => { + // Get external packages from vite config + const viteExternalPackages = await getViteExternalPackages(); + + // Combine allowed packages + const allowedPackages = [ + ...ALLOWED_NON_ESM_PACKAGES, + ...viteExternalPackages, + ]; + + // Read the main package.json + const packageJsonPath = join(__dirname, "..", "package.json"); + const packageJsonContent = await readFile(packageJsonPath, "utf-8"); + const packageJson = JSON.parse(packageJsonContent); + + const allDependencies = { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; + + const nonEsmPackages: Array<{ + name: string; + version: string; + type: string; + reason: string; + }> = []; + const errors: string[] = []; + + for (const [packageName, version] of Object.entries(allDependencies)) { + // Skip type definition packages as they don't have runtime code + if (packageName.startsWith("@types/")) { + continue; + } + + // Skip allowed non-ESM packages (including vite externals) + if (allowedPackages.includes(packageName)) { + continue; + } + + const { info, error } = await checkPackage( + packageName, + version as string, + ); + + if (error) { + errors.push(error); + } else if (info) { + nonEsmPackages.push(info); + } + } + + // Log any errors for debugging + if (errors.length > 0) { + console.warn("Warnings while checking dependencies:", errors); + } + + // Create a detailed error message + if (nonEsmPackages.length > 0) { + const errorMessage = [ + "The following dependencies are not ESM modules:", + "", + ...nonEsmPackages.map( + (pkg) => `- ${pkg.name}@${pkg.version} (${pkg.reason})`, + ), + "", + "To fix this:", + "1. Find ESM alternatives for these packages", + "2. Check if newer versions support ESM", + "3. If a package works despite being CommonJS, add it to ALLOWED_NON_ESM_PACKAGES", + "4. Add it to the external packages to vite.config.js", + "", + "Currently excluded packages:", + `- Manually allowed: ${ALLOWED_NON_ESM_PACKAGES.join(", ")}`, + `- Vite externals: ${viteExternalPackages.join(", ") || "none"}`, + ].join("\n"); + + expect(nonEsmPackages.length, errorMessage).toBe(0); + } + }); +}); + +interface EsmCheckResult { + isEsm: boolean; + reason: string; +} + +interface PackageInfo { + name: string; + version: string; + type: string; + reason: string; +} + +// Helper function to resolve package.json path for both regular and scoped packages +function getPackageJsonPath(packageName: string): string { + const basePath = join(__dirname, "..", "node_modules"); + return join(basePath, packageName, "package.json"); +} + +// Helper function to read and parse package.json +async function readPackageJson(path: string): Promise { + try { + const content = await readFile(path, "utf-8"); + return JSON.parse(content); + } catch { + return null; + } +} + +// Helper function to check a single package for ESM compatibility +async function checkPackage( + packageName: string, + version: string, +): Promise<{ info: PackageInfo | null; error: string | null }> { + const path = getPackageJsonPath(packageName); + const packageJson = await readPackageJson(path); + + if (!packageJson) { + return { + info: null, + error: `Could not find package.json for ${packageName}`, + }; + } + + const esmCheck = checkIfPackageIsEsm(packageJson); + + if (!esmCheck.isEsm) { + return { + info: { + name: packageName, + version: version, + type: packageJson.type || "commonjs", + reason: esmCheck.reason, + }, + error: null, + }; + } + + return { info: null, error: null }; +} + +function checkIfPackageIsEsm(packageJson: any): EsmCheckResult { + // A package is considered ESM if: + // 1. It has "type": "module" + if (packageJson.type === "module") { + return { isEsm: true, reason: "has type: module" }; + } + + // 2. It has "exports" field with ESM exports + if (packageJson.exports) { + const hasEsmExports = checkExportsForEsm(packageJson.exports); + if (hasEsmExports) { + return { isEsm: true, reason: "has ESM exports" }; + } + } + + // 3. It has "module" field (older convention) + if (packageJson.module) { + return { isEsm: true, reason: "has module field" }; + } + + // 4. It only has .mjs files (check main field) + if (packageJson.main && packageJson.main.endsWith(".mjs")) { + return { isEsm: true, reason: "main entry is .mjs" }; + } + + // Not ESM + return { + isEsm: false, + reason: `type: ${packageJson.type || "commonjs"}, no ESM indicators`, + }; +} + +function checkExportsForEsm(exports: any): boolean { + if (typeof exports === "string") { + return exports.endsWith(".mjs") || exports.endsWith(".js"); + } + + if (typeof exports === "object") { + // Check for import conditions + if (exports.import) { + return true; + } + + // Check nested exports + for (const value of Object.values(exports)) { + if (checkExportsForEsm(value)) { + return true; + } + } + } + + return false; +} diff --git a/src/flags.ts b/src/flags.ts new file mode 100644 index 0000000..b6d8b5a --- /dev/null +++ b/src/flags.ts @@ -0,0 +1,9 @@ +export const polarisUnifiedEnabled = + process.env.POLARIS_UNIFIED === "true" || process.env.POLARIS_UNIFIED === "1"; + +export const liquidEnabled = + process.env.LIQUID === "true" || process.env.LIQUID === "1"; + +// LIQUID_VALIDATION_MODE can be "full" or "partial" +export const liquidMcpValidationMode = + process.env.LIQUID_VALIDATION_MODE === "partial" ? "partial" : "full"; diff --git a/src/index.ts b/src/index.ts index fc2ac9a..d28c7e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,19 +2,11 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { readFileSync } from "fs"; -import { dirname, resolve } from "path"; -import { fileURLToPath } from "url"; import { shopifyPrompts } from "./prompts/index.js"; import { shopifyTools } from "./tools/index.js"; -// Get package.json version -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const packageJson = JSON.parse( - readFileSync(resolve(__dirname, "../package.json"), "utf8"), -); -const VERSION = packageJson.version; +declare const __APP_VERSION__: string; +const VERSION = __APP_VERSION__; async function main() { // Create server instance diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 5022dd0..eb5f2ef 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,6 +1,6 @@ import { randomUUID } from "crypto"; import pkg from "../package.json" with { type: "json" }; -import { shopifyDevFetch } from "./tools/shopifyDevFetch.js"; +import { shopifyDevFetch } from "./tools/shopify_dev_fetch/index.js"; const packageVersion = pkg.version; diff --git a/src/test-utils.ts b/src/test-utils.ts new file mode 100644 index 0000000..ac81707 --- /dev/null +++ b/src/test-utils.ts @@ -0,0 +1,23 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { SCHEMAS_CACHE_DIR } from "./tools/introspect_graphql_schema/index.js"; + +export async function injectMockSchemasIntoCache() { + // To avoid hitting the network, we pre-polulate the cache folder with a schema file. + await fs.mkdir(SCHEMAS_CACHE_DIR, { recursive: true }); + + const mockSchemasDir = path.join( + path.dirname(new URL(import.meta.url).pathname), + "../mock-schemas", + ); + const files = await fs.readdir(mockSchemasDir); + + for (const fileName of files) { + const content = await fs.readFile( + path.join(mockSchemasDir, fileName), + "utf-8", + ); + const file = path.join(SCHEMAS_CACHE_DIR, fileName); + await fs.writeFile(file, content); + } +} diff --git a/src/tools/index.test.ts b/src/tools/index.test.ts index 0fe6560..cb84ac0 100644 --- a/src/tools/index.test.ts +++ b/src/tools/index.test.ts @@ -84,15 +84,18 @@ vi.mock("../instrumentation.js", () => ({ })); // Mock introspectGraphqlSchema -vi.mock("./introspectGraphqlSchema.js", () => ({ - introspectGraphqlSchema: vi.fn(), -})); +vi.mock("./introspect_graphql_schema/index.js", { spy: true }); // Mock validateGraphQLOperation vi.mock("../validations/graphqlSchema.js", () => ({ default: vi.fn(), })); +// Mock validateTypescript +vi.mock("../validations/typescript.js", () => ({ + validateComponentCodeBlock: vi.fn(), +})); + vi.mock("../../package.json", () => ({ default: { version: "1.0.0" }, })); @@ -280,7 +283,6 @@ describe("fetchGettingStartedApis", () => { vi.resetModules(); // Reset environment to clean state process.env = { ...originalEnv }; - delete process.env.LIQUID_MCP; }); afterEach(() => { @@ -311,36 +313,34 @@ describe("fetchGettingStartedApis", () => { ); }); - test("adds liquid_mcp query parameter when environment variable is set", async () => { + test("adds liquid_mcp query parameter", async () => { + // Set the environment variable that the actual implementation checks + const originalEnv = process.env.LIQUID_MCP; process.env.LIQUID_MCP = "true"; - const { shopifyTools } = await import("./index.js"); + try { + // Clear the module cache to ensure fresh import with new env var + vi.resetModules(); - const fetchSpy = vi.spyOn(global, "fetch"); - const mockServer = { tool: vi.fn() }; + const { shopifyTools } = await import("./index.js"); - await shopifyTools(mockServer as any); + const fetchSpy = vi.spyOn(global, "fetch"); + const mockServer = { tool: vi.fn() }; - expect(fetchSpy).toHaveBeenCalledWith( - expect.stringContaining("/mcp/getting_started_apis?liquid_mcp=true"), - expect.any(Object), - ); - }); + await shopifyTools(mockServer as any); - test("does not add liquid_mcp query parameter when environment variable is false", async () => { - process.env.LIQUID_MCP = "false"; - - const { shopifyTools } = await import("./index.js"); - - const fetchSpy = vi.spyOn(global, "fetch"); - const mockServer = { tool: vi.fn() }; - - await shopifyTools(mockServer as any); - - expect(fetchSpy).toHaveBeenCalledWith( - expect.stringContaining("/mcp/getting_started_apis"), - expect.any(Object), - ); + expect(fetchSpy).toHaveBeenCalledWith( + expect.stringContaining("/mcp/getting_started_apis?liquid_mcp=true"), + expect.any(Object), + ); + } finally { + // Restore original environment variable + if (originalEnv !== undefined) { + process.env.LIQUID_MCP = originalEnv; + } else { + delete process.env.LIQUID_MCP; + } + } }); }); @@ -741,3 +741,193 @@ describe("validate_graphql_codeblocks tool", () => { ); }); }); + +describe("validate_api_codeblocks tool", () => { + let mockServer: any; + let validateComponentCodeBlockMock: any; + + beforeEach(async () => { + vi.clearAllMocks(); + const { validateComponentCodeBlock } = await import( + "../validations/typescript.js" + ); + validateComponentCodeBlockMock = vi.mocked(validateComponentCodeBlock); + + // Create a mock server that captures the registered tools + mockServer = { + tool: vi.fn((name, description, schema, handler) => { + if (name === "validate_api_codeblocks") { + mockServer.validateTypescriptHandler = handler; + } + }), + validateTypescriptHandler: null, + }; + + // Mock instrumentation + vi.mocked(instrumentationData).mockReturnValue({ + packageVersion: "1.0.0", + timestamp: "2024-01-01T00:00:00.000Z", + }); + vi.mocked(isInstrumentationDisabled).mockReturnValue(false); + }); + + test("calls validateTypescriptWithFormatting with correct parameters", async () => { + // Mock the validation function to return a successful result + validateComponentCodeBlockMock.mockReturnValue({ + result: "success", + resultDetail: + "Code block successfully validated against @shopify/app-bridge-ui-types schemas. Found components: s-button.", + }); + + // Register the tools + await shopifyTools(mockServer); + + const result = await mockServer.validateTypescriptHandler({ + code: ["```Test```"], + packageName: "@shopify/app-bridge-ui-types", + conversationId: "test-conversation-123", + }); + + // Check the MCP response format + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("✅ VALID"); + expect(result.content[0].text).toContain("Code Block 1"); + + // Verify validateComponentCodeBlock was called with correct parameters (once per code snippet) + expect(validateComponentCodeBlockMock).toHaveBeenCalledTimes(1); + expect(validateComponentCodeBlockMock).toHaveBeenCalledWith({ + code: "```Test```", + packageName: "@shopify/app-bridge-ui-types", + }); + }); + + test("handles validation failures correctly", async () => { + validateComponentCodeBlockMock.mockReturnValue({ + result: "failed", + resultDetail: + "Errors: Unknown component: s-invalid. Available components for @shopify/app-bridge-ui-types: s-badge, s-banner, s-box, s-button, s-checkbox, s-text, s-heading, s-link", + }); + + // Register the tools + await shopifyTools(mockServer); + + const result = await mockServer.validateTypescriptHandler({ + code: ["```Test```"], + packageName: "@shopify/app-bridge-ui-types", + conversationId: "test-conversation-123", + }); + + // Check the MCP response format + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("❌ INVALID"); + expect(result.content[0].text).toContain("Unknown component: s-invalid"); + }); + + test("handles multiple codeblocks", async () => { + validateComponentCodeBlockMock.mockReturnValue({ + result: "success", + resultDetail: "All code blocks validated successfully", + validationResults: [], + }); + + // Register the tools + await shopifyTools(mockServer); + + const result = await mockServer.validateTypescriptHandler({ + code: [ + "```Button```", + "```Text```", + ], + packageName: "@shopify/app-bridge-ui-types", + conversationId: "test-conversation-123", + }); + + // Check the MCP response format + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("✅ VALID"); + expect(result.content[0].text).toContain( + "All code blocks validated successfully", + ); + }); + + test("handles errors during validation", async () => { + validateComponentCodeBlockMock.mockImplementation(() => { + throw new Error("Validation service unavailable"); + }); + + // Register the tools + await shopifyTools(mockServer); + + const result = await mockServer.validateTypescriptHandler({ + code: ["```Test```"], + packageName: "@shopify/app-bridge-ui-types", + conversationId: "test-conversation-123", + }); + + // Check the MCP response format for error + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("Validation service unavailable"); + + // Verify validateComponentCodeBlock was called + expect(validateComponentCodeBlockMock).toHaveBeenCalledTimes(1); + }); + + test("records usage data correctly", async () => { + // Setup mock response + validateComponentCodeBlockMock.mockReturnValue({ + result: "success", + resultDetail: + "Code block successfully validated against @shopify/app-bridge-ui-types schemas. Found components: s-button.", + }); + + // Register the tools + await shopifyTools(mockServer); + + const testParams = { + code: ["```Hello```"], + packageName: "@shopify/app-bridge-ui-types", + conversationId: "test-conversation-id", + }; + + // Call the handler + await mockServer.validateTypescriptHandler(testParams); + + // Verify recordUsage was called with correct parameters + const { recordUsage } = await import("../instrumentation.js"); + expect(vi.mocked(recordUsage)).toHaveBeenCalledWith( + "validate_api_codeblocks", + testParams, + expect.arrayContaining([ + expect.objectContaining({ + result: "success", + resultDetail: expect.any(String), + }), + ]), // Array of validation responses + ); + }); + + test("handles validation function errors", async () => { + // Setup mock to throw an error + validateComponentCodeBlockMock.mockImplementation(() => { + throw new Error("TypeScript compiler failed"); + }); + + // Register the tools + await shopifyTools(mockServer); + + // Call the handler and expect it to handle the error gracefully + const result = await mockServer.validateTypescriptHandler({ + code: ["```Hello```"], + packageName: "@shopify/app-bridge-ui-types", + conversationId: "test-conversation-id", + }); + + // Should return error response rather than throwing + expect(result.content[0].type).toBe("text"); + expect(result.content[0].text).toContain("❌ INVALID"); + expect(result.content[0].text).toContain("TypeScript compiler failed"); + + // Verify validateComponentCodeBlock was called + expect(validateComponentCodeBlockMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 7fab5e8..dae15de 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,14 +1,21 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { generateConversationId, recordUsage } from "../instrumentation.js"; -import type { ValidationToolResult } from "../types.js"; -import { ValidationResult } from "../types.js"; +import { ValidationResponse, ValidationResult } from "../types.js"; import validateGraphQLOperation from "../validations/graphqlSchema.js"; import { hasFailedValidation } from "../validations/index.js"; import validateTheme from "../validations/theme.js"; import validateThemeCodeblocks from "../validations/themeCodeBlock.js"; -import { introspectGraphqlSchema } from "./introspectGraphqlSchema.js"; -import { shopifyDevFetch } from "./shopifyDevFetch.js"; +import { validateComponentCodeBlock } from "../validations/typescript.js"; +import { + fetchGraphQLSchemas, + introspectGraphqlSchema, +} from "./introspect_graphql_schema/index.js"; +import { searchShopifyDocs } from "./search_shopify_docs/index.js"; +import { shopifyDevFetch } from "./shopify_dev_fetch/index.js"; + +// Re-export for testing +export { searchShopifyDocs }; const polarisUnifiedEnabled = process.env.POLARIS_UNIFIED === "true" || process.env.POLARIS_UNIFIED === "1"; @@ -55,133 +62,20 @@ const ConversationIdSchema = z.object({ }); // Helper function to add conversationId to tool schemas -const withConversationId = (schema: T) => ({ +export const withConversationId = (schema: T) => ({ ...ConversationIdSchema.shape, ...schema, }); -/** - * Searches Shopify documentation with the given query - * @param prompt The search query for Shopify documentation - * @param options Optional search options - * @returns The formatted response or error message - */ -export async function searchShopifyDocs( - prompt: string, - parameters: Record = {}, -) { - try { - const responseText = await shopifyDevFetch("/mcp/search", { - parameters: { - query: prompt, - ...parameters, - }, - }); - - console.error( - `[search-shopify-docs] Response text (truncated): ${ - responseText.substring(0, 200) + - (responseText.length > 200 ? "..." : "") - }`, - ); - - // Try to parse and format as JSON, otherwise return raw text - try { - const jsonData = JSON.parse(responseText); - const formattedJson = JSON.stringify(jsonData, null, 2); - return { - success: true, - formattedText: formattedJson, - }; - } catch (e) { - // If JSON parsing fails, return the raw text - console.warn(`[search-shopify-docs] Error parsing JSON response: ${e}`); - return { - success: true, - formattedText: responseText, - }; - } - } catch (error) { - console.error( - `[search-shopify-docs] Error searching Shopify documentation: ${error}`, - ); - - return { - success: false, - formattedText: error instanceof Error ? error.message : String(error), - }; - } -} - -/** - * Fetches available GraphQL schemas from Shopify - * @returns Object containing available APIs and versions - */ -async function fetchGraphQLSchemas(): Promise<{ - schemas: { api: string; id: string; version: string; url: string }[]; - apis: { name: string; description: string }[]; - versions: string[]; - latestVersion?: string; -}> { - try { - const responseText = await shopifyDevFetch("/mcp/graphql_schemas"); - - let parsedResponse: GraphQLSchemasResponse; - try { - const jsonData = JSON.parse(responseText); - parsedResponse = GraphQLSchemasResponseSchema.parse(jsonData); - } catch (parseError) { - console.error(`Error parsing schemas JSON: ${parseError}`); - console.error(`Response text: ${responseText.substring(0, 500)}...`); - return { - schemas: [], - apis: [], - versions: [], - }; - } - - // Extract unique APIs and versions - const apisMap = new Map(); - const versions = new Set(); - const schemas: { api: string; id: string; version: string; url: string }[] = - []; - - parsedResponse.apis.forEach((api) => { - apisMap.set(api.name, { name: api.name, description: api.description }); - - api.schemas.forEach((schema) => { - versions.add(schema.version); - schemas.push({ - api: api.name, - id: schema.id, - version: schema.version, - url: schema.url, - }); - }); - }); - - return { - schemas, - apis: Array.from(apisMap.values()), - versions: Array.from(versions), - latestVersion: parsedResponse.latest_version, - }; - } catch (error) { - console.error(`Error fetching schemas: ${error}`); - return { - schemas: [], - apis: [], - versions: [], - }; - } -} - +// Removed unused import.meta.glob export async function shopifyTools(server: McpServer): Promise { const { schemas, apis, versions, latestVersion } = await fetchGraphQLSchemas(); // Extract just the API names for enum definitions - const apiNames = apis.map((api) => api.name); + const apiNames = apis.map( + (api: { name: string; description: string }) => api.name, + ); server.tool( "introspect_graphql_schema", @@ -205,7 +99,10 @@ export async function shopifyTools(server: McpServer): Promise { .default("admin") .describe( `The API to introspect. Valid options are:\n${apis - .map((api) => `- '${api.name}': ${api.description}`) + .map( + (api: { name: string; description: string }) => + `- '${api.name}': ${api.description}`, + ) .join("\n")}\nDefault is 'admin'.`, ), version: z @@ -214,7 +111,7 @@ export async function shopifyTools(server: McpServer): Promise { .default(latestVersion!) .describe( `The version of the API to introspect. MUST be one of ${versions - .map((v) => `'${v}'`) + .map((v: string) => `'${v}'`) .join(" or ")}. Default is '${latestVersion}'.`, ), }), @@ -352,7 +249,10 @@ export async function shopifyTools(server: McpServer): Promise { .default("admin") .describe( `The GraphQL API to validate against. Valid options are:\n${apis - .map((api) => `- '${api.name}': ${api.description}`) + .map( + (api: { name: string; description: string }) => + `- '${api.name}': ${api.description}`, + ) .join("\n")}\nDefault is 'admin'.`, ), version: z @@ -360,7 +260,7 @@ export async function shopifyTools(server: McpServer): Promise { .default(latestVersion!) .describe( `The version of the API to validate against. MUST be one of ${versions - .map((v) => `'${v}'`) + .map((v: string) => `'${v}'`) .join(" or ")}\nDefault is '${latestVersion}'.`, ), codeblocks: z @@ -407,7 +307,9 @@ export async function shopifyTools(server: McpServer): Promise { const gettingStartedApis = await fetchGettingStartedApis(); - const gettingStartedApiNames = gettingStartedApis.map((api) => api.name); + const gettingStartedApiNames = gettingStartedApis.map( + (api: GettingStartedAPI) => api.name, + ); server.tool( "learn_shopify_api", @@ -422,12 +324,13 @@ export async function shopifyTools(server: McpServer): Promise { This tool generates a conversationId that is REQUIRED for all subsequent tool calls. After calling this tool, you MUST extract the conversationId from the response and pass it to every other Shopify tool call. Valid arguments for \`api\` are: - ${gettingStartedApis.map((api) => ` - ${api.name}: ${api.description}`).join("\n")} + ${gettingStartedApis.map((api: GettingStartedAPI) => ` - ${api.name}: ${api.description}`).join("\n")} 🔄 WORKFLOW: 1. Call learn_shopify_api first 2. Extract the conversationId from the response 3. Pass that same conversationId to ALL other Shopify tools + 4. Make sure to call validate_api_codeblocks tool to validate the codeblocks for each tool call. DON'T SEARCH THE WEB WHEN REFERENCING INFORMATION FROM THIS DOCUMENTATION. IT WILL NOT BE ACCURATE. PREFER THE USE OF THE fetch_full_docs TOOL TO RETRIEVE INFORMATION FROM THE DEVELOPER DOCUMENTATION SITE. @@ -480,6 +383,84 @@ ${responseText}`; } }, ); + server.tool( + "validate_api_codeblocks", + + `This tool is used to ensure that codeblock with JS, TS, or web components generated by LLMs don't have hallucinated components, props, and/or prop values. If a user asks for an LLM to generate Shopify API JS, JSX, TS, or web components, this tool should always be used to ensure valid code blocks were generated. + It takes two arguments: code, which is an array of markdown code blocks containing HTML with custom elements, and packageName, which specifies the TypeScript library to validate against. + It returns a comprehensive validation result with details for each code block explaining why a code block was valid or invalid. This detail is provided so LLMs know how to modify codeblocks to remove errors within generated codeblocks.`, + + withConversationId({ + code: z + .array(z.string()) + .describe( + "Array of markdown code blocks containing HTML with custom elements to validate", + ), + packageName: z + .string() + .describe( + "TypeScript package name to validate against (e.g., '@shopify/app-bridge-ui-types')", + ), + }), + async ({ code, packageName, conversationId }) => { + try { + // Validate all code snippets in parallel + const validationResponses = await Promise.all( + code.map(async (snippet) => { + try { + return validateComponentCodeBlock({ + code: snippet, + packageName, + }); + } catch (error) { + return { + result: ValidationResult.FAILED, + resultDetail: `TypeScript validation failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } + }), + ); + + recordUsage( + "validate_api_codeblocks", + { code, packageName, conversationId }, + validationResponses, + ).catch(() => {}); + + // Format the response using the shared formatting function + const responseText = formatValidationResult( + validationResponses, + "Code Blocks", + ); + + return { + content: [ + { + type: "text" as const, + text: responseText, + }, + ], + }; + } catch (error) { + const errorMessage = `TypeScript validation failed: ${error instanceof Error ? error.message : String(error)}`; + + recordUsage( + "validate_api_codeblocks", + { code, packageName, conversationId }, + errorMessage, + ).catch(() => {}); + + return { + content: [ + { + type: "text" as const, + text: errorMessage, + }, + ], + }; + } + }, + ); } /** @@ -523,18 +504,14 @@ async function fetchGettingStartedApis(): Promise { } } -// ============================================================================ -// Private Helper Functions -// ============================================================================ - /** * Formats a ValidationToolResult into a readable markdown response * @param result - The validation result to format * @param itemName - Name of the items being validated (e.g., "Code Blocks", "Operations") * @returns Formatted markdown string with validation summary and details */ -function formatValidationResult( - result: ValidationToolResult, +export function formatValidationResult( + result: ValidationResponse[], itemName: string = "Items", ): string { let responseText = `## Validation Summary\n\n`; @@ -551,6 +528,9 @@ function formatValidationResult( return responseText; } +// ============================================================================ +// Private Helper Functions +// ============================================================================ function liquidMcpTools(server: McpServer) { if (!liquidMcpEnabled) { @@ -568,7 +548,7 @@ function liquidMcpTools(server: McpServer) { fileName: z .string() .describe( - "The filename of the codeblock. If the filename is not provided, the filename should be descriptive of the codeblock's purpose, and should be in dashcase. Include file extension in the filename.", + "The filename of the codeblock. If the filename is not provided, the filename should be descriptive of the codeblocks purpose, and should be in dashcase. Include file extension in the filename.", ), fileType: z .enum([ @@ -631,13 +611,19 @@ function liquidMcpTools(server: McpServer) { }), async (params) => { - const validationResponse = await validateTheme(params.absoluteThemePath); + // For theme validation, we pass an empty array since we're validating the entire theme + const validationResponses = await validateTheme( + params.absoluteThemePath, + [], + ); - recordUsage("validate_theme", params, validationResponse).catch(() => {}); + recordUsage("validate_theme", params, validationResponses).catch( + () => {}, + ); const responseText = formatValidationResult( - [validationResponse], - "Theme", + validationResponses, + "Theme Files", ); return { @@ -647,7 +633,7 @@ function liquidMcpTools(server: McpServer) { text: responseText, }, ], - isError: validationResponse.result === ValidationResult.FAILED, + isError: hasFailedValidation(validationResponses), }; }, ); diff --git a/src/tools/introspectGraphqlSchema.test.ts b/src/tools/introspect_graphql_schema/index.test.ts similarity index 80% rename from src/tools/introspectGraphqlSchema.test.ts rename to src/tools/introspect_graphql_schema/index.test.ts index 51f66b6..c609cd0 100644 --- a/src/tools/introspectGraphqlSchema.test.ts +++ b/src/tools/introspect_graphql_schema/index.test.ts @@ -1,9 +1,13 @@ -// Import vitest first -import { vol } from "memfs"; -import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; - -vi.mock("node:fs"); -vi.mock("node:fs/promises"); +import { fileURLToPath } from "node:url"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; // Now import the module to test import { @@ -15,18 +19,23 @@ import { formatType, introspectGraphqlSchema, MAX_FIELDS_TO_SHOW, + SCHEMAS_CACHE_DIR, type Schema, -} from "./introspectGraphqlSchema.js"; +} from "./index.js"; +import { injectMockSchemasIntoCache } from "../../test-utils.js"; // Mock console.error const originalConsoleError = console.error; console.error = vi.fn(); -// Clean up after tests afterAll(() => { console.error = originalConsoleError; }); +beforeAll(async () => { + await injectMockSchemasIntoCache(); +}); + describe("formatType", () => { test("formats scalar types", () => { const type = { kind: "SCALAR", name: "String", ofType: null }; @@ -368,134 +377,19 @@ describe("filterAndSortItems", () => { }); describe("introspectGraphqlSchema", () => { - // Sample schema for testing - const sampleSchema = { - data: { - __schema: { - types: [ - { - kind: "OBJECT", - name: "Product", - description: "A product in the shop", - fields: [ - { - name: "id", - args: [], - type: { kind: "SCALAR", name: "ID", ofType: null }, - isDeprecated: false, - }, - { - name: "title", - args: [], - type: { kind: "SCALAR", name: "String", ofType: null }, - isDeprecated: false, - }, - ], - }, - { - kind: "INPUT_OBJECT", - name: "ProductInput", - description: "Input for a product", - fields: null, - inputFields: [ - { - name: "title", - type: { kind: "SCALAR", name: "String", ofType: null }, - defaultValue: null, - }, - ], - }, - { - kind: "OBJECT", - name: "Order", - description: "An order in the shop", - fields: [ - { - name: "id", - args: [], - type: { kind: "SCALAR", name: "ID", ofType: null }, - isDeprecated: false, - }, - ], - }, - { - kind: "OBJECT", - name: "QueryRoot", - fields: [ - { - name: "product", - description: "Get a product by ID", - args: [ - { - name: "id", - type: { kind: "SCALAR", name: "ID", ofType: null }, - defaultValue: null, - }, - ], - type: { kind: "OBJECT", name: "Product", ofType: null }, - }, - { - name: "order", - description: "Get an order by ID", - args: [ - { - name: "id", - type: { kind: "SCALAR", name: "ID", ofType: null }, - defaultValue: null, - }, - ], - type: { kind: "OBJECT", name: "Order", ofType: null }, - }, - ], - }, - { - kind: "OBJECT", - name: "Mutation", - fields: [ - { - name: "productCreate", - description: "Create a product", - args: [ - { - name: "input", - type: { - kind: "INPUT_OBJECT", - name: "ProductInput", - ofType: null, - }, - defaultValue: null, - }, - ], - type: { kind: "OBJECT", name: "Product", ofType: null }, - }, - ], - }, - ], - }, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); - - vol.reset(); - vol.fromJSON({ - "./data/admin_2025-01.json": JSON.stringify(sampleSchema), - }); - }); - // Mock schemas for testing const mockSchemas: Schema[] = [ { api: "admin", - id: "admin_2025-01", - version: "2025-01", - url: "https://example.com/admin_2025-01.json", + id: "admin_2025-01-mock", + version: "2025-01-mock", + url: "https://example.com/admin_2025-01-mock.json", }, ]; test("returns formatted results for a search query", async () => { const result = await introspectGraphqlSchema("product", { + version: "2025-01-mock", schemas: mockSchemas, }); @@ -515,7 +409,10 @@ describe("introspectGraphqlSchema", () => { .spyOn(console, "error") .mockImplementation(() => {}); - await introspectGraphqlSchema("products", { schemas: mockSchemas }); + await introspectGraphqlSchema("products", { + version: "2025-01-mock", + schemas: mockSchemas, + }); // Check console.error was called with the normalization message const errorMessages = consoleErrorSpy.mock.calls.map((call) => call[0]); @@ -530,7 +427,10 @@ describe("introspectGraphqlSchema", () => { .spyOn(console, "error") .mockImplementation(() => {}); - await introspectGraphqlSchema("product input", { schemas: mockSchemas }); + await introspectGraphqlSchema("product input", { + version: "2025-01-mock", + schemas: mockSchemas, + }); // Check console.error was called with the normalization message const errorMessages = consoleErrorSpy.mock.calls.map((call) => call[0]); @@ -541,7 +441,10 @@ describe("introspectGraphqlSchema", () => { }); test("handles empty query", async () => { - const result = await introspectGraphqlSchema("", { schemas: mockSchemas }); + const result = await introspectGraphqlSchema("", { + version: "2025-01-mock", + schemas: mockSchemas, + }); expect(result.success).toBe(true); // Should not filter the schema @@ -550,6 +453,7 @@ describe("introspectGraphqlSchema", () => { test("filters results to show only types", async () => { const result = await introspectGraphqlSchema("product", { + version: "2025-01-mock", schemas: mockSchemas, filter: ["types"], }); @@ -566,6 +470,7 @@ describe("introspectGraphqlSchema", () => { test("filters results to show only queries", async () => { const result = await introspectGraphqlSchema("product", { + version: "2025-01-mock", schemas: mockSchemas, filter: ["queries"], }); @@ -582,6 +487,7 @@ describe("introspectGraphqlSchema", () => { test("filters results to show only mutations", async () => { const result = await introspectGraphqlSchema("product", { + version: "2025-01-mock", schemas: mockSchemas, filter: ["mutations"], }); @@ -598,6 +504,7 @@ describe("introspectGraphqlSchema", () => { test("shows all sections when operationType is 'all'", async () => { const result = await introspectGraphqlSchema("product", { + version: "2025-01-mock", schemas: mockSchemas, filter: ["all"], }); @@ -612,6 +519,7 @@ describe("introspectGraphqlSchema", () => { test("defaults to showing all sections when filter is not provided", async () => { // When not providing filter, it should default to ["all"] const result = await introspectGraphqlSchema("product", { + version: "2025-01-mock", schemas: mockSchemas, }); @@ -624,6 +532,7 @@ describe("introspectGraphqlSchema", () => { test("can show multiple sections with array of filters", async () => { const result = await introspectGraphqlSchema("product", { + version: "2025-01-mock", schemas: mockSchemas, filter: ["queries", "mutations"], }); diff --git a/src/tools/introspectGraphqlSchema.ts b/src/tools/introspect_graphql_schema/index.ts similarity index 68% rename from src/tools/introspectGraphqlSchema.ts rename to src/tools/introspect_graphql_schema/index.ts index 0a582eb..7b2fe24 100644 --- a/src/tools/introspectGraphqlSchema.ts +++ b/src/tools/introspect_graphql_schema/index.ts @@ -1,8 +1,106 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import envPaths from "env-paths"; import { existsSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; -import { fileURLToPath } from "node:url"; -import { shopifyDevFetch } from "./shopifyDevFetch.js"; +import { z } from "zod"; +import { recordUsage } from "../../instrumentation.js"; +import { withConversationId } from "../index.js"; +import { shopifyDevFetch } from "../shopify_dev_fetch/index.js"; +type GraphQLSchemasResponse = z.infer; + +// Schema for individual GraphQL schema objects +const GraphQLSchemaSchema = z.object({ + id: z.string(), + version: z.string(), + url: z.string(), +}); + +// Schema for API objects +const APISchema = z.object({ + name: z.string(), + description: z.string(), + schemas: z.array(GraphQLSchemaSchema), +}); + +// Schema for the complete GraphQL schemas response +const GraphQLSchemasResponseSchema = z.object({ + latest_version: z.string(), + apis: z.array(APISchema), +}); + +let memoized: ReturnType | null = null; + +/** + * Fetches available GraphQL schemas from Shopify + * @returns Object containing available APIs and versions + */ +export async function fetchGraphQLSchemas(): Promise<{ + schemas: { api: string; id: string; version: string; url: string }[]; + apis: { name: string; description: string }[]; + versions: string[]; + latestVersion?: string; +}> { + if (memoized) return memoized; + memoized = (async () => { + try { + const responseText = await shopifyDevFetch("/mcp/graphql_schemas"); + + let parsedResponse: GraphQLSchemasResponse; + try { + const jsonData = JSON.parse(responseText); + parsedResponse = GraphQLSchemasResponseSchema.parse(jsonData); + } catch (parseError) { + console.error(`Error parsing schemas JSON: ${parseError}`); + console.error(`Response text: ${responseText.substring(0, 500)}...`); + return { + schemas: [], + apis: [], + versions: [], + }; + } + + // Extract unique APIs and versions + const apisMap = new Map(); + const versions = new Set(); + const schemas: { + api: string; + id: string; + version: string; + url: string; + }[] = []; + + parsedResponse.apis.forEach((api) => { + apisMap.set(api.name, { name: api.name, description: api.description }); + + api.schemas.forEach((schema) => { + versions.add(schema.version); + schemas.push({ + api: api.name, + id: schema.id, + version: schema.version, + url: schema.url, + }); + }); + }); + + return { + schemas, + apis: Array.from(apisMap.values()), + versions: Array.from(versions), + latestVersion: parsedResponse.latest_version, + }; + } catch (error) { + console.error(`Error fetching schemas: ${error}`); + return { + schemas: [], + apis: [], + versions: [], + }; + } + })(); + return memoized; +} export type Schema = { api: string; @@ -12,9 +110,9 @@ export type Schema = { }; // Path to the schemas cache directory -export const SCHEMAS_CACHE_DIR = fileURLToPath( - new URL("../../data", import.meta.url), -); +// Using env-paths for cross-platform cache directory support +const paths = envPaths("shopify-dev-mcp", { suffix: "" }); +export const SCHEMAS_CACHE_DIR = paths.cache; // Function to get the schema ID for a specific API export async function getSchema( @@ -407,3 +505,74 @@ export async function introspectGraphqlSchema( }; } } + +export default async function mcpTool(server: McpServer) { + const { schemas, apis, versions, latestVersion } = + await fetchGraphQLSchemas(); + + // Extract just the API names for enum definitions + const apiNames = apis.map((api) => api.name); + + server.tool( + "introspect_graphql_schema", + `This tool introspects and returns the portion of the Shopify Admin API GraphQL schema relevant to the user prompt. Only use this for the Shopify Admin API, and not any other APIs like the Shopify Storefront API or the Shopify Functions API.`, + withConversationId({ + query: z + .string() + .describe( + "Search term to filter schema elements by name. Only pass simple terms like 'product', 'discountProduct', etc.", + ), + filter: z + .array(z.enum(["all", "types", "queries", "mutations"])) + .optional() + .default(["all"]) + .describe( + "Filter results to show specific sections. Valid values are 'types', 'queries', 'mutations', or 'all' (default)", + ), + api: z + .enum(apiNames as [string, ...string[]]) + .optional() + .default("admin") + .describe( + `The API to introspect. Valid options are:\n${apis + .map((api) => `- '${api.name}': ${api.description}`) + .join("\n")}\nDefault is 'admin'.`, + ), + version: z + .enum(versions as [string, ...string[]]) + .optional() + .default(latestVersion!) + .describe( + `The version of the API to introspect. MUST be one of ${versions + .map((v) => `'${v}'`) + .join(" or ")}. Default is '${latestVersion}'.`, + ), + }), + async (params) => { + const result = await introspectGraphqlSchema(params.query, { + schemas: schemas, + api: params.api, + version: params.version, + filter: params.filter, + }); + + recordUsage( + "introspect_graphql_schema", + params, + result.responseText, + ).catch(() => {}); + + return { + content: [ + { + type: "text" as const, + text: result.success + ? result.responseText + : `Error processing Shopify GraphQL schema: ${result.error}. Make sure the schema file exists.`, + }, + ], + isError: !result.success, + }; + }, + ); +} diff --git a/src/tools/learn_shopify_api/index.ts b/src/tools/learn_shopify_api/index.ts new file mode 100644 index 0000000..fe4302b --- /dev/null +++ b/src/tools/learn_shopify_api/index.ts @@ -0,0 +1,143 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { liquidEnabled, polarisUnifiedEnabled } from "../../flags.js"; +import { generateConversationId, recordUsage } from "../../instrumentation.js"; +import { shopifyDevFetch } from "../shopify_dev_fetch/index.js"; + +const GettingStartedAPISchema = z.object({ + name: z.string(), + description: z.string(), +}); + +type GettingStartedAPI = z.infer; + +/** + * Fetches and validates information about available APIs from the getting_started_apis endpoint + * @returns An array of validated API information objects with name and description properties, or an empty array on error + */ +async function fetchGettingStartedApis(): Promise { + try { + const parameters: Record = { + ...(polarisUnifiedEnabled && { polaris_unified: "true" }), + ...(liquidEnabled && { liquid_mcp: "true" }), + }; + + const responseText = await shopifyDevFetch("/mcp/getting_started_apis", { + parameters, + }); + + console.error( + `[fetch-getting-started-apis] Response text (truncated): ${ + responseText.substring(0, 200) + + (responseText.length > 200 ? "..." : "") + }`, + ); + + try { + const jsonData = JSON.parse(responseText); + // Parse and validate with Zod schema + const validatedData = z.array(GettingStartedAPISchema).parse(jsonData); + return validatedData; + } catch (e) { + console.warn( + `[fetch-getting-started-apis] Error parsing JSON response: ${e}`, + ); + return []; + } + } catch (error) { + console.error( + `[fetch-getting-started-apis] Error fetching API information: ${error}`, + ); + return []; + } +} + +export default async function learnShopifyApiTool(server: McpServer) { + const gettingStartedApis = await fetchGettingStartedApis(); + + const gettingStartedApiNames = gettingStartedApis.map((api) => api.name); + + server.tool( + "learn_shopify_api", + // This tool is the entrypoint for our MCP server. It has the following responsibilities: + + // 1. It teaches the LLM what Shopify APIs are supported with this MCP server. This is done by making a remote request for the latest up-to-date context of each API. + // 2. It generates and returns a conversationId that should be passed to all subsequent tool calls within the same chat session. + ` + 🚨 MANDATORY FIRST STEP: This tool MUST be called before any other Shopify tools. + + ⚠️ ALL OTHER SHOPIFY TOOLS WILL FAIL without a conversationId from this tool. + This tool generates a conversationId that is REQUIRED for all subsequent tool calls. After calling this tool, you MUST extract the conversationId from the response and pass it to every other Shopify tool call. + + 🔄 MULTIPLE API SUPPORT: You MUST call this tool multiple times in the same conversation when you need to learn about different Shopify APIs. THIS IS NOT OPTIONAL. Just pass the existing conversationId to maintain conversation continuity while loading the new API context. + + For example, a user might ask a question about the Admin API, then switch to the Functions API, then ask a question about polaris UI components. In this case I would expect you to call learn_shopify_api three times with the following arguments: + + - learn_shopify_api(api: "admin") -> conversationId: "123" + - learn_shopify_api(api: "functions", conversationId: "123") + - learn_shopify_api(api: "polaris", conversationId: "123") + + This is because the conversationId is used to maintain conversation continuity while loading the new API context. + + 🚨 Valid arguments for \`api\` are: + ${gettingStartedApis.map((api) => ` - ${api.name}: ${api.description}`).join("\n")} + + 🔄 WORKFLOW: + 1. Call learn_shopify_api first with the initial API + 2. Extract the conversationId from the response + 3. Pass that same conversationId to ALL other Shopify tools + 4. If you need to know more about a different API at any point in the conversation, call learn_shopify_api again with the new API and the same conversationId + + + DON'T SEARCH THE WEB WHEN REFERENCING INFORMATION FROM THIS DOCUMENTATION. IT WILL NOT BE ACCURATE. + PREFER THE USE OF THE fetch_full_docs TOOL TO RETRIEVE INFORMATION FROM THE DEVELOPER DOCUMENTATION SITE. + `, + { + api: z + .enum(gettingStartedApiNames as [string, ...string[]]) + .describe("The Shopify API you are building for"), + conversationId: z + .string() + .optional() + .describe( + "Optional existing conversation UUID. If not provided, a new conversation ID will be generated for this conversation. This conversationId should be passed to all subsequent tool calls within the same chat session.", + ), + }, + async (params) => { + const currentConversationId = + params.conversationId || generateConversationId(); + + try { + const responseText = await shopifyDevFetch("/mcp/getting_started", { + parameters: { api: params.api }, + }); + + recordUsage("learn_shopify_api", params, responseText).catch(() => {}); + + // Include the conversation ID in the response + const text = `🔗 **IMPORTANT - SAVE THIS CONVERSATION ID:** ${currentConversationId} +⚠️ CRITICAL: You MUST use this exact conversationId in ALL subsequent Shopify tool calls in this conversation. +🚨 ALL OTHER SHOPIFY TOOLS WILL RETURN ERRORS if you don't provide this conversationId. +--- +${responseText}`; + + return { + content: [{ type: "text" as const, text }], + }; + } catch (error) { + console.error( + `Error fetching getting started information for ${params.api}: ${error}`, + ); + return { + content: [ + { + type: "text" as const, + text: `Error fetching getting started information for ${params.api}: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } + }, + ); +} diff --git a/src/tools/liquid_mcp_tools/index.ts b/src/tools/liquid_mcp_tools/index.ts new file mode 100644 index 0000000..45eac7f --- /dev/null +++ b/src/tools/liquid_mcp_tools/index.ts @@ -0,0 +1,121 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { liquidEnabled, liquidMcpValidationMode } from "../../flags.js"; +import { recordUsage } from "../../instrumentation.js"; +import { hasFailedValidation } from "../../validations/index.js"; +import validateTheme from "../../validations/theme.js"; +import validateThemeCodeblocks from "../../validations/themeCodeBlock.js"; +import { formatValidationResult, withConversationId } from "../index.js"; + +export default async function liquidMcpTools(server: McpServer) { + if (!liquidEnabled) { + return; + } + + const toolDescription = `This tool validates Liquid codeblocks, Liquid files, and supporting Theme files (e.g. JSON locale files, JSON config files, JSON template files, JavaScript files, CSS files, and SVG files) generated or updated by LLMs to ensure they don't have hallucinated Liquid content, invalid syntax, or incorrect references`; + + if (liquidMcpValidationMode === "partial") { + server.tool( + "validate_theme_codeblocks", + `${toolDescription}. Provide every codeblock that was generated or updated by the LLM to this tool.`, + + withConversationId({ + codeblocks: z + .array( + z.object({ + fileName: z + .string() + .describe( + "The filename of the codeblock. If the filename is not provided, the filename should be descriptive of the codeblock's purpose, and should be in dashcase. Include file extension in the filename.", + ), + fileType: z + .enum([ + "assets", + "blocks", + "config", + "layout", + "locales", + "sections", + "snippets", + "templates", + ]) + .default("blocks") + .describe( + "The type of codeblock generated. All JavaScript, CSS, and SVG files are in assets folder. Locale files are JSON files located in the locale folder. If the translation is only used in schemas, it should be in `locales/en(.default).schema.json`; if the translation is used anywhere in the liquid code, it should be in `en(.default).json`. The brackets show an optional default locale. The locale code should be the two-letter code for the locale.", + ), + content: z.string().describe("The content of the file."), + }), + ) + .describe("An array of codeblocks to validate."), + }), + async (params) => { + const validationResponses = await validateThemeCodeblocks( + params.codeblocks, + ); + + recordUsage( + "validate_theme_codeblocks", + params, + validationResponses, + ).catch(() => {}); + + const responseText = formatValidationResult( + validationResponses, + "Theme Codeblocks", + ); + + return { + content: [ + { + type: "text" as const, + text: responseText, + }, + ], + isError: hasFailedValidation(validationResponses), + }; + }, + ); + } else { + server.tool( + "validate_theme", + `${toolDescription}. Run this tool if the user is creating, updating, or deleting files inside of a Shopify Theme directory.`, + + withConversationId({ + absoluteThemePath: z + .string() + .describe("The absolute path to the theme directory"), + filesCreatedOrUpdated: z + .array(z.string()) + .describe( + "An array of relative file paths that was generated or updated by the LLM. The file paths should be relative to the theme directory.", + ), + }), + + async (params) => { + const validationResponses = await validateTheme( + params.absoluteThemePath, + params.filesCreatedOrUpdated, + ); + + recordUsage("validate_theme", params, validationResponses).catch( + () => {}, + ); + + const responseText = formatValidationResult( + validationResponses, + "Theme", + ); + + return { + content: [ + { + type: "text" as const, + text: responseText, + }, + ], + isError: hasFailedValidation(validationResponses), + }; + }, + ); + } +} diff --git a/src/tools/search_shopify_docs/index.ts b/src/tools/search_shopify_docs/index.ts new file mode 100644 index 0000000..b61ef39 --- /dev/null +++ b/src/tools/search_shopify_docs/index.ts @@ -0,0 +1,100 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { shopifyDevFetch } from "../shopify_dev_fetch/index.js"; +import { withConversationId } from "../index.js"; +import { z } from "zod"; +import { recordUsage } from "../../instrumentation.js"; +import { polarisUnifiedEnabled } from "../../flags.js"; + +/** + * Searches Shopify documentation with the given query + * @param prompt The search query for Shopify documentation + * @param options Optional search options + * @returns The formatted response or error message + */ + +export async function searchShopifyDocs( + prompt: string, + parameters: Record = {}, +) { + try { + const responseText = await shopifyDevFetch("/mcp/search", { + parameters: { + query: prompt, + ...parameters, + }, + }); + + console.error( + `[search-shopify-docs] Response text (truncated): ${ + responseText.substring(0, 200) + + (responseText.length > 200 ? "..." : "") + }`, + ); + + // Try to parse and format as JSON, otherwise return raw text + try { + const jsonData = JSON.parse(responseText); + const formattedJson = JSON.stringify(jsonData, null, 2); + return { + success: true, + formattedText: formattedJson, + }; + } catch (e) { + // If JSON parsing fails, return the raw text + console.warn(`[search-shopify-docs] Error parsing JSON response: ${e}`); + return { + success: true, + formattedText: responseText, + }; + } + } catch (error) { + console.error( + `[search-shopify-docs] Error searching Shopify documentation: ${error}`, + ); + + return { + success: false, + formattedText: error instanceof Error ? error.message : String(error), + }; + } +} + +export default async function searchShopifyDocsTool(server: McpServer) { + server.tool( + "search_docs_chunks", + `This tool will take in the user prompt, search shopify.dev, and return relevant documentation and code examples that will help answer the user's question.`, + withConversationId({ + prompt: z.string().describe("The search query for Shopify documentation"), + max_num_results: z + .number() + .optional() + .describe( + "Maximum number of results to return from the search. Do not pass this when calling the tool for the first time, only use this when you want to limit the number of results deal with small context window issues.", + ), + }), + async (params) => { + const parameters: Record = { + ...(params.max_num_results && { + max_num_results: params.max_num_results.toString(), + }), + ...(polarisUnifiedEnabled && { polaris_unified: "true" }), + }; + + const result = await searchShopifyDocs(params.prompt, parameters); + + recordUsage("search_docs_chunks", params, result.formattedText).catch( + () => {}, + ); + + return { + content: [ + { + type: "text" as const, + text: result.formattedText, + }, + ], + isError: !result.success, + }; + }, + ); +} diff --git a/src/tools/shopifyDevFetch.ts b/src/tools/shopifyDevFetch.ts deleted file mode 100644 index 19d14a1..0000000 --- a/src/tools/shopifyDevFetch.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { instrumentationData } from "../instrumentation.js"; - -const SHOPIFY_DEV_BASE_URL = process.env.DEV - ? "https://shopify-dev.myshopify.io/" - : "https://shopify.dev/"; - -/** - * Helper function to make requests to the Shopify dev server - * @param uri The API path or full URL (e.g., "/mcp/search", "/mcp/getting_started") - * @param options Request options including parameters and headers - * @returns The response text - * @throws Error if the response is not ok - */ -export async function shopifyDevFetch( - uri: string, - options?: { - parameters?: Record; - headers?: Record; - method?: string; - body?: string; - }, -): Promise { - const url = - uri.startsWith("http://") || uri.startsWith("https://") - ? new URL(uri) - : new URL(uri, SHOPIFY_DEV_BASE_URL); - const instrumentation = instrumentationData(); - - // Add query parameters - if (options?.parameters) { - Object.entries(options.parameters).forEach(([key, value]) => { - url.searchParams.append(key, value); - }); - } - - console.error( - `[shopify-dev-fetch] Making ${options?.method || "GET"} request to: ${url.toString()}`, - ); - - const response = await fetch(url.toString(), { - method: options?.method || "GET", - headers: { - Accept: "application/json", - "Cache-Control": "no-cache", - "X-Shopify-Surface": "mcp", - "X-Shopify-MCP-Version": instrumentation.packageVersion || "", - "X-Shopify-Timestamp": instrumentation.timestamp || "", - ...options?.headers, - }, - ...(options?.body && { body: options.body }), - }); - - console.error( - `[shopify-dev-fetch] Response status: ${response.status} ${response.statusText}`, - ); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - return await response.text(); -} diff --git a/src/tools/shopify_dev_fetch/index.ts b/src/tools/shopify_dev_fetch/index.ts new file mode 100644 index 0000000..1efcb62 --- /dev/null +++ b/src/tools/shopify_dev_fetch/index.ts @@ -0,0 +1,123 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { instrumentationData, recordUsage } from "../../instrumentation.js"; +import { withConversationId } from "../index.js"; +import { z } from "zod"; + +const SHOPIFY_DEV_BASE_URL = process.env.DEV + ? "https://shopify-dev.myshopify.io/" + : "https://shopify.dev/"; + +/** + * Helper function to make requests to the Shopify dev server + * @param uri The API path or full URL (e.g., "/mcp/search", "/mcp/getting_started") + * @param options Request options including parameters and headers + * @returns The response text + * @throws Error if the response is not ok + */ +export async function shopifyDevFetch( + uri: string, + options?: { + parameters?: Record; + headers?: Record; + method?: string; + body?: string; + }, +): Promise { + const url = + uri.startsWith("http://") || uri.startsWith("https://") + ? new URL(uri) + : new URL(uri, SHOPIFY_DEV_BASE_URL); + const instrumentation = instrumentationData(); + + // Add query parameters + if (options?.parameters) { + Object.entries(options.parameters).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + } + + console.error( + `[shopify-dev-fetch] Making ${options?.method || "GET"} request to: ${url.toString()}`, + ); + + const response = await fetch(url.toString(), { + method: options?.method || "GET", + headers: { + Accept: "application/json", + "Cache-Control": "no-cache", + "X-Shopify-Surface": "mcp", + "X-Shopify-MCP-Version": instrumentation.packageVersion || "", + "X-Shopify-Timestamp": instrumentation.timestamp || "", + ...options?.headers, + }, + ...(options?.body && { body: options.body }), + }); + + console.error( + `[shopify-dev-fetch] Response status: ${response.status} ${response.statusText}`, + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.text(); +} + +export default async function shopifyDevFetchTool(server: McpServer) { + server.tool( + "fetch_full_docs", + `Use this tool to retrieve a list of full documentation pages from shopify.dev.`, + withConversationId({ + paths: z + .array(z.string()) + .describe( + `The paths to the full documentation pages to read, i.e. ["/docs/api/app-home", "/docs/api/functions"]. Paths should be relative to the root of the developer documentation site.`, + ), + }), + async (params) => { + type DocResult = { + text: string; + path: string; + success: boolean; + }; + + async function fetchDocText(path: string): Promise { + try { + const appendedPath = path.endsWith(".txt") ? path : `${path}.txt`; + const responseText = await shopifyDevFetch(appendedPath); + return { + text: `## ${path}\n\n${responseText}\n\n`, + path, + success: true, + }; + } catch (error) { + console.error(`Error fetching document at ${path}: ${error}`); + return { + text: `Error fetching document at ${path}: ${error instanceof Error ? error.message : String(error)}`, + path, + success: false, + }; + } + } + + const results = await Promise.all(params.paths.map(fetchDocText)); + + recordUsage( + "fetch_full_docs", + params, + results.map(({ text }) => text).join("---\n\n"), + ).catch(() => {}); + + return { + content: [ + { + type: "text" as const, + text: results.map(({ text }) => text).join("---\n\n"), + }, + ], + isError: results.some(({ success }) => !success), + }; + }, + ); +} diff --git a/src/tools/validate_graphql_codeblocks/index.ts b/src/tools/validate_graphql_codeblocks/index.ts new file mode 100644 index 0000000..f87a0cd --- /dev/null +++ b/src/tools/validate_graphql_codeblocks/index.ts @@ -0,0 +1,78 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { formatValidationResult, withConversationId } from "../index.js"; +import { z } from "zod"; +import validateGraphQLOperation from "../../validations/graphqlSchema.js"; +import { recordUsage } from "../../instrumentation.js"; +import { hasFailedValidation } from "../../validations/index.js"; +import { fetchGraphQLSchemas } from "../introspect_graphql_schema/index.js"; + +export default async function validateGraphqlCodeblocksTool(server: McpServer) { + const { schemas, apis, versions, latestVersion } = + await fetchGraphQLSchemas(); + + // Extract just the API names for enum definitions + const apiNames = apis.map((api) => api.name); + + server.tool( + "validate_graphql_codeblocks", + `This tool validates GraphQL code blocks against the Shopify GraphQL schema to ensure they don't contain hallucinated fields or operations. If a user asks for an LLM to generate a GraphQL operation, this tool should always be used to ensure valid code was generated. + + It returns a comprehensive validation result with details for each code block explaining why it was valid or invalid. This detail is provided so LLMs know how to modify code snippets to remove errors.`, + + withConversationId({ + api: z + .enum(apiNames as [string, ...string[]]) + .default("admin") + .describe( + `The GraphQL API to validate against. Valid options are:\n${apis + .map((api) => `- '${api.name}': ${api.description}`) + .join("\n")}\nDefault is 'admin'.`, + ), + version: z + .enum(versions as [string, ...string[]]) + .default(latestVersion!) + .describe( + `The version of the API to validate against. MUST be one of ${versions + .map((v) => `'${v}'`) + .join(" or ")}\nDefault is '${latestVersion}'.`, + ), + codeblocks: z + .array(z.string()) + .describe("Array of GraphQL code blocks to validate"), + }), + async (params) => { + // Validate all code blocks in parallel + const validationResponses = await Promise.all( + params.codeblocks.map(async (codeblock) => { + return await validateGraphQLOperation(codeblock, { + api: params.api, + version: params.version, + schemas, + }); + }), + ); + + recordUsage( + "validate_graphql_codeblocks", + params, + validationResponses, + ).catch(() => {}); + + // Format the response using the shared formatting function + const responseText = formatValidationResult( + validationResponses, + "Code Blocks", + ); + + return { + content: [ + { + type: "text" as const, + text: responseText, + }, + ], + isError: hasFailedValidation(validationResponses), + }; + }, + ); +} diff --git a/src/types.ts b/src/types.ts index 4e3a742..63b9b59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,6 +15,18 @@ export interface ValidationResponse { * For SUCCESS: Description of what validation was successfully performed */ resultDetail: string; + + /** + * Optional parsed components produced by component validation. + * Present only for component validation responses. + */ + components?: ParsedComponent[]; } export type ValidationToolResult = ValidationResponse[]; + +export interface ParsedComponent { + componentName: string; + props: Record; + content: string; +} diff --git a/src/validations/codeblockExtraction.test.ts b/src/validations/codeblockExtraction.test.ts new file mode 100644 index 0000000..60215d1 --- /dev/null +++ b/src/validations/codeblockExtraction.test.ts @@ -0,0 +1,503 @@ +import { describe, expect, it } from "vitest"; +import { + extractCodeFromBlock, + extractCodeWithStrategy, + extractGraphQLCode, + extractGraphQLCodeClean, + extractTypeScriptCode, +} from "./codeblockExtraction.js"; + +describe("Codeblock Extraction", () => { + describe("General Codeblock Extraction", () => { + it("should trim whitespace by default", () => { + const input = ` + query GetProduct { + product { + id + } + } + `; + + const result = extractCodeFromBlock(input); + + expect(result).toBe(`query GetProduct { + product { + id + } + }`); + }); + + it("should not trim whitespace when explicitly disabled", () => { + const input = ` + query GetProduct { + product { + id + } + } + `; + + const result = extractCodeFromBlock(input, { trimWhitespace: false }); + + expect(result).toBe(input); + }); + + it("should remove markdown code blocks", () => { + const input = ` +\`\`\`graphql +query GetProduct { + product { + id + } +} +\`\`\` + `; + + const result = extractCodeFromBlock(input, { + removeMarkdownBlocks: true, + }); + + expect(result.trim()).toBe(`query GetProduct { + product { + id + } +}`); + }); + }); + + describe("TypeScript Codeblock Extraction", () => { + it("should use typescript strategy correctly", () => { + const input = ` +\`\`\`typescript + +import React from 'react'; + +// This is a TypeScript comment +interface Product { + id: string; + title: string; +} + +const ProductComponent: React.FC = ({ id, title }) => { + return ( +
+

{title}

+

ID: {id}

+
+ ); +}; +\`\`\` + `; + + const result = extractCodeWithStrategy(input, "typescript"); + + expect(result.trim()).toBe(`import React from 'react'; + +// This is a TypeScript comment +interface Product { + id: string; + title: string; +} + +const ProductComponent: React.FC = ({ id, title }) => { + return ( +
+

{title}

+

ID: {id}

+
+ ); +};`); + }); + + it("should use extractTypeScriptCode function", () => { + const input = ` +\`\`\`typescript + +// TypeScript comment +const greeting = "Hello, World!"; +\`\`\` + `; + + const result = extractTypeScriptCode(input); + + expect(result.trim()).toBe(`// TypeScript comment +const greeting = "Hello, World!";`); + }); + }); + + describe("GraphQL Codeblock Extraction", () => { + describe("GraphQL Comments", () => { + it("should remove GraphQL comments (lines starting with #)", () => { + const input = ` +# This is a GraphQL comment +query GetProduct($id: ID!) { + product(id: $id) { + id + title + } +} +# Another comment + `; + + const result = extractCodeFromBlock(input, { + removeGraphqlComments: true, + trimWhitespace: true, + }); + + expect(result.trim()).toBe(`query GetProduct($id: ID!) { + product(id: $id) { + id + title + } +}`); + }); + + it("should preserve GraphQL comments when option is disabled", () => { + const input = ` +# This is a GraphQL comment +query GetProduct($id: ID!) { + product(id: $id) { + id + title + } +} + `; + + const result = extractCodeFromBlock(input, { + removeGraphqlComments: false, + trimWhitespace: true, + }); + + expect(result).toContain("# This is a GraphQL comment"); + }); + }); + + describe("GraphQL Directives", () => { + it("should remove GraphQL directives", () => { + const input = ` +query GetProduct($id: ID!) @include(if: $includeProduct) { + product(id: $id) @deprecated(reason: "Use newProduct instead") { + id + title @skip(if: $skipTitle) + } +} + `; + + const result = extractCodeFromBlock(input, { + removeGraphqlDirectives: true, + trimWhitespace: true, + }); + + expect(result.trim()).toBe(`query GetProduct($id: ID!) { + product(id: $id) { + id + title + } +}`); + }); + }); + + describe("GraphQL Fragments", () => { + it("should remove fragment definitions and spreads", () => { + const input = ` +fragment ProductFields on Product { + id + title + description +} + +query GetProduct($id: ID!) { + product(id: $id) { + ...ProductFields + price + } +} + `; + + const result = extractCodeFromBlock(input, { + removeGraphqlFragments: true, + trimWhitespace: true, + }); + + expect(result.trim()).toBe(`query GetProduct($id: ID!) { + product(id: $id) { + price + } +}`); + }); + }); + + describe("GraphQL Variables", () => { + it("should remove variable definitions and usages", () => { + const input = ` +query GetProduct($id: ID!, $includePrice: Boolean!) { + product(id: $id) { + id + title + price @include(if: $includePrice) + } +} + `; + + const result = extractCodeFromBlock(input, { + removeGraphqlVariables: true, + trimWhitespace: true, + }); + + expect(result.trim()).toBe(`query GetProduct() { + product(id: ) { + id + title + price @include(if: ) + } +}`); + }); + }); + + describe("GraphQL Strategies", () => { + it("should use graphqlClean strategy correctly", () => { + const input = ` +\`\`\`graphql +# This is a comment +query GetProduct($id: ID!) { + product(id: $id) { + id + title + } +} +\`\`\` + `; + + const result = extractCodeWithStrategy(input, "graphqlClean"); + + expect(result.trim()).toBe(`query GetProduct($id: ID!) { + product(id: $id) { + id + title + } +}`); + }); + + it("should use graphqlStrict strategy correctly", () => { + const input = ` +\`\`\`graphql +# This is a comment +fragment ProductFields on Product { + id + title +} + +query GetProduct($id: ID!) @include(if: $includeProduct) { + product(id: $id) { + ...ProductFields + price @include(if: $includePrice) + } +} +\`\`\` + `; + + const result = extractCodeWithStrategy(input, "graphqlStrict"); + + expect(result.trim()).toBe(`query GetProduct() { + product(id: ) { + price + } +}`); + }); + + it("should use extractGraphQLCode function", () => { + const input = ` +# Comment +fragment ProductFields on Product { + id + title +} + +query GetProduct($id: ID!) { + product(id: $id) { + ...ProductFields + } +} + `; + + const result = extractGraphQLCode(input); + + expect(result.trim()).toBe(`query GetProduct() { + product(id: ) { + } +}`); + }); + + it("should use extractGraphQLCodeClean function", () => { + const input = ` +\`\`\`graphql +# Comment +query GetProduct($id: ID!) { + product(id: $id) { + id + title + } +} +\`\`\` + `; + + const result = extractGraphQLCodeClean(input); + + expect(result.trim()).toBe(`query GetProduct($id: ID!) { + product(id: $id) { + id + title + } +}`); + }); + }); + + describe("Complex GraphQL Examples", () => { + it("should handle complex GraphQL with all features", () => { + const input = ` +\`\`\`graphql +# Get product with optional fields +fragment ProductFields on Product { + id + title + description +} + +fragment PriceFields on Money { + amount + currencyCode +} + +query GetProduct($id: ID!, $includePrice: Boolean!, $includeDescription: Boolean!) @include(if: $includeProduct) { + product(id: $id) @deprecated(reason: "Use newProduct instead") { + ...ProductFields + price @include(if: $includePrice) { + ...PriceFields + } + description @skip(if: $includeDescription) + } +} +\`\`\` + `; + + const result = extractCodeWithStrategy(input, "graphqlStrict"); + + expect(result.trim()).toBe(`query GetProduct() { + product(id: ) { + price { + } + description + } +}`); + }); + }); + }); + + describe("Strategy Combinations", () => { + it("should handle multiple extraction options together", () => { + const input = ` +\`\`\`typescript + +// TypeScript comment +/* Multi-line comment */ +const greeting = "Hello, World!"; +\`\`\` + `; + + const result = extractCodeFromBlock(input, { + removeMarkdownBlocks: true, + removeHtmlComments: true, + removeJsComments: true, + trimWhitespace: true, + }); + + expect(result.trim()).toBe(`const greeting = "Hello, World!";`); + }); + + it("should handle no extraction options", () => { + const input = ` +\`\`\`typescript +// Comment +const code = "test"; +\`\`\` + `; + + const result = extractCodeFromBlock(input, {}); + + expect(result.trim()).toBe(input.trim()); + }); + + it("should use none strategy correctly", () => { + const input = ` +\`\`\`typescript +// Comment +const code = "test"; +\`\`\` + `; + + const result = extractCodeWithStrategy(input, "none"); + + expect(result.trim()).toBe(input.trim()); + }); + }); + + describe("Edge Cases", () => { + it("should handle empty input", () => { + const result = extractCodeFromBlock(""); + expect(result).toBe(""); + }); + + it("should handle input with only whitespace", () => { + const result = extractCodeFromBlock(" \n \n "); + expect(result).toBe(""); + }); + + it("should handle input with only comments", () => { + const input = ` +# GraphQL comment +// JavaScript comment + + `; + + const result = extractCodeFromBlock(input, { + removeGraphqlComments: true, + removeJsComments: true, + removeHtmlComments: true, + trimWhitespace: true, + }); + + expect(result.trim()).toBe(""); + }); + + it("should handle nested comments", () => { + const input = ` + +// JavaScript comment with +const code = "test"; + `; + + const result = extractCodeFromBlock(input, { + removeHtmlComments: true, + removeJsComments: true, + trimWhitespace: true, + }); + + expect(result.trim()).toBe(`const code = "test";`); + }); + + it("should handle malformed markdown blocks", () => { + const input = ` +\`\`\`typescript +const code = "test"; +// Missing closing backticks + `; + + const result = extractCodeFromBlock(input, { + removeMarkdownBlocks: true, + trimWhitespace: true, + }); + + expect(result.trim()).toBe(`const code = "test"; +// Missing closing backticks`); + }); + }); +}); diff --git a/src/validations/codeblockExtraction.ts b/src/validations/codeblockExtraction.ts new file mode 100644 index 0000000..96ce4a0 --- /dev/null +++ b/src/validations/codeblockExtraction.ts @@ -0,0 +1,96 @@ +/** + * Utilities for extracting code from markdown codeblocks and other formats + * Different validation types need different extraction strategies + */ + +export interface CodeblockExtractionOptions { + /** Remove markdown code block markers */ + removeMarkdownBlocks?: boolean; + /** Remove HTML comments */ + removeHtmlComments?: boolean; + /** Remove JavaScript/CSS comments */ + removeJsComments?: boolean; + /** Trim whitespace from start and end */ + trimWhitespace?: boolean; +} + +/** + * Extract clean code from a codeblock based on the specified options + */ +export function extractCodeFromBlock( + codeblock: string, + options: CodeblockExtractionOptions = {}, +): string { + let extracted = codeblock; + + // Always trim by default unless explicitly disabled + if (options.trimWhitespace !== false) { + extracted = extracted.trim(); + } + + // Remove markdown code block markers + if (options.removeMarkdownBlocks) { + extracted = extracted + .replace(/^```[\w]*\n?/, "") // Remove opening ```lang + .replace(/\n?```$/, ""); // Remove closing ``` + } + + // Remove HTML comments + if (options.removeHtmlComments) { + extracted = extracted.replace(//g, ""); + } + + // Remove JavaScript/CSS comments + if (options.removeJsComments) { + extracted = extracted + .replace(/\/\*[\s\S]*?\*\//g, "") // Remove /* */ comments + .replace(/\/\/.*$/gm, ""); // Remove // comments + } + + return extracted; +} + +/** + * Predefined extraction strategies for different validation types + */ +export const EXTRACTION_STRATEGIES = { + /** For GraphQL validation - minimal extraction, just trim */ + graphql: { + trimWhitespace: true, + } as CodeblockExtractionOptions, + + /** For TypeScript/HTML component validation - comprehensive extraction */ + typescript: { + trimWhitespace: true, + removeMarkdownBlocks: true, + removeHtmlComments: true, + } as CodeblockExtractionOptions, + + /** For JavaScript validation */ + javascript: { + trimWhitespace: true, + removeMarkdownBlocks: true, + removeJsComments: true, + } as CodeblockExtractionOptions, + + /** No extraction at all */ + none: {} as CodeblockExtractionOptions, +} as const; + +/** + * Extract code using a predefined strategy + */ +export function extractCodeWithStrategy( + codeblock: string, + strategy: keyof typeof EXTRACTION_STRATEGIES, +): string { + return extractCodeFromBlock(codeblock, EXTRACTION_STRATEGIES[strategy]); +} + +/** + * Extract TypeScript code from codeblocks + * This is what the TypeScript validation uses + */ +export function extractTypeScriptCode(codeblock: string): string { + return extractCodeWithStrategy(codeblock, "typescript"); +} diff --git a/src/validations/components.test.ts b/src/validations/components.test.ts new file mode 100644 index 0000000..f63417b --- /dev/null +++ b/src/validations/components.test.ts @@ -0,0 +1,769 @@ +import { describe, expect, it } from "vitest"; +import { ValidationResponse, ValidationResult } from "../types.js"; +import { validateComponentCodeBlock } from "./components.js"; + +// Helper function to check if validation response is successful +function isValidationSuccessful(response: ValidationResponse): boolean { + return response.result === ValidationResult.SUCCESS; +} + +// Helper function to call the new validation function in a test-friendly way +async function validateComponent( + codeBlocks: string[], + packageName: string +): Promise { + // Handle empty array case like the tool would + if (codeBlocks.length === 0) { + return [ + { + result: ValidationResult.FAILED, + resultDetail: "No code blocks provided for validation", + }, + ]; + } + + // Validate each code block individually (like the tool does) + const results = codeBlocks.map((code) => { + return validateComponentCodeBlock({ + code, + packageName, + }); + }); + + return results; +} + +describe("validateComponent", () => { + describe("package validation", () => { + it("should fail for unsupported packages", async () => { + const codeBlock = "```Hello, World```"; + const validationResults = await validateComponent( + [codeBlock], + "unsupported-package" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + + it("should fail for other UI component packages", async () => { + const codeBlock = + "```Hello, World```"; + const validationResults = await validateComponent( + [codeBlock], + "@shopify/polaris" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + + it("should fail for empty array", async () => { + const validationResults = await validateComponent( + [], + "@shopify/app-bridge-ui-types" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + }); + + describe("multiple codeblocks", () => { + it("should validate multiple valid codeblocks", async () => { + const codeBlocks = [ + "```Button 1```", + "```Button 2```", + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/app-bridge-ui-types" + ); + expect(validationResults).toHaveLength(2); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[1].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(isValidationSuccessful(validationResults[1])).toBe(true); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + expect(validationResults[1].result).toBe(ValidationResult.SUCCESS); + }); + + it("should validate multiple codeblocks with s- components", async () => { + const codeBlocks = [ + "```Button```", + "```Text```", + "```Heading```", + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/app-bridge-ui-types" + ); + expect(validationResults).toHaveLength(3); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[1].components).toHaveLength(1); + expect(validationResults[2].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(isValidationSuccessful(validationResults[1])).toBe(true); + expect(isValidationSuccessful(validationResults[2])).toBe(true); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + expect(validationResults[1].result).toBe(ValidationResult.SUCCESS); + expect(validationResults[2].result).toBe(ValidationResult.SUCCESS); + }); + + it("should validate all codeblocks with s- components", async () => { + const codeBlocks = [ + "```<>ButtonText```", + "```Heading```", + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/app-bridge-ui-types" + ); + expect(validationResults).toHaveLength(2); + expect(validationResults[0].components).toHaveLength(2); + expect(validationResults[1].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(isValidationSuccessful(validationResults[1])).toBe(true); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + expect(validationResults[1].result).toBe(ValidationResult.SUCCESS); + }); + }); + + describe("@shopify/app-bridge-ui-types package", () => { + describe("valid components", () => { + it("s-badge", async () => { + const validationResults = await validateComponent( + ["```Badge```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-banner", async () => { + const validationResults = await validateComponent( + ["```Banner```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-box", async () => { + const validationResults = await validateComponent( + ["```Box```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-button", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-checkbox", async () => { + const validationResults = await validateComponent( + ["```Checkbox```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-text", async () => { + const validationResults = await validateComponent( + ["```Text```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-heading", async () => { + const validationResults = await validateComponent( + ["```Heading```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-link", async () => { + const validationResults = await validateComponent( + ["```Link```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + }); + + describe("valid props", () => { + it("s-button with variant", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "variant" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-button with disabled", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "disabled" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-badge with tone", async () => { + const validationResults = await validateComponent( + ["```Badge```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "tone" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-icon with type", async () => { + const validationResults = await validateComponent( + ["```Icon```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "type" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with numeric placeholder should pass", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "label" + ); + expect(validationResults[0].components![0].props).toHaveProperty( + "placeholder" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with numeric placeholder (different value) should pass", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "label" + ); + expect(validationResults[0].components![0].props).toHaveProperty( + "placeholder" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with text placeholder should pass", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "label" + ); + expect(validationResults[0].components![0].props).toHaveProperty( + "placeholder" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with mixed placeholder should pass", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "label" + ); + expect(validationResults[0].components![0].props).toHaveProperty( + "placeholder" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with no placeholder should pass", async () => { + const validationResults = await validateComponent( + ["``````"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "label" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with numeric attributes should convert correctly", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "label" + ); + expect(validationResults[0].components![0].props).toHaveProperty("min"); + expect(validationResults[0].components![0].props).toHaveProperty("max"); + expect(validationResults[0].components![0].props).toHaveProperty( + "step" + ); + expect(validationResults[0].components![0].props).toHaveProperty( + "placeholder" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with numeric attributes should fail when not in curly braces", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + }); + + describe("components with different prefixes", () => { + it("p-button (different prefix) - should fail because component doesn't exist", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + }); + + describe("props validation", () => { + it("s-button with variant prop - passes basic validation", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "variant" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-button with icon prop - passes basic validation", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(1); + expect(validationResults[0].components![0].props).toHaveProperty( + "icon" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + }); + + describe("complex component combinations", () => { + it("valid form with multiple field types", async () => { + const validationResults = await validateComponent( + [ + "```<>Submit```", + ], + "@shopify/app-bridge-ui-types" + ); + expect(validationResults[0].components).toHaveLength(3); + expect(validationResults[0].components![0].props).toHaveProperty( + "label" + ); + expect(validationResults[0].components![1].props).toHaveProperty( + "label" + ); + expect(validationResults[0].components![2].props).toHaveProperty( + "variant" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + }); + }); + + describe("react components for POS examples", () => { + it("parses and validates valid POS react components. ScrollView is not a valid component", async () => { + const codeBlocks = [ + `import React from 'react'; + import { + Banner, + ScrollView, + Screen, + reactExtension, + } from '@shopify/ui-extensions-react/point-of-sale'; + + const SmartGridModal = () => { + return ( + + + + + + + + + ); + }; + + export default reactExtension( + 'pos.home.modal.render', + () => , + );`, + ]; + + const validationResults = await validateComponent( + codeBlocks, + "@shopify/ui-extensions-react/point-of-sale" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + + it("parses and catches invalid due to missing props POS react components", async () => { + const codeBlocks = [ + `import React from 'react'; + import { + Banner, + ScrollView, + Screen, + reactExtension, + } from '@shopify/ui-extensions-react/point-of-sale'; + + const SmartGridModal = () => { + return ( + + + + + + + + + ); + }; + + export default reactExtension( + 'pos.home.modal.render', + () => , + );`, + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/ui-extensions-react/point-of-sale" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + + describe("react components for POS examples", () => { + it("react component for POS example 1", async () => { + const codeBlocks = [ + `import React from 'react'; + import { + Banner, + ScrollView, + Screen, + reactExtension, + } from '@shopify/ui-extensions-react/point-of-sale'; + + const SmartGridModal = () => { + return ( + + + + + + + + + ); + }; + + export default reactExtension( + 'pos.home.modal.render', + () => , + );`, + ]; + + const validationResults = await validateComponent( + codeBlocks, + "@shopify/ui-extensions-react/point-of-sale" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + }); + + it("react component for POS example 2 - should fail", async () => { + const codeBlocks = [ + `import React from 'react'; + import { + Banner, + ScrollView, + Screen, + reactExtension, + } from '@shopify/ui-extensions-react/point-of-sale'; + + const SmartGridModal = () => { + return ( + + + + + + + + + ); + }; + + export default reactExtension( + 'pos.home.modal.render', + () => , + );`, + ]; + + const validationResults = await validateComponent( + codeBlocks, + "@shopify/ui-extensions-react/point-of-sale" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + }); + }); +}); +describe("react component for POS TS", () => { + it("parses and validates valid POS TS components. ScrollView is not a valid component", async () => { + const codeBlocks = [ + `import { +extend, +Navigator, +Screen, +ScrollView, +Box, +Image, +} from '@shopify/ui-extensions/point-of-sale'; + +export default extend( +'pos.home.modal.render', +(root) => { +const navigator = + root.createComponent(Navigator); + +const imageBoxScreen = + navigator.createComponent(Screen, { + name: 'ImageBox', + title: 'ImageBox', + }); + +const scrollView = + imageBoxScreen.createComponent(ScrollView); + +const box = scrollView.createComponent(Box, { + blockSize: '100px', + inlineSize: '100px', + paddingInlineStart: '100', + paddingInlineEnd: '100', + paddingBlockStart: '100', + paddingBlockEnd: '100', +}); + +const image = box.createComponent(Image, { + src: 'example.png', + size: 'contain', +}); + +box.appendChild(image); +scrollView.appendChild(box); +imageBoxScreen.appendChild(scrollView); +navigator.appendChild(imageBoxScreen); + +root.appendChild(navigator); +}, +);`, + ]; + + const validationResults = await validateComponent( + codeBlocks, + "@shopify/ui-extensions/point-of-sale" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + it("parses and catches invalid due to invalid props", async () => { + const codeBlocks = [ + `import { +extend, +Navigator, +Screen, +ScrollView, +Box, +Image, +} from '@shopify/ui-extensions/point-of-sale'; + +export default extend( +'pos.home.modal.render', +(root) => { +const navigator = + root.createComponent(Navigator); + +const imageBoxScreen = + navigator.createComponent(Screen, { + name: 'ImageBox', + title: 'ImageBox', + }); + +const scrollView = + imageBoxScreen.createComponent(ScrollView); + +const box = scrollView.createComponent(Box, { + title: 'lol this should not work', + blockSize: '100px', + inlineSize: '100px', + paddingInlineStart: '100', + paddingInlineEnd: '100', + paddingBlockStart: '100', + paddingBlockEnd: '100', +}); + +const image = box.createComponent(Image, { + src: 'example.png', + size: 'contain', +}); + +box.appendChild(image); +scrollView.appendChild(box); +imageBoxScreen.appendChild(scrollView); +navigator.appendChild(imageBoxScreen); + +root.appendChild(navigator); +}, +);`, + ]; + + const validationResults = await validateComponent( + codeBlocks, + "@shopify/ui-extensions/point-of-sale" + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); +}); diff --git a/src/validations/components.ts b/src/validations/components.ts new file mode 100644 index 0000000..3cf273f --- /dev/null +++ b/src/validations/components.ts @@ -0,0 +1,249 @@ +import { z } from "zod"; +import * as AppHomeSchemas from "../data/typescriptSchemas/appHome.js"; +import * as PosSchemas from "../data/typescriptSchemas/pos.js"; +import { ValidationResponse, ValidationResult } from "../types.js"; +import { + parseComponents, + type ComponentInfo, +} from "./parseComponentCodeblock.js"; + +// ============================================================================ +// Main Validation Function +// ============================================================================ + +export function validateComponentCodeBlock( + input: ComponentValidationInput, +): ValidationResponse { + try { + // Validate input + const validationResult = ComponentValidationInputSchema.safeParse(input); + if (!validationResult.success) { + return { + result: ValidationResult.FAILED, + resultDetail: `Invalid input: ${validationResult.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join(", ")}`, + }; + } + + const data = validationResult.data; + + const resolveSchema = + "schemas" in data + ? createExplicitSchemaResolver(data.schemas) + : createPackageSchemaResolver((data as any).packageName); + + return validateCodeBlock( + data.code, + resolveSchema, + (data as any).packageName as string | undefined, + ); + } catch (error) { + return { + result: ValidationResult.FAILED, + resultDetail: `Validation failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +// ============================================================================ +// Schema Types and Interfaces +// ============================================================================ + +const ExplicitSchemasInputSchema = z.object({ + code: z + .string() + .min(1, "Code block is required") + .describe("Code block containing components to validate"), + schemas: z + .record(z.string(), z.any()) + .describe("Object mapping component names to their Zod schemas"), +}); + +const PackageNameInputSchema = z.object({ + code: z + .string() + .min(1, "Code block is required") + .describe("Code block containing components to validate"), + packageName: z.string().min(1), +}); + +const ComponentValidationInputSchema = z.union([ + ExplicitSchemasInputSchema, + PackageNameInputSchema, +]); + +type ComponentValidationInput = z.infer; + +// ============================================================================ +// On-demand Schema Resolution (no pre-built maps, no aliasing) +// ============================================================================ + +type SchemaResolver = (componentName: string) => z.ZodType | null; + +const PACKAGE_SCHEMA_MAP = { + "@shopify/app-bridge-ui-types": AppHomeSchemas, + "@shopify/ui-extensions-react/point-of-sale": PosSchemas, + "@shopify/ui-extensions/point-of-sale": PosSchemas, +} as const; + +type SupportedPackage = keyof typeof PACKAGE_SCHEMA_MAP; + +function createPackageSchemaResolver(packageName: string): SchemaResolver { + if (!(packageName in PACKAGE_SCHEMA_MAP)) { + throw new Error( + `Unsupported package: ${packageName}. Supported packages are: ${Object.keys( + PACKAGE_SCHEMA_MAP, + ).join(", ")}`, + ); + } + + const pkg = PACKAGE_SCHEMA_MAP[packageName as SupportedPackage] as any; + const tagMapping = (pkg as any).TAG_TO_TYPE_MAPPING as + | Record + | undefined; + + if (tagMapping) return resolveByTagMapping(pkg, tagMapping); + return resolveByConventionalNames(pkg); +} + +function createExplicitSchemaResolver( + schemas: Record>, +): SchemaResolver { + // Strict: only exact key matches; no aliasing or name variants + return (componentName: string) => schemas[componentName] ?? null; +} + +// ============================================================================ +// Core Validation Logic +// ============================================================================ + +function validateCodeBlock( + codeblock: string, + resolveSchema: SchemaResolver, + packageName?: string, +): ValidationResponse { + try { + const components = parseComponents(codeblock, resolveSchema, packageName); + console.log("Components:", components); + + if (components.length === 0) { + return { + result: ValidationResult.SUCCESS, + resultDetail: "No components found to validate.", + components, + }; + } + + const errors: string[] = []; + const validComponents: string[] = []; + + for (const component of components) { + const result = validateSingleComponent(component, resolveSchema); + if (result.isValid) { + validComponents.push(result.componentName); + } else if (result.error) { + errors.push(result.error); + } + } + + if (errors.length === 0) { + const componentsList = + validComponents.length > 0 + ? ` Found components: ${validComponents.join(", ")}.` + : ""; + return { + result: ValidationResult.SUCCESS, + resultDetail: `All components validated successfully.${componentsList}`, + components, + }; + } + + return { + result: ValidationResult.FAILED, + resultDetail: `Validation errors: ${errors.join("; ")}`, + components, + }; + } catch (error) { + return { + result: ValidationResult.FAILED, + resultDetail: `Failed to parse code block: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +function validateSingleComponent( + component: ComponentInfo, + resolveSchema: SchemaResolver, +): { isValid: boolean; error?: string; componentName: string } { + const schema = resolveSchema(component.componentName); + + if (!schema) { + console.log("Unknown component:", component.componentName); + return { + isValid: false, + error: `Unknown component: ${component.componentName}`, + componentName: component.componentName, + }; + } + + try { + const strictSchema = + schema instanceof z.ZodObject ? schema.strict() : schema; + strictSchema.parse(component.props); + return { + isValid: true, + componentName: component.componentName, + }; + } catch (error) { + if (error instanceof z.ZodError) { + const errorMessages = error.errors.map((err) => { + const path = err.path.length > 0 ? err.path.join(".") : "root"; + return `Property '${path}': ${err.message}`; + }); + + return { + isValid: false, + error: `${component.componentName} validation failed: ${errorMessages.join("; ")}`, + componentName: component.componentName, + }; + } + + return { + isValid: false, + error: `${component.componentName} validation failed: ${error instanceof Error ? error.message : String(error)}`, + componentName: component.componentName, + }; + } +} + +// ============================================================================ +// Component Parsing (Acorn-based) +// ============================================================================ + +function resolveByConventionalNames(pkg: any): SchemaResolver { + return (componentName: string) => { + const candidateNames = [ + `${componentName}PropsSchema`, + `${componentName}Schema`, + ]; + for (const schemaName of candidateNames) { + const schema = pkg[schemaName]; + if (schema instanceof z.ZodType) return schema as z.ZodType; + } + return null; + }; +} + +function resolveByTagMapping( + pkg: any, + tagMapping: Record, +): SchemaResolver { + return (componentName: string) => { + const typeName = tagMapping[componentName]; + if (!typeName) return null; + const schemaName = `${typeName}Schema`; + const schema = pkg[schemaName]; + return schema instanceof z.ZodSchema ? (schema as z.ZodType) : null; + }; +} diff --git a/src/validations/graphqlSchema.test.ts b/src/validations/graphqlSchema.test.ts index d0d7540..ea6f6e4 100644 --- a/src/validations/graphqlSchema.test.ts +++ b/src/validations/graphqlSchema.test.ts @@ -1,753 +1,27 @@ -import { vol } from "memfs"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("node:fs"); -vi.mock("node:fs/promises"); - -import * as introspectGraphqlSchema from "../tools/introspectGraphqlSchema.js"; +import { injectMockSchemasIntoCache } from "../test-utils.js"; +import * as introspectGraphqlSchema from "../tools/introspect_graphql_schema/index.js"; import { ValidationResult } from "../types.js"; import validateGraphQLOperation from "./graphqlSchema.js"; +beforeAll(async () => { + await injectMockSchemasIntoCache(); +}); + // Mock schemas for testing const mockSchemas: introspectGraphqlSchema.Schema[] = [ { api: "admin", - id: "admin_2025-01", - version: "2025-01", - url: "https://example.com/admin_2025-01.json", + id: "admin_2025-01-mock2", + version: "2025-01-mock2", + url: "https://example.com/admin_2025-01-mock2.json", }, ]; -// Comprehensive mock GraphQL schema for admin API -const mockAdminSchema = { - data: { - __schema: { - queryType: { name: "QueryRoot" }, - mutationType: { name: "Mutation" }, - subscriptionType: null, - types: [ - { - kind: "OBJECT", - name: "QueryRoot", - description: "The schema's entry-point for queries.", - fields: [ - { - name: "products", - description: "List of the shop's products.", - args: [ - { - name: "first", - description: - "Returns up to the first `n` elements from the list.", - type: { - kind: "SCALAR", - name: "Int", - ofType: null, - }, - defaultValue: null, - }, - { - name: "after", - description: - "Returns the elements that come after the specified cursor.", - type: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - defaultValue: null, - }, - { - name: "last", - description: - "Returns up to the last `n` elements from the list.", - type: { - kind: "SCALAR", - name: "Int", - ofType: null, - }, - defaultValue: null, - }, - { - name: "before", - description: - "Returns the elements that come before the specified cursor.", - type: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - defaultValue: null, - }, - { - name: "reverse", - description: "Reverse the order of the underlying list.", - type: { - kind: "SCALAR", - name: "Boolean", - ofType: null, - }, - defaultValue: "false", - }, - { - name: "sortKey", - description: "Sort the underlying list by the given key.", - type: { - kind: "ENUM", - name: "ProductSortKeys", - ofType: null, - }, - defaultValue: "ID", - }, - { - name: "query", - description: "Supported filter parameters.", - type: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - defaultValue: null, - }, - ], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "OBJECT", - name: "ProductConnection", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "product", - description: "Returns a Product resource by ID.", - args: [ - { - name: "id", - description: "The ID of the Product to return.", - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "ID", - ofType: null, - }, - }, - defaultValue: null, - }, - ], - type: { - kind: "OBJECT", - name: "Product", - ofType: null, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: null, - }, - { - kind: "OBJECT", - name: "Mutation", - description: "The schema's entry-point for mutations.", - fields: [ - { - name: "productCreate", - description: "Creates a product.", - args: [ - { - name: "product", - description: "The properties for the new product.", - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "INPUT_OBJECT", - name: "ProductInput", - ofType: null, - }, - }, - defaultValue: null, - }, - ], - type: { - kind: "OBJECT", - name: "ProductCreatePayload", - ofType: null, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: null, - }, - { - kind: "OBJECT", - name: "ProductConnection", - description: - "An auto-generated type for paginating through multiple Products.", - fields: [ - { - name: "edges", - description: "A list of edges.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "LIST", - name: null, - ofType: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "OBJECT", - name: "ProductEdge", - ofType: null, - }, - }, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "nodes", - description: "A list of the nodes contained in ProductEdge.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "LIST", - name: null, - ofType: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "OBJECT", - name: "Product", - ofType: null, - }, - }, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "pageInfo", - description: "Information to aid in pagination.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "OBJECT", - name: "PageInfo", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: null, - }, - { - kind: "OBJECT", - name: "ProductEdge", - description: - "An auto-generated type which holds one Product and a cursor during pagination.", - fields: [ - { - name: "cursor", - description: "A cursor for use in pagination.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "node", - description: "The item at the end of ProductEdge.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "OBJECT", - name: "Product", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: null, - }, - { - kind: "OBJECT", - name: "Product", - description: - "A product represents an individual item for sale in a Shopify store.", - fields: [ - { - name: "id", - description: "A globally-unique identifier.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "ID", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "title", - description: "The title of the product.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "handle", - description: - "A human-friendly unique string for the Product automatically generated from its title.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "createdAt", - description: "The date and time when the product was created.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "DateTime", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [ - { - kind: "INTERFACE", - name: "Node", - ofType: null, - }, - ], - enumValues: null, - possibleTypes: null, - }, - { - kind: "INPUT_OBJECT", - name: "ProductInput", - description: "The input fields for a product.", - fields: null, - inputFields: [ - { - name: "title", - description: "The title of the product.", - type: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - defaultValue: null, - }, - { - name: "handle", - description: - "A human-friendly unique string for the Product automatically generated from its title.", - type: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - defaultValue: null, - }, - ], - interfaces: null, - enumValues: null, - possibleTypes: null, - }, - { - kind: "OBJECT", - name: "ProductCreatePayload", - description: "Return type for `productCreate` mutation.", - fields: [ - { - name: "product", - description: "The product object.", - args: [], - type: { - kind: "OBJECT", - name: "Product", - ofType: null, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "userErrors", - description: - "The list of errors that occurred from executing the mutation.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "LIST", - name: null, - ofType: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "OBJECT", - name: "UserError", - ofType: null, - }, - }, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: null, - }, - { - kind: "OBJECT", - name: "PageInfo", - description: "Information about pagination in a connection.", - fields: [ - { - name: "hasNextPage", - description: "Indicates if there are more pages to fetch.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "Boolean", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "hasPreviousPage", - description: - "Indicates if there are any pages prior to the current page.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "Boolean", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "startCursor", - description: - "The cursor corresponding to the first node in edges.", - args: [], - type: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "endCursor", - description: - "The cursor corresponding to the last node in edges.", - args: [], - type: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: null, - }, - { - kind: "INTERFACE", - name: "Node", - description: - "An object with an ID field to support global identification, in accordance with the Relay specification.", - fields: [ - { - name: "id", - description: "A globally-unique identifier.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "ID", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: [ - { - kind: "OBJECT", - name: "Product", - ofType: null, - }, - ], - }, - { - kind: "OBJECT", - name: "UserError", - description: "An error that occurred during a mutation.", - fields: [ - { - name: "field", - description: "The path to the input field that caused the error.", - args: [], - type: { - kind: "LIST", - name: null, - ofType: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - { - name: "message", - description: "The error message.", - args: [], - type: { - kind: "NON_NULL", - name: null, - ofType: { - kind: "SCALAR", - name: "String", - ofType: null, - }, - }, - isDeprecated: false, - deprecationReason: null, - }, - ], - inputFields: null, - interfaces: [], - enumValues: null, - possibleTypes: null, - }, - { - kind: "ENUM", - name: "ProductSortKeys", - description: "The set of valid sort keys for the Product query.", - fields: null, - inputFields: null, - interfaces: null, - enumValues: [ - { - name: "CREATED_AT", - description: "Sort by the `created_at` value.", - isDeprecated: false, - deprecationReason: null, - }, - { - name: "ID", - description: "Sort by the `id` value.", - isDeprecated: false, - deprecationReason: null, - }, - { - name: "PRODUCT_TYPE", - description: "Sort by the `product_type` value.", - isDeprecated: false, - deprecationReason: null, - }, - { - name: "RELEVANCE", - description: - "Sort by relevance to the search terms when the `query` parameter is specified on the connection.", - isDeprecated: false, - deprecationReason: null, - }, - { - name: "TITLE", - description: "Sort by the `title` value.", - isDeprecated: false, - deprecationReason: null, - }, - { - name: "UPDATED_AT", - description: "Sort by the `updated_at` value.", - isDeprecated: false, - deprecationReason: null, - }, - { - name: "VENDOR", - description: "Sort by the `vendor` value.", - isDeprecated: false, - deprecationReason: null, - }, - ], - possibleTypes: null, - }, - { - kind: "SCALAR", - name: "String", - description: "Represents textual data as UTF-8 character sequences.", - fields: null, - inputFields: null, - interfaces: null, - enumValues: null, - possibleTypes: null, - }, - { - kind: "SCALAR", - name: "Boolean", - description: "Represents `true` or `false` values.", - fields: null, - inputFields: null, - interfaces: null, - enumValues: null, - possibleTypes: null, - }, - { - kind: "SCALAR", - name: "Int", - description: "Represents non-fractional signed whole numeric values.", - fields: null, - inputFields: null, - interfaces: null, - enumValues: null, - possibleTypes: null, - }, - { - kind: "SCALAR", - name: "ID", - description: - "Represents a unique identifier that is Base64 obfuscated.", - fields: null, - inputFields: null, - interfaces: null, - enumValues: null, - possibleTypes: null, - }, - { - kind: "SCALAR", - name: "DateTime", - description: - 'An ISO-8601 encoded UTC date time string. Example value: `"2019-07-03T20:47:55Z"`.', - fields: null, - inputFields: null, - interfaces: null, - enumValues: null, - possibleTypes: null, - }, - ], - directives: [], - }, - }, -}; - describe("validateGraphQLOperation", () => { beforeEach(() => { vi.clearAllMocks(); - - // Set up memfs with the mock schema - vol.reset(); - vol.fromJSON({ - "./data/admin_2025-01.json": JSON.stringify(mockAdminSchema), - }); }); describe("schema name validation", () => { @@ -759,7 +33,7 @@ describe("validateGraphQLOperation", () => { expect(result.result).toBe(ValidationResult.FAILED); expect(result.resultDetail).toBe( - 'Validation error: Schema configuration for API "unsupported-api" version "2025-01" not found in provided schemas. Currently supported schemas: admin (2025-01)', + 'Validation error: Schema configuration for API "unsupported-api" version "2025-01" not found in provided schemas. Currently supported schemas: admin (2025-01-mock2)', ); }); @@ -767,7 +41,7 @@ describe("validateGraphQLOperation", () => { // This test will use the real schema and proceed to validation const result = await validateGraphQLOperation( "query { nonExistentField }", - { api: "admin", version: "2025-01", schemas: mockSchemas }, + { api: "admin", version: "2025-01-mock2", schemas: mockSchemas }, ); // Should proceed past schema name validation but may fail on field validation @@ -784,28 +58,32 @@ describe("validateGraphQLOperation", () => { expect(result.result).toBe(ValidationResult.FAILED); expect(result.resultDetail).toContain("Currently supported schemas:"); - expect(result.resultDetail).toContain("admin (2025-01)"); + expect(result.resultDetail).toContain("admin (2025-01-mock2)"); }); it("should validate against specific version", async () => { const schemasWithVersions: introspectGraphqlSchema.Schema[] = [ { api: "admin", - id: "admin_2025-01", - version: "2025-01", - url: "https://example.com/admin_2025-01.json", + id: "admin_2025-01-mock2", + version: "2025-01-mock2", + url: "https://example.com/admin_2025-01-mock2.json", }, { api: "admin", - id: "admin_2024-10", - version: "2024-10", - url: "https://example.com/admin_2024-10.json", + id: "admin_2025-01-mock", + version: "2025-01-mock", + url: "https://example.com/admin_2025-01-mock.json", }, ]; const result = await validateGraphQLOperation( "query { products(first: 10) { edges { node { id title } } } }", - { api: "admin", version: "2025-01", schemas: schemasWithVersions }, + { + api: "admin", + version: "2025-01-mock2", + schemas: schemasWithVersions, + }, ); // Should succeed or fail based on actual schema validation @@ -831,7 +109,7 @@ describe("validateGraphQLOperation", () => { it("should fail for empty code", async () => { const result = await validateGraphQLOperation("", { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -844,7 +122,7 @@ describe("validateGraphQLOperation", () => { it("should fail for code with only whitespace", async () => { const result = await validateGraphQLOperation(" \n \n", { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -857,7 +135,7 @@ describe("validateGraphQLOperation", () => { it("should process valid GraphQL code", async () => { const result = await validateGraphQLOperation( "query { nonExistentField }", - { api: "admin", version: "2025-01", schemas: mockSchemas }, + { api: "admin", version: "2025-01-mock2", schemas: mockSchemas }, ); // Should proceed past processing (GraphQL was found and processed) @@ -872,7 +150,7 @@ describe("validateGraphQLOperation", () => { it("should handle GraphQL with extra whitespace", async () => { const result = await validateGraphQLOperation( " \n query { nonExistentField } \n ", - { api: "admin", version: "2025-01", schemas: mockSchemas }, + { api: "admin", version: "2025-01-mock2", schemas: mockSchemas }, ); // Should proceed past processing (GraphQL was found and processed) @@ -891,7 +169,7 @@ describe("validateGraphQLOperation", () => { const result = await validateGraphQLOperation(invalidGraphQL, { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -904,7 +182,7 @@ describe("validateGraphQLOperation", () => { const result = await validateGraphQLOperation(malformedGraphQL, { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -917,7 +195,7 @@ describe("validateGraphQLOperation", () => { const result = await validateGraphQLOperation(validSyntax, { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -934,7 +212,7 @@ describe("validateGraphQLOperation", () => { const result = await validateGraphQLOperation(queryWithInvalidField, { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -966,7 +244,7 @@ describe("validateGraphQLOperation", () => { const result = await validateGraphQLOperation(validQuery, { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -989,7 +267,7 @@ describe("validateGraphQLOperation", () => { const result = await validateGraphQLOperation(mutation, { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -1011,7 +289,7 @@ describe("validateGraphQLOperation", () => { const result = await validateGraphQLOperation(invalidMutation, { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -1053,7 +331,7 @@ describe("validateGraphQLOperation", () => { const result = await validateGraphQLOperation(validQuery, { api: "admin", - version: "2025-01", + version: "2025-01-mock2", schemas: mockSchemas, }); @@ -1067,7 +345,7 @@ describe("validateGraphQLOperation", () => { // Test with an invalid query that should fail GraphQL validation const result = await validateGraphQLOperation( "query { products { id } }", // This will fail because products connection needs to specify edges - { api: "admin", version: "2025-01", schemas: mockSchemas }, + { api: "admin", version: "2025-01-mock2", schemas: mockSchemas }, ); expect(result.result).toBe(ValidationResult.FAILED); @@ -1077,7 +355,7 @@ describe("validateGraphQLOperation", () => { it("should provide clear error messages for invalid operations", async () => { const result = await validateGraphQLOperation( "query { nonExistentField }", - { api: "admin", version: "2025-01", schemas: mockSchemas }, + { api: "admin", version: "2025-01-mock2", schemas: mockSchemas }, ); expect(result.result).toBe(ValidationResult.FAILED); diff --git a/src/validations/graphqlSchema.ts b/src/validations/graphqlSchema.ts index 0b45082..8c9d563 100644 --- a/src/validations/graphqlSchema.ts +++ b/src/validations/graphqlSchema.ts @@ -3,7 +3,7 @@ import { getSchema, loadSchemaContent, type Schema, -} from "../tools/introspectGraphqlSchema.js"; +} from "../tools/introspect_graphql_schema/index.js"; import { ValidationResponse, ValidationResult } from "../types.js"; // ============================================================================ @@ -116,7 +116,7 @@ async function performGraphQLValidation( ): Promise { const operation = graphqlCode.trim(); - const parseResult = parseGraphQLDocument(operation); + const parseResult = parseGraphQLDocument(graphqlCode); if (parseResult.success === false) { return validationResult( ValidationResult.FAILED, diff --git a/src/validations/parseComponentCodeblock.test.ts b/src/validations/parseComponentCodeblock.test.ts new file mode 100644 index 0000000..20988a8 --- /dev/null +++ b/src/validations/parseComponentCodeblock.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { parseComponents } from "./parseComponentCodeblock.js"; + +// A no-op resolver; parseCodeblock does not filter by schema +const noSchema = () => null; + +describe("parseComponents (codeblock parser)", () => { + it("parses a single JSX component with literal and boolean attributes", () => { + const code = "```Click```"; + const result = parseComponents(code, noSchema); + expect(result).toHaveLength(1); + expect(result[0].componentName).toBe("s-button"); + expect(result[0].props.variant).toBe("primary"); + expect(result[0].props.disabled).toBe(true); + }); + + it("parses multiple JSX components inside a fragment", () => { + const code = "```<>Text```"; + const result = parseComponents(code, noSchema); + expect(result).toHaveLength(2); + expect(result[0].componentName).toBe("s-button"); + expect(result[1].componentName).toBe("s-text"); + }); + + it("parses createComponent calls for POS TS when packageName matches", () => { + const code = ` + import { extend, Box } from '@shopify/ui-extensions/point-of-sale'; + export default extend('pos.home.modal.render', (root) => { + const box = root.createComponent(Box, { + blockSize: '100px', + paddingInlineEnd: '100', + }); + }); + `; + const result = parseComponents( + code, + noSchema, + "@shopify/ui-extensions/point-of-sale", + ); + expect(result).toHaveLength(1); + expect(result[0].componentName).toBe("Box"); + expect(result[0].props.blockSize).toBe("100px"); + expect(result[0].props.paddingInlineEnd).toBe("100"); + }); + + it("ignores createComponent calls when packageName does not match POS", () => { + const code = ` + import { extend, Box } from '@shopify/ui-extensions/point-of-sale'; + export default extend('pos.home.modal.render', (root) => { + const box = root.createComponent(Box, { blockSize: '100px' }); + }); + `; + const result = parseComponents( + code, + noSchema, + "@shopify/app-bridge-ui-types", + ); + expect(result).toHaveLength(0); + }); + + it("parses numeric and boolean values from object literals in createComponent", () => { + const code = ` + import { extend, NumberField } from '@shopify/ui-extensions/point-of-sale'; + export default extend('pos.home.modal.render', (root) => { + root.createComponent(NumberField, { label: 'Qty', min: 0, max: 100, required: true }); + }); + `; + const result = parseComponents( + code, + noSchema, + "@shopify/ui-extensions/point-of-sale", + ); + expect(result).toHaveLength(1); + expect(result[0].componentName).toBe("NumberField"); + expect(result[0].props.label).toBe("Qty"); + expect(result[0].props.min).toBe(0); + expect(result[0].props.max).toBe(100); + expect(result[0].props.required).toBe(true); + }); + + it("does not include common HTML elements", () => { + const code = "```
```"; + const result = parseComponents(code, noSchema); + expect(result.find((c) => c.componentName === "div")).toBeUndefined(); + expect(result.find((c) => c.componentName === "s-link")).toBeDefined(); + }); +}); diff --git a/src/validations/parseComponentCodeblock.ts b/src/validations/parseComponentCodeblock.ts new file mode 100644 index 0000000..e1b0e66 --- /dev/null +++ b/src/validations/parseComponentCodeblock.ts @@ -0,0 +1,299 @@ +import { Parser } from "acorn"; +import jsx from "acorn-jsx"; +import tsPlugin from "acorn-typescript"; +import { base as walkBase, full as walkFull } from "acorn-walk"; +import { z } from "zod"; +import { extractTypeScriptCode } from "./codeblockExtraction.js"; + +type SchemaResolver = (componentName: string) => z.ZodType | null; + +export interface ComponentInfo { + componentName: string; + props: Record; + content: string; +} +export function parseComponents( + codeblock: string, + resolveSchema: SchemaResolver, + packageName?: string, +): ComponentInfo[] { + const components: ComponentInfo[] = []; + const cleanedCode = extractTypeScriptCode(codeblock); + + const ExtendedParser = (Parser as any).extend( + (tsPlugin as any)(), + (jsx as any)(), + ); + + try { + const ast: any = ExtendedParser.parse(cleanedCode, { + ecmaVersion: "latest", + sourceType: "module", + allowHashBang: true, + locations: true, + ranges: true, + }); + const jsxBase = { + ...walkBase, + JSXElement(node: any, st: any, c: any) { + c(node.openingElement, st); + for (const child of node.children) c(child, st); + }, + JSXFragment(node: any, st: any, c: any) { + for (const child of node.children) c(child, st); + }, + JSXOpeningElement(node: any, st: any, c: any) { + for (const attr of node.attributes) c(attr, st); + }, + JSXAttribute(node: any, st: any, c: any) { + if (node.value) c(node.value, st); + }, + JSXExpressionContainer(node: any, st: any, c: any) { + c(node.expression, st); + }, + JSXText() {}, // ensure traversal doesn't error on text + }; + + walkFull( + ast, + (node: any) => { + addJsxComponentIfMatch(node, resolveSchema, cleanedCode, components); + addCreateComponentIfMatch( + node, + resolveSchema, + cleanedCode, + components, + packageName, + ); + }, + jsxBase, + ); + } catch (e) { + console.log("Error parsing components:", e); + return components; + } + + return components; +} + +function addJsxComponentIfMatch( + node: any, + resolveSchema: SchemaResolver, + source: string, + out: ComponentInfo[], +): void { + if (!node || node.type !== "JSXOpeningElement") return; + const nameNode = node.name; + const componentName = jsxNameToString(nameNode); + if (!componentName) return; + if (isCommonHtmlElement(componentName)) return; + const props = extractJsxAttributes(node.attributes, source); + out.push({ + componentName, + props, + content: source.slice(node.start, node.end), + }); +} + +function addCreateComponentIfMatch( + node: any, + resolveSchema: SchemaResolver, + source: string, + out: ComponentInfo[], + packageName?: string, +): void { + if (!node || node.type !== "CallExpression") return; + const callee = node.callee; + const isCreate = + callee && + callee.type === "MemberExpression" && + !callee.computed && + callee.property && + callee.property.type === "Identifier" && + callee.property.name === "createComponent"; + if (!isCreate) return; + if (packageName && packageName !== "@shopify/ui-extensions/point-of-sale") + return; + const callArgs = node.arguments ?? []; + const firstArg = callArgs[0]; + const secondArg = callArgs[1]; + const componentName = + firstArg && firstArg.type === "Identifier" ? firstArg.name : null; + if (!componentName) return; + let props: Record = {}; + if (secondArg && secondArg.type === "ObjectExpression") { + props = extractObjectLiteralProps(secondArg, source); + } + out.push({ + componentName, + props, + content: source.slice(node.start, node.end), + }); +} +function jsxNameToString(nameNode: any): string | null { + if (!nameNode) return null; + switch (nameNode.type) { + case "JSXIdentifier": + return nameNode.name as string; + case "JSXMemberExpression": { + let current: any = nameNode; + while (current.object && current.property) { + if (current.property && current.property.type === "JSXIdentifier") { + return current.property.name as string; + } + current = current.object; + } + return null; + } + case "JSXNamespacedName": + return nameNode.name?.name ?? nameNode.local?.name ?? null; + default: + return null; + } +} + +function extractJsxAttributes( + attrs: any[], + source: string, +): Record { + const props: Record = {}; + + for (const attr of attrs) { + if (attr.type === "JSXAttribute") { + const key = attr.name?.name; + if (!key) continue; + + if (attr.value == null) { + props[key] = true; + continue; + } + + if (attr.value.type === "Literal") { + props[key] = attr.value.value; + continue; + } + + if (attr.value.type === "JSXExpressionContainer") { + const expr = attr.value.expression; + if (expr.type === "Literal") { + props[key] = expr.value; + continue; + } + if (expr.type === "Identifier") { + if (expr.name === "true") props[key] = true; + else if (expr.name === "false") props[key] = false; + else props[key] = expr.name; + continue; + } + props[key] = source.slice(expr.start, expr.end); + continue; + } + } else if (attr.type === "JSXSpreadAttribute") { + continue; + } + } + + return props; +} + +function extractObjectLiteralProps( + objExpr: any, + source: string, +): Record { + const props: Record = {}; + if (!objExpr || objExpr.type !== "ObjectExpression") return props; + + for (const prop of objExpr.properties ?? []) { + if (prop.type === "SpreadElement") continue; + if (prop.type !== "Property") continue; + + let key: string | null = null; + if (!prop.computed) { + if (prop.key.type === "Identifier") key = prop.key.name as string; + else if (prop.key.type === "Literal") key = String(prop.key.value); + } + if (!key) continue; + + const valueNode = prop.value; + switch (valueNode.type) { + case "Literal": + props[key] = (valueNode as any).value; + break; + case "TemplateLiteral": { + const hasExpressions = (valueNode.expressions ?? []).length > 0; + if (!hasExpressions) { + const cooked = (valueNode.quasis ?? []) + .map((q: any) => q.value?.cooked ?? "") + .join(""); + props[key] = cooked; + } else { + props[key] = source.slice(valueNode.start, valueNode.end); + } + break; + } + case "Identifier": { + if (valueNode.name === "true") props[key] = true; + else if (valueNode.name === "false") props[key] = false; + else props[key] = valueNode.name; + break; + } + case "ObjectExpression": { + props[key] = extractObjectLiteralProps(valueNode, source); + break; + } + default: + props[key] = source.slice(valueNode.start, valueNode.end); + break; + } + } + + return props; +} + +function isCommonHtmlElement(componentName: string): boolean { + const commonElements = new Set([ + "div", + "span", + "p", + "a", + "img", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "table", + "tr", + "td", + "th", + "thead", + "tbody", + "form", + "input", + "button", + "select", + "option", + "textarea", + "nav", + "header", + "footer", + "main", + "section", + "article", + "aside", + "br", + "hr", + "strong", + "em", + "code", + "pre", + ]); + + return commonElements.has(componentName.toLowerCase()); +} + +// Removed unused parseStringValue and isNumericAttribute helpers diff --git a/src/validations/theme.test.ts b/src/validations/theme.test.ts index d6e194f..5343b13 100644 --- a/src/validations/theme.test.ts +++ b/src/validations/theme.test.ts @@ -7,16 +7,19 @@ import validateTheme from "./theme.js"; describe("validateTheme", () => { let tempThemeDirectory: string; + let blocksDirectory: string; let snippetsDirectory: string; let localesDirectory: string; beforeEach(async () => { tempThemeDirectory = await mkdtemp(join(tmpdir(), "theme-test-")); + blocksDirectory = join(tempThemeDirectory, "blocks"); snippetsDirectory = join(tempThemeDirectory, "snippets"); localesDirectory = join(tempThemeDirectory, "locales"); await Promise.all([ + mkdir(blocksDirectory, { recursive: true }), mkdir(snippetsDirectory, { recursive: true }), mkdir(localesDirectory, { recursive: true }), ]); @@ -28,49 +31,107 @@ describe("validateTheme", () => { it("should successfully validate a theme", async () => { // Create the test.liquid file with the specified content - const liquidFile = join(snippetsDirectory, "test.liquid"); - await writeFile(liquidFile, "{{ 'hello' }}"); + const relativeFilePath = join("snippets", "test.liquid"); + const filePath = join(snippetsDirectory, "test.liquid"); + await writeFile(filePath, "{{ 'hello' }}"); // Run validateTheme on the temporary directory - const result = await validateTheme(tempThemeDirectory); + const responses = await validateTheme(tempThemeDirectory, [ + relativeFilePath, + ]); - // Assert the response was a success - expect(result.result).toBe(ValidationResult.SUCCESS); - expect(result.resultDetail).toBe( - `Theme at ${tempThemeDirectory} passed all checks from Shopify's Theme Check.`, - ); + expect(responses).toContainEqual({ + result: ValidationResult.SUCCESS, + resultDetail: `Theme file ${relativeFilePath} passed all checks from Shopify's Theme Check.`, + }); }); - it("should fail to validate a theme", async () => { + it("should fail to validate a theme with an unknown filter", async () => { // Create the test.liquid file with the specified content - const liquidFile = join(snippetsDirectory, "test.liquid"); - await writeFile(liquidFile, "{{ 'hello' | non-existent-filter }}"); + const relativeFilePath = join("snippets", "test.liquid"); + const filePath = join(snippetsDirectory, "test.liquid"); + await writeFile(filePath, "{{ 'hello' | non-existent-filter }}"); // Run validateTheme on the temporary directory - const result = await validateTheme(tempThemeDirectory); + const responses = await validateTheme(tempThemeDirectory, [ + relativeFilePath, + ]); + + expect(responses).toContainEqual({ + result: ValidationResult.FAILED, + resultDetail: `Theme file ${relativeFilePath} failed to validate: + +ERROR: Unknown filter 'non-existent-filter' used.`, + }); + }); - // Assert the response was a success - expect(result.result).toBe(ValidationResult.FAILED); - expect(result.resultDetail).toContain( - "Unknown filter 'non-existent-filter' used.", + it("should fail to validate a theme with an invalid schema", async () => { + // Create the test.liquid file with the specified content + const relativeFilePath = join("blocks", "test.liquid"); + const filePath = join(blocksDirectory, "test.liquid"); + const schemaName = "Long long long long long long name"; + await writeFile( + filePath, + ` +{% schema %} + { + "name": "${schemaName}" + } +{% endschema %}`, ); + + // Run validateTheme on the temporary directory + const responses = await validateTheme(tempThemeDirectory, [ + relativeFilePath, + ]); + + expect(responses).toContainEqual({ + result: ValidationResult.FAILED, + resultDetail: `Theme file ${relativeFilePath} failed to validate: + +ERROR: Schema name '${schemaName}' is too long (max 25 characters)`, + }); }); it("should successfully validate a theme with an unknown filter if its check is exempted", async () => { // Create the test.liquid file with the specified content - const liquidFile = join(snippetsDirectory, "test.liquid"); - await writeFile(liquidFile, "{{ 'hello' | non-existent-filter }}"); + const relativeSnippetFilePath = join("snippets", "test.liquid"); + const snippetFilePath = join(snippetsDirectory, "test.liquid"); + await writeFile(snippetFilePath, "{{ 'hello' | non-existent-filter }}"); const themeCheckYml = join(tempThemeDirectory, ".theme-check.yml"); await writeFile(themeCheckYml, "ignore:\n- snippets/test.liquid"); // Run validateTheme on the temporary directory - const result = await validateTheme(tempThemeDirectory); + const responses = await validateTheme(tempThemeDirectory, [ + relativeSnippetFilePath, + ]); - // Assert the response was a success - expect(result.result).toBe(ValidationResult.SUCCESS); - expect(result.resultDetail).toBe( - `Theme at ${tempThemeDirectory} passed all checks from Shopify's Theme Check.`, - ); + expect(responses).toContainEqual({ + result: ValidationResult.SUCCESS, + resultDetail: `Theme file ${relativeSnippetFilePath} passed all checks from Shopify's Theme Check.`, + }); + }); + + it("should fail to validate only files that were touched by the LLM", async () => { + for (let i = 0; i < 5; i++) { + const snippetFilePath = join(snippetsDirectory, `test-${i}.liquid`); + await writeFile(snippetFilePath, "{{ 'hello' | non-existent-filter }}"); + } + + const relativeSnippetFilePath = join("snippets", "test-0.liquid"); + + // Run validateTheme on the temporary directory + const responses = await validateTheme(tempThemeDirectory, [ + relativeSnippetFilePath, + ]); + + expect(responses).toHaveLength(1); + expect(responses).toContainEqual({ + result: ValidationResult.FAILED, + resultDetail: `Theme file ${relativeSnippetFilePath} failed to validate: + +ERROR: Unknown filter 'non-existent-filter' used.`, + }); }); }); diff --git a/src/validations/theme.ts b/src/validations/theme.ts index eff2626..1265d7a 100644 --- a/src/validations/theme.ts +++ b/src/validations/theme.ts @@ -1,20 +1,22 @@ -import { themeCheckRun } from "@shopify/theme-check-node"; +import { Offense, themeCheckRun } from "@shopify/theme-check-node"; import { access } from "fs/promises"; -import { join } from "path"; +import { join, normalize } from "path"; import { ValidationResponse, ValidationResult } from "../types.js"; /** * Validates Shopify Theme * @param absoluteThemePath - The path to the theme directory + * @param filesCreatedOrUpdated - An array of relative file paths that was generated or updated by the LLM. The file paths should be relative to the theme directory. * @returns ValidationResponse containing the success of running theme-check for the whole theme */ export default async function validateTheme( absoluteThemePath: string, -): Promise { + filesCreatedOrUpdated: string[], +): Promise { try { let configPath: string | undefined = join( absoluteThemePath, - "theme-check.yml", + ".theme-check.yml", ); try { @@ -29,24 +31,54 @@ export default async function validateTheme( (message) => console.error(message), ); - if (results.offenses.length > 0) { - const formattedOffenses = results.offenses - .map((offense) => `${offense.uri}: ${offense.message}`) - .join("\n"); - return { - result: ValidationResult.FAILED, - resultDetail: `Theme at ${absoluteThemePath} failed to validate:\n\n${formattedOffenses}`, - }; + const groupedOffensesByFileUri = groupOffensesByFileUri(results.offenses); + + const responses: ValidationResponse[] = []; + + for (const relativeFilePath of filesCreatedOrUpdated) { + const uri = Object.keys(groupedOffensesByFileUri).find((uri) => + normalize(uri).endsWith(normalize(relativeFilePath)), + ); + if (uri) { + responses.push({ + result: ValidationResult.FAILED, + resultDetail: `Theme file ${relativeFilePath} failed to validate:\n\n${groupedOffensesByFileUri[uri].join("\n")}`, + }); + } else { + responses.push({ + result: ValidationResult.SUCCESS, + resultDetail: `Theme file ${relativeFilePath} passed all checks from Shopify's Theme Check.`, + }); + } } - return { - result: ValidationResult.SUCCESS, - resultDetail: `Theme at ${absoluteThemePath} passed all checks from Shopify's Theme Check.`, - }; + return responses; } catch (error) { - return { + return filesCreatedOrUpdated.map((filePath) => ({ result: ValidationResult.FAILED, - resultDetail: `Validation error: Could not validate ${absoluteThemePath}. Details: ${error instanceof Error ? error.message : String(error)}`, - }; + resultDetail: `Validation error: Could not validate ${filePath}. Details: ${error instanceof Error ? error.message : String(error)}`, + })); } } + +export function groupOffensesByFileUri(offenses: Offense[]) { + return offenses.reduce( + (acc, o) => { + let formattedMessage = `ERROR: ${o.message}`; + + if (o.suggest && o.suggest.length > 0) { + formattedMessage += `; SUGGESTED FIXES: ${o.suggest.map((s) => s.message).join("OR ")}.`; + } + + const uri = o.uri; + + if (acc[uri]) { + acc[uri].push(formattedMessage); + } else { + acc[uri] = [formattedMessage]; + } + return acc; + }, + {} as Record, + ); +} diff --git a/src/validations/themeCodeBlock.test.ts b/src/validations/themeCodeBlock.test.ts index 7ae05c2..cdf3af9 100644 --- a/src/validations/themeCodeBlock.test.ts +++ b/src/validations/themeCodeBlock.test.ts @@ -147,7 +147,7 @@ describe("validateThemeCodeblocks", () => { expect(result).toContainEqual({ result: ValidationResult.FAILED, resultDetail: - "Theme codeblock test.liquid has the following offenses from using Shopify's Theme Check:\n\nERROR: The variable 'some_var' is assigned but not used; SUGGESTED FIXES: Remove the unused variable 'some_var'", + "Theme codeblock test.liquid has the following offenses from using Shopify's Theme Check:\n\nERROR: The variable 'some_var' is assigned but not used; SUGGESTED FIXES: Remove the unused variable 'some_var'.", }); }); @@ -168,4 +168,58 @@ describe("validateThemeCodeblocks", () => { "Theme codeblock test.liquid has the following offenses from using Shopify's Theme Check:\n\nERROR: 'snippets/non-existent-snippet.liquid' does not exist", }); }); + + it("should fail to validate liquid code with schema errors", async () => { + const schemaName = "Long long long long long long name"; + const codeblocks = [ + { + fileName: "test.liquid", + fileType: "blocks" as const, + content: ` +{% schema %} + { + "name": "${schemaName}" + } +{% endschema %}`, + }, + ]; + + const result = await validateThemeCodeblocks(codeblocks); + + expect(result).toContainEqual({ + result: ValidationResult.FAILED, + resultDetail: `Theme codeblock test.liquid has the following offenses from using Shopify's Theme Check: + +ERROR: Schema name '${schemaName}' is too long (max 25 characters)`, + }); + }); + + it("should fail to validate liquid code with LiquidDoc errors", async () => { + const codeblocks = [ + { + fileName: "example-snippet.liquid", + fileType: "snippets" as const, + content: ` +{% doc %} + @param {string} param +{% enddoc %} +{{ param }} +`, + }, + { + fileName: "test.liquid", + fileType: "blocks" as const, + content: `{% render 'example-snippet' %}`, + }, + ]; + + const result = await validateThemeCodeblocks(codeblocks); + + expect(result).toContainEqual({ + result: ValidationResult.FAILED, + resultDetail: `Theme codeblock test.liquid has the following offenses from using Shopify's Theme Check: + +ERROR: Missing required argument 'param' in render tag for snippet 'example-snippet'.; SUGGESTED FIXES: Add required argument 'param'.`, + }); + }); }); diff --git a/src/validations/themeCodeBlock.ts b/src/validations/themeCodeBlock.ts index cc51228..0a7f751 100644 --- a/src/validations/themeCodeBlock.ts +++ b/src/validations/themeCodeBlock.ts @@ -2,16 +2,23 @@ import { AbstractFileSystem, check, Config, + extractDocDefinition, FileStat, FileTuple, FileType, - Offense, + LiquidHtmlNode, path, recommended, + SectionSchema, + SourceCodeType, + ThemeBlockSchema, + toSchema, toSourceCode, } from "@shopify/theme-check-common"; import { ThemeLiquidDocsManager } from "@shopify/theme-check-docs-updater"; +import { normalize } from "path"; import { ValidationResponse, ValidationResult } from "../types.js"; +import { groupOffensesByFileUri } from "./theme.js"; type ThemeCodeblock = { fileName: string; @@ -62,7 +69,7 @@ async function validatePartialTheme( if (fileUriToOffenses[uri]) { validationResults.push({ result: ValidationResult.FAILED, - resultDetail: `Theme codeblock ${name} has the following offenses from using Shopify's Theme Check:\n\n${fileUriToOffenses[uri].join("\n\n")}`, + resultDetail: `Theme codeblock ${name} has the following offenses from using Shopify's Theme Check:\n\n${fileUriToOffenses[uri].join("\n")}`, }); } else { validationResults.push({ @@ -95,6 +102,50 @@ async function runThemeCheck(theme: Theme) { fs: mockFs, themeDocset: docsManager, jsonValidationSet: docsManager, + getBlockSchema: async (blockName) => { + const blockUri = `file:///blocks/${blockName}.liquid`; + const sourceCode = themeSourceCode.find((s) => s.uri === blockUri); + + if (!sourceCode) { + return undefined; + } + + return toSchema( + "theme", + blockUri, + sourceCode, + async () => true, + ) as Promise; + }, + getSectionSchema: async (sectionName) => { + const sectionUri = `file:///sections/${sectionName}.liquid`; + const sourceCode = themeSourceCode.find((s) => s.uri === sectionUri); + + if (!sourceCode) { + return undefined; + } + + return toSchema( + "theme", + sectionUri, + sourceCode, + async () => true, + ) as Promise; + }, + async getDocDefinition(relativePath) { + const sourceCode = themeSourceCode.find((s) => + normalize(s.uri).endsWith(normalize(relativePath)), + ); + + if (!sourceCode || sourceCode.type !== SourceCodeType.LiquidHtml) { + return undefined; + } + + return extractDocDefinition( + sourceCode.uri, + sourceCode.ast as LiquidHtmlNode, + ); + }, }); } @@ -106,26 +157,6 @@ function createTheme(codeblocks: ThemeCodeblock[]): Theme { }, {} as Theme); } -function groupOffensesByFileUri(offenses: Offense[]) { - return offenses.reduce( - (acc, o) => { - let formattedMessage = `ERROR: ${o.message}`; - - if (o.suggest && o.suggest.length > 0) { - formattedMessage += `; SUGGESTED FIXES: ${o.suggest.map((s) => s.message).join("OR ")}`; - } - - if (acc[o.uri]) { - acc[o.uri].push(formattedMessage); - } else { - acc[o.uri] = [formattedMessage]; - } - return acc; - }, - {} as Record, - ); -} - // We mimic a theme on a file system to be able to run theme checks class MockFileSystem implements AbstractFileSystem { constructor(private partialTheme: Theme) {} diff --git a/src/validations/typescript.test.ts b/src/validations/typescript.test.ts new file mode 100644 index 0000000..df0a71a --- /dev/null +++ b/src/validations/typescript.test.ts @@ -0,0 +1,403 @@ +import { describe, expect, it } from "vitest"; +import { ValidationResponse, ValidationResult } from "../types.js"; +import { validateComponentCodeBlock } from "./typescript.js"; + +// Helper function to check if validation response is successful +function isValidationSuccessful(response: ValidationResponse): boolean { + return response.result === ValidationResult.SUCCESS; +} + +// Helper function to call the new validation function in a test-friendly way +async function validateComponent( + codeBlocks: string[], + packageName: string, +): Promise { + // Handle empty array case like the tool would + if (codeBlocks.length === 0) { + return [ + { + result: ValidationResult.FAILED, + resultDetail: "No code blocks provided for validation", + }, + ]; + } + + // Validate each code block individually (like the tool does) + const results = codeBlocks.map((code) => { + return validateComponentCodeBlock({ + code, + packageName, + }); + }); + + return results; +} + +describe("validateComponent", () => { + describe("package validation", () => { + it("should fail for unsupported packages", async () => { + const codeBlock = "```Hello, World```"; + const validationResults = await validateComponent( + [codeBlock], + "unsupported-package", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + + it("should fail for other UI component packages", async () => { + const codeBlock = + "```Hello, World```"; + const validationResults = await validateComponent( + [codeBlock], + "@shopify/polaris", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + + it("should fail for empty array", async () => { + const validationResults = await validateComponent( + [], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + + it("should fail for fake components when package definitions cannot be loaded", async () => { + const codeBlock = + "```Hello, World```"; + const validationResults = await validateComponent( + [codeBlock], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("should fail for fake components against @shopify/app-bridge-ui-types", async () => { + const codeBlock = + "```Hello, World```"; + const validationResults = await validateComponent( + [codeBlock], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + }); + + describe("multiple codeblocks", () => { + it("should validate multiple valid codeblocks", async () => { + const codeBlocks = [ + "```Button 1```", + "```Button 2```", + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/app-bridge-ui-types", + ); + expect(validationResults).toHaveLength(2); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(isValidationSuccessful(validationResults[1])).toBe(true); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + expect(validationResults[1].result).toBe(ValidationResult.SUCCESS); + }); + + it("should validate multiple codeblocks with s- components", async () => { + const codeBlocks = [ + "```Button```", + "```Text```", + "```Heading```", + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/app-bridge-ui-types", + ); + expect(validationResults).toHaveLength(3); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(isValidationSuccessful(validationResults[1])).toBe(true); + expect(isValidationSuccessful(validationResults[2])).toBe(true); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + expect(validationResults[1].result).toBe(ValidationResult.SUCCESS); + expect(validationResults[2].result).toBe(ValidationResult.SUCCESS); + }); + + it("should validate all codeblocks with s- components", async () => { + const codeBlocks = [ + "```ButtonText```", + "```Heading```", + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/app-bridge-ui-types", + ); + expect(validationResults).toHaveLength(2); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(isValidationSuccessful(validationResults[1])).toBe(true); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + expect(validationResults[1].result).toBe(ValidationResult.SUCCESS); + }); + }); + + describe("@shopify/app-bridge-ui-types package", () => { + describe("valid components", () => { + it("s-badge", async () => { + const validationResults = await validateComponent( + ["```Badge```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-banner", async () => { + const validationResults = await validateComponent( + ["```Banner```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-box", async () => { + const validationResults = await validateComponent( + ["```Box```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-button", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-checkbox", async () => { + const validationResults = await validateComponent( + ["```Checkbox```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-text", async () => { + const validationResults = await validateComponent( + ["```Text```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-heading", async () => { + const validationResults = await validateComponent( + ["```Heading```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-link", async () => { + const validationResults = await validateComponent( + ["```Link```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + }); + + describe("valid props", () => { + it("s-button with variant", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-button with disabled", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-badge with tone", async () => { + const validationResults = await validateComponent( + ["```Badge```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-icon with type", async () => { + const validationResults = await validateComponent( + ["```Icon```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with numeric placeholder should pass", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with numeric placeholder (different value) should pass", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with text placeholder should pass", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with mixed placeholder should pass", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with no placeholder should pass", async () => { + const validationResults = await validateComponent( + ["``````"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-number-field with numeric attributes should convert correctly", async () => { + const validationResults = await validateComponent( + [ + "``````", + ], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + }); + + describe("components with different prefixes", () => { + it("p-button (different prefix) - should fail because component doesn't exist", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-fake-element - should fail because component doesn't exist", async () => { + const validationResults = await validateComponent( + ["```Fake```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + + it("s-custom-component - should fail because component doesn't exist", async () => { + const validationResults = await validateComponent( + ["```Custom```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + }); + + describe("props validation", () => { + it("s-button with variant prop - passes basic validation", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("s-button with icon prop - passes basic validation", async () => { + const validationResults = await validateComponent( + ["```Button```"], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + }); + + describe("complex component combinations", () => { + it("valid form with multiple field types", async () => { + const validationResults = await validateComponent( + [ + "```Submit```", + ], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + }); + + it("mix of components with s- prefix", async () => { + const validationResults = await validateComponent( + [ + "```ButtonTextInvalid```", + ], + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(false); + expect(validationResults[0].result).toBe(ValidationResult.FAILED); + }); + }); + }); + + describe("real life examples", () => { + it("tophat take 1 - validates components with s- prefix", async () => { + const codeBlocks = [ + "```SaveCancel```", + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + }); + + it("tophat take 2 - should pass for valid components", async () => { + const codeBlocks = [ + "```Hello worldClick me```", + ]; + const validationResults = await validateComponent( + codeBlocks, + "@shopify/app-bridge-ui-types", + ); + expect(isValidationSuccessful(validationResults[0])).toBe(true); + expect(validationResults).toHaveLength(1); + expect(validationResults[0].result).toBe(ValidationResult.SUCCESS); + }); + }); +}); diff --git a/src/validations/typescript.ts b/src/validations/typescript.ts new file mode 100644 index 0000000..83b335c --- /dev/null +++ b/src/validations/typescript.ts @@ -0,0 +1,376 @@ +import { z } from "zod"; +import * as AppHomeSchemas from "../data/typescriptSchemas/appHome.js"; +import { ValidationResponse, ValidationResult } from "../types.js"; +import { extractTypeScriptCode } from "./codeblockExtraction.js"; + +// ============================================================================ +// Main Validation Function +// ============================================================================ + +export function validateComponentCodeBlock( + input: TypeScriptValidationInput, +): ValidationResponse { + try { + // Validate input + const validationResult = TypeScriptValidationInputSchema.safeParse(input); + if (!validationResult.success) { + return { + result: ValidationResult.FAILED, + resultDetail: `Invalid input: ${validationResult.error.issues + .map((issue) => `${issue.path.join(".")}: ${issue.message}`) + .join(", ")}`, + }; + } + + const { code, packageName } = validationResult.data; + + // Check if package is supported + if (!(packageName in PACKAGE_SCHEMA_MAP)) { + const supportedPackages = Object.keys(PACKAGE_SCHEMA_MAP); + return { + result: ValidationResult.FAILED, + resultDetail: `Unsupported package: ${packageName}. Supported packages are: ${supportedPackages.join(", ")}`, + }; + } + + // Validate the code block and return the result directly + return validateCodeBlock(code, packageName); + } catch (error) { + return { + result: ValidationResult.FAILED, + resultDetail: `Validation failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Maps package names to their corresponding schema modules + * Each schema module is expected to export TAG_TO_TYPE_MAPPING + */ +const PACKAGE_SCHEMA_MAP = { + "@shopify/app-bridge-ui-types": AppHomeSchemas, +} as const; + +type SupportedPackage = keyof typeof PACKAGE_SCHEMA_MAP; + +// ============================================================================ +// Component Tag Name to Schema Mapping +// ============================================================================ + +/** + * Gets the zod schema for a component tag name from a package + */ +function getComponentSchema( + tagName: string, + packageName: string, +): z.ZodType | null { + if (!(packageName in PACKAGE_SCHEMA_MAP)) { + return null; + } + + const packageInfo = PACKAGE_SCHEMA_MAP[packageName as SupportedPackage]; + const tagMapping = (packageInfo as any).TAG_TO_TYPE_MAPPING; + + if (!tagMapping) { + return null; + } + + const typeName = tagMapping[tagName as keyof typeof tagMapping]; + + if (!typeName) { + return null; + } + + const schemaName = `${typeName}Schema`; + const schema = (packageInfo as any)[schemaName]; + + return schema instanceof z.ZodSchema ? schema : null; +} + +/** + * Gets all available component schemas for a package + */ +function getAvailableComponents(packageName: string): string[] { + if (!(packageName in PACKAGE_SCHEMA_MAP)) { + return []; + } + + const packageInfo = PACKAGE_SCHEMA_MAP[packageName as SupportedPackage]; + const tagMapping = (packageInfo as any).TAG_TO_TYPE_MAPPING; + + if (!tagMapping) { + return []; + } + + // Simply return all tag names from the mapping + return Object.keys(tagMapping); +} + +// ============================================================================ +// Interfaces +// ============================================================================ + +const TypeScriptValidationInputSchema = z.object({ + code: z + .string() + .min(1, "Code block is required") + .describe( + "Markdown code block containing HTML with custom elements to validate", + ), + packageName: z + .string() + .min(1, "Package name is required") + .describe( + "TypeScript package name to validate against (e.g., '@shopify/app-bridge-ui-types')", + ), +}); + +type TypeScriptValidationInput = z.infer< + typeof TypeScriptValidationInputSchema +>; + +interface ComponentInfo { + tagName: string; + props: Record; + content: string; +} + +function validateComponentProps( + tagName: string, + props: Record, + packageName: string, +): { + isValid: boolean; + errors: string[]; + warnings: string[]; +} { + const schema = getComponentSchema(tagName, packageName); + + if (!schema) { + return { + isValid: false, + errors: [`Unknown component: ${tagName} for package ${packageName}`], + warnings: [], + }; + } + + try { + schema.parse(props); + return { + isValid: true, + errors: [], + warnings: [], + }; + } catch (error) { + if (error instanceof z.ZodError) { + const errors = error.errors.map((err: any) => { + const path = err.path.length > 0 ? err.path.join(".") : "root"; + return `Property '${path}': ${err.message}`; + }); + + return { + isValid: false, + errors, + warnings: [], + }; + } + + return { + isValid: false, + errors: [ + `Validation failed: ${error instanceof Error ? error.message : String(error)}`, + ], + warnings: [], + }; + } +} + +function validateSingleComponent( + component: ComponentInfo, + packageName: string, +): { isValid: boolean; error?: string; tagName: string } { + const schema = getComponentSchema(component.tagName, packageName); + + if (!schema) { + const availableComponents = getAvailableComponents(packageName); + return { + isValid: false, + error: `Unknown component: ${component.tagName}. Available components for ${packageName}: ${availableComponents.join(", ")}`, + tagName: component.tagName, + }; + } + + const validationResult = validateComponentProps( + component.tagName, + component.props, + packageName, + ); + + if (validationResult.errors.length > 0) { + return { + isValid: false, + error: validationResult.errors.join("; "), + tagName: component.tagName, + }; + } + + return { + isValid: true, + tagName: component.tagName, + }; +} + +/** + * Validates all components and collects results + * Eliminates nested loops by using functional approach + */ +function validateAllComponents( + components: ComponentInfo[], + packageName: string, +): { errors: string[]; validComponents: string[] } { + const results = components.map((component) => + validateSingleComponent(component, packageName), + ); + + return { + errors: results.filter((r) => !r.isValid).map((r) => r.error!), + validComponents: results.filter((r) => r.isValid).map((r) => r.tagName), + }; +} + +/** + * Validates a code block - simplified without nested loops + */ +function validateCodeBlock( + codeblock: string, + packageName: string, +): ValidationResponse { + try { + const components = parseCodeBlock(codeblock); + const { errors, validComponents } = validateAllComponents( + components, + packageName, + ); + + if (errors.length === 0) { + const componentsList = + validComponents.length > 0 + ? ` Found components: ${validComponents.join(", ")}.` + : ""; + return { + result: ValidationResult.SUCCESS, + resultDetail: `Code block successfully validated against ${packageName} schemas.${componentsList}`, + }; + } + + return { + result: ValidationResult.FAILED, + resultDetail: `Errors: ${errors.join("; ")}`, + }; + } catch (error) { + return { + result: ValidationResult.FAILED, + resultDetail: `Failed to parse code block: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +// ============================================================================ +// Component Validation (Legacy - keeping for compatibility) +// ============================================================================ + +/** + * Validates a single component against the package schemas + * @deprecated Use validateSingleComponent instead + */ +function validateComponent( + component: ComponentInfo, + packageName: string, +): ValidationResponse { + const result = validateSingleComponent(component, packageName); + + if (!result.isValid) { + return { + result: ValidationResult.FAILED, + resultDetail: result.error!, + }; + } + + return { + result: ValidationResult.SUCCESS, + resultDetail: `Component ${result.tagName} validated successfully`, + }; +} + +// ============================================================================ +// Code Block Parsing +// ============================================================================ + +/** + * Parses a code block to extract component information + * Uses shared cleaning utility for consistent code processing + */ +function parseCodeBlock(codeblock: string): ComponentInfo[] { + const components: ComponentInfo[] = []; + + // Use shared cleaning utility - removes markdown blocks, HTML comments, and trims + const cleanedCode = extractTypeScriptCode(codeblock); + + // Simple regex to find all s- component opening tags + // Works perfectly for LLM-generated HTML + const tagMatches = cleanedCode.matchAll( + /<(s-[a-zA-Z0-9-]+)([^>]*?)(?:\s*\/?>)/g, + ); + + for (const match of tagMatches) { + const tagName = match[1]; + const attributeString = match[2].trim(); + + // Parse attributes + const props = parseAttributes(attributeString); + + components.push({ + tagName, + props, + content: match[0], + }); + } + + return components; +} + +/** + * Parses HTML attributes from a string + * Optimized for LLM-generated component attributes + */ +function parseAttributes(attributeString: string): Record { + const props: Record = {}; + + if (!attributeString.trim()) return props; + + // Simple regex that handles quoted values with spaces properly + // Supports: key="value with spaces" or key='value' or key=value or just key + const attributeRegex = + /([a-zA-Z0-9-]+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g; + let match; + + while ((match = attributeRegex.exec(attributeString)) !== null) { + const name = match[1]; + const value = match[2] || match[3] || match[4] || true; + + // Convert string values to appropriate types + let parsedValue: any = value; + if (typeof value === "string") { + if (value === "true") parsedValue = true; + else if (value === "false") parsedValue = false; + else if (/^\d+$/.test(value)) parsedValue = parseInt(value, 10); + else if (/^\d*\.\d+$/.test(value)) parsedValue = parseFloat(value); + // Keep as string otherwise + else parsedValue = value; + } + + props[name] = parsedValue; + } + + return props; +} diff --git a/tsconfig.json b/tsconfig.json index 9d4d978..a449872 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", "outDir": "./dist", "rootDir": "./src", "strict": true, diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..2d4180f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,46 @@ +import { readFileSync } from "fs"; +import { builtinModules } from "module"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vite"; + +const packageJson = JSON.parse(readFileSync("./package.json", "utf-8")); + +export default defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(packageJson.version), + }, + build: { + lib: { + entry: fileURLToPath(new URL("./src/index.ts", import.meta.url)), + formats: ["esm"], + fileName: () => "index.js", + }, + outDir: "dist", + emptyOutDir: true, + rollupOptions: { + external: [ + ...builtinModules, + ...builtinModules.map((m) => `node:${m}`), + "@shopify/theme-check-node", + "@shopify/theme-check-common", + "@shopify/theme-check-docs-updater", + "env-paths", + ], + output: { + interop: "auto", + }, + }, + }, + test: { + environment: "node", + include: ["src/**/*.test.ts"], + globals: true, + coverage: { + provider: "v8", + }, + alias: { + // Similar to the moduleNameMapper in Jest config + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + }, +}); diff --git a/vitest.config.js b/vitest.config.js deleted file mode 100644 index 9e737a8..0000000 --- a/vitest.config.js +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["src/**/*.test.ts"], - globals: true, - coverage: { - provider: "v8", - }, - alias: { - // Similar to the moduleNameMapper in Jest config - "^(\\.{1,2}/.*)\\.js$": "$1", - }, - }, -});