diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index 9c229c22718..1765f7732fb 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -532,6 +532,41 @@ outputs: { } ``` +### Typed JSON Outputs + +When using `type: 'json'` and you know the object shape in advance, **describe the inner fields in the description** so downstream blocks know what properties are available. For well-known, stable objects, use nested output definitions instead: + +```typescript +outputs: { + // BAD: Opaque json with no info about what's inside + plan: { type: 'json', description: 'Zone plan information' }, + + // GOOD: Describe the known fields in the description + plan: { + type: 'json', + description: 'Zone plan information (id, name, price, currency, frequency, is_subscribed)', + }, + + // BEST: Use nested output definition when the shape is stable and well-known + plan: { + id: { type: 'string', description: 'Plan identifier' }, + name: { type: 'string', description: 'Plan name' }, + price: { type: 'number', description: 'Plan price' }, + currency: { type: 'string', description: 'Price currency' }, + }, +} +``` + +Use the nested pattern when: +- The object has a small, stable set of fields (< 10) +- Downstream blocks will commonly access specific properties +- The API response shape is well-documented and unlikely to change + +Use `type: 'json'` with a descriptive string when: +- The object has many fields or a dynamic shape +- It represents a list/array of items +- The shape varies by operation + ## V2 Block Pattern When creating V2 blocks (alongside legacy V1): @@ -695,6 +730,62 @@ Please provide the SVG and I'll convert it to a React component. You can usually find this in the service's brand/press kit page, or copy it from their website. ``` +## Advanced Mode for Optional Fields + +Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. This includes: +- Pagination tokens +- Time range filters (start/end time) +- Sort order options +- Reply settings +- Rarely used IDs (e.g., reply-to tweet ID, quote tweet ID) +- Max results / limits + +```typescript +{ + id: 'startTime', + title: 'Start Time', + type: 'short-input', + placeholder: 'ISO 8601 timestamp', + condition: { field: 'operation', value: ['search', 'list'] }, + mode: 'advanced', // Rarely used, hide from basic view +} +``` + +## WandConfig for Complex Inputs + +Use `wandConfig` for fields that are hard to fill out manually, such as timestamps, comma-separated lists, and complex query strings. This gives users an AI-assisted input experience. + +```typescript +// Timestamps - use generationType: 'timestamp' to inject current date context +{ + id: 'startTime', + title: 'Start Time', + type: 'short-input', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate an ISO 8601 timestamp based on the user description. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, +} + +// Comma-separated lists - simple prompt without generationType +{ + id: 'mediaIds', + title: 'Media IDs', + type: 'short-input', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate a comma-separated list of media IDs. Return ONLY the comma-separated values.', + }, +} +``` + +## Naming Convention + +All tool IDs referenced in `tools.access` and returned by `tools.config.tool` MUST use `snake_case` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase. + ## Checklist Before Finishing - [ ] All subBlocks have `id`, `title` (except switch), and `type` @@ -702,9 +793,24 @@ You can usually find this in the service's brand/press kit page, or copy it from - [ ] DependsOn set for fields that need other values - [ ] Required fields marked correctly (boolean or condition) - [ ] OAuth inputs have correct `serviceId` -- [ ] Tools.access lists all tool IDs -- [ ] Tools.config.tool returns correct tool ID +- [ ] Tools.access lists all tool IDs (snake_case) +- [ ] Tools.config.tool returns correct tool ID (snake_case) - [ ] Outputs match tool outputs - [ ] Block registered in registry.ts - [ ] If icon missing: asked user to provide SVG - [ ] If triggers exist: `triggers` config set, trigger subBlocks spread +- [ ] Optional/rarely-used fields set to `mode: 'advanced'` +- [ ] Timestamps and complex inputs have `wandConfig` enabled + +## Final Validation (Required) + +After creating the block, you MUST validate it against every tool it references: + +1. **Read every tool definition** that appears in `tools.access` — do not skip any +2. **For each tool, verify the block has correct:** + - SubBlock inputs that cover all required tool params (with correct `condition` to show for that operation) + - SubBlock input types that match the tool param types (e.g., dropdown for enums, short-input for strings) + - `tools.config.params` correctly maps subBlock IDs to tool param names (if they differ) + - Type coercions in `tools.config.params` for any params that need conversion (Number(), Boolean(), JSON.parse()) +3. **Verify block outputs** cover the key fields returned by all tools +4. **Verify conditions** — each subBlock should only show for the operations that actually use it diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index c3221b2e9cc..aaa4cb857ce 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -102,6 +102,7 @@ export const {service}{Action}Tool: ToolConfig = { - Always use `?? []` for optional array fields - Set `optional: true` for outputs that may not exist - Never output raw JSON dumps - extract meaningful fields +- When using `type: 'json'` and you know the object shape, define `properties` with the inner fields so downstream consumers know the structure. Only use bare `type: 'json'` when the shape is truly dynamic ## Step 3: Create Block @@ -436,6 +437,12 @@ If creating V2 versions (API-aligned outputs): - [ ] Ran `bun run scripts/generate-docs.ts` - [ ] Verified docs file created +### Final Validation (Required) +- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs +- [ ] Verified block subBlocks cover all required tool params with correct conditions +- [ ] Verified block outputs match what the tools actually return +- [ ] Verified `tools.config.params` correctly maps and coerces all param types + ## Example Command When the user asks to add an integration: @@ -685,13 +692,40 @@ return NextResponse.json({ | `isUserFile` | `@/lib/core/utils/user-file` | Type guard for UserFile objects | | `FileInputSchema` | `@/lib/uploads/utils/file-schemas` | Zod schema for file validation | +### Advanced Mode for Optional Fields + +Optional fields that are rarely used should be set to `mode: 'advanced'` so they don't clutter the basic UI. Examples: pagination tokens, time range filters, sort order, max results, reply settings. + +### WandConfig for Complex Inputs + +Use `wandConfig` for fields that are hard to fill out manually: +- **Timestamps**: Use `generationType: 'timestamp'` to inject current date context into the AI prompt +- **JSON arrays**: Use `generationType: 'json-object'` for structured data +- **Complex queries**: Use a descriptive prompt explaining the expected format + +```typescript +{ + id: 'startTime', + title: 'Start Time', + type: 'short-input', + mode: 'advanced', + wandConfig: { + enabled: true, + prompt: 'Generate an ISO 8601 timestamp. Return ONLY the timestamp string.', + generationType: 'timestamp', + }, +} +``` + ### Common Gotchas 1. **OAuth serviceId must match** - The `serviceId` in oauth-input must match the OAuth provider configuration -2. **Tool IDs are snake_case** - `stripe_create_payment`, not `stripeCreatePayment` +2. **All tool IDs MUST be snake_case** - `stripe_create_payment`, not `stripeCreatePayment`. This applies to tool `id` fields, registry keys, `tools.access` arrays, and `tools.config.tool` return values 3. **Block type is snake_case** - `type: 'stripe'`, not `type: 'Stripe'` 4. **Alphabetical ordering** - Keep imports and registry entries alphabetically sorted 5. **Required can be conditional** - Use `required: { field: 'op', value: 'create' }` instead of always true 6. **DependsOn clears options** - When a dependency changes, selector options are refetched 7. **Never pass Buffer directly to fetch** - Convert to `new Uint8Array(buffer)` for TypeScript compatibility 8. **Always handle legacy file params** - Keep hidden `fileContent` params for backwards compatibility +9. **Optional fields use advanced mode** - Set `mode: 'advanced'` on rarely-used optional fields +10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled diff --git a/.claude/commands/add-tools.md b/.claude/commands/add-tools.md index c83a95b1ba3..fb7bfdc55db 100644 --- a/.claude/commands/add-tools.md +++ b/.claude/commands/add-tools.md @@ -147,9 +147,18 @@ closedAt: { }, ``` -### Nested Properties -For complex outputs, define nested structure: +### Typed JSON Outputs + +When using `type: 'json'` and you know the object shape in advance, **always define the inner structure** using `properties` so downstream consumers know what fields are available: + ```typescript +// BAD: Opaque json with no info about what's inside +metadata: { + type: 'json', + description: 'Response metadata', +}, + +// GOOD: Define the known properties metadata: { type: 'json', description: 'Response metadata', @@ -159,7 +168,10 @@ metadata: { count: { type: 'number', description: 'Total count' }, }, }, +``` +For arrays of objects, define the item structure: +```typescript items: { type: 'array', description: 'List of items', @@ -173,6 +185,8 @@ items: { }, ``` +Only use bare `type: 'json'` without `properties` when the shape is truly dynamic or unknown. + ## Critical Rules for transformResponse ### Handle Nullable Fields @@ -272,8 +286,13 @@ If creating V2 tools (API-aligned outputs), use `_v2` suffix: - Version: `'2.0.0'` - Outputs: Flat, API-aligned (no content/metadata wrapper) +## Naming Convention + +All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`). Never use camelCase or PascalCase for tool IDs. + ## Checklist Before Finishing +- [ ] All tool IDs use snake_case - [ ] All params have explicit `required: true` or `required: false` - [ ] All params have appropriate `visibility` - [ ] All nullable response fields use `?? null` @@ -281,4 +300,22 @@ If creating V2 tools (API-aligned outputs), use `_v2` suffix: - [ ] No raw JSON dumps in outputs - [ ] Types file has all interfaces - [ ] Index.ts exports all tools -- [ ] Tool IDs use snake_case + +## Final Validation (Required) + +After creating all tools, you MUST validate every tool before finishing: + +1. **Read every tool file** you created — do not skip any +2. **Cross-reference with the API docs** to verify: + - All required params are marked `required: true` + - All optional params are marked `required: false` + - Param types match the API (string, number, boolean, json) + - Request URL, method, headers, and body match the API spec + - `transformResponse` extracts the correct fields from the API response + - All output fields match what the API actually returns + - No fields are missing from outputs that the API provides + - No extra fields are defined in outputs that the API doesn't return +3. **Verify consistency** across tools: + - Shared types in `types.ts` match all tools that use them + - Tool IDs in the barrel export match the tool file definitions + - Error handling is consistent (error checks, meaningful messages) diff --git a/.claude/commands/validate-integration.md b/.claude/commands/validate-integration.md new file mode 100644 index 00000000000..77091b2b9dd --- /dev/null +++ b/.claude/commands/validate-integration.md @@ -0,0 +1,283 @@ +--- +description: Validate an existing Sim integration (tools, block, registry) against the service's API docs +argument-hint: [api-docs-url] +--- + +# Validate Integration Skill + +You are an expert auditor for Sim integrations. Your job is to thoroughly validate that an existing integration is correct, complete, and follows all conventions. + +## Your Task + +When the user asks you to validate an integration: +1. Read the service's API documentation (via WebFetch or Context7) +2. Read every tool, the block, and registry entries +3. Cross-reference everything against the API docs and Sim conventions +4. Report all issues found, grouped by severity (critical, warning, suggestion) +5. Fix all issues after reporting them + +## Step 1: Gather All Files + +Read **every** file for the integration — do not skip any: + +``` +apps/sim/tools/{service}/ # All tool files, types.ts, index.ts +apps/sim/blocks/blocks/{service}.ts # Block definition +apps/sim/tools/registry.ts # Tool registry entries for this service +apps/sim/blocks/registry.ts # Block registry entry for this service +apps/sim/components/icons.tsx # Icon definition +apps/sim/lib/auth/auth.ts # OAuth scopes (if OAuth service) +apps/sim/lib/oauth/oauth.ts # OAuth provider config (if OAuth service) +``` + +## Step 2: Pull API Documentation + +Fetch the official API docs for the service. This is the **source of truth** for: +- Endpoint URLs, HTTP methods, and auth headers +- Required vs optional parameters +- Parameter types and allowed values +- Response shapes and field names +- Pagination patterns (which param name, which response field) +- Rate limits and error formats + +## Step 3: Validate Tools + +For **every** tool file, check: + +### Tool ID and Naming +- [ ] Tool ID uses `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`, `slack_send_message`) +- [ ] Tool `name` is human-readable (e.g., `'X Create Tweet'`) +- [ ] Tool `description` is a concise one-liner describing what it does +- [ ] Tool `version` is set (`'1.0.0'` or `'2.0.0'` for V2) + +### Params +- [ ] All required API params are marked `required: true` +- [ ] All optional API params are marked `required: false` +- [ ] Every param has explicit `required: true` or `required: false` — never omitted +- [ ] Param types match the API (`'string'`, `'number'`, `'boolean'`, `'json'`) +- [ ] Visibility is correct: + - `'hidden'` — ONLY for OAuth access tokens and system-injected params + - `'user-only'` — for API keys, credentials, and account-specific IDs the user must provide + - `'user-or-llm'` — for everything else (search queries, content, filters, IDs that could come from other blocks) +- [ ] Every param has a `description` that explains what it does + +### Request +- [ ] URL matches the API endpoint exactly (correct base URL, path segments, path params) +- [ ] HTTP method matches the API spec (GET, POST, PUT, PATCH, DELETE) +- [ ] Headers include correct auth pattern: + - OAuth: `Authorization: Bearer ${params.accessToken}` + - API Key: correct header name and format per the service's docs +- [ ] `Content-Type` header is set for POST/PUT/PATCH requests +- [ ] Body sends all required fields and only includes optional fields when provided +- [ ] For GET requests with query params: URL is constructed correctly with query string +- [ ] ID fields in URL paths are `.trim()`-ed to prevent copy-paste whitespace errors +- [ ] Path params use template literals correctly: `` `https://api.service.com/v1/${params.id.trim()}` `` + +### Response / transformResponse +- [ ] Correctly parses the API response (`await response.json()`) +- [ ] Extracts the right fields from the response structure (e.g., `data.data` vs `data` vs `data.results`) +- [ ] All nullable fields use `?? null` +- [ ] All optional arrays use `?? []` +- [ ] Error cases are handled: checks for missing/empty data and returns meaningful error +- [ ] Does NOT do raw JSON dumps — extracts meaningful, individual fields + +### Outputs +- [ ] All output fields match what the API actually returns +- [ ] No fields are missing that the API provides and users would commonly need +- [ ] No phantom fields defined that the API doesn't return +- [ ] `optional: true` is set on fields that may not exist in all responses +- [ ] When using `type: 'json'` and the shape is known, `properties` defines the inner fields +- [ ] When using `type: 'array'`, `items` defines the item structure with `properties` +- [ ] Field descriptions are accurate and helpful + +### Types (types.ts) +- [ ] Has param interfaces for every tool (e.g., `XCreateTweetParams`) +- [ ] Has response interfaces for every tool (extending `ToolResponse`) +- [ ] Optional params use `?` in the interface (e.g., `replyTo?: string`) +- [ ] Field names in types match actual API field names +- [ ] Shared response types are properly reused (e.g., `XTweetResponse` shared across tweet tools) + +### Barrel Export (index.ts) +- [ ] Every tool is exported +- [ ] All types are re-exported (`export * from './types'`) +- [ ] No orphaned exports (tools that don't exist) + +### Tool Registry (tools/registry.ts) +- [ ] Every tool is imported and registered +- [ ] Registry keys use snake_case and match tool IDs exactly +- [ ] Entries are in alphabetical order within the file + +## Step 4: Validate Block + +### Block ↔ Tool Alignment (CRITICAL) + +This is the most important validation — the block must be perfectly aligned with every tool it references. + +For **each tool** in `tools.access`: +- [ ] The operation dropdown has an option whose ID matches the tool ID (or the `tools.config.tool` function correctly maps to it) +- [ ] Every **required** tool param (except `accessToken`) has a corresponding subBlock input that is: + - Shown when that operation is selected (correct `condition`) + - Marked as `required: true` (or conditionally required) +- [ ] Every **optional** tool param has a corresponding subBlock input (or is intentionally omitted if truly never needed) +- [ ] SubBlock `id` values are unique across the entire block — no duplicates even across different conditions +- [ ] The `tools.config.tool` function returns the correct tool ID for every possible operation value +- [ ] The `tools.config.params` function correctly maps subBlock IDs to tool param names when they differ + +### SubBlocks +- [ ] Operation dropdown lists ALL tool operations available in `tools.access` +- [ ] Dropdown option labels are human-readable and descriptive +- [ ] Conditions use correct syntax: + - Single value: `{ field: 'operation', value: 'x_create_tweet' }` + - Multiple values (OR): `{ field: 'operation', value: ['x_create_tweet', 'x_delete_tweet'] }` + - Negation: `{ field: 'operation', value: 'delete', not: true }` + - Compound: `{ field: 'op', value: 'send', and: { field: 'type', value: 'dm' } }` +- [ ] Condition arrays include ALL operations that use that field — none missing +- [ ] `dependsOn` is set for fields that need other values (selectors depending on credential, cascading dropdowns) +- [ ] SubBlock types match tool param types: + - Enum/fixed options → `dropdown` + - Free text → `short-input` + - Long text/content → `long-input` + - True/false → `dropdown` with Yes/No options (not `switch` unless purely UI toggle) + - Credentials → `oauth-input` with correct `serviceId` +- [ ] Dropdown `value: () => 'default'` is set for dropdowns with a sensible default + +### Advanced Mode +- [ ] Optional, rarely-used fields are set to `mode: 'advanced'`: + - Pagination tokens / next tokens + - Time range filters (start/end time) + - Sort order / direction options + - Max results / per page limits + - Reply settings / threading options + - Rarely used IDs (reply-to, quote-tweet, etc.) + - Exclude filters +- [ ] **Required** fields are NEVER set to `mode: 'advanced'` +- [ ] Fields that users fill in most of the time are NOT set to `mode: 'advanced'` + +### WandConfig +- [ ] Timestamp fields have `wandConfig` with `generationType: 'timestamp'` +- [ ] Comma-separated list fields have `wandConfig` with a descriptive prompt +- [ ] Complex filter/query fields have `wandConfig` with format examples in the prompt +- [ ] All `wandConfig` prompts end with "Return ONLY the [format] - no explanations, no extra text." +- [ ] `wandConfig.placeholder` describes what to type in natural language + +### Tools Config +- [ ] `tools.access` lists **every** tool ID the block can use — none missing +- [ ] `tools.config.tool` returns the correct tool ID for each operation +- [ ] Type coercions are in `tools.config.params` (runs at execution time), NOT in `tools.config.tool` (runs at serialization time before variable resolution) +- [ ] `tools.config.params` handles: + - `Number()` conversion for numeric params that come as strings from inputs + - `Boolean` / string-to-boolean conversion for toggle params + - Empty string → `undefined` conversion for optional dropdown values + - Any subBlock ID → tool param name remapping +- [ ] No `Number()`, `JSON.parse()`, or other coercions in `tools.config.tool` — these would destroy dynamic references like `` + +### Block Outputs +- [ ] Outputs cover the key fields returned by ALL tools (not just one operation) +- [ ] Output types are correct (`'string'`, `'number'`, `'boolean'`, `'json'`) +- [ ] `type: 'json'` outputs either: + - Describe inner fields in the description string (GOOD): `'User profile (id, name, username, bio)'` + - Use nested output definitions (BEST): `{ id: { type: 'string' }, name: { type: 'string' } }` +- [ ] No opaque `type: 'json'` with vague descriptions like `'Response data'` +- [ ] Outputs that only appear for certain operations use `condition` if supported, or document which operations return them + +### Block Metadata +- [ ] `type` is snake_case (e.g., `'x'`, `'cloudflare'`) +- [ ] `name` is human-readable (e.g., `'X'`, `'Cloudflare'`) +- [ ] `description` is a concise one-liner +- [ ] `longDescription` provides detail for docs +- [ ] `docsLink` points to `'https://docs.sim.ai/tools/{service}'` +- [ ] `category` is `'tools'` +- [ ] `bgColor` uses the service's brand color hex +- [ ] `icon` references the correct icon component from `@/components/icons` +- [ ] `authMode` is set correctly (`AuthMode.OAuth` or `AuthMode.ApiKey`) +- [ ] Block is registered in `blocks/registry.ts` alphabetically + +### Block Inputs +- [ ] `inputs` section lists all subBlock params that the block accepts +- [ ] Input types match the subBlock types +- [ ] When using `canonicalParamId`, inputs list the canonical ID (not the raw subBlock IDs) + +## Step 5: Validate OAuth Scopes (if OAuth service) + +- [ ] `auth.ts` scopes include ALL scopes needed by ALL tools in the integration +- [ ] `oauth.ts` provider config scopes match `auth.ts` scopes +- [ ] Block `requiredScopes` (if defined) matches `auth.ts` scopes +- [ ] No excess scopes that aren't needed by any tool +- [ ] Each scope has a human-readable description in `oauth-required-modal.tsx`'s `SCOPE_DESCRIPTIONS` + +## Step 6: Validate Pagination Consistency + +If any tools support pagination: +- [ ] Pagination param names match the API docs (e.g., `pagination_token` vs `next_token` vs `cursor`) +- [ ] Different API endpoints that use different pagination param names have separate subBlocks in the block +- [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs +- [ ] Pagination subBlocks are set to `mode: 'advanced'` + +## Step 7: Validate Error Handling + +- [ ] `transformResponse` checks for error conditions before accessing data +- [ ] Error responses include meaningful messages (not just generic "failed") +- [ ] HTTP error status codes are handled (check `response.ok` or status codes) + +## Step 8: Report and Fix + +### Report Format + +Group findings by severity: + +**Critical** (will cause runtime errors or incorrect behavior): +- Wrong endpoint URL or HTTP method +- Missing required params or wrong `required` flag +- Incorrect response field mapping (accessing wrong path in response) +- Missing error handling that would cause crashes +- Tool ID mismatch between tool file, registry, and block `tools.access` +- OAuth scopes missing in `auth.ts` that tools need +- `tools.config.tool` returning wrong tool ID for an operation +- Type coercions in `tools.config.tool` instead of `tools.config.params` + +**Warning** (follows conventions incorrectly or has usability issues): +- Optional field not set to `mode: 'advanced'` +- Missing `wandConfig` on timestamp/complex fields +- Wrong `visibility` on params (e.g., `'hidden'` instead of `'user-or-llm'`) +- Missing `optional: true` on nullable outputs +- Opaque `type: 'json'` without property descriptions +- Missing `.trim()` on ID fields in request URLs +- Missing `?? null` on nullable response fields +- Block condition array missing an operation that uses that field +- Missing scope description in `oauth-required-modal.tsx` + +**Suggestion** (minor improvements): +- Better description text +- Inconsistent naming across tools +- Missing `longDescription` or `docsLink` +- Pagination fields that could benefit from `wandConfig` + +### Fix All Issues + +After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity. + +### Validation Output + +After fixing, confirm: +1. `bun run lint` passes with no fixes needed +2. TypeScript compiles clean (no type errors) +3. Re-read all modified files to verify fixes are correct + +## Checklist Summary + +- [ ] Read ALL tool files, block, types, index, and registries +- [ ] Pulled and read official API documentation +- [ ] Validated every tool's ID, params, request, response, outputs, and types against API docs +- [ ] Validated block ↔ tool alignment (every tool param has a subBlock, every condition is correct) +- [ ] Validated advanced mode on optional/rarely-used fields +- [ ] Validated wandConfig on timestamps and complex inputs +- [ ] Validated tools.config mapping, tool selector, and type coercions +- [ ] Validated block outputs match what tools return, with typed JSON where possible +- [ ] Validated OAuth scopes alignment across auth.ts, oauth.ts, block, and modal (if OAuth) +- [ ] Validated pagination consistency across tools and block +- [ ] Validated error handling (error checks, meaningful messages) +- [ ] Validated registry entries (tools and block, alphabetical, correct imports) +- [ ] Reported all issues grouped by severity +- [ ] Fixed all critical and warning issues +- [ ] Ran `bun run lint` after fixes +- [ ] Verified TypeScript compiles clean diff --git a/.claude/rules/sim-testing.md b/.claude/rules/sim-testing.md index 1f17125a3e2..85a7554637b 100644 --- a/.claude/rules/sim-testing.md +++ b/.claude/rules/sim-testing.md @@ -8,51 +8,210 @@ paths: Use Vitest. Test files: `feature.ts` → `feature.test.ts` +## Global Mocks (vitest.setup.ts) + +These modules are mocked globally — do NOT re-mock them in test files unless you need to override behavior: + +- `@sim/db` → `databaseMock` +- `drizzle-orm` → `drizzleOrmMock` +- `@sim/logger` → `loggerMock` +- `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` +- `@/blocks/registry` +- `@trigger.dev/sdk` + ## Structure ```typescript /** * @vitest-environment node */ -import { databaseMock, loggerMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) -vi.mock('@sim/db', () => databaseMock) -vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) -import { myFunction } from '@/lib/feature' +import { GET, POST } from '@/app/api/my-route/route' -describe('myFunction', () => { - beforeEach(() => vi.clearAllMocks()) - it.concurrent('isolated tests run in parallel', () => { ... }) +describe('my route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + + it('returns data', async () => { + const req = createMockRequest('GET') + const res = await GET(req) + expect(res.status).toBe(200) + }) }) ``` -## @sim/testing Package +## Performance Rules (Critical) -Always prefer over local mocks. +### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()` -| Category | Utilities | -|----------|-----------| -| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` | -| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` | -| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | -| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +This is the #1 cause of slow tests. It forces complete module re-evaluation per test. + +```typescript +// BAD — forces module re-evaluation every test (~50-100ms each) +beforeEach(() => { + vi.resetModules() + vi.doMock('@/lib/auth', () => ({ getSession: vi.fn() })) +}) +it('test', async () => { + const { GET } = await import('./route') // slow dynamic import +}) + +// GOOD — module loaded once, mocks reconfigured per test (~1ms each) +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +import { GET } from '@/app/api/my-route/route' + +beforeEach(() => { vi.clearAllMocks() }) +it('test', () => { + mockGetSession.mockResolvedValue({ user: { id: '1' } }) +}) +``` + +**Only exception:** Singleton modules that cache state at module scope (e.g., Redis clients, connection pools). These genuinely need `vi.resetModules()` + dynamic import to get a fresh instance per test. + +### NEVER use `vi.importActual()` + +This defeats the purpose of mocking by loading the real module and all its dependencies. + +```typescript +// BAD — loads real module + all transitive deps +vi.mock('@/lib/workspaces/utils', async () => { + const actual = await vi.importActual('@/lib/workspaces/utils') + return { ...actual, myFn: vi.fn() } +}) + +// GOOD — mock everything, only implement what tests need +vi.mock('@/lib/workspaces/utils', () => ({ + myFn: vi.fn(), + otherFn: vi.fn(), +})) +``` + +### NEVER use `mockAuth()`, `mockConsoleLogger()`, or `setupCommonApiMocks()` from `@sim/testing` + +These helpers internally use `vi.doMock()` which is slow. Use direct `vi.hoisted()` + `vi.mock()` instead. + +### Mock heavy transitive dependencies + +If a module under test imports `@/blocks` (200+ files), `@/tools/registry`, or other heavy modules, mock them: + +```typescript +vi.mock('@/blocks', () => ({ + getBlock: () => null, + getAllBlocks: () => ({}), + getAllBlockTypes: () => [], + registry: {}, +})) +``` + +### Use `@vitest-environment node` unless DOM is needed + +Only use `@vitest-environment jsdom` if the test uses `window`, `document`, `FormData`, or other browser APIs. Node environment is significantly faster. + +### Avoid real timers in tests + +```typescript +// BAD +await new Promise(r => setTimeout(r, 500)) + +// GOOD — use minimal delays or fake timers +await new Promise(r => setTimeout(r, 1)) +// or +vi.useFakeTimers() +``` + +## Mock Pattern Reference + +### Auth mocking (API routes) + +```typescript +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +// In tests: +mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }) +mockGetSession.mockResolvedValue(null) // unauthenticated +``` + +### Hybrid auth mocking -## Rules +```typescript +const { mockCheckSessionOrInternalAuth } = vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), +})) -1. `@vitest-environment node` directive at file top -2. `vi.mock()` calls before importing mocked modules -3. `@sim/testing` utilities over local mocks -4. `it.concurrent` for isolated tests (no shared mutable state) -5. `beforeEach(() => vi.clearAllMocks())` to reset state +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) -## Hoisted Mocks +// In tests: +mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, userId: 'user-1', authType: 'session', +}) +``` -For mutable mock references: +### Database chain mocking ```typescript -const mockFn = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/module', () => ({ myFunction: mockFn })) -mockFn.mockResolvedValue({ data: 'test' }) +const { mockSelect, mockFrom, mockWhere } = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockSelect }, +})) + +beforeEach(() => { + mockSelect.mockReturnValue({ from: mockFrom }) + mockFrom.mockReturnValue({ where: mockWhere }) + mockWhere.mockResolvedValue([{ id: '1', name: 'test' }]) +}) ``` + +## @sim/testing Package + +Always prefer over local test data. + +| Category | Utilities | +|----------|-----------| +| **Mocks** | `loggerMock`, `databaseMock`, `drizzleOrmMock`, `setupGlobalFetchMock()` | +| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` | +| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | +| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +| **Requests** | `createMockRequest()`, `createEnvMock()` | + +## Rules Summary + +1. `@vitest-environment node` unless DOM is required +2. `vi.hoisted()` + `vi.mock()` + static imports — never `vi.resetModules()` + `vi.doMock()` + dynamic imports +3. `vi.mock()` calls before importing mocked modules +4. `@sim/testing` utilities over local mocks +5. `beforeEach(() => vi.clearAllMocks())` to reset state — no redundant `afterEach` +6. No `vi.importActual()` — mock everything explicitly +7. No `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` — use direct mocks +8. Mock heavy deps (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them +9. Use absolute imports in test files +10. Avoid real timers — use 1ms delays or `vi.useFakeTimers()` diff --git a/.cursor/rules/sim-testing.mdc b/.cursor/rules/sim-testing.mdc index 8bf0d74f107..ec140388e84 100644 --- a/.cursor/rules/sim-testing.mdc +++ b/.cursor/rules/sim-testing.mdc @@ -7,51 +7,210 @@ globs: ["apps/sim/**/*.test.ts", "apps/sim/**/*.test.tsx"] Use Vitest. Test files: `feature.ts` → `feature.test.ts` +## Global Mocks (vitest.setup.ts) + +These modules are mocked globally — do NOT re-mock them in test files unless you need to override behavior: + +- `@sim/db` → `databaseMock` +- `drizzle-orm` → `drizzleOrmMock` +- `@sim/logger` → `loggerMock` +- `@/stores/console/store`, `@/stores/terminal`, `@/stores/execution/store` +- `@/blocks/registry` +- `@trigger.dev/sdk` + ## Structure ```typescript /** * @vitest-environment node */ -import { databaseMock, loggerMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) -vi.mock('@sim/db', () => databaseMock) -vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) -import { myFunction } from '@/lib/feature' +import { GET, POST } from '@/app/api/my-route/route' -describe('myFunction', () => { - beforeEach(() => vi.clearAllMocks()) - it.concurrent('isolated tests run in parallel', () => { ... }) +describe('my route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + + it('returns data', async () => { + const req = createMockRequest('GET') + const res = await GET(req) + expect(res.status).toBe(200) + }) }) ``` -## @sim/testing Package +## Performance Rules (Critical) -Always prefer over local mocks. +### NEVER use `vi.resetModules()` + `vi.doMock()` + `await import()` -| Category | Utilities | -|----------|-----------| -| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` | -| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` | -| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | -| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +This is the #1 cause of slow tests. It forces complete module re-evaluation per test. + +```typescript +// BAD — forces module re-evaluation every test (~50-100ms each) +beforeEach(() => { + vi.resetModules() + vi.doMock('@/lib/auth', () => ({ getSession: vi.fn() })) +}) +it('test', async () => { + const { GET } = await import('./route') // slow dynamic import +}) + +// GOOD — module loaded once, mocks reconfigured per test (~1ms each) +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +import { GET } from '@/app/api/my-route/route' + +beforeEach(() => { vi.clearAllMocks() }) +it('test', () => { + mockGetSession.mockResolvedValue({ user: { id: '1' } }) +}) +``` + +**Only exception:** Singleton modules that cache state at module scope (e.g., Redis clients, connection pools). These genuinely need `vi.resetModules()` + dynamic import to get a fresh instance per test. + +### NEVER use `vi.importActual()` + +This defeats the purpose of mocking by loading the real module and all its dependencies. + +```typescript +// BAD — loads real module + all transitive deps +vi.mock('@/lib/workspaces/utils', async () => { + const actual = await vi.importActual('@/lib/workspaces/utils') + return { ...actual, myFn: vi.fn() } +}) + +// GOOD — mock everything, only implement what tests need +vi.mock('@/lib/workspaces/utils', () => ({ + myFn: vi.fn(), + otherFn: vi.fn(), +})) +``` + +### NEVER use `mockAuth()`, `mockConsoleLogger()`, or `setupCommonApiMocks()` from `@sim/testing` + +These helpers internally use `vi.doMock()` which is slow. Use direct `vi.hoisted()` + `vi.mock()` instead. + +### Mock heavy transitive dependencies + +If a module under test imports `@/blocks` (200+ files), `@/tools/registry`, or other heavy modules, mock them: + +```typescript +vi.mock('@/blocks', () => ({ + getBlock: () => null, + getAllBlocks: () => ({}), + getAllBlockTypes: () => [], + registry: {}, +})) +``` + +### Use `@vitest-environment node` unless DOM is needed + +Only use `@vitest-environment jsdom` if the test uses `window`, `document`, `FormData`, or other browser APIs. Node environment is significantly faster. + +### Avoid real timers in tests + +```typescript +// BAD +await new Promise(r => setTimeout(r, 500)) + +// GOOD — use minimal delays or fake timers +await new Promise(r => setTimeout(r, 1)) +// or +vi.useFakeTimers() +``` + +## Mock Pattern Reference + +### Auth mocking (API routes) + +```typescript +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) + +// In tests: +mockGetSession.mockResolvedValue({ user: { id: 'user-1', email: 'test@example.com' } }) +mockGetSession.mockResolvedValue(null) // unauthenticated +``` + +### Hybrid auth mocking -## Rules +```typescript +const { mockCheckSessionOrInternalAuth } = vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), +})) -1. `@vitest-environment node` directive at file top -2. `vi.mock()` calls before importing mocked modules -3. `@sim/testing` utilities over local mocks -4. `it.concurrent` for isolated tests (no shared mutable state) -5. `beforeEach(() => vi.clearAllMocks())` to reset state +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) -## Hoisted Mocks +// In tests: +mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, userId: 'user-1', authType: 'session', +}) +``` -For mutable mock references: +### Database chain mocking ```typescript -const mockFn = vi.hoisted(() => vi.fn()) -vi.mock('@/lib/module', () => ({ myFunction: mockFn })) -mockFn.mockResolvedValue({ data: 'test' }) +const { mockSelect, mockFrom, mockWhere } = vi.hoisted(() => ({ + mockSelect: vi.fn(), + mockFrom: vi.fn(), + mockWhere: vi.fn(), +})) + +vi.mock('@sim/db', () => ({ + db: { select: mockSelect }, +})) + +beforeEach(() => { + mockSelect.mockReturnValue({ from: mockFrom }) + mockFrom.mockReturnValue({ where: mockWhere }) + mockWhere.mockResolvedValue([{ id: '1', name: 'test' }]) +}) ``` + +## @sim/testing Package + +Always prefer over local test data. + +| Category | Utilities | +|----------|-----------| +| **Mocks** | `loggerMock`, `databaseMock`, `drizzleOrmMock`, `setupGlobalFetchMock()` | +| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutionContext()` | +| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` | +| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` | +| **Requests** | `createMockRequest()`, `createEnvMock()` | + +## Rules Summary + +1. `@vitest-environment node` unless DOM is required +2. `vi.hoisted()` + `vi.mock()` + static imports — never `vi.resetModules()` + `vi.doMock()` + dynamic imports +3. `vi.mock()` calls before importing mocked modules +4. `@sim/testing` utilities over local mocks +5. `beforeEach(() => vi.clearAllMocks())` to reset state — no redundant `afterEach` +6. No `vi.importActual()` — mock everything explicitly +7. No `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` — use direct mocks +8. Mock heavy deps (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them +9. Use absolute imports in test files +10. Avoid real timers — use 1ms delays or `vi.useFakeTimers()` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73e5e852fb6..e3a1bdb3b66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ on: concurrency: group: ci-${{ github.ref }} - cancel-in-progress: false + cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 10dd6f0b012..456472576fb 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -10,7 +10,7 @@ permissions: jobs: test-build: name: Test and Build - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: blacksmith-8vcpu-ubuntu-2404 steps: - name: Checkout code @@ -38,6 +38,20 @@ jobs: key: ${{ github.repository }}-node-modules path: ./node_modules + - name: Mount Turbo cache (Sticky Disk) + uses: useblacksmith/stickydisk@v1 + with: + key: ${{ github.repository }}-turbo-cache + path: ./.turbo + + - name: Restore Next.js build cache + uses: actions/cache@v4 + with: + path: ./apps/sim/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-nextjs- + - name: Install dependencies run: bun install --frozen-lockfile @@ -85,6 +99,7 @@ jobs: NEXT_PUBLIC_APP_URL: 'https://www.sim.ai' DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only + TURBO_CACHE_DIR: .turbo run: bun run test - name: Check schema and migrations are in sync @@ -110,6 +125,7 @@ jobs: RESEND_API_KEY: 'dummy_key_for_ci_only' AWS_REGION: 'us-west-2' ENCRYPTION_KEY: '7cf672e460e430c1fba707575c2b0e2ad5a99dddf9b7b7e3b5646e630861db1c' # dummy key for CI only + TURBO_CACHE_DIR: .turbo run: bunx turbo run build --filter=sim - name: Upload coverage to Codecov diff --git a/CLAUDE.md b/CLAUDE.md index edc351d71da..229fd25b559 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,27 +167,51 @@ Import from `@/components/emcn`, never from subpaths (except CSS files). Use CVA ## Testing -Use Vitest. Test files: `feature.ts` → `feature.test.ts` +Use Vitest. Test files: `feature.ts` → `feature.test.ts`. See `.cursor/rules/sim-testing.mdc` for full details. + +### Global Mocks (vitest.setup.ts) + +`@sim/db`, `drizzle-orm`, `@sim/logger`, `@/blocks/registry`, `@trigger.dev/sdk`, and store mocks are provided globally. Do NOT re-mock them unless overriding behavior. + +### Standard Test Pattern ```typescript /** * @vitest-environment node */ -import { databaseMock, loggerMock } from '@sim/testing' -import { describe, expect, it, vi } from 'vitest' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), +})) -vi.mock('@sim/db', () => databaseMock) -vi.mock('@sim/logger', () => loggerMock) +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) -import { myFunction } from '@/lib/feature' +import { GET } from '@/app/api/my-route/route' -describe('feature', () => { - beforeEach(() => vi.clearAllMocks()) - it.concurrent('runs in parallel', () => { ... }) +describe('my route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + }) + it('returns data', async () => { ... }) }) ``` -Use `@sim/testing` mocks/factories over local test data. See `.cursor/rules/sim-testing.mdc` for details. +### Performance Rules + +- **NEVER** use `vi.resetModules()` + `vi.doMock()` + `await import()` — use `vi.hoisted()` + `vi.mock()` + static imports +- **NEVER** use `vi.importActual()` — mock everything explicitly +- **NEVER** use `mockAuth()`, `mockConsoleLogger()`, `setupCommonApiMocks()` from `@sim/testing` — they use `vi.doMock()` internally +- **Mock heavy deps** (`@/blocks`, `@/tools/registry`, `@/triggers`) in tests that don't need them +- **Use `@vitest-environment node`** unless DOM APIs are needed (`window`, `document`, `FormData`) +- **Avoid real timers** — use 1ms delays or `vi.useFakeTimers()` + +Use `@sim/testing` mocks/factories over local test data. ## Utils Rules diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index 0a4afc98186..461baf2f549 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -1,5 +1,8 @@ import type React from 'react' +import type { Root } from 'fumadocs-core/page-tree' import { findNeighbour } from 'fumadocs-core/page-tree' +import type { ApiPageProps } from 'fumadocs-openapi/ui' +import { createAPIPage } from 'fumadocs-openapi/ui' import { Pre } from 'fumadocs-ui/components/codeblock' import defaultMdxComponents from 'fumadocs-ui/mdx' import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/page' @@ -12,28 +15,75 @@ import { LLMCopyButton } from '@/components/page-actions' import { StructuredData } from '@/components/structured-data' import { CodeBlock } from '@/components/ui/code-block' import { Heading } from '@/components/ui/heading' +import { ResponseSection } from '@/components/ui/response-section' +import { i18n } from '@/lib/i18n' +import { getApiSpecContent, openapi } from '@/lib/openapi' import { type PageData, source } from '@/lib/source' +const SUPPORTED_LANGUAGES: Set = new Set(i18n.languages) +const BASE_URL = 'https://docs.sim.ai' + +function resolveLangAndSlug(params: { slug?: string[]; lang: string }) { + const isValidLang = SUPPORTED_LANGUAGES.has(params.lang) + const lang = isValidLang ? params.lang : 'en' + const slug = isValidLang ? params.slug : [params.lang, ...(params.slug ?? [])] + return { lang, slug } +} + +const APIPage = createAPIPage(openapi, { + playground: { enabled: false }, + content: { + renderOperationLayout: async (slots) => { + return ( +
+
+ {slots.header} + {slots.apiPlayground} + {slots.authSchemes &&
{slots.authSchemes}
} + {slots.paremeters} + {slots.body &&
{slots.body}
} + {slots.responses} + {slots.callbacks} +
+
+ {slots.apiExample} +
+
+ ) + }, + }, +}) + export default async function Page(props: { params: Promise<{ slug?: string[]; lang: string }> }) { const params = await props.params - const page = source.getPage(params.slug, params.lang) + const { lang, slug } = resolveLangAndSlug(params) + const page = source.getPage(slug, lang) if (!page) notFound() - const data = page.data as PageData - const MDX = data.body - const baseUrl = 'https://docs.sim.ai' - const markdownContent = await data.getText('processed') + const data = page.data as unknown as PageData & { + _openapi?: { method?: string } + getAPIPageProps?: () => ApiPageProps + } + const isOpenAPI = '_openapi' in data && data._openapi != null + const isApiReference = slug?.some((s) => s === 'api-reference') ?? false - const pageTreeRecord = source.pageTree as Record - const pageTree = - pageTreeRecord[params.lang] ?? pageTreeRecord.en ?? Object.values(pageTreeRecord)[0] - const neighbours = pageTree ? findNeighbour(pageTree, page.url) : null + const pageTreeRecord = source.pageTree as Record + const pageTree = pageTreeRecord[lang] ?? pageTreeRecord.en ?? Object.values(pageTreeRecord)[0] + const rawNeighbours = pageTree ? findNeighbour(pageTree, page.url) : null + const neighbours = isApiReference + ? { + previous: rawNeighbours?.previous?.url.includes('/api-reference/') + ? rawNeighbours.previous + : undefined, + next: rawNeighbours?.next?.url.includes('/api-reference/') ? rawNeighbours.next : undefined, + } + : rawNeighbours const generateBreadcrumbs = () => { const breadcrumbs: Array<{ name: string; url: string }> = [ { name: 'Home', - url: baseUrl, + url: BASE_URL, }, ] @@ -41,7 +91,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l let currentPath = '' urlParts.forEach((part, index) => { - if (index === 0 && ['en', 'es', 'fr', 'de', 'ja', 'zh'].includes(part)) { + if (index === 0 && SUPPORTED_LANGUAGES.has(part)) { currentPath = `/${part}` return } @@ -56,12 +106,12 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l if (index === urlParts.length - 1) { breadcrumbs.push({ name: data.title, - url: `${baseUrl}${page.url}`, + url: `${BASE_URL}${page.url}`, }) } else { breadcrumbs.push({ name: name, - url: `${baseUrl}${currentPath}`, + url: `${BASE_URL}${currentPath}`, }) } }) @@ -73,7 +123,6 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l const CustomFooter = () => (
- {/* Navigation links */}
{neighbours?.previous ? ( - {/* Divider line */}
- {/* Social icons */}
) + if (isOpenAPI && data.getAPIPageProps) { + const apiProps = data.getAPIPageProps() + const apiPageContent = getApiSpecContent( + data.title, + data.description, + apiProps.operations ?? [] + ) + + return ( + <> + + , + }} + > +
+
+
+ +
+ +
+ {data.title} + {data.description} +
+ + + +
+ + ) + } + + const MDX = data.body + const markdownContent = await data.getText('processed') + return ( <> }) { const params = await props.params - const page = source.getPage(params.slug, params.lang) + const { lang, slug } = resolveLangAndSlug(params) + const page = source.getPage(slug, lang) if (!page) notFound() - const data = page.data as PageData - const baseUrl = 'https://docs.sim.ai' - const fullUrl = `${baseUrl}${page.url}` + const data = page.data as unknown as PageData + const fullUrl = `${BASE_URL}${page.url}` - const ogImageUrl = `${baseUrl}/api/og?title=${encodeURIComponent(data.title)}` + const ogImageUrl = `${BASE_URL}/api/og?title=${encodeURIComponent(data.title)}` return { title: data.title, @@ -286,10 +389,10 @@ export async function generateMetadata(props: { url: fullUrl, siteName: 'Sim Documentation', type: 'article', - locale: params.lang === 'en' ? 'en_US' : `${params.lang}_${params.lang.toUpperCase()}`, + locale: lang === 'en' ? 'en_US' : `${lang}_${lang.toUpperCase()}`, alternateLocale: ['en', 'es', 'fr', 'de', 'ja', 'zh'] - .filter((lang) => lang !== params.lang) - .map((lang) => (lang === 'en' ? 'en_US' : `${lang}_${lang.toUpperCase()}`)), + .filter((l) => l !== lang) + .map((l) => (l === 'en' ? 'en_US' : `${l}_${l.toUpperCase()}`)), images: [ { url: ogImageUrl, @@ -323,13 +426,13 @@ export async function generateMetadata(props: { alternates: { canonical: fullUrl, languages: { - 'x-default': `${baseUrl}${page.url.replace(`/${params.lang}`, '')}`, - en: `${baseUrl}${page.url.replace(`/${params.lang}`, '')}`, - es: `${baseUrl}/es${page.url.replace(`/${params.lang}`, '')}`, - fr: `${baseUrl}/fr${page.url.replace(`/${params.lang}`, '')}`, - de: `${baseUrl}/de${page.url.replace(`/${params.lang}`, '')}`, - ja: `${baseUrl}/ja${page.url.replace(`/${params.lang}`, '')}`, - zh: `${baseUrl}/zh${page.url.replace(`/${params.lang}`, '')}`, + 'x-default': `${BASE_URL}${page.url.replace(`/${lang}`, '')}`, + en: `${BASE_URL}${page.url.replace(`/${lang}`, '')}`, + es: `${BASE_URL}/es${page.url.replace(`/${lang}`, '')}`, + fr: `${BASE_URL}/fr${page.url.replace(`/${lang}`, '')}`, + de: `${BASE_URL}/de${page.url.replace(`/${lang}`, '')}`, + ja: `${BASE_URL}/ja${page.url.replace(`/${lang}`, '')}`, + zh: `${BASE_URL}/zh${page.url.replace(`/${lang}`, '')}`, }, }, } diff --git a/apps/docs/app/[lang]/layout.tsx b/apps/docs/app/[lang]/layout.tsx index d76a11f103a..250e249c7bb 100644 --- a/apps/docs/app/[lang]/layout.tsx +++ b/apps/docs/app/[lang]/layout.tsx @@ -55,8 +55,11 @@ type LayoutProps = { params: Promise<{ lang: string }> } +const SUPPORTED_LANGUAGES: Set = new Set(i18n.languages) + export default async function Layout({ children, params }: LayoutProps) { - const { lang } = await params + const { lang: rawLang } = await params + const lang = SUPPORTED_LANGUAGES.has(rawLang) ? rawLang : 'en' const structuredData = { '@context': 'https://schema.org', @@ -107,6 +110,7 @@ export default async function Layout({ children, params }: LayoutProps) { title: , }} sidebar={{ + tabs: false, defaultOpenLevel: 0, collapsible: false, footer: null, diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 70ec578bf95..120feee2567 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "fumadocs-ui/css/neutral.css"; @import "fumadocs-ui/css/preset.css"; +@import "fumadocs-openapi/css/preset.css"; /* Prevent overscroll bounce effect on the page */ html, @@ -8,18 +9,12 @@ body { overscroll-behavior: none; } -@theme { - --color-fd-primary: #33c482; /* Green from Sim logo */ - --font-geist-sans: var(--font-geist-sans); - --font-geist-mono: var(--font-geist-mono); +/* Reserve scrollbar space to prevent layout jitter between pages */ +html { + scrollbar-gutter: stable; } -/* Ensure primary color is set in both light and dark modes */ -:root { - --color-fd-primary: #33c482; -} - -.dark { +@theme { --color-fd-primary: #33c482; } @@ -34,12 +29,6 @@ body { "Liberation Mono", "Courier New", monospace; } -/* Target any potential border classes */ -* { - --fd-border-sidebar: transparent !important; -} - -/* Override any CSS custom properties for borders */ :root { --fd-border: transparent !important; --fd-border-sidebar: transparent !important; @@ -86,7 +75,6 @@ body { [data-sidebar-container], #nd-sidebar { background: transparent !important; - background-color: transparent !important; border: none !important; --color-fd-muted: transparent !important; --color-fd-card: transparent !important; @@ -96,9 +84,7 @@ body { aside[data-sidebar], aside#nd-sidebar { background: transparent !important; - background-color: transparent !important; border: none !important; - border-right: none !important; } /* Fumadocs v16: Add sidebar placeholder styling for grid area */ @@ -157,7 +143,6 @@ aside#nd-sidebar { #nd-sidebar > div { padding: 0.5rem 12px 12px; background: transparent !important; - background-color: transparent !important; } /* Override sidebar item styling to match Raindrop */ @@ -434,10 +419,6 @@ aside[data-sidebar], #nd-sidebar, #nd-sidebar * { border: none !important; - border-right: none !important; - border-left: none !important; - border-top: none !important; - border-bottom: none !important; } /* Override fumadocs background colors for sidebar */ @@ -447,7 +428,6 @@ aside[data-sidebar], --color-fd-muted: transparent !important; --color-fd-secondary: transparent !important; background: transparent !important; - background-color: transparent !important; } /* Force normal text flow in sidebar */ @@ -564,16 +544,682 @@ main[data-main] { padding-top: 1.5rem !important; } -/* Override Fumadocs default content padding */ -article[data-content], -div[data-content] { - padding-top: 1.5rem !important; -} - -/* Remove any unwanted borders/outlines from video elements */ +/* Remove any unwanted outlines from video elements */ video { outline: none !important; - border-style: solid !important; +} + +/* API Reference Pages — Mintlify-style overrides */ + +/* OpenAPI pages: span main + TOC grid columns for wide two-column layout. + The grid has columns: spacer | sidebar | main | toc | spacer. + By spanning columns 3-4, the article fills both main and toc areas, + while the grid structure stays identical to non-OpenAPI pages (no jitter). */ +#nd-page:has(.api-page-header) { + grid-column: 3 / span 2 !important; + max-width: 1400px !important; +} + +/* Hide the empty TOC aside on OpenAPI pages so it doesn't overlay content */ +#nd-docs-layout:has(#nd-page .api-page-header) #nd-toc { + display: none; +} + +/* Hide the default "Response Body" heading rendered by fumadocs-openapi */ +.response-section-wrapper > .response-section-content > h2, +.response-section-wrapper > .response-section-content > h3 { + display: none !important; +} + +/* Hide default accordion triggers (status code rows) — we show our own dropdown */ +.response-section-wrapper [data-orientation="vertical"] > [data-state] > h3 { + display: none !important; +} + +/* Ensure API reference pages use the same font as the rest of the docs */ +#nd-page:has(.api-page-header), +#nd-page:has(.api-page-header) h2, +#nd-page:has(.api-page-header) h3, +#nd-page:has(.api-page-header) h4, +#nd-page:has(.api-page-header) p, +#nd-page:has(.api-page-header) span, +#nd-page:has(.api-page-header) div, +#nd-page:has(.api-page-header) label, +#nd-page:has(.api-page-header) button { + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* Method badge pills in page content — colored background pills */ +#nd-page span.font-mono.font-medium[class*="text-green"] { + background-color: rgb(220 252 231 / 0.6); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; +} +html.dark #nd-page span.font-mono.font-medium[class*="text-green"] { + background-color: rgb(34 197 94 / 0.15); +} + +#nd-page span.font-mono.font-medium[class*="text-blue"] { + background-color: rgb(219 234 254 / 0.6); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; +} +html.dark #nd-page span.font-mono.font-medium[class*="text-blue"] { + background-color: rgb(59 130 246 / 0.15); +} + +#nd-page span.font-mono.font-medium[class*="text-orange"] { + background-color: rgb(255 237 213 / 0.6); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; +} +html.dark #nd-page span.font-mono.font-medium[class*="text-orange"] { + background-color: rgb(249 115 22 / 0.15); +} + +#nd-page span.font-mono.font-medium[class*="text-red"] { + background-color: rgb(254 226 226 / 0.6); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; +} +html.dark #nd-page span.font-mono.font-medium[class*="text-red"] { + background-color: rgb(239 68 68 / 0.15); +} + +/* Sidebar links with method badges — flex for vertical centering */ +#nd-sidebar a:has(span.font-mono.font-medium) { + display: flex !important; + align-items: center !important; + gap: 6px; +} + +/* Sidebar method badges — ensure proper inline flex display */ +#nd-sidebar a span.font-mono.font-medium { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2.25rem; + font-size: 10px !important; + line-height: 1 !important; + padding: 2.5px 4px; + border-radius: 3px; + flex-shrink: 0; +} + +/* Sidebar GET badges */ +#nd-sidebar a span.font-mono.font-medium[class*="text-green"] { + background-color: rgb(220 252 231 / 0.6); +} +html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-green"] { + background-color: rgb(34 197 94 / 0.15); +} + +/* Sidebar POST badges */ +#nd-sidebar a span.font-mono.font-medium[class*="text-blue"] { + background-color: rgb(219 234 254 / 0.6); +} +html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-blue"] { + background-color: rgb(59 130 246 / 0.15); +} + +/* Sidebar PUT badges */ +#nd-sidebar a span.font-mono.font-medium[class*="text-orange"] { + background-color: rgb(255 237 213 / 0.6); +} +html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-orange"] { + background-color: rgb(249 115 22 / 0.15); +} + +/* Sidebar DELETE badges */ +#nd-sidebar a span.font-mono.font-medium[class*="text-red"] { + background-color: rgb(254 226 226 / 0.6); +} +html.dark #nd-sidebar a span.font-mono.font-medium[class*="text-red"] { + background-color: rgb(239 68 68 / 0.15); +} + +/* Code block containers — match regular docs styling */ +#nd-page:has(.api-page-header) figure.shiki { + border-radius: 0.75rem !important; + background-color: var(--color-fd-card) !important; +} + +/* Hide "Filter Properties" search bar everywhere — main page and popovers */ +input[placeholder="Filter Properties"] { + display: none !important; +} +div:has(> input[placeholder="Filter Properties"]) { + display: none !important; +} +/* Remove top border on first visible property after hidden Filter Properties */ +div:has(> input[placeholder="Filter Properties"]) + .text-sm.border-t { + border-top: none !important; +} + +/* Hide "TypeScript Definitions" copy panel on API pages */ +#nd-page:has(.api-page-header) div.not-prose.rounded-xl.border.p-3.mb-4 { + display: none !important; +} +#nd-page:has(.api-page-header) div.not-prose.rounded-xl.border.p-3:has(> div > p.font-medium) { + display: none !important; +} + +/* Hide info tags (Format, Default, etc.) everywhere — main page and popovers */ +div.flex.flex-row.gap-2.flex-wrap.not-prose:has(> div.bg-fd-secondary) { + display: none !important; +} +div.flex.flex-row.items-start.bg-fd-secondary.border.rounded-lg.text-xs { + display: none !important; +} + +/* Method+path bar — cleaner, lighter styling like Gumloop. + Override bg-fd-card CSS variable directly for reliability. */ +#nd-page:has(.api-page-header) div.flex.flex-row.items-center.rounded-xl.border.not-prose { + --color-fd-card: rgb(249 250 251) !important; + background-color: rgb(249 250 251) !important; + border-color: rgb(229 231 235) !important; +} +html.dark + #nd-page:has(.api-page-header) + div.flex.flex-row.items-center.rounded-xl.border.not-prose { + --color-fd-card: rgb(24 24 27) !important; + background-color: rgb(24 24 27) !important; + border-color: rgb(63 63 70) !important; +} +/* Method badge inside path bar — cleaner sans-serif, softer colors */ +#nd-page:has(.api-page-header) + div.flex.flex-row.items-center.rounded-xl.border.not-prose + span.font-mono.font-medium { + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif !important; + font-weight: 600 !important; + font-size: 0.6875rem !important; + letter-spacing: 0.025em; + text-transform: uppercase; +} +/* POST — softer blue */ +#nd-page:has(.api-page-header) + div.flex.flex-row.items-center.rounded-xl.border.not-prose + span.font-mono.font-medium[class*="text-blue"] { + color: rgb(37 99 235) !important; + background-color: rgb(219 234 254 / 0.7) !important; +} +html.dark + #nd-page:has(.api-page-header) + div.flex.flex-row.items-center.rounded-xl.border.not-prose + span.font-mono.font-medium[class*="text-blue"] { + color: rgb(96 165 250) !important; + background-color: rgb(59 130 246 / 0.15) !important; +} +/* GET — softer green */ +#nd-page:has(.api-page-header) + div.flex.flex-row.items-center.rounded-xl.border.not-prose + span.font-mono.font-medium[class*="text-green"] { + color: rgb(22 163 74) !important; + background-color: rgb(220 252 231 / 0.7) !important; +} +html.dark + #nd-page:has(.api-page-header) + div.flex.flex-row.items-center.rounded-xl.border.not-prose + span.font-mono.font-medium[class*="text-green"] { + color: rgb(74 222 128) !important; + background-color: rgb(34 197 94 / 0.15) !important; +} + +/* Path text inside method+path bar — monospace, bright like Gumloop */ +#nd-page:has(.api-page-header) div.flex.flex-row.items-center.rounded-xl.border.not-prose code { + color: rgb(55 65 81) !important; + background: none !important; + border: none !important; + padding: 0 !important; + font-size: 0.8125rem !important; +} +html.dark + #nd-page:has(.api-page-header) + div.flex.flex-row.items-center.rounded-xl.border.not-prose + code { + color: rgb(229 231 235) !important; +} + +/* Inline code in API pages — neutral color instead of red. + Exclude code inside the method+path bar (handled above). */ +#nd-page:has(.api-page-header) .prose :not(pre) > code { + color: rgb(79 70 229) !important; +} +html.dark #nd-page:has(.api-page-header) .prose :not(pre) > code { + color: rgb(165 180 252) !important; +} + +/* Response Section — custom dropdown-based rendering (Mintlify style) */ + +/* Hide divider lines between accordion items */ +.response-section-wrapper [data-orientation="vertical"].divide-y > * { + border-top-width: 0 !important; + border-bottom-width: 0 !important; +} +.response-section-wrapper [data-orientation="vertical"].divide-y { + border-top: none !important; +} + +/* Remove content type labels inside accordion items (we show one in the header) */ +.response-section-wrapper [data-orientation="vertical"] p.not-prose:has(code.text-xs) { + display: none !important; +} + +/* Hide the top-level response description (e.g. "Execution was successfully cancelled.") + but NOT field descriptions inside Schema which also use prose-no-margin. + The response description is a direct child of AccordionContent (role=region) with mb-2. */ +.response-section-wrapper [data-orientation="vertical"] [role="region"] > .prose-no-margin.mb-2, +.response-section-wrapper + [data-orientation="vertical"] + [role="region"] + > div + > .prose-no-margin.mb-2 { + display: none !important; +} + +/* Remove left padding on accordion content so it aligns with Path Parameters */ +.response-section-wrapper [data-orientation="vertical"] [role="region"] { + padding-inline-start: 0 !important; +} + +/* Response section header */ +.response-section-header { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1.75rem; + margin-bottom: 0.5rem; +} + +.response-section-title { + font-size: 1.5rem; + font-weight: 600; + margin: 0; + color: var(--color-fd-foreground); + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, -apple-system, sans-serif; +} + +.response-section-meta { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; +} + +/* Status code dropdown */ +.response-section-dropdown-wrapper { + position: relative; +} + +.response-section-dropdown-trigger { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.25rem; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-fd-muted-foreground); + background: none; + border: none; + cursor: pointer; + border-radius: 0.25rem; + transition: color 0.15s; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} +.response-section-dropdown-trigger:hover { + color: var(--color-fd-foreground); +} + +.response-section-chevron { + width: 0.75rem; + height: 0.75rem; + transition: transform 0.15s; +} +.response-section-chevron-open { + transform: rotate(180deg); +} + +.response-section-dropdown-menu { + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + z-index: 50; + min-width: 5rem; + background-color: white; + border: 1px solid rgb(229 231 235); + border-radius: 0.5rem; + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1); + padding: 0.25rem; + overflow: hidden; +} +html.dark .response-section-dropdown-menu { + background-color: rgb(24 24 27); + border-color: rgb(63 63 70); + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3); +} + +.response-section-dropdown-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 0.375rem 0.5rem; + font-size: 0.875rem; + color: var(--color-fd-muted-foreground); + background: none; + border: none; + cursor: pointer; + border-radius: 0.25rem; + transition: + background-color 0.1s, + color 0.1s; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} +.response-section-dropdown-item:hover { + background-color: rgb(243 244 246); + color: var(--color-fd-foreground); +} +html.dark .response-section-dropdown-item:hover { + background-color: rgb(39 39 42); +} +.response-section-dropdown-item-selected { + color: var(--color-fd-foreground); +} + +.response-section-check { + width: 0.875rem; + height: 0.875rem; +} + +.response-section-content-type { + font-size: 0.875rem; + color: var(--color-fd-muted-foreground); + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} + +/* Response schema container — remove border to match Path Parameters style */ +.response-section-wrapper [data-orientation="vertical"] .border.px-3.py-2.rounded-lg { + border: none !important; + padding: 0 !important; + border-radius: 0 !important; + background-color: transparent; +} + +/* Property row — reorder: name (1) → type badge (2) → required badge (3) */ +#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose { + display: flex; + flex-wrap: wrap; + align-items: center; +} + +/* Name span — order 1 */ +#nd-page:has(.api-page-header) + .flex.flex-wrap.items-center.gap-3.not-prose + > span.font-medium.font-mono.text-fd-primary { + order: 1; +} + +/* Type badge — order 2, grey pill like Mintlify */ +#nd-page:has(.api-page-header) + .flex.flex-wrap.items-center.gap-3.not-prose + > span.text-sm.font-mono.text-fd-muted-foreground { + order: 2; + background-color: rgb(240 240 243); + color: rgb(100 100 110); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.6875rem; + line-height: 1.25rem; + font-weight: 500; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} +html.dark + #nd-page:has(.api-page-header) + .flex.flex-wrap.items-center.gap-3.not-prose + > span.text-sm.font-mono.text-fd-muted-foreground { + background-color: rgb(39 39 42); + color: rgb(212 212 216); +} + +/* Hide the "*" inside the name span — we'll add "required" as a ::after on the flex row */ +#nd-page:has(.api-page-header) span.font-medium.font-mono.text-fd-primary > span.text-red-400 { + display: none; +} + +/* Required badge — order 3, light red pill */ +#nd-page:has(.api-page-header) + .flex.flex-wrap.items-center.gap-3.not-prose:has(span.text-red-400)::after { + content: "required"; + order: 3; + display: inline-flex; + align-items: center; + background-color: rgb(254 235 235); + color: rgb(220 38 38); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.6875rem; + line-height: 1.25rem; + font-weight: 500; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} +html.dark + #nd-page:has(.api-page-header) + .flex.flex-wrap.items-center.gap-3.not-prose:has(span.text-red-400)::after { + background-color: rgb(127 29 29 / 0.2); + color: rgb(252 165 165); +} + +/* Optional "?" indicator — hide it */ +#nd-page:has(.api-page-header) + span.font-medium.font-mono.text-fd-primary + > span.text-fd-muted-foreground { + display: none; +} + +/* Hide the auth scheme type label (e.g. "apiKey") next to Authorization heading */ +#nd-page:has(.api-page-header) .flex.items-start.justify-between.gap-2 > div.not-prose { + display: none !important; +} + +/* Auth property — replace "" with "string" badge, add "header" and "required" badges. + Auth properties use my-4 (vs py-4 for regular properties). */ + +/* Auth property flex row — name: order 1, type: order 2, ::before "header": order 3, ::after "required": order 4 */ +#nd-page:has(.api-page-header) + div.my-4 + > .flex.flex-wrap.items-center.gap-3.not-prose + > span.font-medium.font-mono.text-fd-primary { + order: 1; +} +#nd-page:has(.api-page-header) + div.my-4 + > .flex.flex-wrap.items-center.gap-3.not-prose + > span.text-sm.font-mono.text-fd-muted-foreground { + order: 2; + font-size: 0; + padding: 0 !important; + background: none !important; + line-height: 0; +} +#nd-page:has(.api-page-header) + div.my-4 + > .flex.flex-wrap.items-center.gap-3.not-prose + > span.text-sm.font-mono.text-fd-muted-foreground::after { + content: "string"; + font-size: 0.6875rem; + line-height: 1.25rem; + font-weight: 500; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + background-color: rgb(240 240 243); + color: rgb(100 100 110); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + display: inline-flex; + align-items: center; +} +html.dark + #nd-page:has(.api-page-header) + div.my-4 + > .flex.flex-wrap.items-center.gap-3.not-prose + > span.text-sm.font-mono.text-fd-muted-foreground::after { + background-color: rgb(39 39 42); + color: rgb(212 212 216); +} + +/* "header" badge via ::before on the auth flex row */ +#nd-page:has(.api-page-header) div.my-4 > .flex.flex-wrap.items-center.gap-3.not-prose::before { + content: "header"; + order: 3; + display: inline-flex; + align-items: center; + background-color: rgb(240 240 243); + color: rgb(100 100 110); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.6875rem; + line-height: 1.25rem; + font-weight: 500; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} +html.dark + #nd-page:has(.api-page-header) + div.my-4 + > .flex.flex-wrap.items-center.gap-3.not-prose::before { + background-color: rgb(39 39 42); + color: rgb(212 212 216); +} + +/* "required" badge via ::after on the auth flex row — light red pill */ +#nd-page:has(.api-page-header) div.my-4 > .flex.flex-wrap.items-center.gap-3.not-prose::after { + content: "required"; + order: 4; + display: inline-flex; + align-items: center; + background-color: rgb(254 235 235); + color: rgb(220 38 38); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.6875rem; + line-height: 1.25rem; + font-weight: 500; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} +html.dark + #nd-page:has(.api-page-header) + div.my-4 + > .flex.flex-wrap.items-center.gap-3.not-prose::after { + background-color: rgb(127 29 29 / 0.2); + color: rgb(252 165 165); +} + +/* Hide "In: header" text below auth property — redundant with the header badge */ +#nd-page:has(.api-page-header) div.my-4 .prose-no-margin p:has(> code) { + display: none !important; +} + +/* Section dividers — bottom border after Authorization and Body sections. */ +.api-section-divider { + padding-bottom: 0.5rem; + border-bottom: 1px solid rgb(229 231 235 / 0.6); +} +html.dark .api-section-divider { + border-bottom-color: rgb(255 255 255 / 0.07); +} + +/* Property rows — breathing room like Mintlify. + Regular properties use border-t py-4; auth properties use border-t my-4. */ +#nd-page:has(.api-page-header) .text-sm.border-t.py-4 { + padding-top: 1.25rem !important; + padding-bottom: 1.25rem !important; +} +#nd-page:has(.api-page-header) .text-sm.border-t.my-4 { + margin-top: 1.25rem !important; + margin-bottom: 1.25rem !important; + padding-top: 1.25rem; +} + +/* Divider lines between fields — very subtle like Mintlify */ +#nd-page:has(.api-page-header) .text-sm.border-t { + border-color: rgb(229 231 235 / 0.6); +} +html.dark #nd-page:has(.api-page-header) .text-sm.border-t { + border-color: rgb(255 255 255 / 0.07); +} + +/* Body/Callback section "application/json" label — remove inline code styling */ +#nd-page:has(.api-page-header) .flex.gap-2.items-center.justify-between p.not-prose code.text-xs, +#nd-page:has(.api-page-header) .flex.justify-between.gap-2.items-end p.not-prose code.text-xs { + background: none !important; + border: none !important; + padding: 0 !important; + color: var(--color-fd-muted-foreground) !important; + font-size: 0.875rem !important; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif !important; +} + +/* Object/array type triggers in property rows — order 2 + badge chip styling */ +#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > button, +#nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > span:has(> button) { + order: 2; + background-color: rgb(240 240 243); + color: rgb(100 100 110); + padding: 0.125rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.6875rem; + line-height: 1.25rem; + font-weight: 500; + font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; +} +html.dark #nd-page:has(.api-page-header) .flex.flex-wrap.items-center.gap-3.not-prose > button, +html.dark + #nd-page:has(.api-page-header) + .flex.flex-wrap.items-center.gap-3.not-prose + > span:has(> button) { + background-color: rgb(39 39 42); + color: rgb(212 212 216); +} + +/* Section headings (Authorization, Path Parameters, etc.) — consistent top spacing */ +#nd-page:has(.api-page-header) .min-w-0.flex-1 h2 { + margin-top: 1.75rem !important; + margin-bottom: 0.25rem !important; +} + +/* Code examples in right column — wrap long lines instead of horizontal scroll */ +#nd-page:has(.api-page-header) pre { + white-space: pre-wrap !important; + word-break: break-all !important; +} +#nd-page:has(.api-page-header) pre code { + width: 100% !important; + word-break: break-all !important; + overflow-wrap: break-word !important; +} + +/* API page header — constrain title/copy-page to left content column, not full width. + Only applies on OpenAPI pages (which have the two-column layout). */ +@media (min-width: 1280px) { + .api-page-header { + max-width: calc(100% - 400px - 1.5rem); + } +} + +/* Footer navigation — constrain to left content column on OpenAPI pages only. + Target pages that contain the two-column layout via :has() selector. */ +#nd-page:has(.api-page-header) > div:last-child { + max-width: calc(100% - 400px - 1.5rem); +} +@media (max-width: 1024px) { + #nd-page:has(.api-page-header) > div:last-child { + max-width: 100%; + } } /* Tailwind v4 content sources */ diff --git a/apps/docs/app/layout.config.tsx b/apps/docs/app/layout.config.tsx deleted file mode 100644 index 1998c90b8cb..00000000000 --- a/apps/docs/app/layout.config.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared' - -/** - * Shared layout configurations - * - * you can customise layouts individually from: - * Home Layout: app/(home)/layout.tsx - * Docs Layout: app/docs/layout.tsx - */ -export const baseOptions: BaseLayoutProps = { - nav: { - title: ( - <> - - - - My App - - ), - }, -} diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx index e6fbe18cd11..7bd6039f8d0 100644 --- a/apps/docs/components/docs-layout/sidebar-components.tsx +++ b/apps/docs/components/docs-layout/sidebar-components.tsx @@ -52,15 +52,26 @@ export function SidebarItem({ item }: { item: Item }) { ) } +function isApiReferenceFolder(node: Folder): boolean { + if (node.index?.url.includes('/api-reference/')) return true + for (const child of node.children) { + if (child.type === 'page' && child.url.includes('/api-reference/')) return true + if (child.type === 'folder' && isApiReferenceFolder(child)) return true + } + return false +} + export function SidebarFolder({ item, children }: { item: Folder; children: ReactNode }) { const pathname = usePathname() const hasActiveChild = checkHasActiveChild(item, pathname) + const isApiRef = isApiReferenceFolder(item) + const isOnApiRefPage = stripLangPrefix(pathname).startsWith('/api-reference') const hasChildren = item.children.length > 0 - const [open, setOpen] = useState(hasActiveChild) + const [open, setOpen] = useState(hasActiveChild || (isApiRef && isOnApiRefPage)) useEffect(() => { - setOpen(hasActiveChild) - }, [hasActiveChild]) + setOpen(hasActiveChild || (isApiRef && isOnApiRefPage)) + }, [hasActiveChild, isApiRef, isOnApiRefPage]) const active = item.index ? isActive(item.index.url, pathname, false) : false @@ -157,16 +168,18 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac {hasChildren && (
- {/* Mobile: simple indent */} -
{children}
- {/* Desktop: styled with border */} -
    - {children} -
+
+ {/* Mobile: simple indent */} +
{children}
+ {/* Desktop: styled with border */} +
    + {children} +
+
)}
diff --git a/apps/docs/components/docs-layout/toc-footer.tsx b/apps/docs/components/docs-layout/toc-footer.tsx index 01eb5d81a17..3e59619e5fa 100644 --- a/apps/docs/components/docs-layout/toc-footer.tsx +++ b/apps/docs/components/docs-layout/toc-footer.tsx @@ -1,19 +1,16 @@ 'use client' -import { useState } from 'react' import { ArrowRight, ChevronRight } from 'lucide-react' import Link from 'next/link' export function TOCFooter() { - const [isHovered, setIsHovered] = useState(false) - return (
Start building today
-
Trusted by over 60,000 builders.
+
Trusted by over 70,000 builders.
Build Agentic workflows visually on a drag-and-drop canvas or with natural language.
@@ -21,18 +18,19 @@ export function TOCFooter() { href='https://sim.ai/signup' target='_blank' rel='noopener noreferrer' - onMouseEnter={() => setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} className='group mt-2 inline-flex h-8 w-fit items-center justify-center gap-1 whitespace-nowrap rounded-[10px] border border-[#2AAD6C] bg-gradient-to-b from-[#3ED990] to-[#2AAD6C] px-3 pr-[10px] pl-[12px] font-medium text-sm text-white shadow-[inset_0_2px_4px_0_#5EE8A8] outline-none transition-all hover:shadow-lg focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50' aria-label='Get started with Sim - Sign up for free' > Get started - - {isHovered ? ( -
diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 9e68974089e..6e8d1b46508 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -526,6 +526,17 @@ export function SlackMonoIcon(props: SVGProps) { ) } +export function GammaIcon(props: SVGProps) { + return ( + + + + ) +} + export function GithubIcon(props: SVGProps) { return ( @@ -1198,6 +1209,17 @@ export function AlgoliaIcon(props: SVGProps) { ) } +export function AmplitudeIcon(props: SVGProps) { + return ( + + + + ) +} + export function GoogleBooksIcon(props: SVGProps) { return ( @@ -1254,6 +1276,20 @@ export function GoogleSlidesIcon(props: SVGProps) { ) } +export function GoogleContactsIcon(props: SVGProps) { + return ( + + + + + + + ) +} + export function GoogleCalendarIcon(props: SVGProps) { return ( ) { export function LinkupIcon(props: SVGProps) { return ( - - - - + + ) } @@ -2428,6 +2462,17 @@ export function OutlookIcon(props: SVGProps) { ) } +export function PagerDutyIcon(props: SVGProps) { + return ( + + + + ) +} + export function MicrosoftExcelIcon(props: SVGProps) { const id = useId() const gradientId = `excel_gradient_${id}` @@ -2962,6 +3007,19 @@ export function QdrantIcon(props: SVGProps) { ) } +export function AshbyIcon(props: SVGProps) { + return ( + + + + ) +} + export function ArxivIcon(props: SVGProps) { return ( @@ -3956,6 +4014,28 @@ export function IntercomIcon(props: SVGProps) { ) } +export function LoopsIcon(props: SVGProps) { + return ( + + + + ) +} + +export function LumaIcon(props: SVGProps) { + return ( + + + + ) +} + export function MailchimpIcon(props: SVGProps) { return ( ) { ) } +export function DatabricksIcon(props: SVGProps) { + return ( + + + + ) +} + export function DatadogIcon(props: SVGProps) { return ( @@ -4705,6 +4796,22 @@ export function GoogleGroupsIcon(props: SVGProps) { ) } +export function GoogleMeetIcon(props: SVGProps) { + return ( + + + + + + + + + ) +} + export function CursorIcon(props: SVGProps) { return ( @@ -4713,6 +4820,19 @@ export function CursorIcon(props: SVGProps) { ) } +export function DubIcon(props: SVGProps) { + return ( + + + + ) +} + export function DuckDuckGoIcon(props: SVGProps) { return ( @@ -4825,6 +4945,17 @@ export function CirclebackIcon(props: SVGProps) { ) } +export function GreenhouseIcon(props: SVGProps) { + return ( + + + + ) +} + export function GreptileIcon(props: SVGProps) { return ( @@ -5496,6 +5627,35 @@ export function GoogleMapsIcon(props: SVGProps) { ) } +export function GooglePagespeedIcon(props: SVGProps) { + return ( + + + + + + + + + + ) +} + export function GoogleTranslateIcon(props: SVGProps) { return ( diff --git a/apps/docs/components/navbar/navbar.tsx b/apps/docs/components/navbar/navbar.tsx index db82c690624..231a0b334e4 100644 --- a/apps/docs/components/navbar/navbar.tsx +++ b/apps/docs/components/navbar/navbar.tsx @@ -1,12 +1,17 @@ 'use client' import Link from 'next/link' +import { usePathname } from 'next/navigation' import { LanguageDropdown } from '@/components/ui/language-dropdown' import { SearchTrigger } from '@/components/ui/search-trigger' import { SimLogoFull } from '@/components/ui/sim-logo' import { ThemeToggle } from '@/components/ui/theme-toggle' +import { cn } from '@/lib/utils' export function Navbar() { + const pathname = usePathname() + const isApiReference = pathname.includes('/api-reference') + return (
{/* Right cluster aligns with TOC edge */} -
+
+ + Documentation + + + API + Platform diff --git a/apps/docs/components/structured-data.tsx b/apps/docs/components/structured-data.tsx index c3aebd10d08..5875f3d7329 100644 --- a/apps/docs/components/structured-data.tsx +++ b/apps/docs/components/structured-data.tsx @@ -25,8 +25,8 @@ export function StructuredData({ headline: title, description: description, url: url, - datePublished: dateModified || new Date().toISOString(), - dateModified: dateModified || new Date().toISOString(), + ...(dateModified && { datePublished: dateModified }), + ...(dateModified && { dateModified }), author: { '@type': 'Organization', name: 'Sim Team', @@ -91,12 +91,6 @@ export function StructuredData({ inLanguage: ['en', 'es', 'fr', 'de', 'ja', 'zh'], } - const faqStructuredData = title.toLowerCase().includes('faq') && { - '@context': 'https://schema.org', - '@type': 'FAQPage', - mainEntity: [], - } - const softwareStructuredData = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', @@ -151,15 +145,6 @@ export function StructuredData({ }} /> )} - {faqStructuredData && ( -