From 84e1cef033a44ec011efa04c0bd44db10f1d8313 Mon Sep 17 00:00:00 2001 From: ben Date: Fri, 26 Jun 2026 17:34:11 +0200 Subject: [PATCH 1/4] feat(P074): consolidate React embed on the @simplepdf/embed core Rebuild @simplepdf/react-embed-pdf on the framework-free @simplepdf/embed core: grouped handle (actions / events / lifecycle), camelCase + agentic surface generated from embed-api.json, opt-in /ai-sdk. Non-breaking minor (react@1.11.0, embed@0.5.0). --- ...bed-company-identifier-snake-operations.md | 12 + .changeset/react-embed-pdf-2-core-rebuild.md | 23 + .changeset/rename-boxed-text-to-comb-text.md | 15 - .../rename-remove-fields-to-delete-fields.md | 21 - ...replace-create-field-with-detect-fields.md | 15 - .github/workflows/embed.yaml | 41 + .github/workflows/react.yaml | 17 +- README.md | 12 +- copilot/README.md | 22 +- copilot/package.json | 3 +- copilot/src/components/chat/chat_pane.tsx | 123 +- .../chat/hooks/use_detect_user_added_field.ts | 36 +- copilot/src/components/chat/tool_icons.tsx | 10 +- copilot/src/components/chat/toolbar.tsx | 2 +- copilot/src/components/editor_pane.tsx | 21 - copilot/src/lib/tools/definitions.test.ts | 24 +- copilot/src/lib/tools/definitions.ts | 9 +- copilot/src/lib/tools/middleware.ts | 2 +- copilot/src/locales/ar.json | 22 +- copilot/src/locales/cs.json | 22 +- copilot/src/locales/da.json | 22 +- copilot/src/locales/de.json | 22 +- copilot/src/locales/el.json | 22 +- copilot/src/locales/en.json | 22 +- copilot/src/locales/es.json | 22 +- copilot/src/locales/et.json | 22 +- copilot/src/locales/fi.json | 22 +- copilot/src/locales/fr.json | 22 +- copilot/src/locales/he.json | 22 +- copilot/src/locales/hi.json | 22 +- copilot/src/locales/it.json | 22 +- copilot/src/locales/nl.json | 22 +- copilot/src/locales/no.json | 22 +- copilot/src/locales/pl.json | 22 +- copilot/src/locales/pt.json | 22 +- copilot/src/locales/ro.json | 22 +- copilot/src/locales/sv.json | 22 +- copilot/src/locales/tr.json | 22 +- copilot/src/locales/uk.json | 22 +- copilot/src/locales/vi.json | 22 +- copilot/src/locales/zh.json | 22 +- copilot/src/routes/index.tsx | 140 +- copilot/src/server/tools.ts | 42 +- documentation/IFRAME.md | 201 +- embed/README.md | 133 +- embed/embed-api.json | 33 +- embed/package.json | 33 +- embed/scripts/check-bundle-size.mjs | 1 - embed/scripts/generate.mjs | 75 +- embed/src/ai-sdk.ts | 2 +- embed/src/bridge.ts | 114 +- embed/src/case-transform.ts | 54 + embed/src/generated/contract.ts | 56 +- embed/src/generated/drift.ts | 12 +- embed/src/generated/schemas.ts | 16 +- embed/src/generated/tools.ts | 26 +- embed/src/index.ts | 6 +- embed/src/mount.ts | 222 +- embed/src/protocol.ts | 4 +- embed/src/react.tsx | 236 -- embed/src/tools.ts | 67 +- embed/src/types.ts | 81 +- embed/test/bridge.test.ts | 193 +- embed/test/case-transform.test.ts | 56 + embed/test/mount.test.ts | 167 +- embed/test/tools.test.ts | 43 +- embed/tsup.config.ts | 15 +- package-lock.json | 56 +- react/README.md | 110 +- react/package-lock.json | 2059 ----------------- react/package.json | 26 +- react/rollup.config.js | 27 +- react/src/ai-sdk.test.tsx | 34 + react/src/ai-sdk.tsx | 58 + react/src/embed-pdf.test.tsx | 216 ++ react/src/embed-pdf.tsx | 407 ++++ react/src/hook.test.ts | 429 ---- react/src/hook.tsx | 185 -- react/src/index.test.tsx | 826 ------- react/src/index.tsx | 446 +--- react/src/not-mounted.ts | 14 + react/src/utils.test.ts | 208 -- react/src/utils.ts | 70 - react/tsconfig.json | 2 +- 84 files changed, 2466 insertions(+), 5649 deletions(-) create mode 100644 .changeset/embed-company-identifier-snake-operations.md create mode 100644 .changeset/react-embed-pdf-2-core-rebuild.md delete mode 100644 .changeset/rename-boxed-text-to-comb-text.md delete mode 100644 .changeset/rename-remove-fields-to-delete-fields.md delete mode 100644 .changeset/replace-create-field-with-detect-fields.md create mode 100644 .github/workflows/embed.yaml delete mode 100644 copilot/src/components/editor_pane.tsx create mode 100644 embed/src/case-transform.ts delete mode 100644 embed/src/react.tsx create mode 100644 embed/test/case-transform.test.ts delete mode 100644 react/package-lock.json create mode 100644 react/src/ai-sdk.test.tsx create mode 100644 react/src/ai-sdk.tsx create mode 100644 react/src/embed-pdf.test.tsx create mode 100644 react/src/embed-pdf.tsx delete mode 100644 react/src/hook.test.ts delete mode 100644 react/src/hook.tsx delete mode 100644 react/src/index.test.tsx create mode 100644 react/src/not-mounted.ts delete mode 100644 react/src/utils.test.ts delete mode 100644 react/src/utils.ts diff --git a/.changeset/embed-company-identifier-snake-operations.md b/.changeset/embed-company-identifier-snake-operations.md new file mode 100644 index 00000000..297d6ef5 --- /dev/null +++ b/.changeset/embed-company-identifier-snake-operations.md @@ -0,0 +1,12 @@ +--- +"@simplepdf/embed": minor +--- + +camelCase SDK surface grouped into `actions` / `events` / `lifecycle`, `companyIdentifier`, and direct loading of SimplePDF documents URLs. + +- **Grouped handle**: `createEmbed` returns `{ actions, events, lifecycle }` — `embed.actions.*` (operations), `embed.events.on(type, handler)` (subscriptions), `embed.lifecycle.dispose()` (teardown). +- **camelCase everywhere on the SDK**, with the snake_case wire kept behind a transform owned by the bridge: method names + their arguments + results + the agentic tool names/args are camelCase (`embed.actions.getFields()`, `embed.actions.setFieldValue({ fieldId, value })`, `embed.actions.submit({ downloadCopy })`, `tools.getDocumentContent`). The editor's snake_case wire is generated from `embed-api.json` and transformed at the postMessage boundary — consumers never see it. +- **Events are the deliberate exception**: `embed.events.on(type, handler)` delivers the editor's outbound payloads VERBATIM (snake_case fields, e.g. `document_id`) for `EDITOR_READY` / `DOCUMENT_LOADED` / `PAGE_FOCUSED` / `SUBMISSION_SENT`, so the React layer's `onEmbedEvent` stays 1.x-compatible. +- **`companyIdentifier`** replaces `tenant` in `createEmbed` (it is the consumer's own SimplePDF subdomain — `tenant` read as if SimplePDF were multi-tenant per consumer). +- **Documents URLs load directly**: when `document.url` is a `./documents/` URL (https, single tenant label), `createEmbed` navigates the iframe straight to it (carrying `?context=`) instead of host-fetching — so prefilled/stored documents open as themselves. +- The React layer moved OUT of this package into `@simplepdf/react-embed-pdf` (the `/react` subpath is removed); the editor iframe is granted `clipboard-read; clipboard-write` by default. diff --git a/.changeset/react-embed-pdf-2-core-rebuild.md b/.changeset/react-embed-pdf-2-core-rebuild.md new file mode 100644 index 00000000..72c645c9 --- /dev/null +++ b/.changeset/react-embed-pdf-2-core-rebuild.md @@ -0,0 +1,23 @@ +--- +"@simplepdf/react-embed-pdf": minor +--- + +Rebuilt on the `@simplepdf/embed` core, adding an AI-SDK-native agentic surface — a non-breaking superset of the 1.x component API. + +`@simplepdf/react-embed-pdf` no longer hand-rolls its own iframe bridge; it is a thin React layer over the shared `@simplepdf/embed` core (the same core `web-embed-pdf` and future framework adapters sit on). + +**The 1.x `` contract is preserved (drop-in):** the props (`companyIdentifier`, `documentURL`, `mode` — still defaulting to `"modal"`, `onEmbedEvent`, `locale`, `baseDomain`, `context`, `className`, `style`) and, crucially, `onEmbedEvent` still emits the editor's events VERBATIM: `{ type: 'EDITOR_READY' | 'DOCUMENT_LOADED' | 'PAGE_FOCUSED' | 'SUBMISSION_SENT', data }` with snake_case payloads. `useEmbed()` still returns `{ embedRef, actions }`. + +**New (additive):** + +- A new opt-in `@simplepdf/react-embed-pdf/ai-sdk` subpath exposes the agentic surface: `useEmbedTools(embedRef)` binds the tool registry to the live editor for the Vercel AI SDK (`useChat({ tools })`), plus `simplePDFToolDefinitions` (server) and `createSimplePDFExecutor`. It mirrors `@simplepdf/embed`'s `/ai-sdk`, so the package root stays zod-free. +- `useEmbed().actions` now exposes the FULL editor surface (camelCase): `createField`, `getFields`, `setFieldValue`, `focusField`, `movePage`, `rotatePage`, `deletePages`, `download`, … — not just the original six. +- A typed `document` prop (`{ url } | { dataUrl } | { file }`), the same shape as `createEmbed`. It also accepts data URLs and File/Blob, and a SimplePDF documents URL loads directly (prefill etc.). `documentURL` is now `@deprecated` (still works) in favor of it. +- An optional `logger` prop surfaces the bridge's structured lifecycle/error logging. +- The forwarded `ref` (`embedRef.current`) stays the flat 1.x actions handle — `embedRef.current.selectTool(...)`, etc. — now exposing the full camelCase action set. (The framework-free `@simplepdf/embed` core groups its handle as `embed.actions` / `embed.events` / `embed.lifecycle`; the React layer flattens it to keep the 1.x ref contract.) + +**Imperative actions stay backward-compatible.** `selectTool` and `submit` gained camelCase argument shapes to match the rest of the SDK (`selectTool({ tool })`, `submit({ downloadCopy })`), but the 1.x forms — `selectTool(toolType)` and `submit({ downloadCopyOnDevice })` — still work as deprecated overloads that normalize to the new shape, so existing `useEmbed().actions` callers don't change. A relative `documentURL` / trigger `href` (e.g. `/form.pdf`) is still accepted — it is resolved against the page URL, as in 1.x. + +One behavioral note: calling an action before `` has mounted now resolves to `{ success: false, error: { code: 'unexpected:iframe_not_mounted' } }` (1.x used `bad_request:embed_ref_not_available`). Code that checks `result.success` is unaffected; only code branching on the exact pre-mount error string needs updating. + +Packaging is preserved: still dual CJS + ESM, so `require()` consumers keep working. `zod` remains a peer dependency, now required **only** by the agentic `/ai-sdk` subpath (it validates tool input) — the package root (``, `useEmbed`) is zod-free, so a non-agentic app never loads it. Install `zod` only if you import `/ai-sdk`; npm 7+ adds it automatically, pnpm / Yarn PnP users add it explicitly. diff --git a/.changeset/rename-boxed-text-to-comb-text.md b/.changeset/rename-boxed-text-to-comb-text.md deleted file mode 100644 index 5123708d..00000000 --- a/.changeset/rename-boxed-text-to-comb-text.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@simplepdf/react-embed-pdf": major ---- - -Renames the `BOXED_TEXT` tool type to `COMB_TEXT`. "Comb" is the Acrobat / PDF-spec term for box-per-character fields (IBAN, dates, CERFA), so `actions.selectTool(...)` and the `SELECT_TOOL` iframe event now use `COMB_TEXT`, and the `ToolType` union exposes `COMB_TEXT` instead of `BOXED_TEXT`. - -Already-deployed embeds keep working without a code change: the editor still accepts the legacy `BOXED_TEXT` value at runtime, so this only changes the TypeScript type. If you are not calling `actions.selectTool('BOXED_TEXT')` or `sendEvent("SELECT_TOOL", { tool: "BOXED_TEXT" })`, you can safely update to this new major version. - -```ts -// Before -await actions.selectTool('BOXED_TEXT'); - -// After -await actions.selectTool('COMB_TEXT'); -``` diff --git a/.changeset/rename-remove-fields-to-delete-fields.md b/.changeset/rename-remove-fields-to-delete-fields.md deleted file mode 100644 index 72909e03..00000000 --- a/.changeset/rename-remove-fields-to-delete-fields.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@simplepdf/react-embed-pdf": major ---- - -Renames `actions.removeFields` to `actions.deleteFields` and the corresponding iframe event from `REMOVE_FIELDS` to `DELETE_FIELDS`. The result payload field is renamed from `removed_count` to `deleted_count`. Aligns naming with the new `DELETE_PAGES` event so all destructive operations use `delete_*` consistently. - -If you are not using `actions.removeFields(...)` or `sendEvent("REMOVE_FIELDS", ...)`, you can safely update to this new major version. - -```ts -// Before -const result = await actions.removeFields({ page: 1 }); -if (result.success) { - console.log(result.data.removed_count); -} - -// After -const result = await actions.deleteFields({ page: 1 }); -if (result.success) { - console.log(result.data.deleted_count); -} -``` diff --git a/.changeset/replace-create-field-with-detect-fields.md b/.changeset/replace-create-field-with-detect-fields.md deleted file mode 100644 index 5b69de98..00000000 --- a/.changeset/replace-create-field-with-detect-fields.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@simplepdf/react-embed-pdf": major ---- - -Replaces `createField` with `detectFields` for automatic form field detection. This is a breaking change: the `createField` action and `CreateFieldOptions` type have been removed. - -If you are not using `actions.createField(...)` or `sendEvent("CREATE_FIELD", ...)`, you can safely update to this new major version. - -```ts -// Before (removed) -await actions.createField({ type: "TEXT", page: 1, x: 100, y: 700, width: 200, height: 30 }); - -// After -await actions.detectFields(); -``` diff --git a/.github/workflows/embed.yaml b/.github/workflows/embed.yaml new file mode 100644 index 00000000..7a952d3e --- /dev/null +++ b/.github/workflows/embed.yaml @@ -0,0 +1,41 @@ +name: Embed + +on: + push: + branches: + - main + paths: + - embed/** + - .github/workflows/embed.yaml + pull_request: + branches: + - main + paths: + - embed/** + - .github/workflows/embed.yaml + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + # @simplepdf/embed is a workspace package, so install from the root. + - name: Install dependencies + run: npm ci + + - name: Types + run: npm run --workspace @simplepdf/embed test:types + + - name: Tests + run: npm run --workspace @simplepdf/embed test + + - name: Bundle size budgets + run: npm run --workspace @simplepdf/embed check:size diff --git a/.github/workflows/react.yaml b/.github/workflows/react.yaml index 2d75438c..dc38c956 100644 --- a/.github/workflows/react.yaml +++ b/.github/workflows/react.yaml @@ -6,20 +6,19 @@ on: - main paths: - react/** + - embed/** - .github/workflows/react.yaml pull_request: branches: - main paths: - react/** + - embed/** - .github/workflows/react.yaml jobs: test: runs-on: ubuntu-latest - defaults: - run: - working-directory: react steps: - name: Checkout code @@ -30,14 +29,20 @@ jobs: with: node-version: "22" + # react-embed-pdf depends on the @simplepdf/embed workspace package, so install + # from the workspace ROOT (the internal dep resolves via the workspace, not npm) + # rather than a per-package `npm ci`, and build the core before type-checking. - name: Install dependencies run: npm ci + - name: Build the core + run: npm run --workspace @simplepdf/embed build + - name: Formatting - run: npm run test:format + run: npm run --workspace @simplepdf/react-embed-pdf test:format - name: Types - run: npm run test:types + run: npm run --workspace @simplepdf/react-embed-pdf test:types - name: Tests - run: npm test + run: npm run --workspace @simplepdf/react-embed-pdf test diff --git a/README.md b/README.md index 2eaa26e9..8497354a 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ https://github.com/SimplePDF/simplepdf-embed/assets/10613140/8924f018-6076-4e44- # Get started -- 🧩 [Typed bridge](./embed/README.md) - `@simplepdf/embed` (typed client + React hook + AI SDK adapter, generated from the editor manifest) +- 🧩 [Typed bridge](./embed/README.md) - `@simplepdf/embed` (framework-free typed client + AI SDK adapter, generated from the editor manifest; the React layer is `@simplepdf/react-embed-pdf`) - ⚛️ [React component](./react/README.md) - `@simplepdf/react-embed-pdf` - 🚀 [Script tag](./web/README.md) - `@simplepdf/web-embed-pdf` - 🛠 [Iframe API](./documentation/IFRAME.md) - `postMessage` events @@ -146,7 +146,7 @@ With a [Pro plan](https://simplepdf.com/pricing), you can: ```jsx // React - branding configured in your dashboard settings - + ``` @@ -252,7 +252,7 @@ Use `getDocumentContent()` to extract text from the PDF. See the [React](./react ### Downloading the modified PDF -Use `submit({ downloadCopyOnDevice: true })` to trigger a browser download of the modified PDF. +Use `submit({ downloadCopy: true })` to trigger a browser download of the modified PDF. ### Server-side PDF generation & storage @@ -260,8 +260,8 @@ SimplePDF handles PDF generation and storage so you don't have to. When users su | Method | How it works | Use case | | ------------------------------------------- | ----------------------------- | ------------------------------------ | -| `submit` with `downloadCopyOnDevice: true` | Browser downloads the PDF | End-user saves their work | -| `submit` with `downloadCopyOnDevice: false` | PDF sent to SimplePDF servers | Server-side collection via webhooks | +| `submit` with `downloadCopy: true` | Browser downloads the PDF | End-user saves their work | +| `submit` with `downloadCopy: false` | PDF sent to SimplePDF servers | Server-side collection via webhooks | | S3/Azure/SharePoint integration | PDF stored in your storage | Programmatic access via your storage | **Available integrations:** @@ -349,7 +349,7 @@ Yes. Use viewer mode to display PDFs without any editing capabilities. ``` diff --git a/copilot/README.md b/copilot/README.md index 809b1dad..14a58fbe 100644 --- a/copilot/README.md +++ b/copilot/README.md @@ -144,7 +144,7 @@ See the privacy notes above for the per-route audio-egress disclosure. ### Load a specific document via `?url=` -To open a specific document instead of the bundled demo forms, append `?url=`. The value is used verbatim as the editor iframe `src`, so pass a valid SimplePDF document URL (e.g. a `/documents/` link, optionally with a `?prefill=`): +To open a specific document instead of the bundled demo forms, append `?url=`. The value is passed as the `document` to ``; a SimplePDF documents URL (a `/documents/` link, optionally with a `?prefill=`) is navigated to directly by the embed core, so pass a valid one: ``` http://localhost:3001/?url=https%3A%2F%2Fdemo.simplepdf.com%2Fdocuments%2Fc28f061b-1974-4251-ba7a-d08bedc3ef28%3Fprefill%3D35fdf39e-2e06-4712-bb9d-f62d2f88ce50 @@ -212,16 +212,16 @@ The chat sidebar advertises these tools to the model. Each runs inside the ifram | Tool | Purpose | |------|---------| -| `get_fields` | List form fields currently on the document | -| `get_document_content` | Extract text content per page | -| `detect_fields` | Auto-detect missing fields on scanned PDFs | -| `focus_field` | Highlight + scroll to a field | -| `set_field_value` | Write a value into a field | -| `select_tool` | Switch the editor toolbar (`TEXT`, `COMB_TEXT`, `CHECKBOX`, `SIGNATURE`, `PICTURE`) | -| `go_to` | Navigate to a specific page (1-indexed) | -| `move_page` | Reorder a visible page (`from_page` → `to_page`, both 1-indexed). Destructive — only fired on explicit user request | -| `delete_page` | Remove a visible page and its fields (last remaining page can't be deleted). Destructive — only fired on explicit user request | -| `rotate_page` | Rotate a visible page 90° clockwise per call. Destructive — only fired on explicit user request | +| `getFields` | List form fields currently on the document | +| `getDocumentContent` | Extract text content per page | +| `detectFields` | Auto-detect missing fields on scanned PDFs | +| `focusField` | Highlight + scroll to a field | +| `setFieldValue` | Write a value into a field | +| `selectTool` | Switch the editor toolbar (`TEXT`, `COMB_TEXT`, `CHECKBOX`, `SIGNATURE`, `PICTURE`) | +| `goTo` | Navigate to a specific page (1-indexed) | +| `movePage` | Reorder a visible page (`fromPage` → `toPage`, both 1-indexed). Destructive — only fired on explicit user request | +| `deletePages` | Remove visible pages and their fields (last remaining page can't be deleted). Destructive — only fired on explicit user request | +| `rotatePage` | Rotate a visible page 90° clockwise per call. Destructive — only fired on explicit user request | | `submit` (Pro mode) / `download` (demo mode) | Finalize: real iframe `SUBMIT` on a Pro fork (lands in BYOS + webhooks) vs. an in-browser `DOWNLOAD` on the hosted demo | Tool input + output schemas + the bridge that posts these events into the iframe live in the [`@simplepdf/embed`](../embed) package (generated from the editor contract); copilot's tool catalogue + middleware live in `src/lib/tools/` (`definitions.ts`, `middleware.ts`). System prompt: `src/server/tools.ts`. Public iframe contract these tools exercise: [`documentation/IFRAME.md`](../documentation/IFRAME.md). diff --git a/copilot/package.json b/copilot/package.json index fd2fb749..ccb02bb1 100644 --- a/copilot/package.json +++ b/copilot/package.json @@ -27,7 +27,8 @@ "@ai-sdk/openai": "^3.0.53", "@ai-sdk/react": "^3.0.170", "@aws-sdk/client-s3": "^3.1034.0", - "@simplepdf/embed": "^0.4.0", + "@simplepdf/embed": "0.5.0", + "@simplepdf/react-embed-pdf": "1.11.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-devtools": "latest", "@tanstack/react-router": "latest", diff --git a/copilot/src/components/chat/chat_pane.tsx b/copilot/src/components/chat/chat_pane.tsx index 67745d09..a43e7c7c 100644 --- a/copilot/src/components/chat/chat_pane.tsx +++ b/copilot/src/components/chat/chat_pane.tsx @@ -1,14 +1,13 @@ import { useChat } from '@ai-sdk/react' -import type { - BridgeResult, - DocumentContentPage, - DocumentContentResult, - Embed, - FieldRecord, -} from '@simplepdf/embed' -import { createSimplePDFExecutor } from '@simplepdf/embed/ai-sdk' -import { OVERLAY_TOOL_TYPES } from '@simplepdf/embed/protocol' -import type { SimplePDFToolName } from '@simplepdf/embed/tools' +import { + OVERLAY_TOOL_TYPES, + type BridgeResult, + type DocumentContentPage, + type DocumentContentResult, + type EmbedActions, + type FieldRecord, +} from '@simplepdf/react-embed-pdf' +import { type EmbedTools, type SimplePDFToolName } from '@simplepdf/react-embed-pdf/ai-sdk' import { getRouteApi } from '@tanstack/react-router' import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls, type UIMessage } from 'ai' import { ArrowUp, Mic, X } from 'lucide-react' @@ -94,7 +93,13 @@ const joinVoiceDraft = (prefix: string, transcript: string): string => const homeRoute = getRouteApi('/') type ChatPaneProps = { - bridge: Embed | null + // The agentic tools (for the LLM) + imperative actions (for direct calls), both + // bound to the live editor by useEmbed. resetKey identifies the editor instance + // (it changes on a form / locale / url remount) so the field-detection poll knows + // when the document context changed. + tools: EmbedTools + actions: EmbedActions + resetKey: string isReady: boolean requiresUserUpload: boolean language: string @@ -266,11 +271,11 @@ const wrapToolResult = (result: BridgeResult): unknown => { const createCompactionMiddleware = ({ getByokActive }: { getByokActive: () => boolean }): ToolMiddleware => { const compactFields = (fields: FieldRecord[]): CompactedField[] => fields.map((field) => { - const base: CompactedField = { id: field.field_id, type: field.type, page: field.page } + const base: CompactedField = { id: field.fieldId, type: field.type, page: field.page } if (field.value !== null && field.value !== '') { base.value = field.value } - if (field.name !== null && field.name !== '' && field.name !== field.field_id) { + if (field.name !== null && field.name !== '' && field.name !== field.fieldId) { base.name = field.name } return base @@ -296,10 +301,10 @@ const createCompactionMiddleware = ({ getByokActive }: { getByokActive: () => bo if (!result.success) { return result } - if (toolName === 'get_fields' && hasFieldsShape(result.data)) { + if (toolName === 'getFields' && hasFieldsShape(result.data)) { return { success: true, data: { fields: compactFields(result.data.fields) } } } - if (toolName === 'get_document_content' && hasDocumentContentShape(result.data)) { + if (toolName === 'getDocumentContent' && hasDocumentContentShape(result.data)) { const name = result.data.name === '' ? null : result.data.name const pages = getByokActive() ? result.data.pages : truncatePages(result.data.pages) const compacted: CompactedDocumentContent = { name, pages } @@ -353,7 +358,7 @@ const createToolbarSyncMiddleware = ({ onChange }: { onChange: (tool: ToolbarTool) => void }): ToolMiddleware => async ({ toolName, input }, next) => { const result = await next() - if (toolName === 'select_tool' && result.success) { + if (toolName === 'selectTool' && result.success) { const nextTool = isToolbarTool(input.tool) ? input.tool : null onChange(nextTool) } @@ -403,7 +408,9 @@ const resolveActiveModelLabel = ({ } export const ChatPane = ({ - bridge, + tools, + actions, + resetKey, isReady, requiresUserUpload, language, @@ -420,8 +427,6 @@ export const ChatPane = ({ const draftRef = useRef(draft) draftRef.current = draft const [toolbarTool, setToolbarTool] = useState(null) - const bridgeRef = useRef(bridge) - bridgeRef.current = bridge const languageRef = useRef(language) languageRef.current = language // Scroll stickiness matches vercel/ai-chatbot: the hook keeps the view @@ -681,20 +686,12 @@ export const ChatPane = ({ const downloadCountRef = useRef(0) const fireDownload = useCallback((): void => { - const activeBridge = bridgeRef.current - if (activeBridge === null) { - return - } - void activeBridge.download() - }, []) + void actions.download() + }, [actions]) const fireSubmit = useCallback((): void => { - const activeBridge = bridgeRef.current - if (activeBridge === null) { - return - } - void activeBridge.submit({ download_copy: false }) - }, []) + void actions.submit({ downloadCopy: false }) + }, [actions]) const handleDownloadRequested = useCallback((): void => { downloadCountRef.current += 1 @@ -769,7 +766,8 @@ export const ChatPane = ({ const isStreamingRef = useRef(false) const onFieldAddedRef = useRef<(event: FieldAddedEvent) => void>(() => {}) useDetectUserAddedField({ - bridge, + actions, + resetKey, isReady, toolbarTool, isCursorOverEditor, @@ -777,23 +775,23 @@ export const ChatPane = ({ onFieldAddedRef, }) - // Build the tool executor (package executor wrapped in copilot middleware) per - // bridge instance. The bridge itself comes from useIframeBridge and swaps on - // form / locale reset; everything the middleware needs is read via closures over - // stable refs or callbacks, - // so one factory call per bridge lifetime is enough. - const tools = useMemo((): ((context: MiddlewareContext) => Promise>) | null => { - if (bridge === null) { - return null - } - const rawExecutor = createSimplePDFExecutor({ embed: bridge }) - // Force download_copy:false on submit: copilot never has the agent also + // The tool executor: copilot middleware wrapped around the agentic tools bound to + // the live editor by useEmbed. The tools read the current editor through a stable + // ref, so this composes once and stays valid across editor remounts. + const runToolCall = useMemo((): ((context: MiddlewareContext) => Promise>) => { + // Force downloadCopy:false on submit: copilot never has the agent also // download a copy (preserves pre-migration behavior — the model would // otherwise control this flag). - const executor = (toolName: SimplePDFToolName, input: ToolInput): Promise> => - toolName === 'submit' - ? rawExecutor(toolName, { ...input, download_copy: false }) - : rawExecutor(toolName, input) + const executor = (toolName: SimplePDFToolName, input: ToolInput): Promise> => { + const tool = tools[toolName] + if (tool === undefined) { + return Promise.resolve({ + success: false, + error: { code: 'bad_request:invalid_input', message: `Unknown tool: ${toolName}` }, + }) + } + return toolName === 'submit' ? tool.execute({ ...input, downloadCopy: false }) : tool.execute(input) + } const sharedMiddleware: ToolMiddleware[] = [ createToolbarSyncMiddleware({ onChange: setToolbarTool }), createCompactionMiddleware({ getByokActive: () => byokConfigRef.current !== null }), @@ -806,7 +804,7 @@ export const ChatPane = ({ ? [createDemoDownloadMiddleware({ onRequestDownload: handleDownloadRequested }), ...sharedMiddleware] : sharedMiddleware return composeMiddleware(middleware, ({ toolName, input }) => executor(toolName, input)) - }, [bridge, handleDownloadRequested]) + }, [tools, handleDownloadRequested]) const { messages, status, error, sendMessage, stop, addToolOutput, setMessages } = useChat({ transport, @@ -832,24 +830,12 @@ export const ChatPane = ({ ) return } - const activeTools = tools - if (activeTools === null) { - await Promise.resolve( - addToolOutput({ - tool: toolName, - toolCallId: toolCall.toolCallId, - state: 'output-error', - errorText: 'Iframe bridge is not ready yet', - }), - ) - return - } const startedAt = performance.now() const callInput = toToolInput(toolCall.input) monitoring.info('chat.tool_call', { tool_name: toolName, input: callInput }) const result = await (async (): Promise> => { try { - return await activeTools({ toolName, input: callInput }) + return await runToolCall({ toolName, input: callInput }) } catch (error) { return toUnexpectedToolResult(error) } @@ -919,14 +905,13 @@ export const ChatPane = ({ } }, [status]) - const handleToolbarSelect = useCallback((tool: ToolbarTool): void => { - setToolbarTool(tool) - const activeBridge = bridgeRef.current - if (activeBridge === null) { - return - } - void activeBridge.selectTool({ tool }) - }, []) + const handleToolbarSelect = useCallback( + (tool: ToolbarTool): void => { + setToolbarTool(tool) + void actions.selectTool({ tool }) + }, + [actions], + ) // Download button styling is driven by message count: quiet (white) until // the conversation has at least 5 messages, then brand-blue to signal diff --git a/copilot/src/components/chat/hooks/use_detect_user_added_field.ts b/copilot/src/components/chat/hooks/use_detect_user_added_field.ts index 3ce47c3c..fdd58d3f 100644 --- a/copilot/src/components/chat/hooks/use_detect_user_added_field.ts +++ b/copilot/src/components/chat/hooks/use_detect_user_added_field.ts @@ -1,4 +1,4 @@ -import type { FieldType, IframeBridge, OverlayToolType } from '@simplepdf/embed' +import type { EmbedActions, FieldType, OverlayToolType } from '@simplepdf/react-embed-pdf' import { type MutableRefObject, useEffect, useRef } from 'react' // WORKAROUND: the SimplePDF editor does not currently emit an outbound @@ -9,11 +9,11 @@ import { type MutableRefObject, useEffect, useRef } from 'react' // deleted in one place the day the editor ships a real outbound event. // // The polling LOOP is gated aggressively to minimise iframe round-trips: -// - bridge ready AND isReady AND toolbarTool is a placement tool AND the -// user's cursor is over the editor iframe. +// - isReady (a document is loaded) AND toolbarTool is a placement tool AND +// the user's cursor is over the editor. // When any of those flip off, the loop pauses; when they flip back on, // it resumes. The SET OF SEEN FIELD IDS persists across gate flips — -// only a bridge change resets it. This is what gives the post-LLM-turn +// only a resetKey change resets it. This is what gives the post-LLM-turn // reconciliation for free: while the LLM is streaming, the user's cursor // often moves to the chat panel and back. Without persistence, each // cursor re-entry would re-seed the seen set with the (now-larger) field @@ -42,7 +42,10 @@ const POLL_INTERVAL_MS = 200 export type FieldAddedEvent = { tools: FieldType[]; delta: number } type UseDetectUserAddedFieldArgs = { - bridge: IframeBridge | null + actions: EmbedActions + // Identifies the editor instance; a change means a new document context, which + // invalidates the seen-id set (the ids belonged to the previous document). + resetKey: string isReady: boolean toolbarTool: OverlayToolType | null isCursorOverEditor: boolean @@ -51,7 +54,8 @@ type UseDetectUserAddedFieldArgs = { } export const useDetectUserAddedField = ({ - bridge, + actions, + resetKey, isReady, toolbarTool, isCursorOverEditor, @@ -59,19 +63,19 @@ export const useDetectUserAddedField = ({ onFieldAddedRef, }: UseDetectUserAddedFieldArgs): void => { const seenIdsRef = useRef | null>(null) - const lastBridgeRef = useRef(null) + const lastResetKeyRef = useRef(null) useEffect(() => { - // Bridge swap is the only event that invalidates the seen set; the + // A resetKey change is the only event that invalidates the seen set; the // ids belong to a different document context. Tool changes, cursor // re-entry, and isReady transitions do NOT reset — see the file header // for why persistence drives the post-stream reconciliation. - if (lastBridgeRef.current !== bridge) { + if (lastResetKeyRef.current !== resetKey) { seenIdsRef.current = null - lastBridgeRef.current = bridge + lastResetKeyRef.current = resetKey } - const gatesOpen = bridge !== null && isReady && toolbarTool !== null && isCursorOverEditor + const gatesOpen = isReady && toolbarTool !== null && isCursorOverEditor if (!gatesOpen) { return } @@ -84,21 +88,21 @@ export const useDetectUserAddedField = ({ // the seen set. return } - const result = await bridge.getFields() + const result = await actions.getFields() if (cancelled || !result.success) { return } const currentFields = result.data.fields - const currentIds = new Set(currentFields.map((field) => field.field_id)) + const currentIds = new Set(currentFields.map((field) => field.fieldId)) if (seenIdsRef.current === null) { seenIdsRef.current = currentIds return } const seen = seenIdsRef.current - const addedFields = currentFields.filter((field) => !seen.has(field.field_id)) + const addedFields = currentFields.filter((field) => !seen.has(field.fieldId)) if (isStreamingRef.current) { // Race-safety: if the stream started between the top of poll and - // the getFields resolve, hold the seen set for the next tick. + // the get_fields resolve, hold the seen set for the next tick. return } // Always refresh the seen set to the live id set. Adds get reported, @@ -127,5 +131,5 @@ export const useDetectUserAddedField = ({ clearTimeout(timeoutId) } } - }, [bridge, isReady, toolbarTool, isCursorOverEditor, isStreamingRef, onFieldAddedRef]) + }, [actions, resetKey, isReady, toolbarTool, isCursorOverEditor, isStreamingRef, onFieldAddedRef]) } diff --git a/copilot/src/components/chat/tool_icons.tsx b/copilot/src/components/chat/tool_icons.tsx index 2cfb308a..7522d153 100644 --- a/copilot/src/components/chat/tool_icons.tsx +++ b/copilot/src/components/chat/tool_icons.tsx @@ -1,11 +1,11 @@ type ToolKind = 'read' | 'write' const READING_TOOL_NAMES = new Set([ - 'get_fields', - 'get_document_content', - 'detect_fields', - 'focus_field', - 'go_to', + 'getFields', + 'getDocumentContent', + 'detectFields', + 'focusField', + 'goTo', ]) export const getToolKind = (toolName: string): ToolKind => diff --git a/copilot/src/components/chat/toolbar.tsx b/copilot/src/components/chat/toolbar.tsx index 0a3b100b..73c526c5 100644 --- a/copilot/src/components/chat/toolbar.tsx +++ b/copilot/src/components/chat/toolbar.tsx @@ -1,4 +1,4 @@ -import type { OverlayToolType } from '@simplepdf/embed' +import type { OverlayToolType } from '@simplepdf/react-embed-pdf' import { Check, Download, ImageIcon, MousePointer, PenTool, Send, Type } from 'lucide-react' import type { ComponentType } from 'react' import { useTranslation } from 'react-i18next' diff --git a/copilot/src/components/editor_pane.tsx b/copilot/src/components/editor_pane.tsx deleted file mode 100644 index 965c05dc..00000000 --- a/copilot/src/components/editor_pane.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { forwardRef } from 'react' - -type EditorPaneProps = { - editorSrc: string - iframeKey: string -} - -export const EditorPane = forwardRef(({ editorSrc, iframeKey }, ref) => { - return ( - +``` -// Detect fields in the document -await sendEvent("DETECT_FIELDS", {}); +```ts +// No DOM is created, and lifecycle.dispose() leaves your iframe in place. +const embed = createEmbed({ target: "#editor", companyIdentifier: "acme" }); +``` -// Delete all fields (or specific ones) -await sendEvent("DELETE_FIELDS", {}); // Delete all -await sendEvent("DELETE_FIELDS", { page: 1 }); // Delete page 1 only -await sendEvent("DELETE_FIELDS", { - field_ids: ["f_kj8n2hd9x3m1p", "f_q7v5c4b6a0wyz"], -}); // Delete specific fields +### 3. Drive the editor programmatically -// Extract document content -const content = await sendEvent("GET_DOCUMENT_CONTENT", { - extraction_mode: "auto", -}); -console.log("Document name:", content.name); -console.log("Pages:", content.pages); // [{ page: 1, content: "..." }, ...] +Every method resolves to a typed `BridgeResult` and never throws: + +```ts +const fields = await embed.actions.getFields(); +if (fields.success) { + console.log(fields.data.fields); // typed FieldRecord[] +} -// Submit the document -await sendEvent("SUBMIT", { download_copy: true }); +await embed.actions.goTo({ page: 3 }); +await embed.actions.selectTool({ tool: "TEXT" }); // or 'CHECKBOX' | 'SIGNATURE' | 'PICTURE' | 'COMB_TEXT' | null +await embed.actions.setFieldValue({ fieldId: "f_kj8n2hd9x3m1p", value: "Jane Doe" }); -// Move a visible page (1-indexed) to a new position -await sendEvent("MOVE_PAGE", { from_page: 2, to_page: 5 }); +const content = await embed.actions.getDocumentContent({ extractionMode: "auto" }); +console.log(content.success && content.data.pages); // [{ page: 1, content: "…" }, …] -// Delete one or more visible pages (1-indexed). At least one visible page must remain -await sendEvent("DELETE_PAGES", { pages: [3] }); -await sendEvent("DELETE_PAGES", { pages: [2, 4, 6] }); +const submitted = await embed.actions.submit({ downloadCopy: true }); +if (!submitted.success) { + console.error(submitted.error.code, submitted.error.message); // closed BridgeErrorCode +} -// Rotate a visible page (1-indexed) 90° clockwise -await sendEvent("ROTATE_PAGE", { page: 1 }); +embed.lifecycle.dispose(); // readiness is observable via embed.events.on('DOCUMENT_LOADED', …) ``` +See the [`@simplepdf/embed` README](../embed/README.md#actions) for the full method set, the lifecycle, and the typed error model. + +### 4. Fill and read a document (what an agent does for you) + +"Fill and read this document for me" is just these operations in sequence — read the fields, fill one, then walk the user to a signature: navigate to its page, focus the field, and open the signature tool. + +```ts +// read +const fields = await embed.actions.getFields(); +const content = await embed.actions.getDocumentContent({ extractionMode: "auto" }); + +// fill +await embed.actions.setFieldValue({ fieldId: "f_full_name", value: "Jane Doe" }); + +// walk the user to the signature: navigate → focus → open the signature tool +await embed.actions.goTo({ page: 3 }); +await embed.actions.focusField({ fieldId: "f_signature" }); +await embed.actions.selectTool({ tool: "SIGNATURE" }); +``` + +An AI agent does exactly this — the next section exposes these operations as tools the model calls. + +### 5. Drive the editor from an LLM (agentic) + +The same operations are exposed as [Vercel AI SDK](https://sdk.vercel.ai) tools: + +```ts +// server — execute-less tool definitions for streamText / generateText +import { simplePDFToolDefinitions } from "@simplepdf/embed/ai-sdk"; +streamText({ model, tools: simplePDFToolDefinitions() }); + +// browser — run the model's tool calls against the live editor +import { createSimplePDFExecutor } from "@simplepdf/embed/ai-sdk"; +const execute = createSimplePDFExecutor({ embed }); +``` + +In React, `@simplepdf/react-embed-pdf/ai-sdk`'s `useEmbedTools(embedRef)` is the same registry pre-bound to the live editor — drop it straight into `useChat({ tools })`. + --- ## Reference: the editor contract (the spec) @@ -231,4 +208,4 @@ It describes every operation (its `request_type`, input/output JSON Schema, and ### Wire shape -Every request you post is `{ "type": , "request_id": , "data": }`; the editor replies with `{ "type": "REQUEST_RESULT", "data": { "request_id": , "result": } }`, where `result` is `{ "success": true, "data": … }` or `{ "success": false, "error": { "code", "message" } }`. Outbound events (`EDITOR_READY`, `DOCUMENT_LOADED`, `PAGE_FOCUSED`, `SUBMISSION_SENT`) are pushed the same way. `request_type` is the operation name (e.g. `get_fields`, `set_field_value`, `submit`) and is accepted case-insensitively (the historical `SCREAMING_SNAKE` forms still work). The full set of `code` values is in the contract's `editor_error_schema`. +Every request you post is `{ "type": , "request_id": , "data": }`; the editor replies with `{ "type": "REQUEST_RESULT", "data": { "request_id": , "result": } }`, where `result` is `{ "success": true, "data": … }` or `{ "success": false, "error": { "code", "message" } }`. Outbound events (`EDITOR_READY`, `DOCUMENT_LOADED`, `PAGE_FOCUSED`, `SUBMISSION_SENT`) are pushed the same way. `request_type` is the operation name (e.g. `GET_FIELDS`, `SET_FIELD_VALUE`, `SUBMIT`) and is accepted case-insensitively. Payload fields are `snake_case` (e.g. `field_id`, `download_copy`). The full set of `code` values is in the contract's `editor_error_schema`. diff --git a/embed/README.md b/embed/README.md index af5dc331..775e754f 100644 --- a/embed/README.md +++ b/embed/README.md @@ -4,13 +4,15 @@ A typed, zero-dependency client for embedding and programmatically driving the [ Types, schemas, and tools are **generated from a pinned copy of the editor's published manifest** ([`/embed/json`](https://simplepdf.com/embed/json)), so the client cannot drift from the contract. +> Using React? Use [`@simplepdf/react-embed-pdf`](../react) — `` + `useEmbed` (+ the agentic `tools`), built on this core. + ## Install ```bash npm install @simplepdf/embed ``` -Zero runtime dependencies at the root. Optional peers (`zod`, `react`, `react-dom`) are needed only by the subpaths that use them. `/ai-sdk` produces values for the Vercel AI SDK but never imports `ai`; bring your own. +Zero runtime dependencies at the root. `zod` is an optional peer, needed only by the `/schemas`, `/tools`, and `/ai-sdk` subpaths. `/ai-sdk` produces values for the Vercel AI SDK but never imports `ai`; bring your own. ## Subpaths @@ -21,16 +23,15 @@ Zero runtime dependencies at the root. Optional peers (`zod`, `react`, `react-do | `@simplepdf/embed/schemas` | zod schema for every operation input | `zod` | | `@simplepdf/embed/tools` | SDK-agnostic agentic tool registry + `routeToolCall` + `isSimplePDFToolName` | `zod` | | `@simplepdf/embed/ai-sdk` | `simplePDFToolDefinitions()` (server) + `createSimplePDFExecutor({ embed })` (browser) for the Vercel AI SDK | `zod` | -| `@simplepdf/embed/react` | ``, `useEmbed`, `useIframeBridge` | `react`, `react-dom` | ## Conventions -One deliberate naming boundary, so nothing surprises you: +The SDK is **camelCase** (the JS/TS idiom); the editor's snake_case wire stays behind a transform the bridge owns, so you never type it: -- **Package config is camelCase** (the JS idiom): `createEmbed`'s options. `target`, `tenant`, `baseDomain`, `locale`, `context`, `iframeAttrs`, the `document` arms. -- **Editor operations are snake_case**, verbatim from the contract: every method input/output on the `embed` handle and every event payload. `data_url`, `download_copy`, `field_ids`, `document_id`. +- **Methods, their arguments + results, and the agentic tool names/args are camelCase**: `embed.actions.getFields()`, `embed.actions.setFieldValue({ fieldId, value })`, `embed.actions.submit({ downloadCopy })`. They are generated from the contract and lowered to the snake_case wire (`field_id`, `download_copy`) at the `postMessage` boundary — automatically. +- **Events are the one exception — delivered VERBATIM**: `embed.events.on(type, handler)` hands the handler the editor's outbound payload unchanged (snake_case fields like `document_id`), so events stay byte-for-byte compatible with the published manifest and with `@simplepdf/react-embed-pdf`'s 1.x `onEmbedEvent`. -The operation surface is byte-for-byte [`/embed/json`](https://simplepdf.com/embed/json) and the raw `postMessage` wire, so what you (or an agent) read in the spec is exactly what you send. The editor owns all validation and FIFO ordering; the bridge just posts, correlates by `request_id`, and times out a dead iframe. Every method resolves to a `BridgeResult` and never throws; only construction errors (bad config) throw, synchronously. +The editor owns all validation and FIFO ordering; the bridge just posts, correlates by `request_id`, and times out a dead iframe. Every method resolves to a `BridgeResult` and never throws; only construction errors (bad config) throw, synchronously. ## Quick start @@ -45,20 +46,22 @@ import { createEmbed } from '@simplepdf/embed' const embed = createEmbed({ target: '#editor', // a CSS selector, or the HTMLElement itself - tenant: 'acme', // your companyIdentifier + companyIdentifier: 'acme', // your .simplepdf.com subdomain ('embed' = free editor) document: { url: 'https://example.com/form.pdf' }, }) -embed.on('submission_sent', ({ document_id, submission_id }) => { - console.log('submitted', document_id, submission_id) +embed.events.on('SUBMISSION_SENT', (data) => { + console.log('submitted', data.document_id, data.submission_id) }) -const fields = await embed.getFields() +const fields = await embed.actions.getFields() if (fields.success) { fields.data.fields // typed FieldRecord[] } ``` +The handle has three groups: **`embed.actions`** (operations), **`embed.events`** (subscriptions), **`embed.lifecycle`** (teardown). + ## Where the editor goes One function. It does the right thing based on what `target` points at: @@ -68,26 +71,26 @@ One function. It does the right thing based on what `target` points at: ```ts // Point at a container, we make the iframe: -createEmbed({ target: '#editor', tenant: 'acme', document: { url } }) +createEmbed({ target: '#editor', companyIdentifier: 'acme', document: { url } }) // Point at your own iframe, we bridge to it: // -createEmbed({ target: '#ed', tenant: 'acme' }) +createEmbed({ target: '#ed', companyIdentifier: 'acme' }) ``` -Either way you get the same typed `Embed` handle. In React, `` is the container case and `useIframeBridge` is the own-iframe case (see [React](#react)). +Either way you get the same typed `Embed` handle. ### Options | Option | Type | Notes | | --- | --- | --- | | `target` | `string \| HTMLElement` | **required**: a container to fill, or an `' const iframeCountBefore = document.querySelectorAll('iframe').length - const embed = mount({ target: '#ed', tenant: 'acme' }) + const embed = mount({ target: '#ed', companyIdentifier: 'acme' }) // No new iframe is created: we bridge to the one you rendered. expect(document.querySelectorAll('iframe').length).toBe(iframeCountBefore) // dispose() must NOT remove an iframe the consumer owns. - embed.dispose() + embed.lifecycle.dispose() expect(document.querySelector('#ed')).not.toBeNull() }) - it('rejects an attached iframe whose src origin does not match the tenant', () => { + it('rejects an attached iframe whose src origin does not match the companyIdentifier', () => { document.body.innerHTML = '' - expect(() => mount({ target: '#ed', tenant: 'acme' })).toThrow('https://other.simplepdf.com') + expect(() => mount({ target: '#ed', companyIdentifier: 'acme' })).toThrow('https://other.simplepdf.com') + }) + + it('rejects a documents URL when attaching to an existing iframe (mount-only feature)', () => { + document.body.innerHTML = '' + expect(() => + mount({ target: '#ed', companyIdentifier: 'acme', document: { url: 'https://demo.simplepdf.com/documents/abc' } }), + ).toThrow(/documents URL/) }) it('does not false-positive the origin guard when the iframe has no usable src', () => { for (const html of ['', '', '']) { document.body.innerHTML = html - expect(() => mount({ target: '#x', tenant: 'acme' })).not.toThrow() + expect(() => mount({ target: '#x', companyIdentifier: 'acme' })).not.toThrow() } }) - it('rejects a missing tenant', () => { - // @ts-expect-error tenant is required; exercise the runtime guard for untyped JS callers - expect(() => createEmbed({ target: '#root' })).toThrow(/tenant is required/) + it('rejects a missing companyIdentifier', () => { + // @ts-expect-error companyIdentifier is required; exercise the runtime guard for untyped JS callers + expect(() => createEmbed({ target: '#root' })).toThrow(/companyIdentifier is required/) }) it('rejects a non-element, non-string target with a clean error', () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed({ target: 123, tenant: 'acme' })).toThrow(/target must be/) + expect(() => createEmbed({ target: 123, companyIdentifier: 'acme' })).toThrow(/target must be/) }) it('rejects a non-object document with a clean error', () => { @@ -197,6 +314,6 @@ describe(createEmbed.name, () => { it('rejects a non-string baseDomain with a clean error', () => { // @ts-expect-error exercising the runtime guard for untyped JS callers - expect(() => createEmbed({ target: '#root', tenant: 'acme', baseDomain: 123 })).toThrow(/baseDomain must be a string/) + expect(() => createEmbed({ target: '#root', companyIdentifier: 'acme', baseDomain: 123 })).toThrow(/baseDomain must be a string/) }) }) diff --git a/embed/test/tools.test.ts b/embed/test/tools.test.ts index 1c11135d..4dcf08fd 100644 --- a/embed/test/tools.test.ts +++ b/embed/test/tools.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi } from 'vitest' import { isSimplePDFToolName, routeToolCall, SIMPLEPDF_TOOLS } from '../src/tools' -import type { BridgeResult, Embed } from '../src/types' +import type { BridgeResult, IframeActions } from '../src/types' const okResult: BridgeResult = { success: true, data: null } -const makeEmbedStub = (): Embed => { +const makeActionsStub = (): IframeActions => { const method = (): Promise> => Promise.resolve(okResult) return { createField: vi.fn(method), @@ -22,33 +22,28 @@ const makeEmbedStub = (): Embed => { selectTool: vi.fn(method), setFieldValue: vi.fn(method), submit: vi.fn(method), - getState: () => ({ kind: 'document_loaded', documentId: 'doc_1' }), - on: () => () => {}, - dispose: () => {}, - state: { kind: 'document_loaded', documentId: 'doc_1' }, - iframe: null, } } describe(isSimplePDFToolName.name, () => { it('accepts agentic tool names', () => { - expect(isSimplePDFToolName('get_fields')).toBe(true) - expect(isSimplePDFToolName('go_to')).toBe(true) - expect(isSimplePDFToolName('create_field')).toBe(true) + expect(isSimplePDFToolName('getFields')).toBe(true) + expect(isSimplePDFToolName('goTo')).toBe(true) + expect(isSimplePDFToolName('createField')).toBe(true) }) - it('rejects load_document (host/setup, not an agentic tool) and unknown names', () => { - expect(isSimplePDFToolName('load_document')).toBe(false) + it('rejects loadDocument (host/setup, not an agentic tool) and unknown names', () => { + expect(isSimplePDFToolName('loadDocument')).toBe(false) expect(isSimplePDFToolName('not_a_tool')).toBe(false) expect(isSimplePDFToolName(42)).toBe(false) }) }) describe('SIMPLEPDF_TOOLS', () => { - it('exposes the 14 agentic operations with descriptions + input schemas (load_document excluded)', () => { + it('exposes the 14 agentic operations with descriptions + input schemas (loadDocument excluded)', () => { const names = Object.keys(SIMPLEPDF_TOOLS) expect(names).toHaveLength(14) - expect(names).not.toContain('load_document') + expect(names).not.toContain('loadDocument') for (const definition of Object.values(SIMPLEPDF_TOOLS)) { expect(typeof definition.description).toBe('string') expect(definition.inputSchema).toBeDefined() @@ -57,25 +52,25 @@ describe('SIMPLEPDF_TOOLS', () => { }) describe(routeToolCall.name, () => { - it('validates input against the tool schema, then dispatches to the matching bridge method', async () => { - const embed = makeEmbedStub() - await routeToolCall(embed, 'go_to', { page: 2 }) - expect(embed.goTo).toHaveBeenCalledWith({ page: 2 }) + it('validates input against the tool schema, then dispatches to the matching action', async () => { + const actions = makeActionsStub() + await routeToolCall(actions, 'goTo', { page: 2 }) + expect(actions.goTo).toHaveBeenCalledWith({ page: 2 }) }) it('returns bad_request:invalid_input on schema-invalid input without dispatching', async () => { - const embed = makeEmbedStub() - const result = await routeToolCall(embed, 'go_to', { page: 'not-a-number' }) + const actions = makeActionsStub() + const result = await routeToolCall(actions, 'goTo', { page: 'not-a-number' }) expect(result.success).toBe(false) if (!result.success) { expect(result.error.code).toBe('bad_request:invalid_input') } - expect(embed.goTo).not.toHaveBeenCalled() + expect(actions.goTo).not.toHaveBeenCalled() }) it('dispatches no-input tools without requiring input', async () => { - const embed = makeEmbedStub() - await routeToolCall(embed, 'get_fields', undefined) - expect(embed.getFields).toHaveBeenCalledTimes(1) + const actions = makeActionsStub() + await routeToolCall(actions, 'getFields', undefined) + expect(actions.getFields).toHaveBeenCalledTimes(1) }) }) diff --git a/embed/tsup.config.ts b/embed/tsup.config.ts index 5b9d479e..7ee74cbc 100644 --- a/embed/tsup.config.ts +++ b/embed/tsup.config.ts @@ -1,9 +1,11 @@ import { defineConfig } from 'tsup' -// Multi-entry ESM build with per-entry .d.ts. Peer deps are externalized so the -// root entry carries zero runtime dependencies and the subpaths pull only the -// peer they need. Code shared across entries (the bridge, generated contract) is -// split into shared chunks rather than duplicated. +// Multi-entry dual ESM + CJS build with per-entry .d.ts. CJS is kept so consumers +// that `require()` (incl. @simplepdf/react-embed-pdf's own CJS bundle, which keeps +// this core external) still resolve. Peer deps are externalized so the root entry +// carries zero runtime dependencies and the subpaths pull only the peer they need. +// Code shared across entries (the bridge, generated contract) is split into shared +// chunks rather than duplicated. export default defineConfig({ entry: { index: 'src/index.ts', @@ -11,13 +13,12 @@ export default defineConfig({ schemas: 'src/schemas.ts', tools: 'src/tools.ts', 'ai-sdk': 'src/ai-sdk.ts', - react: 'src/react.tsx', }, - format: ['esm'], + format: ['esm', 'cjs'], dts: true, treeshake: true, splitting: true, sourcemap: true, clean: true, - external: ['react', 'react-dom', 'react/jsx-runtime', 'zod'], + external: ['zod'], }) diff --git a/package-lock.json b/package-lock.json index 86aa6a31..dc8918a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,11 +19,7 @@ "version": "0.4.0", "license": "MIT", "devDependencies": { - "@types/react": "^19.2.0", - "@types/react-dom": "^19.2.0", "jsdom": "^26.1.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.17", @@ -33,17 +29,9 @@ "node": ">=18" }, "peerDependencies": { - "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0", "zod": "^4.0.0" }, "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, "zod": { "optional": true } @@ -175,36 +163,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "embed/node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "embed/node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, - "embed/node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, - "license": "MIT" - }, "embed/node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -5208,6 +5166,9 @@ "name": "@simplepdf/react-embed-pdf", "version": "1.10.0", "license": "MIT", + "dependencies": { + "@simplepdf/embed": "^0.4.0" + }, "devDependencies": { "@rollup/plugin-terser": "^0.4.4", "@testing-library/dom": "10.4.1", @@ -5229,14 +5190,21 @@ "rollup-plugin-typescript2": "^0.36.0", "sass": "^1.71.1", "typescript": "^5.9.3", - "vitest": "4.0.17" + "vitest": "4.0.17", + "zod": "^4.4.3" }, "engines": { "node": ">=18" }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0" + "react-dom": "^18.2.0 || ^19.0.0", + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } } }, "react/node_modules/@isaacs/cliui": { diff --git a/react/README.md b/react/README.md index 0412afc4..6153af23 100644 --- a/react/README.md +++ b/react/README.md @@ -36,6 +36,12 @@ Easily add [SimplePDF](https://simplepdf.com) to your React app, by using the `E npm install @simplepdf/react-embed-pdf ``` +The package root (``, `useEmbed`) has no `zod` dependency. Only the agentic tools — the opt-in [`@simplepdf/react-embed-pdf/ai-sdk`](#agentic--useembedtools-vercel-ai-sdk) subpath — need `zod` (a peer). Install it alongside if you use them (npm 7+ adds it automatically; pnpm / Yarn PnP users must add it explicitly): + +```sh +npm install zod +``` + ## Related documentation For shared product behavior and account-specific features, see the main embed README: @@ -67,27 +73,27 @@ _Example_ ```jsx import { EmbedPDF } from '@simplepdf/react-embed-pdf'; - + Opens sample.pdf ; ``` ### Modal mode -Wrap any HTML element with `EmbedPDF` to open a modal with the editor on user click. +Pass `mode="modal"` and wrap any HTML element to open a modal with the editor on user click. ```jsx import { EmbedPDF } from "@simplepdf/react-embed-pdf"; // Opens the PDF on click - + Opens sample.pdf // Let the user pick the PDF - + ``` @@ -103,7 +109,7 @@ import { EmbedPDF } from "@simplepdf/react-embed-pdf"; // The PDF picker is displayed when rendering the component @@ -125,7 +131,7 @@ import { EmbedPDF } from '@simplepdf/react-embed-pdf'; companyIdentifier="react-viewer" mode="inline" style={{ width: 900, height: 800 }} - documentURL="https://cdn.simplepdf.com/simple-pdf/assets/sample.pdf" + document={{ url: 'https://cdn.simplepdf.com/simple-pdf/assets/sample.pdf' }} />; ``` @@ -135,18 +141,23 @@ See [Data Privacy & `companyIdentifier`](../README.md#data-privacy--companyident _Some actions require a SimplePDF account. See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for storage and submission behavior._ -Use `const { embedRef, actions } = useEmbed();` to programmatically control the embed editor: +`const { embedRef, actions } = useEmbed();` drives the editor imperatively (`actions`); the agentic `tools` come from the opt-in `@simplepdf/react-embed-pdf/ai-sdk` subpath (`useEmbedTools(embedRef)`). Attach `embedRef` to the component: ``. -| Action | Description | -| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `actions.goTo({ page })` | Navigate to a specific page | -| `actions.selectTool(toolType)` | Select a tool: `'TEXT'`, `'COMB_TEXT'`, `'CHECKBOX'`, `'PICTURE'`, `'SIGNATURE'`, or `null` to deselect (`CURSOR`) | -| `actions.detectFields()` | Automatically detect form fields in the document | -| `actions.deleteFields(options?)` | Delete fields by `fieldIds` or `page`, or all fields if no options | -| `actions.getDocumentContent({ extractionMode })` | Extract document content (`extractionMode: 'auto'` or `'ocr'`) | -| `actions.submit({ downloadCopyOnDevice })` | Submit the document | +#### Imperative — `actions` -All actions return a `Promise` with a result object: `{ success: true, data: ... }` or `{ success: false, error: { code, message } }`. +Actions are camelCase (the editor's snake_case wire is transformed for you). `useEmbed().actions` exposes the FULL editor surface; the most common: + +| Action | Description | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------- | +| `actions.goTo({ page })` | Navigate to a specific page | +| `actions.selectTool({ tool })` | Select a tool: `'TEXT'`, `'COMB_TEXT'`, `'CHECKBOX'`, `'PICTURE'`, `'SIGNATURE'`, or `null` to deselect | +| `actions.detectFields()` | Automatically detect form fields in the document | +| `actions.deleteFields({ fieldIds?, page? })` | Delete fields by id or page, or all fields if both are omitted | +| `actions.getDocumentContent({ extractionMode })` | Extract document content (`'auto'` or `'ocr'`) | +| `actions.setFieldValue({ fieldId, value })` | Set a field's value | +| `actions.submit({ downloadCopy })` | Submit the document | + +…plus `createField`, `getFields`, `focusField`, `movePage`, `rotatePage`, `deletePages`, `download`, and `loadDocument`. All actions return a `Promise` with a result object: `{ success: true, data: ... }` or `{ success: false, error: { code, message } }`. ```jsx import { EmbedPDF, useEmbed } from '@simplepdf/react-embed-pdf'; @@ -155,46 +166,65 @@ const Editor = () => { const { embedRef, actions } = useEmbed(); const handleSubmit = async () => { - const result = await actions.submit({ downloadCopyOnDevice: false }); - if (result.success) { - console.log('Submitted!'); - } + const result = await actions.submit({ downloadCopy: false }); + if (result.success) console.log('Submitted!'); }; const handleExtract = async () => { const result = await actions.getDocumentContent({ extractionMode: 'auto' }); - if (result.success) { - console.log('Document name:', result.data.name); - console.log('Pages:', result.data.pages); - } - }; - - const handleDetectFields = async () => { - const result = await actions.detectFields(); - if (result.success) { - console.log('Fields detected!'); - } + if (result.success) console.log('Pages:', result.data.pages); }; return ( <> - - + + ); }; ``` +**"Fill and read this document for me"** is just these actions in sequence — exactly what an AI agent calls on your behalf (read the fields, fill one, then walk the user to a signature: navigate to its page, focus it, open the signature tool): + +```jsx +const { embedRef, actions } = useEmbed(); + +const fields = await actions.getFields(); // read +await actions.setFieldValue({ fieldId: 'f_full_name', value: 'Jane Doe' }); // fill +await actions.goTo({ page: 3 }); +await actions.focusField({ fieldId: 'f_signature' }); +await actions.selectTool({ tool: 'SIGNATURE' }); +``` + +#### Agentic — `useEmbedTools` (Vercel AI SDK) + +The agentic tools live in the opt-in `@simplepdf/react-embed-pdf/ai-sdk` subpath — importing it is what pulls `zod`, so a non-agentic app never loads it (mirroring `@simplepdf/embed`'s `/ai-sdk`). `useEmbedTools(embedRef)` binds the SimplePDF tool set to the live editor — drop it straight into the AI SDK and an LLM can drive the editor: + +```jsx +import { useChat } from '@ai-sdk/react'; +import { EmbedPDF, useEmbed } from '@simplepdf/react-embed-pdf'; +import { useEmbedTools } from '@simplepdf/react-embed-pdf/ai-sdk'; + +const CopilotEditor = () => { + const { embedRef } = useEmbed(); + const tools = useEmbedTools(embedRef); + useChat({ tools }); // the model's tool calls run against the live editor + return ; +}; +``` + +For server-side tool definitions (execute-less, for `streamText`), import `simplePDFToolDefinitions` from `@simplepdf/react-embed-pdf/ai-sdk`. `embedRef.current` is the flat editor-actions handle — every camelCase operation, with the 1.x `selectTool` / `submit` overloads; subscribe to editor events via the `onEmbedEvent` prop. (The framework-free `@simplepdf/embed` core exposes the grouped `embed.actions` / `embed.events` / `embed.lifecycle` handle for non-React use.) + See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction, downloading, and server-side storage options. ### Available props @@ -210,13 +240,13 @@ See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction, ref EmbedActions No - Used for programmatic control of the editor (see Programmatic Control section) + The live editor-actions handle, for programmatic control (see Programmatic Control). Attach the embedRef from useEmbed(). mode "inline" | "modal" No (defaults to "modal") - Inline the editor or display it inside a modal + Inline the editor in your layout, or display it inside a click-to-open modal locale @@ -257,10 +287,10 @@ See [Retrieving PDF Data](../README.md#retrieving-pdf-data) for text extraction, Events sent by the Iframe - documentURL - string + document + { url: string } | { dataUrl: string } | { file: File | Blob } No - Supports blob URLs, CORS URLs, and authenticated URLs (against the same origin). Available for inline mode only + The document to open (same typed shape as createEmbed): a URL (CORS / authenticated same-origin / a SimplePDF documents URL), a data URL, or a File/Blob style diff --git a/react/package-lock.json b/react/package-lock.json deleted file mode 100644 index 7a99847a..00000000 --- a/react/package-lock.json +++ /dev/null @@ -1,2059 +0,0 @@ -{ - "name": "@simplepdf/react-embed-pdf", - "version": "1.9.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@simplepdf/react-embed-pdf", - "version": "1.9.0", - "license": "MIT", - "devDependencies": { - "@rollup/plugin-terser": "^0.4.4", - "@types/react": "*", - "@types/react-dom": "*", - "autoprefixer": "^10.4.18", - "postcss": "^8.4.35", - "prettier": "^3.4.2", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "rimraf": "^5.0.5", - "rollup": "^4.12.0", - "rollup-plugin-scss": "^4.0.0", - "rollup-plugin-typescript2": "^0.36.0", - "sass": "^1.71.1", - "typescript": "^5.7.3" - }, - "peerDependencies": { - "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", - "dev": true, - "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", - "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", - "dev": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", - "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "dependencies": { - "detect-libc": "^1.0.3", - "is-glob": "^4.0.3", - "micromatch": "^4.0.5", - "node-addon-api": "^7.0.0" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.0", - "@parcel/watcher-darwin-arm64": "2.5.0", - "@parcel/watcher-darwin-x64": "2.5.0", - "@parcel/watcher-freebsd-x64": "2.5.0", - "@parcel/watcher-linux-arm-glibc": "2.5.0", - "@parcel/watcher-linux-arm-musl": "2.5.0", - "@parcel/watcher-linux-arm64-glibc": "2.5.0", - "@parcel/watcher-linux-arm64-musl": "2.5.0", - "@parcel/watcher-linux-x64-glibc": "2.5.0", - "@parcel/watcher-linux-x64-musl": "2.5.0", - "@parcel/watcher-win32-arm64": "2.5.0", - "@parcel/watcher-win32-ia32": "2.5.0", - "@parcel/watcher-win32-x64": "2.5.0" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", - "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", - "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", - "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", - "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", - "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", - "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", - "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", - "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", - "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", - "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", - "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", - "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", - "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@rollup/plugin-terser": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", - "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", - "dev": true, - "dependencies": { - "serialize-javascript": "^6.0.1", - "smob": "^1.0.0", - "terser": "^5.17.4" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", - "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", - "dev": true, - "dependencies": { - "estree-walker": "^2.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.30.1.tgz", - "integrity": "sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.1.tgz", - "integrity": "sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.1.tgz", - "integrity": "sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.1.tgz", - "integrity": "sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.1.tgz", - "integrity": "sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.1.tgz", - "integrity": "sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.1.tgz", - "integrity": "sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.1.tgz", - "integrity": "sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.1.tgz", - "integrity": "sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.1.tgz", - "integrity": "sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.1.tgz", - "integrity": "sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.1.tgz", - "integrity": "sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.1.tgz", - "integrity": "sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.1.tgz", - "integrity": "sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz", - "integrity": "sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz", - "integrity": "sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.1.tgz", - "integrity": "sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.1.tgz", - "integrity": "sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.1.tgz", - "integrity": "sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "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==", - "dev": true - }, - "node_modules/@types/react": { - "version": "19.0.4", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.4.tgz", - "integrity": "sha512-3O4QisJDYr1uTUMZHA2YswiQZRq+Pd8D+GdVFYikTutYsTz+QZgWkAPnP7rx9txoI6EXKcPiluMqWPFV3tT9Wg==", - "dev": true, - "dependencies": { - "csstype": "^3.0.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.0.2", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.0.2.tgz", - "integrity": "sha512-c1s+7TKFaDRRxr1TxccIX2u7sfCnc3RxkVyBIUA2lCpyqCF+QoAwQ/CBg7bsMdVwP120HEH143VQezKtef5nCg==", - "dev": true, - "peerDependencies": { - "@types/react": "^19.0.0" - } - }, - "node_modules/acorn": { - "version": "8.14.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", - "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", - "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.20", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", - "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "browserslist": "^4.23.3", - "caniuse-lite": "^1.0.30001646", - "fraction.js": "^4.3.7", - "normalize-range": "^0.1.2", - "picocolors": "^1.0.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "optional": true, - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001692", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz", - "integrity": "sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ] - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true - }, - "node_modules/commondir": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", - "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", - "dev": true - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true - }, - "node_modules/detect-libc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", - "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", - "dev": true, - "optional": true, - "bin": { - "detect-libc": "bin/detect-libc.js" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, - "node_modules/electron-to-chromium": { - "version": "1.5.80", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.80.tgz", - "integrity": "sha512-LTrKpW0AqIuHwmlVNV+cjFYTnXtM9K37OGhpe0ZI10ScPSxqVSryZHIY3WnCS5NSYbBODRTZyhRMS2h5FAEqAw==", - "dev": true - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "optional": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-cache-dir": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", - "integrity": "sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==", - "dev": true, - "dependencies": { - "commondir": "^1.0.1", - "make-dir": "^3.0.2", - "pkg-dir": "^4.1.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/avajs/find-cache-dir?sponsor=1" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/foreground-child": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", - "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fraction.js": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", - "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", - "dev": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "patreon", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, - "node_modules/immutable": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", - "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", - "dev": true - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "optional": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true - }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dev": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "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 - }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "dev": true, - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "optional": true, - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/nanoid": { - "version": "3.3.8", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", - "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "optional": true - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/postcss": { - "version": "8.4.49", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", - "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true - }, - "node_modules/prettier": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", - "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", - "dev": true, - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "dev": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "dev": true, - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/readdirp": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.0.tgz", - "integrity": "sha512-4+hHiVsxlm4OVSNFpAIrOGyGeG9kNLGcLMqvSGL5Rj2NOYBDQiQ6lJRViwAZ80i8SNbY8kCpdjgJy5PNALARew==", - "dev": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/rimraf": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", - "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", - "dev": true, - "dependencies": { - "glob": "^10.3.7" - }, - "bin": { - "rimraf": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rollup": { - "version": "4.30.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz", - "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.6" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.30.1", - "@rollup/rollup-android-arm64": "4.30.1", - "@rollup/rollup-darwin-arm64": "4.30.1", - "@rollup/rollup-darwin-x64": "4.30.1", - "@rollup/rollup-freebsd-arm64": "4.30.1", - "@rollup/rollup-freebsd-x64": "4.30.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.30.1", - "@rollup/rollup-linux-arm-musleabihf": "4.30.1", - "@rollup/rollup-linux-arm64-gnu": "4.30.1", - "@rollup/rollup-linux-arm64-musl": "4.30.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.30.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.30.1", - "@rollup/rollup-linux-riscv64-gnu": "4.30.1", - "@rollup/rollup-linux-s390x-gnu": "4.30.1", - "@rollup/rollup-linux-x64-gnu": "4.30.1", - "@rollup/rollup-linux-x64-musl": "4.30.1", - "@rollup/rollup-win32-arm64-msvc": "4.30.1", - "@rollup/rollup-win32-ia32-msvc": "4.30.1", - "@rollup/rollup-win32-x64-msvc": "4.30.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup-plugin-scss": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/rollup-plugin-scss/-/rollup-plugin-scss-4.0.1.tgz", - "integrity": "sha512-3W3+3OzR+shkDl3hJ1XTAuGkP4AfiLgIjie2GtcoZ9pHfRiNqeDbtCu1EUnkjZ98EPIM6nnMIXkKlc7Sx5bRvA==", - "dev": true, - "dependencies": { - "rollup-pluginutils": "^2.3.3" - } - }, - "node_modules/rollup-plugin-typescript2": { - "version": "0.36.0", - "resolved": "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.36.0.tgz", - "integrity": "sha512-NB2CSQDxSe9+Oe2ahZbf+B4bh7pHwjV5L+RSYpCu7Q5ROuN94F9b6ioWwKfz3ueL3KTtmX4o2MUH2cgHDIEUsw==", - "dev": true, - "dependencies": { - "@rollup/pluginutils": "^4.1.2", - "find-cache-dir": "^3.3.2", - "fs-extra": "^10.0.0", - "semver": "^7.5.4", - "tslib": "^2.6.2" - }, - "peerDependencies": { - "rollup": ">=1.26.3", - "typescript": ">=2.4.0" - } - }, - "node_modules/rollup-pluginutils": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz", - "integrity": "sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==", - "dev": true, - "dependencies": { - "estree-walker": "^0.6.1" - } - }, - "node_modules/rollup-pluginutils/node_modules/estree-walker": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-0.6.1.tgz", - "integrity": "sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==", - "dev": true - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/sass": { - "version": "1.83.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.1.tgz", - "integrity": "sha512-EVJbDaEs4Rr3F0glJzFSOvtg2/oy2V/YrGFPqPY24UqcLDWcI9ZY5sN+qyO3c/QCZwzgfirvhXvINiJCE/OLcA==", - "dev": true, - "dependencies": { - "chokidar": "^4.0.0", - "immutable": "^5.0.2", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=14.0.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "dev": true, - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", - "dev": true, - "dependencies": { - "randombytes": "^2.1.0" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/smob": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/smob/-/smob-1.5.0.tgz", - "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", - "dev": true - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/terser": { - "version": "5.37.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", - "integrity": "sha512-B8wRRkmre4ERucLM/uXx4MOV5cbnOlVAqUst+1+iLKPI0dOgFO28f84ptoQt9HEI537PMzfYa/d+GEPKTRXmYA==", - "dev": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.8.2", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "optional": true, - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true - }, - "node_modules/typescript": { - "version": "5.7.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", - "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", - "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - } - } -} diff --git a/react/package.json b/react/package.json index 9ecd438d..a3771d57 100644 --- a/react/package.json +++ b/react/package.json @@ -11,6 +11,18 @@ "main": "dist/index.cjs", "module": "dist/index.esm.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.esm.js", + "require": "./dist/index.cjs" + }, + "./ai-sdk": { + "types": "./dist/ai-sdk.d.ts", + "import": "./dist/ai-sdk.esm.js", + "require": "./dist/ai-sdk.cjs" + } + }, "author": "bendersej", "license": "MIT", "private": false, @@ -30,9 +42,18 @@ "build": "rollup -c", "start": "rollup -c -w" }, + "dependencies": { + "@simplepdf/embed": "^0.4.0" + }, "peerDependencies": { "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0" + "react-dom": "^18.2.0 || ^19.0.0", + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } }, "devDependencies": { "@rollup/plugin-terser": "^0.4.4", @@ -55,7 +76,8 @@ "rollup-plugin-typescript2": "^0.36.0", "sass": "^1.71.1", "typescript": "^5.9.3", - "vitest": "4.0.17" + "vitest": "4.0.17", + "zod": "^4.4.3" }, "files": ["dist"], "keywords": ["react", "typescript", "npm", "pdf"] diff --git a/react/rollup.config.js b/react/rollup.config.js index ffa56668..742bde4f 100644 --- a/react/rollup.config.js +++ b/react/rollup.config.js @@ -3,26 +3,37 @@ import postcss from 'postcss'; import autoprefixer from 'autoprefixer'; import typescript from 'rollup-plugin-typescript2'; import terser from '@rollup/plugin-terser'; -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); -const pkg = require('./package.json'); +// Dual CJS + ESM (kept from 1.x so the minor stays non-breaking for require() consumers). +// react / react-dom / zod / the core are external (peer or dependency), resolved at the +// consumer rather than bundled in. +const isExternal = (id) => + id === 'react' || + id === 'react-dom' || + id === 'zod' || + id === '@simplepdf/embed' || + id.startsWith('@simplepdf/embed/'); export default { - input: 'src/index.tsx', + // Two entries mirroring the core: the zod-free root, and the opt-in agentic /ai-sdk + // (which pulls zod). A consumer importing only the root never loads zod. + input: { index: 'src/index.tsx', 'ai-sdk': 'src/ai-sdk.tsx' }, output: [ { - file: pkg.main, + dir: 'dist', format: 'cjs', exports: 'named', sourcemap: true, - strict: true, + entryFileNames: '[name].cjs', + chunkFileNames: 'chunks/[name]-[hash].cjs', }, { - file: pkg.module, + dir: 'dist', format: 'es', exports: 'named', sourcemap: true, + entryFileNames: '[name].esm.js', + chunkFileNames: 'chunks/[name]-[hash].esm.js', }, ], plugins: [ @@ -38,5 +49,5 @@ export default { }, }), ], - external: ['react', 'react-dom'], + external: isExternal, }; diff --git a/react/src/ai-sdk.test.tsx b/react/src/ai-sdk.test.tsx new file mode 100644 index 00000000..cfe3da95 --- /dev/null +++ b/react/src/ai-sdk.test.tsx @@ -0,0 +1,34 @@ +/// + +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { useEmbed } from './index'; +import { useEmbedTools, type EmbedTools } from './ai-sdk'; + +// useEmbed pulls in , which imports scss (a build concern, irrelevant here). +vi.mock('./styles.scss', () => ({})); + +describe('useEmbedTools', () => { + it('exposes the agentic registry, each tool execute null-safe before mounts', async () => { + const captured: EmbedTools[] = []; + const Probe = (): null => { + const { embedRef } = useEmbed(); + captured.push(useEmbedTools(embedRef)); + return null; + }; + render(); + const tools = captured[0]; + if (tools === undefined) { + throw new Error('expected useEmbedTools to have rendered'); + } + // The agentic registry is present, each entry an AI-SDK-ready tool. + expect(typeof tools.goTo?.description).toBe('string'); + expect(tools.goTo?.inputSchema).toBeDefined(); + const result = await tools.goTo.execute({ page: 1 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('unexpected:iframe_not_mounted'); + } + }); +}); diff --git a/react/src/ai-sdk.tsx b/react/src/ai-sdk.tsx new file mode 100644 index 00000000..cb323e3f --- /dev/null +++ b/react/src/ai-sdk.tsx @@ -0,0 +1,58 @@ +// The agentic surface for @simplepdf/react-embed-pdf — the React mirror of +// @simplepdf/embed's /tools + /ai-sdk subpaths. Importing THIS module (not the package +// root) is what pulls in `zod`, so a -only app never loads it — the same +// pay-for-use contract the core has. Pair it with useEmbed(): +// +// const { embedRef } = useEmbed() +// const tools = useEmbedTools(embedRef) // bound to the live editor +// useChat({ tools }) + +import * as React from 'react'; +import type { RefObject } from 'react'; +import type { BridgeResult } from '@simplepdf/embed'; +import { isSimplePDFToolName, routeToolCall, SIMPLEPDF_TOOLS, type SimplePDFToolName } from '@simplepdf/embed/tools'; +import type { EmbedActions } from './embed-pdf'; +import { notMounted } from './not-mounted'; + +// Re-export the core agentic surface so React consumers get everything from one subpath: +// server-side tool definitions (execute-less, for streamText), the browser executor, and +// the tool-name guard — mirroring @simplepdf/embed/ai-sdk + /tools. +export { createSimplePDFExecutor, simplePDFToolDefinitions } from '@simplepdf/embed/ai-sdk'; +export { isSimplePDFToolName }; +export type { SimplePDFToolName }; + +type ToolInputSchema = (typeof SIMPLEPDF_TOOLS)[SimplePDFToolName]['inputSchema']; + +// One agentic tool, bound to the live editor. The shape ({ description, inputSchema, +// execute }) is exactly what the AI SDK consumes, so the whole `tools` record drops +// straight into useChat({ tools }) / streamText — no executor to wire. +export type EmbedTool = { + description: string; + inputSchema: ToolInputSchema; + execute: (input: unknown) => Promise>; +}; + +// Record (not Record) matches the AI SDK's string-keyed +// `tools` param. A literal-keyed Record can't be built here without an `as` cast (Object +// .fromEntries / reduce both erase the key type), and `Partial>` would break that +// AI-SDK assignability — so the registry stays string-keyed (it is complete at runtime). +export type EmbedTools = Record; + +// The agentic registry bound to the live editor via useEmbed().embedRef. Stable and +// null-safe before the editor mounts (each `execute` reads embedRef.current at call time). +export const useEmbedTools = (embedRef: RefObject): EmbedTools => + React.useMemo( + () => + Object.keys(SIMPLEPDF_TOOLS) + .filter(isSimplePDFToolName) + .reduce((accumulator, name) => { + accumulator[name] = { + description: SIMPLEPDF_TOOLS[name].description, + inputSchema: SIMPLEPDF_TOOLS[name].inputSchema, + execute: (input) => + embedRef.current === null ? notMounted() : routeToolCall(embedRef.current, name, input), + }; + return accumulator; + }, {}), + [embedRef], + ); diff --git a/react/src/embed-pdf.test.tsx b/react/src/embed-pdf.test.tsx new file mode 100644 index 00000000..ecee85bc --- /dev/null +++ b/react/src/embed-pdf.test.tsx @@ -0,0 +1,216 @@ +/// + +import * as React from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { EmbedPDF, useEmbed, type EmbedActions } from './index'; + +// scss is a build concern; in tests it is irrelevant to behavior. +vi.mock('./styles.scss', () => ({})); + +// User-facing behavior only: what renders, how the modal opens/closes, the 1.x +// onEmbedEvent contract, and the useEmbed contract (null-safe before mount). + +describe('EmbedPDF (inline)', () => { + it('renders the editor iframe inside the host element for the companyIdentifier origin', () => { + const { container } = render(); + const iframe = container.querySelector('iframe'); + if (!(iframe instanceof HTMLIFrameElement)) { + throw new Error('expected an iframe under the host element'); + } + const src = new URL(iframe.getAttribute('src') ?? ''); + expect(src.origin).toBe('https://acme.simplepdf.com'); + expect(src.pathname).toBe('/en/editor'); + }); + + it('defaults companyIdentifier to the free no-account editor', () => { + const { container } = render(); + const iframe = container.querySelector('iframe'); + if (!(iframe instanceof HTMLIFrameElement)) { + throw new Error('expected an iframe under the host element'); + } + expect(new URL(iframe.getAttribute('src') ?? '').origin).toBe('https://react-editor.simplepdf.com'); + }); + + it('navigates straight to a SimplePDF documents URL (preserving its query)', () => { + const { container } = render( + , + ); + const iframe = container.querySelector('iframe'); + if (!(iframe instanceof HTMLIFrameElement)) { + throw new Error('expected an iframe under the host element'); + } + const src = new URL(iframe.getAttribute('src') ?? ''); + expect(src.origin).toBe('https://demo.simplepdf.com'); + expect(src.pathname).toBe('/documents/abc'); + expect(src.searchParams.get('prefill')).toBe('p1'); + }); + + it('accepts the deprecated documentURL as an alias for document (1.x compatibility)', () => { + const { container } = render( + , + ); + const iframe = container.querySelector('iframe'); + if (!(iframe instanceof HTMLIFrameElement)) { + throw new Error('expected an iframe under the host element'); + } + const src = new URL(iframe.getAttribute('src') ?? ''); + expect(src.origin).toBe('https://demo.simplepdf.com'); + expect(src.pathname).toBe('/documents/abc'); + }); + + it('accepts a relative documentURL (1.x) by resolving it against the page URL', () => { + // 1.x fetched the documentURL (relative resolves against the page); the core now + // requires an absolute URL, so the compat boundary resolves it — the editor must + // still mount (the old core would throw in createEmbed and render no iframe). + const { container } = render(); + const iframe = container.querySelector('iframe'); + if (!(iframe instanceof HTMLIFrameElement)) { + throw new Error('expected the editor to mount for a relative documentURL'); + } + expect(new URL(iframe.getAttribute('src') ?? '').origin).toBe('https://acme.simplepdf.com'); + }); + + it('forwards editor events to onEmbedEvent verbatim (the 1.x EmbedEvent contract)', async () => { + const onEmbedEvent = vi.fn(); + const { container } = render(); + const iframe = container.querySelector('iframe'); + if (!(iframe instanceof HTMLIFrameElement)) { + throw new Error('expected an iframe under the host element'); + } + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc123' } }), + origin: 'https://acme.simplepdf.com', + source: iframe.contentWindow, + }), + ); + await waitFor(() => { + expect(onEmbedEvent).toHaveBeenCalledWith({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc123' } }); + }); + }); +}); + +describe('EmbedPDF (modal, default)', () => { + it('opens on the trigger and closes on the close button (modal is the default mode)', async () => { + const user = userEvent.setup(); + render( + + + , + ); + expect(document.querySelector('iframe')).toBeNull(); + + await user.click(screen.getByText('Open editor')); + expect(document.querySelector('iframe')).not.toBeNull(); + + await user.click(screen.getByLabelText('Close PDF editor modal')); + expect(document.querySelector('iframe')).toBeNull(); + }); + + it("falls back to the trigger's href as the document when no document prop is given (1.x)", async () => { + const user = userEvent.setup(); + render( + + Open + , + ); + await user.click(screen.getByText('Open')); + const iframe = document.querySelector('iframe'); + if (!(iframe instanceof HTMLIFrameElement)) { + throw new Error('expected an iframe after opening the modal'); + } + const src = new URL(iframe.getAttribute('src') ?? ''); + expect(src.origin).toBe('https://demo.simplepdf.com'); + expect(src.pathname).toBe('/documents/abc'); + }); +}); + +describe('useEmbed', () => { + it('actions resolve to a not-mounted Result before mounts', async () => { + const captured: { actions: EmbedActions }[] = []; + const Probe = (): null => { + const { actions } = useEmbed(); + captured.push({ actions }); + return null; + }; + render(); + const first = captured[0]; + if (first === undefined) { + throw new Error('expected useEmbed to have rendered'); + } + const result = await first.actions.goTo({ page: 1 }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe('unexpected:iframe_not_mounted'); + } + }); +}); + +describe('useEmbed action backward-compat (1.x)', () => { + // Mount , capture the live handle + actions, and spy the + // iframe's postMessage — so a test can assert the deprecated 1.x argument shapes are + // normalized all the way to the wire, BOTH through the forwarded ref handle and + // through useEmbed().actions (the ref must carry the overloads, not just actions). + const mountAndCapture = async (): Promise<{ + embed: EmbedActions; + actions: EmbedActions; + posted: { type: string; data: unknown }[]; + }> => { + const captured: { value: { embedRef: React.RefObject; actions: EmbedActions } | null } = { + value: null, + }; + const Harness = (): React.ReactElement => { + const result = useEmbed(); + captured.value = { embedRef: result.embedRef, actions: result.actions }; + return ; + }; + const { container } = render(); + await waitFor(() => { + expect(captured.value?.embedRef.current).not.toBeNull(); + }); + const c = captured.value; + if (c === null) { + throw new Error('expected useEmbed to render'); + } + const embed = c.embedRef.current; + if (embed === null) { + throw new Error('expected the embed to mount'); + } + // The handle no longer exposes the iframe; reach it via the rendered DOM to spy postMessage. + const iframe = container.querySelector('iframe'); + if (!(iframe instanceof HTMLIFrameElement) || iframe.contentWindow === null) { + throw new Error('expected the iframe contentWindow'); + } + const posted: { type: string; data: unknown }[] = []; + vi.spyOn(iframe.contentWindow, 'postMessage').mockImplementation((message: unknown) => { + if (typeof message === 'string') { + posted.push(JSON.parse(message)); + } + }); + return { embed, actions: c.actions, posted }; + }; + + it('selectTool accepts the deprecated positional tool — via the ref handle and via actions', async () => { + const { embed, actions, posted } = await mountAndCapture(); + void embed.selectTool('TEXT'); // deprecated positional, through the forwarded ref handle + void actions.selectTool(null); // deprecated positional deselect, through useEmbed().actions + void actions.selectTool({ tool: 'COMB_TEXT' }); // new shape passes through unchanged + const calls = posted.filter((message) => message.type === 'SELECT_TOOL').map((message) => message.data); + expect(calls).toEqual([{ tool: 'TEXT' }, { tool: null }, { tool: 'COMB_TEXT' }]); + }); + + it('submit accepts the deprecated { downloadCopyOnDevice } — via the ref handle and via actions', async () => { + const { embed, actions, posted } = await mountAndCapture(); + void embed.submit({ downloadCopyOnDevice: true }); // deprecated shape, through the ref handle + void actions.submit({ downloadCopy: false }); // new shape, through actions + // The wire is snake_case (downloadCopy -> download_copy), proving normalize + transform. + const calls = posted.filter((message) => message.type === 'SUBMIT').map((message) => message.data); + expect(calls).toEqual([{ download_copy: true }, { download_copy: false }]); + }); +}); diff --git a/react/src/embed-pdf.tsx b/react/src/embed-pdf.tsx new file mode 100644 index 00000000..214e2f5b --- /dev/null +++ b/react/src/embed-pdf.tsx @@ -0,0 +1,407 @@ +// The React home for embedding + agentically driving the SimplePDF editor, built +// on the framework-free @simplepdf/embed core. +// +// // render +// const { embedRef, actions } = useEmbed() // drive +// - actions: imperative methods you call (actions.goTo({ page })) +// The agentic tools are the opt-in @simplepdf/react-embed-pdf/ai-sdk subpath +// (useEmbedTools(embedRef)), keeping zod off this entry. +// +// Config + actions are camelCase (JS/TS idiom); the bridge transforms to the +// snake_case wire. ONE deliberate exception: onEmbedEvent forwards the editor's +// outbound events VERBATIM (SCREAMING_SNAKE `type` + snake_case `data`) — the stable +// 1.x EmbedEvent contract. useEffect is deliberate: mounting / driving the editor +// iframe is exactly the "synchronize with an external system" case. + +import * as React from 'react'; +import { createPortal } from 'react-dom'; +import { createEmbed, type EmbedDocument } from '@simplepdf/embed'; +import type { + BridgeLogger, + BridgeResult, + EditorEvent, + EditorEventMap, + Embed, + IframeActions, + Locale, + LogPayload, + SelectToolInput, + SubmitInput, +} from '@simplepdf/embed'; +import { notMounted } from './not-mounted'; + +import './styles.scss'; + +const DEFAULT_COMPANY_IDENTIFIER = 'react-editor'; + +const assignRef = (ref: React.ForwardedRef, value: EmbedActions | null): void => { + if (typeof ref === 'function') { + ref(value); + } else if (ref !== null) { + ref.current = value; + } +}; + +// 1.x backward-compat for the two imperative actions whose argument shape changed. +// Defined once and applied at the single boundary to the (pure camelCase) core, so +// BOTH the forwarded ref handle and useEmbed().actions accept the deprecated forms. +const normalizeSelectTool = (input: SelectToolInput | SelectToolInput['tool']): SelectToolInput => + typeof input === 'object' && input !== null ? input : { tool: input }; +const normalizeSubmit = (input: SubmitInput | { downloadCopyOnDevice: boolean }): SubmitInput => + 'downloadCopyOnDevice' in input ? { downloadCopy: input.downloadCopyOnDevice } : input; + +// 1.x accepted a relative `documentURL` / trigger href (it fetched the URL, which resolves +// against the page); the core now requires an absolute URL, so resolve relative values here +// to stay backward-compatible. Absolute URLs pass through unchanged; under SSR (no window) +// or an unusable base (e.g. about:blank), the raw value is kept — the core then validates it. +const toAbsoluteUrl = (url: string): string => { + if (typeof window === 'undefined') { + return url; + } + try { + return new URL(url, window.location.href).href; + } catch { + return url; + } +}; + +// The forwarded ref is the FLAT 1.x EmbedActions (`embedRef.current.selectTool(...)`), not +// the core's grouped handle — so existing 1.x ref consumers keep working (1.11 stays a +// non-breaking minor). It flattens the core's `embed.actions` group and overloads +// selectTool/submit for the deprecated 1.x argument shapes. +const toEmbedActions = (embed: Embed): EmbedActions => ({ + ...embed.actions, + selectTool: (input) => embed.actions.selectTool(normalizeSelectTool(input)), + submit: (input) => embed.actions.submit(normalizeSubmit(input)), +}); + +// --- EmbedPDF --------------------------------------------------------------- + +// The editor's outbound events, forwarded to onEmbedEvent VERBATIM — the stable, +// 1.x-compatible event contract (SCREAMING_SNAKE `type` + snake_case `data`). It is +// the core's EditorEvent re-exported (single owner; no restated copy). +export type EmbedEvent = EditorEvent; + +type CommonEmbedPDFProps = { + // Your companyIdentifier: the .simplepdf.com subdomain from + // your SimplePDF account (defaults to the free no-account 'react-editor'). + companyIdentifier?: string; + baseDomain?: string; + // The document to open, same typed shape as createEmbed: one of { url } | + // { dataUrl } | { file }. A SimplePDF documents URL loads directly (prefill etc.). + document?: EmbedDocument; + /** @deprecated Use `document={{ url: '…' }}` instead. Kept for 1.x compatibility. */ + documentURL?: string; + context?: Record; + locale?: Locale; + onEmbedEvent?: (event: EmbedEvent) => void | Promise; + // Optional: structured logging of the bridge lifecycle + errors. + logger?: BridgeLogger; +}; + +type InlineEmbedPDFProps = CommonEmbedPDFProps & { + // Opt into inline: the editor renders directly in your layout. + mode: 'inline'; + className?: string; + style?: React.CSSProperties; +}; + +type ModalEmbedPDFProps = CommonEmbedPDFProps & { + // Modal is the DEFAULT (mode omitted === 'modal'): a click-to-open editor. + mode?: 'modal'; + // The clickable trigger; clicking it opens the editor (loading `document`) in a modal. + children: React.ReactNode; +}; + +export type EmbedPDFProps = InlineEmbedPDFProps | ModalEmbedPDFProps; + +type SurfaceProps = { + companyIdentifier: string; + baseDomain?: string; + document?: EmbedDocument; + locale?: Locale; + context?: Record; + logger?: BridgeLogger; + onEmbedEvent?: (event: EmbedEvent) => void | Promise; + className?: string; + style?: React.CSSProperties; +}; + +// Renders a container div and mounts the editor iframe inside it via createEmbed. +// Mount/unmount of this component drives create/dispose, so the modal gets the +// same lifecycle for free (it mounts the surface only while open). +const EmbedSurface = React.forwardRef((props, ref) => { + const { companyIdentifier, baseDomain, document: embedDocument, locale, context, className, style } = props; + const containerRef = React.useRef(null); + + // Keep callbacks + logger in a ref so changing them does not remount the iframe. + const callbacksRef = React.useRef({ onEmbedEvent: props.onEmbedEvent, logger: props.logger }); + callbacksRef.current = { onEmbedEvent: props.onEmbedEvent, logger: props.logger }; + + // A stable logger that always delegates to the latest `logger` prop, so a + // changed logger reaches the already-mounted bridge without a remount. + const stableLogger = React.useMemo(() => { + const delegate = + (level: 'debug' | 'info' | 'warn' | 'error') => + (event: string, payload: LogPayload): void => { + // A consumer logger that throws must never break the bridge or event + // forwarding (this is the catch sink for onEmbedEvent failures too). + try { + callbacksRef.current.logger?.[level](event, payload); + } catch { + // swallow: logging is best-effort. + } + }; + return { debug: delegate('debug'), info: delegate('info'), warn: delegate('warn'), error: delegate('error') }; + }, []); + + // Remount the iframe only when the editor config actually changes. Key a url / + // data-URL on the string itself; key a File/Blob on object IDENTITY via a counter + // that bumps when a different instance is passed (two distinct same-metadata Files + // must still remount). A fresh `{ url }` literal each render does not remount. + const currentFile = embedDocument !== undefined && 'file' in embedDocument ? embedDocument.file : null; + const fileKeyRef = React.useRef<{ file: Blob | null; key: number }>({ file: null, key: 0 }); + if (currentFile !== fileKeyRef.current.file) { + fileKeyRef.current = { file: currentFile, key: fileKeyRef.current.key + 1 }; + } + const documentSource = ((): string | null => { + if (embedDocument === undefined) { + return null; + } + if ('url' in embedDocument) { + return embedDocument.url; + } + if ('dataUrl' in embedDocument) { + return embedDocument.dataUrl; + } + return `file:${fileKeyRef.current.key}`; + })(); + const documentName = embedDocument?.name ?? null; + const documentPage = embedDocument?.page ?? null; + const contextKey = React.useMemo((): string => { + if (context === undefined) { + return 'null'; + } + try { + return JSON.stringify(context); + } catch { + // Circular / non-serializable context (a programmer error; encodeContext + // drops it too). Key on the top-level shape so render never throws. + return `unserializable:${Object.keys(context).sort().join(',')}`; + } + }, [context]); + + React.useEffect(() => { + const container = containerRef.current; + if (container === null) { + return; + } + const embed = createEmbed({ + target: container, + companyIdentifier, + baseDomain, + document: embedDocument, + locale, + context, + logger: stableLogger, + }); + assignRef(ref, toEmbedActions(embed)); + // Forward each editor event to onEmbedEvent as the verbatim 1.x { type, data }. The + // `forwarders` map is exhaustiveness-checked (satisfies) so a NEW editor event is a + // compile error here until it is forwarded; the explicit per-type subscriptions below + // keep each payload typed (no cast). The consumer callback is isolated so a throw / + // rejected promise can't break the bridge. + const forwardEvent = (event: EmbedEvent): void => { + void Promise.resolve() + .then(() => callbacksRef.current.onEmbedEvent?.(event)) + .catch((error) => { + stableLogger.error('on_embed_event_failed', { + message: error instanceof Error ? error.message : String(error), + }); + }); + }; + const forwarders = { + EDITOR_READY: (data) => forwardEvent({ type: 'EDITOR_READY', data }), + DOCUMENT_LOADED: (data) => forwardEvent({ type: 'DOCUMENT_LOADED', data }), + PAGE_FOCUSED: (data) => forwardEvent({ type: 'PAGE_FOCUSED', data }), + SUBMISSION_SENT: (data) => forwardEvent({ type: 'SUBMISSION_SENT', data }), + } satisfies { [TEventType in keyof EditorEventMap]: (data: EditorEventMap[TEventType]) => void }; + const unsubscribers = [ + embed.events.on('EDITOR_READY', forwarders.EDITOR_READY), + embed.events.on('DOCUMENT_LOADED', forwarders.DOCUMENT_LOADED), + embed.events.on('PAGE_FOCUSED', forwarders.PAGE_FOCUSED), + embed.events.on('SUBMISSION_SENT', forwarders.SUBMISSION_SENT), + ]; + return () => { + for (const unsubscribe of unsubscribers) { + unsubscribe(); + } + embed.lifecycle.dispose(); + assignRef(ref, null); + }; + // embedDocument/context are read here but fully determined by the document + // primitives + contextKey deps below; stableLogger is stable. `ref` is deliberately + // EXCLUDED: a stable object ref (the useEmbed norm) is captured once, and excluding it + // means an unstable inline callback ref can't trigger a full iframe teardown + remount + // (which would silently lose editor state) on every parent re-render. + }, [companyIdentifier, baseDomain, locale, documentSource, documentName, documentPage, contextKey, stableLogger]); + + return
; +}); +EmbedSurface.displayName = 'EmbedSurface'; + +const CloseIcon: React.FC = () => ( + + + +); + +// A valid React element is assumed to accept onClick + carry an href (DOM elements +// + components that forward them). Narrowing via a type guard avoids an `as` cast +// at the clone / href read. +const isTriggerElement = ( + node: React.ReactNode, +): node is React.ReactElement<{ href?: string; onClick?: React.MouseEventHandler }> => React.isValidElement(node); + +// The trigger child's href, the modal document fallback (1.x pattern: a +// trigger opens that PDF). The `document` prop takes precedence. +const hrefOf = (node: React.ReactNode): string | undefined => (isTriggerElement(node) ? node.props.href : undefined); + +// Click-to-open modal chrome. The editor surface is only mounted while open, so +// the embed is created on open and disposed on close. +const ModalChrome = ({ + trigger, + children, +}: { + trigger: React.ReactNode; + children: React.ReactNode; +}): React.ReactElement => { + const [isOpen, setIsOpen] = React.useState(false); + const handleOpen = React.useCallback((event: React.MouseEvent): void => { + event.preventDefault(); + setIsOpen(true); + }, []); + const handleClose = React.useCallback((): void => setIsOpen(false), []); + return ( + <> + {isOpen + ? createPortal( +
+
+ +
{children}
+
+
, + document.body, + ) + : null} + {isTriggerElement(trigger) ? React.cloneElement(trigger, { onClick: handleOpen }) : trigger} + + ); +}; + +// The single React entry point for embedding the editor: a click-to-open modal by +// default (1.x), or inline with `mode="inline"`. Forwards the typed Embed handle. +export const EmbedPDF = React.forwardRef((props, ref) => { + const companyIdentifier = props.companyIdentifier ?? DEFAULT_COMPANY_IDENTIFIER; + // `document` wins; otherwise the deprecated `documentURL` (1.x), resolved if relative. + const propDocument = + props.document ?? (props.documentURL !== undefined ? { url: toAbsoluteUrl(props.documentURL) } : undefined); + if (props.mode !== 'inline') { + // Modal is the default. Document: `document` / `documentURL`, else the trigger + // child's href (the 1.x pattern). On click, open the editor in a portal. + const triggerHref = hrefOf(props.children); + const modalDocument = propDocument ?? (triggerHref !== undefined ? { url: toAbsoluteUrl(triggerHref) } : undefined); + return ( + + + + ); + } + // Inline: the editor renders directly in your layout. + return ( + + ); +}); +EmbedPDF.displayName = 'EmbedPDF'; + +// --- useEmbed --------------------------------------------------------------- + +// The editor operations (the `embed.actions` group), derived from IframeActions so a new +// editor operation fails the build here until it is added. Two methods carry 1.x +// backward-compat overloads (the imperative API shipped in 1.x): `selectTool` also +// accepts the old positional tool, and `submit` also accepts the old +// `{ downloadCopyOnDevice }`. Both normalize to the new shape before hitting the (pure +// camelCase) core — the deprecated forms keep 1.x callers working, so 1.11 stays a +// non-breaking minor. +export type EmbedActions = Omit & { + /** Pass `{ tool }`. The bare tool value is the deprecated 1.x positional form. */ + selectTool: (input: SelectToolInput | SelectToolInput['tool']) => Promise; + /** Pass `{ downloadCopy }`. `{ downloadCopyOnDevice }` is the deprecated 1.x form. */ + submit: (input: SubmitInput | { downloadCopyOnDevice: boolean }) => Promise; +}; + +// The single hook. Attach `embedRef` to , then drive the +// editor with `actions` (imperative), stable + null-safe before the editor mounts; +// lifecycle is observed via . The agentic tools live in the +// opt-in `@simplepdf/react-embed-pdf/ai-sdk` subpath (useEmbedTools(embedRef)) — keeping +// `zod` off this entry, so a -only app never loads it. +export const useEmbed = (): { + embedRef: React.RefObject; + actions: EmbedActions; +} => { + const embedRef = React.useRef(null); + + const actions = React.useMemo( + () => ({ + createField: (input) => embedRef.current?.createField(input) ?? notMounted(), + deleteFields: (input) => embedRef.current?.deleteFields(input) ?? notMounted(), + deletePages: (input) => embedRef.current?.deletePages(input) ?? notMounted(), + detectFields: () => embedRef.current?.detectFields() ?? notMounted(), + download: () => embedRef.current?.download() ?? notMounted(), + focusField: (input) => embedRef.current?.focusField(input) ?? notMounted(), + getDocumentContent: (input) => embedRef.current?.getDocumentContent(input) ?? notMounted(), + getFields: () => embedRef.current?.getFields() ?? notMounted(), + goTo: (input) => embedRef.current?.goTo(input) ?? notMounted(), + loadDocument: (input) => embedRef.current?.loadDocument(input) ?? notMounted(), + movePage: (input) => embedRef.current?.movePage(input) ?? notMounted(), + rotatePage: (input) => embedRef.current?.rotatePage(input) ?? notMounted(), + // selectTool/submit normalize the deprecated 1.x arg shapes inside + // embedRef.current (the flat EmbedActions), so actions just delegate. + selectTool: (input) => embedRef.current?.selectTool(input) ?? notMounted(), + setFieldValue: (input) => embedRef.current?.setFieldValue(input) ?? notMounted(), + submit: (input) => embedRef.current?.submit(input) ?? notMounted(), + }), + [], + ); + + return { embedRef, actions }; +}; diff --git a/react/src/hook.test.ts b/react/src/hook.test.ts deleted file mode 100644 index bce15722..00000000 --- a/react/src/hook.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach, expectTypeOf } from 'vitest'; -import { renderHook } from '@testing-library/react'; -import { sendEvent, useEmbed, EmbedActions } from './hook'; - -type MessageEventHandler = (event: MessageEvent) => void; - -interface MockContentWindow { - postMessage: ReturnType; -} - -interface MockIframe { - contentWindow: MockContentWindow; -} - -const createMockIframe = (): { iframe: MockIframe; postMessageSpy: ReturnType } => { - const postMessageSpy = vi.fn(); - return { - iframe: { contentWindow: { postMessage: postMessageSpy } }, - postMessageSpy, - }; -}; - -const extractRequestId = (postMessageSpy: ReturnType): string => { - const rawMessage = postMessageSpy.mock.calls[0]?.[0]; - if (typeof rawMessage !== 'string') { - throw new Error('Expected postMessage to be called with a string'); - } - const parsed = JSON.parse(rawMessage); - return parsed.request_id; -}; - -const createMessageEvent = ({ - source, - data, -}: { - source: MockContentWindow | Record; - data: string; -}): MessageEvent => ({ source, data }) as unknown as MessageEvent; - -describe('sendEvent', () => { - let removeEventListenerSpy: ReturnType; - let messageHandler: MessageEventHandler | null = null; - - beforeEach(() => { - vi.useFakeTimers(); - - vi.spyOn(window, 'addEventListener').mockImplementation((type, handler) => { - if (type === 'message') { - messageHandler = handler as MessageEventHandler; - } - }); - - removeEventListenerSpy = vi.spyOn(window, 'removeEventListener').mockImplementation(() => {}); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - messageHandler = null; - }); - - it('resolves with result when matching REQUEST_RESULT received', async () => { - const { iframe } = createMockIframe(); - - const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); - - const requestId = extractRequestId(iframe.contentWindow.postMessage); - - messageHandler?.( - createMessageEvent({ - source: iframe.contentWindow, - data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: requestId, result: { success: true } }, - }), - }), - ); - - const result = await resultPromise; - expect(result).toEqual({ success: true }); - }); - - it('ignores messages from different source', async () => { - const { iframe } = createMockIframe(); - - const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); - - const requestId = extractRequestId(iframe.contentWindow.postMessage); - - messageHandler?.( - createMessageEvent({ - source: { postMessage: vi.fn() }, - data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: requestId, result: { success: true } }, - }), - }), - ); - - vi.advanceTimersByTime(30000); - const result = await resultPromise; - expect(result).toEqual({ - success: false, - error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, - }); - }); - - it('ignores messages with different request_id', async () => { - const { iframe } = createMockIframe(); - - const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); - - messageHandler?.( - createMessageEvent({ - source: iframe.contentWindow, - data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: 'wrong_id', result: { success: true } }, - }), - }), - ); - - vi.advanceTimersByTime(30000); - const result = await resultPromise; - expect(result).toEqual({ - success: false, - error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, - }); - }); - - it('ignores non-REQUEST_RESULT messages', async () => { - const { iframe } = createMockIframe(); - - const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); - - messageHandler?.( - createMessageEvent({ - source: iframe.contentWindow, - data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), - }), - ); - - vi.advanceTimersByTime(30000); - const result = await resultPromise; - expect(result).toEqual({ - success: false, - error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, - }); - }); - - it('times out after 30 seconds with error result', async () => { - const { iframe } = createMockIframe(); - - const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); - - vi.advanceTimersByTime(30000); - - const result = await resultPromise; - expect(result).toEqual({ - success: false, - error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, - }); - }); - - it('removes event listener after successful response', async () => { - const { iframe } = createMockIframe(); - - const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); - - const requestId = extractRequestId(iframe.contentWindow.postMessage); - - messageHandler?.( - createMessageEvent({ - source: iframe.contentWindow, - data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: requestId, result: { success: true } }, - }), - }), - ); - - await resultPromise; - expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); - }); - - it('removes event listener after timeout', async () => { - const { iframe } = createMockIframe(); - - const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); - - vi.advanceTimersByTime(30000); - - await resultPromise; - expect(removeEventListenerSpy).toHaveBeenCalledWith('message', expect.any(Function)); - }); - - it('handles invalid JSON in message gracefully', async () => { - const { iframe } = createMockIframe(); - - const resultPromise = sendEvent(iframe as unknown as HTMLIFrameElement, { type: 'SUBMIT', data: {} }); - - messageHandler?.( - createMessageEvent({ - source: iframe.contentWindow, - data: 'not valid json', - }), - ); - - vi.advanceTimersByTime(30000); - const result = await resultPromise; - expect(result).toEqual({ - success: false, - error: { code: 'unexpected:request_timed_out', message: 'The request timed out: try again' }, - }); - }); -}); - -describe('useEmbed', () => { - const expectedError = { - success: false, - error: { - code: 'bad_request:embed_ref_not_available', - message: 'embedRef is not available: make sure to pass embedRef to the component', - }, - }; - - describe('initial state', () => { - it('returns embedRef and actions object', () => { - const { result } = renderHook(() => useEmbed()); - - expect(result.current.embedRef).toBeDefined(); - expect(result.current.embedRef.current).toBeNull(); - expect(result.current.actions).toBeDefined(); - }); - }); - - describe('actions without ref attached', () => { - it('goTo returns error when embedRef not attached', async () => { - const { result } = renderHook(() => useEmbed()); - const actionResult = await result.current.actions.goTo({ page: 1 }); - expect(actionResult).toEqual(expectedError); - }); - - it('selectTool returns error when embedRef not attached', async () => { - const { result } = renderHook(() => useEmbed()); - const actionResult = await result.current.actions.selectTool('TEXT'); - expect(actionResult).toEqual(expectedError); - }); - - it('detectFields returns error when embedRef not attached', async () => { - const { result } = renderHook(() => useEmbed()); - const actionResult = await result.current.actions.detectFields(); - expect(actionResult).toEqual(expectedError); - }); - - it('deleteFields returns error when embedRef not attached', async () => { - const { result } = renderHook(() => useEmbed()); - const actionResult = await result.current.actions.deleteFields({}); - expect(actionResult).toEqual(expectedError); - }); - - it('getDocumentContent returns error when embedRef not attached', async () => { - const { result } = renderHook(() => useEmbed()); - const actionResult = await result.current.actions.getDocumentContent({ extractionMode: 'auto' }); - expect(actionResult).toEqual(expectedError); - }); - - it('submit returns error when embedRef not attached', async () => { - const { result } = renderHook(() => useEmbed()); - const actionResult = await result.current.actions.submit({ downloadCopyOnDevice: false }); - expect(actionResult).toEqual(expectedError); - }); - }); - - describe('actions with ref attached', () => { - const createMockEmbedRef = (): { - ref: EmbedActions; - spies: Record>; - } => { - const spies = { - goTo: vi.fn().mockResolvedValue({ success: true }), - selectTool: vi.fn().mockResolvedValue({ success: true }), - detectFields: vi.fn().mockResolvedValue({ success: true }), - deleteFields: vi.fn().mockResolvedValue({ success: true }), - getDocumentContent: vi.fn().mockResolvedValue({ success: true }), - submit: vi.fn().mockResolvedValue({ success: true }), - }; - - return { - ref: spies, - spies, - }; - }; - - it('goTo delegates to ref.goTo', async () => { - const { result } = renderHook(() => useEmbed()); - const { ref, spies } = createMockEmbedRef(); - (result.current.embedRef as React.MutableRefObject).current = ref; - - const actionResult = await result.current.actions.goTo({ page: 1 }); - - expect(spies.goTo).toHaveBeenCalledWith({ page: 1 }); - expect(actionResult).toEqual({ success: true }); - }); - - it('selectTool delegates to ref.selectTool', async () => { - const { result } = renderHook(() => useEmbed()); - const { ref, spies } = createMockEmbedRef(); - (result.current.embedRef as React.MutableRefObject).current = ref; - - const actionResult = await result.current.actions.selectTool('TEXT'); - - expect(spies.selectTool).toHaveBeenCalledWith('TEXT'); - expect(actionResult).toEqual({ success: true }); - }); - - it('detectFields delegates to ref.detectFields', async () => { - const { result } = renderHook(() => useEmbed()); - const { ref, spies } = createMockEmbedRef(); - (result.current.embedRef as React.MutableRefObject).current = ref; - - const actionResult = await result.current.actions.detectFields(); - - expect(spies.detectFields).toHaveBeenCalled(); - expect(actionResult).toEqual({ success: true }); - }); - - it('deleteFields delegates to ref.deleteFields', async () => { - const { result } = renderHook(() => useEmbed()); - const { ref, spies } = createMockEmbedRef(); - (result.current.embedRef as React.MutableRefObject).current = ref; - - const actionResult = await result.current.actions.deleteFields({}); - - expect(spies.deleteFields).toHaveBeenCalledWith({}); - expect(actionResult).toEqual({ success: true }); - }); - - it('getDocumentContent delegates to ref.getDocumentContent', async () => { - const { result } = renderHook(() => useEmbed()); - const { ref, spies } = createMockEmbedRef(); - (result.current.embedRef as React.MutableRefObject).current = ref; - - const actionResult = await result.current.actions.getDocumentContent({ extractionMode: 'auto' }); - - expect(spies.getDocumentContent).toHaveBeenCalledWith({ extractionMode: 'auto' }); - expect(actionResult).toEqual({ success: true }); - }); - - it('submit delegates to ref.submit', async () => { - const { result } = renderHook(() => useEmbed()); - const { ref, spies } = createMockEmbedRef(); - (result.current.embedRef as React.MutableRefObject).current = ref; - - const actionResult = await result.current.actions.submit({ downloadCopyOnDevice: false }); - - expect(spies.submit).toHaveBeenCalledWith({ downloadCopyOnDevice: false }); - expect(actionResult).toEqual({ success: true }); - }); - }); - - it('maintains stable action references across renders', () => { - const { result, rerender } = renderHook(() => useEmbed()); - - const initialActions = result.current.actions; - rerender(); - - expect(result.current.actions.goTo).toBe(initialActions.goTo); - expect(result.current.actions.submit).toBe(initialActions.submit); - }); -}); - -describe('Type assertions', () => { - // These types are intentionally inlined to act as a "frozen" contract. - // If the actual types change, these tests will fail at compile time. - - type ExpectedToolType = 'TEXT' | 'COMB_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE'; - - type ExpectedErrorResult = { - success: false; - error: { code: string; message: string }; - }; - - type ExpectedSuccessResult = TData extends undefined - ? { success: true } - : { success: true; data: TData }; - - type ExpectedActionResult = ExpectedSuccessResult | ExpectedErrorResult; - - describe('EmbedActions', () => { - it('goTo accepts { page: number } and returns ActionResult', () => { - expectTypeOf().parameter(0).toEqualTypeOf<{ page: number }>(); - expectTypeOf().returns.resolves.toExtend(); - }); - - it('selectTool accepts ToolType | null and returns ActionResult', () => { - expectTypeOf().parameter(0).toEqualTypeOf(); - expectTypeOf().returns.resolves.toExtend(); - }); - - it('detectFields accepts no arguments and returns ActionResult', () => { - expectTypeOf().parameters.toEqualTypeOf<[]>(); - expectTypeOf().returns.resolves.toExtend(); - }); - - it('deleteFields accepts optional { fieldIds?, page? } and returns ActionResult with deleted_count', () => { - expectTypeOf() - .parameter(0) - .toEqualTypeOf<{ fieldIds?: string[]; page?: number } | undefined>(); - expectTypeOf().returns.resolves.toExtend< - ExpectedActionResult<{ deleted_count: number }> - >(); - }); - - it('getDocumentContent requires { extractionMode } and returns ActionResult with document content', () => { - expectTypeOf().parameter(0).toEqualTypeOf<{ - extractionMode: 'auto' | 'ocr'; - }>(); - expectTypeOf().returns.resolves.toExtend< - ExpectedActionResult<{ name: string; pages: { page: number; content: string }[] }> - >(); - }); - - it('submit requires { downloadCopyOnDevice } and returns ActionResult', () => { - expectTypeOf().parameter(0).toEqualTypeOf<{ downloadCopyOnDevice: boolean }>(); - expectTypeOf().returns.resolves.toExtend(); - }); - }); -}); diff --git a/react/src/hook.tsx b/react/src/hook.tsx deleted file mode 100644 index 9fbb6e00..00000000 --- a/react/src/hook.tsx +++ /dev/null @@ -1,185 +0,0 @@ -import * as React from 'react'; -import { generateRandomID } from './utils'; - -const DEFAULT_REQUEST_TIMEOUT_IN_MS = 30000; - -type ExtractionMode = 'auto' | 'ocr'; - -type ToolType = 'TEXT' | 'COMB_TEXT' | 'CHECKBOX' | 'PICTURE' | 'SIGNATURE'; - -type ErrorCodePrefix = 'bad_request' | 'unexpected' | 'forbidden'; - -type ErrorResult = { - success: false; - error: { code: `${ErrorCodePrefix}:${string}`; message: string }; -}; - -type SuccessResult = TData extends undefined ? { success: true } : { success: true; data: TData }; - -type ActionResult = SuccessResult | ErrorResult; - -type DocumentContentPage = { - page: number; - content: string; -}; - -type DocumentContentResult = { - name: string; - pages: DocumentContentPage[]; -}; - -type DeleteFieldsResult = { - deleted_count: number; -}; - -export type EmbedActions = { - goTo: (options: { page: number }) => Promise; - - selectTool: (toolType: ToolType | null) => Promise; - - detectFields: () => Promise; - - deleteFields: (options?: { fieldIds?: string[]; page?: number }) => Promise>; - - getDocumentContent: (options: { extractionMode: ExtractionMode }) => Promise>; - - submit: (options: { downloadCopyOnDevice: boolean }) => Promise; -}; - -export type EventPayload = { - type: string; - data: unknown; -}; - -type RequestResultEvent = { - type: 'REQUEST_RESULT'; - data: { - request_id: string; - result: ActionResult; - }; -}; - -export const sendEvent = ( - iframe: HTMLIFrameElement, - payload: EventPayload, -): Promise> => { - const requestId = generateRandomID(); - return new Promise>((resolve) => { - const handleMessage = (event: MessageEvent) => { - const parsedEvent: RequestResultEvent | null = (() => { - try { - const parsed = JSON.parse(event.data); - if (parsed.type !== 'REQUEST_RESULT') { - return null; - } - return parsed; - } catch { - return null; - } - })(); - - if (parsedEvent === null) { - return; - } - - const isTargetIframe = event.source === iframe.contentWindow; - const isMatchingResponse = parsedEvent.data.request_id === requestId; - - if (isTargetIframe && isMatchingResponse) { - resolve(parsedEvent.data.result); - window.removeEventListener('message', handleMessage); - clearTimeout(timeoutId); - } - }; - - window.addEventListener('message', handleMessage); - - iframe.contentWindow?.postMessage(JSON.stringify({ ...payload, request_id: requestId }), '*'); - - const timeoutId = setTimeout(() => { - resolve({ - success: false, - error: { - code: 'unexpected:request_timed_out', - message: 'The request timed out: try again', - }, - }); - window.removeEventListener('message', handleMessage); - }, DEFAULT_REQUEST_TIMEOUT_IN_MS); - }); -}; - -export const useEmbed = (): { embedRef: React.RefObject; actions: EmbedActions } => { - const embedRef = React.useRef(null); - - const createAction = ( - actionFn: (ref: EmbedActions, ...args: TArgs) => Promise>, - ): ((...args: TArgs) => Promise>) => { - return async (...args: TArgs): Promise> => { - if (embedRef.current === null) { - return { - success: false, - error: { - code: 'bad_request:embed_ref_not_available', - message: 'embedRef is not available: make sure to pass embedRef to the component', - }, - }; - } - return actionFn(embedRef.current, ...args); - }; - }; - - const handleGoTo = React.useCallback( - createAction<[{ page: number }]>(async (ref, options) => { - return ref.goTo(options); - }), - [], - ); - - const handleSelectTool = React.useCallback( - createAction<[ToolType | null]>(async (ref, toolType) => { - return ref.selectTool(toolType); - }), - [], - ); - - const handleDetectFields = React.useCallback( - createAction(async (ref) => { - return ref.detectFields(); - }), - [], - ); - - const handleDeleteFields = React.useCallback( - createAction<[{ fieldIds?: string[]; page?: number }?], DeleteFieldsResult>(async (ref, options) => { - return ref.deleteFields(options); - }), - [], - ); - - const handleGetDocumentContent = React.useCallback( - createAction<[{ extractionMode: ExtractionMode }], DocumentContentResult>(async (ref, options) => { - return ref.getDocumentContent(options); - }), - [], - ); - - const handleSubmit = React.useCallback( - createAction<[{ downloadCopyOnDevice: boolean }]>(async (ref, options) => { - return ref.submit(options); - }), - [], - ); - - return { - embedRef, - actions: { - goTo: handleGoTo, - selectTool: handleSelectTool, - detectFields: handleDetectFields, - deleteFields: handleDeleteFields, - getDocumentContent: handleGetDocumentContent, - submit: handleSubmit, - }, - }; -}; diff --git a/react/src/index.test.tsx b/react/src/index.test.tsx deleted file mode 100644 index c7542ad7..00000000 --- a/react/src/index.test.tsx +++ /dev/null @@ -1,826 +0,0 @@ -/// - -import * as React from 'react'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen, waitFor, act } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { EmbedPDF } from './index'; -import type { EmbedActions } from './hook'; - -vi.mock('./styles.scss', () => ({})); - -/** - * These tests focus on user-facing behavior: - * - What users see (iframe, modal, styling) - * - How users interact (clicking triggers, calling ref methods) - * - What users receive (events via callbacks) - * - * Tests intentionally avoid implementation details like: - * - Internal postMessage protocol format - * - Internal state machine transitions - * - Internal timing workarounds - */ - -type MessageEventHandler = (event: MessageEvent) => void; - -const createMessageEvent = ({ - origin, - source, - data, -}: { - origin: string; - source: Window | null; - data: string; -}): MessageEvent => ({ origin, source, data }) as MessageEvent; - -const getIframe = (): HTMLIFrameElement => { - const iframe = screen.getByTitle('SimplePDF'); - return iframe as HTMLIFrameElement; -}; - -const getIframeSrcUrl = (): URL => { - const iframe = getIframe(); - const src = iframe.getAttribute('src'); - if (src === null) { - throw new Error('Expected iframe to have src attribute'); - } - return new URL(src); -}; - -class MockFileReader { - result: string | null = 'data:application/pdf;base64,dGVzdA=='; - onload: ((e: ProgressEvent) => void) | null = null; - onerror: ((e: ProgressEvent) => void) | null = null; - - readAsDataURL(): void { - queueMicrotask(() => { - this.onload?.({} as ProgressEvent); - }); - } -} - -class MockFileReaderWithError { - result: string | null = null; - onload: ((e: ProgressEvent) => void) | null = null; - onerror: ((e: ProgressEvent) => void) | null = null; - - readAsDataURL(): void { - queueMicrotask(() => { - this.onerror?.({} as ProgressEvent); - }); - } -} - -describe('EmbedPDF', () => { - let messageHandler: MessageEventHandler | null = null; - - beforeEach(() => { - vi.spyOn(window, 'addEventListener').mockImplementation((type, handler) => { - if (type === 'message') { - messageHandler = handler as MessageEventHandler; - } - }); - vi.spyOn(window, 'removeEventListener').mockImplementation(() => {}); - }); - - afterEach(() => { - messageHandler = null; - }); - - describe('inline mode', () => { - it('renders an iframe with title', () => { - render(); - - expect(screen.getByTitle('SimplePDF')).toBeInTheDocument(); - }); - - it('applies className prop to iframe', () => { - render(); - - expect(screen.getByTitle('SimplePDF')).toHaveClass('custom-class'); - }); - - it('applies style prop to iframe with border: 0', () => { - render(); - - const iframe = screen.getByTitle('SimplePDF'); - expect(iframe).toHaveStyle({ width: '100%', height: '500px', border: '0px' }); - }); - - it.each([ - { baseDomain: undefined, companyIdentifier: undefined, expectedOrigin: 'https://react-editor.simplepdf.com' }, - { baseDomain: 'custom.com', companyIdentifier: 'myco', expectedOrigin: 'https://myco.custom.com' }, - { baseDomain: 'simplepdf.nil:3000', companyIdentifier: 'e2e', expectedOrigin: 'http://e2e.simplepdf.nil:3000' }, - ])( - 'sets iframe src with correct domain ($expectedOrigin)', - async ({ baseDomain, companyIdentifier, expectedOrigin }) => { - render(); - - await waitFor(() => { - const url = getIframeSrcUrl(); - expect(url.origin).toBe(expectedOrigin); - expect(url.pathname).toBe('/en/editor'); - }); - }, - ); - - it.each([ - { locale: undefined, expectedPath: '/en/editor' }, - { locale: 'fr' as const, expectedPath: '/fr/editor' }, - { locale: 'de' as const, expectedPath: '/de/editor' }, - ])('sets correct locale in URL path ($expectedPath)', async ({ locale, expectedPath }) => { - render(); - - await waitFor(() => { - const url = getIframeSrcUrl(); - expect(url.pathname).toBe(expectedPath); - }); - }); - - it('adds loadingPlaceholder param when documentURL provided', async () => { - render(); - - await waitFor(() => { - const url = getIframeSrcUrl(); - expect(url.searchParams.get('loadingPlaceholder')).toBe('true'); - }); - }); - - it('adds context param when context provided', async () => { - render(); - - await waitFor(() => { - const url = getIframeSrcUrl(); - const encodedContext = url.searchParams.get('context'); - if (encodedContext === null) { - throw new Error('Expected context param to be present'); - } - const decodedContext = JSON.parse(atob(decodeURIComponent(encodedContext))); - expect(decodedContext).toEqual({ key: 'value' }); - }); - }); - - it('sets up message event listener on mount', () => { - render(); - - expect(window.addEventListener).toHaveBeenCalledWith('message', expect.any(Function), false); - }); - - it('removes message event listener on unmount', () => { - const { unmount } = render(); - - unmount(); - - expect(window.removeEventListener).toHaveBeenCalledWith('message', expect.any(Function)); - }); - }); - - describe('modal mode', () => { - it('renders children trigger element', () => { - render( - - - , - ); - - expect(screen.getByRole('button', { name: 'Open PDF Editor' })).toBeInTheDocument(); - }); - - it('does not render modal initially', () => { - render( - - - , - ); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('opens modal when trigger clicked', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - it('renders modal with aria-modal attribute', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); - - expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); - }); - - it('renders close button with aria-label', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); - - expect(screen.getByRole('button', { name: 'Close PDF editor modal' })).toBeInTheDocument(); - }); - - it('closes modal when close button clicked', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - - await user.click(screen.getByRole('button', { name: 'Close PDF editor modal' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('renders iframe inside modal', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); - - expect(screen.getByTitle('SimplePDF')).toBeInTheDocument(); - }); - - it('sets iframe src with editor URL when modal opens', async () => { - const user = userEvent.setup(); - render( - - - , - ); - - await user.click(screen.getByRole('button', { name: 'Open PDF Editor' })); - - const url = getIframeSrcUrl(); - expect(url.origin).toBe('https://testco.simplepdf.com'); - expect(url.pathname).toBe('/en/editor'); - }); - - it('extracts href from anchor child for document loading', async () => { - const user = userEvent.setup(); - render( - -
Edit PDF - , - ); - - await user.click(screen.getByRole('link', { name: 'Edit PDF' })); - - const url = getIframeSrcUrl(); - expect(url.searchParams.get('loadingPlaceholder')).toBe('true'); - }); - - it('renders null for non-element children', () => { - render({'plain text'}); - - expect(screen.queryByText('plain text')).not.toBeInTheDocument(); - }); - }); - - describe('ref handlers', () => { - it('exposes action methods via ref', async () => { - const ref = React.createRef(); - - render(); - - await waitFor(() => { - expect(ref.current).not.toBeNull(); - }); - - expect(typeof ref.current?.goTo).toBe('function'); - expect(typeof ref.current?.selectTool).toBe('function'); - expect(typeof ref.current?.detectFields).toBe('function'); - expect(typeof ref.current?.deleteFields).toBe('function'); - expect(typeof ref.current?.getDocumentContent).toBe('function'); - expect(typeof ref.current?.submit).toBe('function'); - }); - - describe('action methods before modal opens (iframe not available)', () => { - it.each([ - { action: 'goTo' as const, args: { page: 1 } }, - { action: 'selectTool' as const, args: 'TEXT' as const }, - { action: 'detectFields' as const, args: undefined }, - { action: 'deleteFields' as const, args: {} }, - { action: 'getDocumentContent' as const, args: {} }, - { action: 'submit' as const, args: { downloadCopyOnDevice: false } }, - ])('$action returns error when iframe not available (modal not opened)', async ({ action, args }) => { - const ref = React.createRef(); - - render( - - - , - ); - - await waitFor(() => { - expect(ref.current).not.toBeNull(); - }); - - if (ref.current === null) { - throw new Error('Expected ref to be available'); - } - - const result = await (ref.current[action] as (arg: never) => Promise)(args as never); - - expect(result).toEqual({ - success: false, - error: { - code: 'unexpected:iframe_not_available', - message: 'Iframe not available', - }, - }); - }); - }); - - describe('action methods when editor is ready (inline mode)', () => { - interface MockContentWindow { - postMessage: ReturnType; - } - - const setupMockContentWindow = (iframe: HTMLIFrameElement): MockContentWindow => { - const mockContentWindow: MockContentWindow = { - postMessage: vi.fn(), - }; - Object.defineProperty(iframe, 'contentWindow', { - value: mockContentWindow, - writable: true, - }); - return mockContentWindow; - }; - - const simulateEditorReady = async ( - iframe: HTMLIFrameElement, - mockContentWindow: MockContentWindow, - ): Promise => { - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: mockContentWindow as unknown as Window, - data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), - }), - ); - }); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: mockContentWindow as unknown as Window, - data: JSON.stringify({ type: 'DOCUMENT_LOADED', data: { document_id: 'doc123' } }), - }), - ); - }); - - // Allow editor ready promise to resolve - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 250)); - }); - }; - - const extractRequestId = (mockContentWindow: MockContentWindow): string => { - const calls = mockContentWindow.postMessage.mock.calls; - const lastCall = calls[calls.length - 1]; - if (!lastCall || typeof lastCall[0] !== 'string') { - throw new Error('Expected postMessage to be called with a string'); - } - const parsed = JSON.parse(lastCall[0]); - return parsed.request_id; - }; - - const simulateActionResponse = async ({ - mockContentWindow, - result, - }: { - mockContentWindow: MockContentWindow; - result: { success: true } | { success: true; data: unknown }; - }): Promise => { - const requestId = extractRequestId(mockContentWindow); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: mockContentWindow as unknown as Window, - data: JSON.stringify({ - type: 'REQUEST_RESULT', - data: { request_id: requestId, result }, - }), - }), - ); - }); - }; - - it('goTo resolves with success when iframe responds', async () => { - const ref = React.createRef(); - - render(); - - const iframe = getIframe(); - const mockContentWindow = setupMockContentWindow(iframe); - - await simulateEditorReady(iframe, mockContentWindow); - - if (ref.current === null) { - throw new Error('Expected ref to be available'); - } - - const resultPromise = ref.current.goTo({ page: 2 }); - - await waitFor(() => { - expect(mockContentWindow.postMessage).toHaveBeenCalled(); - }); - - await simulateActionResponse({ - mockContentWindow, - result: { success: true }, - }); - - const result = await resultPromise; - expect(result).toEqual({ success: true }); - }); - - it('submit resolves with success when iframe responds', async () => { - const ref = React.createRef(); - - render(); - - const iframe = getIframe(); - const mockContentWindow = setupMockContentWindow(iframe); - - await simulateEditorReady(iframe, mockContentWindow); - - if (ref.current === null) { - throw new Error('Expected ref to be available'); - } - - const resultPromise = ref.current.submit({ downloadCopyOnDevice: false }); - - await waitFor(() => { - expect(mockContentWindow.postMessage).toHaveBeenCalled(); - }); - - await simulateActionResponse({ - mockContentWindow, - result: { success: true }, - }); - - const result = await resultPromise; - expect(result).toEqual({ success: true }); - }); - - it('getDocumentContent resolves with data when iframe responds', async () => { - const ref = React.createRef(); - - render(); - - const iframe = getIframe(); - const mockContentWindow = setupMockContentWindow(iframe); - - await simulateEditorReady(iframe, mockContentWindow); - - if (ref.current === null) { - throw new Error('Expected ref to be available'); - } - - const resultPromise = ref.current.getDocumentContent({ extractionMode: 'auto' }); - - await waitFor(() => { - expect(mockContentWindow.postMessage).toHaveBeenCalled(); - }); - - await simulateActionResponse({ - mockContentWindow, - result: { - success: true, - data: { - name: 'document.pdf', - pages: [{ page: 1, content: 'Hello world' }], - }, - }, - }); - - const result = await resultPromise; - expect(result).toEqual({ - success: true, - data: { - name: 'document.pdf', - pages: [{ page: 1, content: 'Hello world' }], - }, - }); - }); - }); - }); - - describe('event handling', () => { - it('calls onEmbedEvent when EDITOR_READY received', async () => { - const onEmbedEvent = vi.fn(); - - render(); - - const iframe = getIframe(); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: iframe.contentWindow, - data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), - }), - ); - }); - - expect(onEmbedEvent).toHaveBeenCalledWith({ type: 'EDITOR_READY', data: {} }); - }); - - it.each([ - { type: 'DOCUMENT_LOADED', data: { document_id: 'doc123' } }, - { type: 'PAGE_FOCUSED', data: { previous_page: 1, current_page: 2, total_pages: 5 } }, - { type: 'SUBMISSION_SENT', data: { document_id: 'doc123', submission_id: 'sub456' } }, - ])('calls onEmbedEvent for $type', async ({ type, data }) => { - const onEmbedEvent = vi.fn(); - - render(); - - const iframe = getIframe(); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: iframe.contentWindow, - data: JSON.stringify({ type, data }), - }), - ); - }); - - expect(onEmbedEvent).toHaveBeenCalledWith({ type, data }); - }); - - it('ignores events from different origins', async () => { - const onEmbedEvent = vi.fn(); - - render(); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://malicious.com', - source: null, - data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), - }), - ); - }); - - expect(onEmbedEvent).not.toHaveBeenCalled(); - }); - - it('ignores events from untrusted iframe source', async () => { - const onEmbedEvent = vi.fn(); - - render(); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: {} as Window, - data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), - }), - ); - }); - - expect(onEmbedEvent).not.toHaveBeenCalled(); - }); - - it('handles invalid JSON in message gracefully', async () => { - const onEmbedEvent = vi.fn(); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - render(); - - const iframe = getIframe(); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: iframe.contentWindow, - data: 'not valid json', - }), - ); - }); - - expect(onEmbedEvent).not.toHaveBeenCalled(); - expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to parse iFrame event payload'); - }); - - it('handles error when onEmbedEvent throws', async () => { - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const onEmbedEvent = vi.fn().mockRejectedValue(new Error('Handler error')); - - render(); - - const iframe = getIframe(); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: iframe.contentWindow, - data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), - }), - ); - }); - - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('onEmbedEvent failed to execute')); - }); - }); - - describe('document loading', () => { - let originalFetch: typeof globalThis.fetch; - let originalFileReader: typeof globalThis.FileReader; - - beforeEach(() => { - originalFetch = globalThis.fetch; - originalFileReader = globalThis.FileReader; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - globalThis.FileReader = originalFileReader; - }); - - it('calls fetch with documentURL when provided', async () => { - const fetchMock = vi.fn().mockImplementation( - () => - new Promise(() => { - // Never resolve to prevent state updates - }), - ); - globalThis.fetch = fetchMock; - - render(); - - await waitFor(() => { - expect(fetchMock).toHaveBeenCalledWith('https://example.com/document.pdf', { - method: 'GET', - credentials: 'same-origin', - }); - }); - }); - - it('adds open param to URL when CORS fallback is triggered', async () => { - globalThis.fetch = vi.fn().mockRejectedValue(new Error('CORS error')); - - render(); - - await waitFor(() => { - const url = getIframeSrcUrl(); - expect(url.searchParams.get('open')).toBe('https://example.com/doc.pdf'); - }); - }); - - it('falls back to CORS proxy when fetch returns non-ok response', async () => { - globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 403 }); - - render(); - - await waitFor(() => { - const url = getIframeSrcUrl(); - expect(url.searchParams.get('open')).toBe('https://example.com/forbidden.pdf'); - }); - }); - - it('loads document via fetch and FileReader when CORS allows', async () => { - const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' }); - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(mockBlob), - }); - - globalThis.FileReader = MockFileReader as unknown as typeof FileReader; - - render(); - - await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalledWith('https://example.com/doc.pdf', { - method: 'GET', - credentials: 'same-origin', - }); - }); - }); - - it('falls back to CORS proxy when FileReader fails', async () => { - const mockBlob = new Blob(['pdf content'], { type: 'application/pdf' }); - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - blob: () => Promise.resolve(mockBlob), - }); - - globalThis.FileReader = MockFileReaderWithError as unknown as typeof FileReader; - - render(); - - await waitFor(() => { - const url = getIframeSrcUrl(); - expect(url.searchParams.get('open')).toBe('https://example.com/doc.pdf'); - }); - }); - - it('handles unmount during fetch without errors', async () => { - let resolveFetch: (value: Response) => void; - globalThis.fetch = vi.fn().mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const { unmount } = render(); - - await waitFor(() => { - expect(globalThis.fetch).toHaveBeenCalled(); - }); - - unmount(); - - await act(async () => { - resolveFetch!({ - ok: true, - blob: () => Promise.resolve(new Blob(['pdf'], { type: 'application/pdf' })), - } as Response); - }); - - expect(consoleErrorSpy).not.toHaveBeenCalledWith( - expect.stringContaining("Can't perform a React state update on an unmounted component"), - ); - }); - }); - - describe('context changes', () => { - it('re-encodes context when it changes', () => { - const decodeContext = (url: URL): unknown => { - const encoded = url.searchParams.get('context'); - if (encoded === null) { - throw new Error('Expected context param to be present'); - } - return JSON.parse(atob(decodeURIComponent(encoded))); - }; - - const { rerender } = render(); - - const initialUrl = getIframeSrcUrl(); - expect(decodeContext(initialUrl)).toEqual({ v: 1 }); - - rerender(); - - const newUrl = getIframeSrcUrl(); - expect(decodeContext(newUrl)).toEqual({ v: 2 }); - }); - }); - - describe('unknown event types', () => { - it('ignores unknown event types', async () => { - const onEmbedEvent = vi.fn(); - - render(); - - const iframe = getIframe(); - - await act(async () => { - messageHandler?.( - createMessageEvent({ - origin: 'https://react-editor.simplepdf.com', - source: iframe.contentWindow, - data: JSON.stringify({ type: 'UNKNOWN_EVENT', data: {} }), - }), - ); - }); - - expect(onEmbedEvent).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/react/src/index.tsx b/react/src/index.tsx index f8e53b7f..d3b664ed 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -1,418 +1,28 @@ -import * as React from 'react'; -import { createPortal } from 'react-dom'; -import { sendEvent, EmbedActions, useEmbed } from './hook'; -import { buildEditorDomain, encodeContext, buildEditorURL, extractDocumentName, type Locale } from './utils'; - -import './styles.scss'; - -export { useEmbed }; - -export type EmbedEvent = - | { type: 'EDITOR_READY'; data: Record } - | { type: 'DOCUMENT_LOADED'; data: { document_id: string } } - | { type: 'PAGE_FOCUSED'; data: { previous_page: number | null; current_page: number; total_pages: number } } - | { type: 'SUBMISSION_SENT'; data: { document_id: string; submission_id: string } }; - -type Props = InlineProps | ModalProps; - -interface CommonProps { - companyIdentifier?: string; - context?: Record; - onEmbedEvent?: (event: EmbedEvent) => Promise | void; - locale?: Locale; - /** - * Override the base domain for self-hosted deployments (e.g., "yourdomain.com"). - * Interested in enterprise self-hosting? Contact sales@simplepdf.com - */ - baseDomain?: string; -} - -interface InlineProps extends CommonProps { - mode: 'inline'; - className?: string; - style?: React.CSSProperties; - documentURL?: string; -} - -interface ModalProps extends CommonProps { - mode?: 'modal'; - children: React.ReactNode; -} - -interface InternalProps { - editorURL: string; -} - -const CloseIcon: React.FC = () => ( - - - -); - -const isInlineComponent = (props: Props): props is InlineProps => (props as InlineProps).mode === 'inline'; - -const InlineComponent = React.forwardRef>( - ({ className, style }, iframeRef) => { - return