diff --git a/.changeset/web-embed-on-core.md b/.changeset/web-embed-on-core.md new file mode 100644 index 00000000..95b8a581 --- /dev/null +++ b/.changeset/web-embed-on-core.md @@ -0,0 +1,11 @@ +--- +'@simplepdf/web-embed-pdf': minor +--- + +**Your pages are now ready for AI agents.** While the editor is open, an agent in your visitor's browser (ChatGPT's browser, Chrome with WebMCP) can read and fill the form, move between pages and submit it, with the same permissions as any other integration. It is on by default: add `webmcp="false"` to the script tag to turn it off. + +- **More PDF links open in SimplePDF**: `Report.PDF`, `form.pdf?v=2` and `guide.pdf#page=3` used to open in the browser. +- **Japanese and Dutch**: pages in either language open the editor in that language. +- **Stored documents open directly**: a link to a SimplePDF document (`/documents/`) opens it in the editor. +- **Clear errors for invalid company identifiers**: a value that cannot form an editor address (a URL, spaces, dots, underscores) logs an error and opens nothing. An identifier without an account still opens the editor, which explains the problem. +- **Same script, same API**: now built on `@simplepdf/embed`, the engine behind the React component. `window.simplePDF`, the script-tag attributes and the modal are unchanged. diff --git a/package-lock.json b/package-lock.json index f028ec76..8a1c04a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "embed": { "name": "@simplepdf/embed", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "devDependencies": { "@tanstack/ai": "^0.38.0", @@ -1484,6 +1484,67 @@ "node": ">= 8" } }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve/node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/@rollup/plugin-terser": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", @@ -2318,6 +2379,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -2894,6 +2962,16 @@ "node": ">=6" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -3442,6 +3520,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5698,10 +5783,10 @@ }, "react": { "name": "@simplepdf/react-embed-pdf", - "version": "1.12.0", + "version": "1.13.0", "license": "MIT", "dependencies": { - "@simplepdf/embed": "0.6.0" + "@simplepdf/embed": "0.7.0" }, "devDependencies": { "@rollup/plugin-terser": "^0.4.4", @@ -6658,7 +6743,11 @@ "name": "@simplepdf/web-embed-pdf", "version": "1.8.4", "license": "MIT", + "dependencies": { + "@simplepdf/embed": "0.7.0" + }, "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "@types/jsdom": "^21.1.7", "jsdom": "^26.0.0", diff --git a/web/README.md b/web/README.md index d399836d..0f781478 100644 --- a/web/README.md +++ b/web/README.md @@ -58,7 +58,7 @@ See [Data Privacy & companyIdentifier](../README.md#data-privacy--companyidentif ## How does it work? -**Anchor links (`a`) with an href pointing to a PDF file (`.pdf`) or [SimplePDF forms](https://simplepdf.com/portal) are automatically opened in [SimplePDF](https://simplepdf.com)** +**Anchor links (`a`) with an href pointing to a PDF file (`.pdf`, in any case, with or without a query string or fragment) or [SimplePDF forms](https://simplepdf.com/portal) are automatically opened in [SimplePDF](https://simplepdf.com)** ### I don't want every PDF document to be opened in SimplePDF @@ -89,6 +89,8 @@ SimplePDF automatically detects the language of the page (using the `lang` attri - Spanish (`es`) - French (`fr`) - Italian (`it`) +- Japanese (`ja`) +- Dutch (`nl`) - Portuguese (`pt`) **If you wish to override the automatic detection, you can specify the `locale` attribute on the script tag as follows**: @@ -104,6 +106,35 @@ SimplePDF automatically detects the language of the page (using the `lang` attri ``` +### In-browser agents (WebMCP) + +While the editor is open, an agent running in the visitor's browser (ChatGPT's browser, Chrome with WebMCP) finds the editor's operations as tools on your page: read and fill fields, move between pages, submit. Every call goes through the same permissions as any other integration. Nothing happens in browsers without an agent. + +To turn it off, add `webmcp="false"` to the script tag (any value other than `true` or `false` turns it off and logs an error): + + +```html + +``` + + +Or from JavaScript: + +```javascript +// Turn it off +window.simplePDF.setConfig({ webMCP: { enabled: false } }); + +// Keep it on, but withhold the operations a person must do themselves +window.simplePDF.setConfig({ webMCP: { enabled: true, exclude: ['submit'] } }); +``` + +The option applies to the next editor opened: an editor already open keeps the tools it registered until it closes. + ### Opening the editor programmatically #### Open the editor with a specific PDF diff --git a/web/package.json b/web/package.json index 3e4fb783..56b0e726 100644 --- a/web/package.json +++ b/web/package.json @@ -31,6 +31,7 @@ "start": "rollup -c -w" }, "devDependencies": { + "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", "@types/jsdom": "^21.1.7", "jsdom": "^26.0.0", @@ -50,5 +51,8 @@ "typescript", "npm", "pdf" - ] + ], + "dependencies": { + "@simplepdf/embed": "0.7.0" + } } diff --git a/web/rollup.config.js b/web/rollup.config.js index 0afab22c..2df3df01 100644 --- a/web/rollup.config.js +++ b/web/rollup.config.js @@ -1,5 +1,6 @@ import typescript from 'rollup-plugin-typescript2'; import terser from '@rollup/plugin-terser'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; import pkg from './package.json' with { type: 'json' }; @@ -7,6 +8,7 @@ function createOutputConfig({ file, minify }) { return { file, format: 'umd', + inlineDynamicImports: true, name: 'simplePDF', strict: true, plugins: minify ? [terser({ format: { comments: false } })] : [], @@ -22,6 +24,6 @@ export default { minify: true, }), ], - plugins: [typescript()], + plugins: [nodeResolve({ browser: true }), typescript()], external: [], }; diff --git a/web/src/__tests__/fixtures/pdf-link-cases.json b/web/src/__tests__/fixtures/pdf-link-cases.json new file mode 100644 index 00000000..89b0ecf5 --- /dev/null +++ b/web/src/__tests__/fixtures/pdf-link-cases.json @@ -0,0 +1,33 @@ +[ + { "href": "https://pdfobject.com/pdf/sample-3pp.pdf", "classes": [], "opens_in_simplepdf": true }, + { "href": "https://example.com/files/Consent.PDF", "classes": [], "opens_in_simplepdf": true }, + { "href": "https://example.com/uploads/intake-form.pdf?ver=2", "classes": [], "opens_in_simplepdf": true }, + { "href": "https://example.com/files/guide.pdf#page=3", "classes": [], "opens_in_simplepdf": true }, + { "href": "https://example.com/download.php?file=Form.PDF", "classes": [], "opens_in_simplepdf": true }, + { "href": "https://example.com/some-pdf-without-extension", "classes": ["simplepdf"], "opens_in_simplepdf": true }, + { + "href": "https://yourcompany.simplepdf.com/form/d8d57ec7-f3e9-4fc9-8cc5-4a92c02d30d0", + "classes": [], + "opens_in_simplepdf": true + }, + { + "href": "https://yourcompany.simplepdf.com/documents/d8d57ec7-f3e9-4fc9-8cc5-4a92c02d30d0", + "classes": [], + "opens_in_simplepdf": true + }, + { "href": "https://pdfobject.com/pdf/sample-3pp.pdf", "classes": ["exclude-simplepdf"], "opens_in_simplepdf": false }, + { + "href": "https://yourcompany.simplepdf.com/form/d8d57ec7-f3e9-4fc9-8cc5-4a92c02d30d0", + "classes": ["exclude-simplepdf"], + "opens_in_simplepdf": false + }, + { "href": "https://www.pdfsomething.com/anything", "classes": [], "opens_in_simplepdf": false }, + { "href": "https://www.website.com/some-pdf", "classes": [], "opens_in_simplepdf": false }, + { "href": "https://www.website.com/some-other.pdf.png", "classes": [], "opens_in_simplepdf": false }, + { + "href": "https://www.simplepdf.app/s/article/How-to-Manage-PDF-Settings", + "classes": [], + "opens_in_simplepdf": false + }, + { "href": "https://www.app.pdf/some-url", "classes": [], "opens_in_simplepdf": false } +] diff --git a/web/src/__tests__/open-editor.test.ts b/web/src/__tests__/open-editor.test.ts new file mode 100644 index 00000000..720e2b4a --- /dev/null +++ b/web/src/__tests__/open-editor.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +// The script tag passes untyped values: whatever a page puts in the attributes or in setConfig +// must either open the editor or refuse with a console error, never throw on the page. +describe('openEditor with untyped configuration', () => { + beforeAll(async () => { + await import('../index'); + }); + + afterEach(() => { + window.simplePDF?.closeEditor(); + window.simplePDF?.setConfig({ companyIdentifier: 'embed' }); + vi.restoreAllMocks(); + }); + + const openAndSettle = async (): Promise => { + window.simplePDF?.openEditor({ href: null }); + await new Promise((resolve) => setTimeout(resolve, 0)); + }; + + const readEditorOrigin = (): string | null => { + const source = document.getElementById('simplePDF_iframe')?.getAttribute('src'); + return source === undefined || source === null ? null : new URL(source).origin; + }; + + it('treats a null baseDomain as absent and opens the production editor', async () => { + Object.assign(window.simplePDF?.config ?? {}, { baseDomain: null }); + + await expect(openAndSettle()).resolves.toBeUndefined(); + + expect(readEditorOrigin()).toBe('https://embed.simplepdf.com'); + Object.assign(window.simplePDF?.config ?? {}, { baseDomain: undefined }); + }); + + it.each([ + ['simplepdf.com:00443', 'https://embed.simplepdf.com'], + ['SIMPLEPDF.COM:08443', 'https://embed.simplepdf.com:8443'], + ])('accepts the editor messages when baseDomain is %s', async (baseDomain, editorOrigin) => { + const registeredTools: string[] = []; + Object.defineProperty(document, 'modelContext', { + configurable: true, + value: { + registerTool: (tool: { name: string }) => { + registeredTools.push(tool.name); + }, + }, + }); + window.simplePDF?.setConfig({ baseDomain }); + + await openAndSettle(); + const iframe = document.getElementById('simplePDF_iframe'); + if (!(iframe instanceof HTMLIFrameElement)) { + throw new Error('The editor iframe was not created'); + } + window.dispatchEvent( + new MessageEvent('message', { + data: JSON.stringify({ type: 'EDITOR_READY', data: {} }), + origin: editorOrigin, + source: iframe.contentWindow, + }), + ); + + await vi.waitFor(() => expect(registeredTools).toContain('simplepdf_embed_get_fields')); + Object.assign(window.simplePDF?.config ?? {}, { baseDomain: undefined }); + }); + + it.each(['simplepdf.com@evil.example', 'evil.example/x', '-.com', 'simplepdf', 42])( + 'refuses the baseDomain %s with a console error and opens nothing', + async (baseDomain) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + Object.assign(window.simplePDF?.config ?? {}, { baseDomain }); + + await openAndSettle(); + + expect(document.getElementById('simplePDF_modal')).toBeNull(); + expect(consoleError).toHaveBeenCalledWith(expect.stringContaining('is not a domain name')); + Object.assign(window.simplePDF?.config ?? {}, { baseDomain: undefined }); + }, + ); + + it('reports a companyIdentifier that is not a string and closes the modal instead of throwing', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + Object.assign(window.simplePDF?.config ?? {}, { companyIdentifier: 123 }); + + await expect(openAndSettle()).resolves.toBeUndefined(); + + expect(document.getElementById('simplePDF_modal')).toBeNull(); + expect(consoleError).toHaveBeenCalledWith('@simplepdf/web-embed-pdf: the editor could not open', expect.anything()); + }); +}); diff --git a/web/src/__tests__/shared.test.ts b/web/src/__tests__/shared.test.ts index dc5df7e8..41b6e321 100644 --- a/web/src/__tests__/shared.test.ts +++ b/web/src/__tests__/shared.test.ts @@ -1,42 +1,33 @@ import { JSDOM } from 'jsdom'; import { describe, it, expect } from 'vitest'; import { getSimplePDFElements } from '../shared'; +import pdfLinkCases from './fixtures/pdf-link-cases.json'; +// The link cases live in a fixture so the WordPress plugin's PHP mirror of this rule (its +// "PDFs on your site" report) can assert the same file. describe('getSimplePDFElements', () => { - it('detects elements to open with SimplePDF', () => { + it('opens exactly the links the shared cases expect in SimplePDF', () => { + const anchors = pdfLinkCases + .map(({ href, classes }, index) => `case ${index}`) + .join(''); + const dom = new JSDOM(`${anchors}`, { url: 'http://localhost' }); + + const detectedCases = getSimplePDFElements(dom.window.document).map(({ innerHTML }) => innerHTML); + const expectedCases = pdfLinkCases.flatMap(({ opens_in_simplepdf }, index) => + opens_in_simplepdf ? [`case ${index}`] : [], + ); + + expect(detectedCases).toStrictEqual(expectedCases); + }); + + it('opens a non-link element with the simplepdf class', () => { const dom = new JSDOM( - ` - - - - PDF link - Regular link with class - - SimplePDF form link - SimplePDF document link - - PDF link with class exclusion - SimplePDF form link with exclusion - Regular link containing .pdf - Should not be opened with SimplePDF - Should not be opened with SimplePDF - Should not be opened with SimplePDF - Should not be opened with SimplePDF - - - `, + `Plain text`, { url: 'http://localhost' }, ); - const detectedElements = getSimplePDFElements(dom.window.document); - expect(detectedElements).toHaveLength(5); - expect(detectedElements.map(({ innerHTML }) => innerHTML)).toStrictEqual( - expect.arrayContaining([ - 'Button with class', - 'PDF link', - 'Regular link with class', - 'SimplePDF form link', - 'SimplePDF document link', - ]), - ); + + expect(getSimplePDFElements(dom.window.document).map(({ innerHTML }) => innerHTML)).toStrictEqual([ + 'Button with class', + ]); }); }); diff --git a/web/src/shared.ts b/web/src/shared.ts index b7fe923f..d7425d20 100644 --- a/web/src/shared.ts +++ b/web/src/shared.ts @@ -1,15 +1,27 @@ -import { EditorConfig, EditorContext, Locale, ConfigSetter, OutgoingIframeEvent, IncomingIframeEvent } from './types'; +import { createEmbed, type EmbedDocument, type WebMCPOptions } from '@simplepdf/embed'; +import type { ConfigSetter, EditorConfig, EditorContext, Locale } from './types'; const MODAL_ID = 'simplePDF_modal' as const; const MODAL_CLOSE_BUTTON_ID = 'simplePDF_modal_close_button' as const; const MODAL_STYLE_ID = 'simplePDF_modal_style' as const; const IFRAME_ID = 'simplePDF_iframe' as const; +const IFRAME_CONTAINER_SELECTOR = `#${MODAL_ID} .simplePDF_iframeContainer`; +const HOST_NAME_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; const UNEXPECTED_ERROR_INITIALIZATION = 'Unexpected: window.simplePDF not initialized'; -const UNEXPECTED_ERROR_IFRAME_NOT_INSTANTIATED = 'Unexpected: SimplePDF iframe not instantiated'; + +const SUPPORTED_LOCALES = { + de: true, + en: true, + es: true, + fr: true, + it: true, + ja: true, + nl: true, + pt: true, +} satisfies Record; const editorContext: EditorContext = { - getFromConfig: (key: 'companyIdentifier' | 'locale') => window.simplePDF?.config?.[key] ?? null, log: (message: string, details: Record) => { const debugAttribute = document.currentScript?.getAttribute('debug'); const isDebug = debugAttribute !== null && debugAttribute !== undefined; @@ -21,23 +33,30 @@ const editorContext: EditorContext = { console.warn(`@simplepdf/web-embed-pdf: ${message}`, details); }, autoOpenListeners: window.simplePDF?._ctx.listenersMap ?? new Map(), - outgoingEventsQueue: [], - isIframeReady: false, - getEditor: () => { - return { - iframe: document.getElementById(IFRAME_ID) as HTMLIFrameElement | null, - modal: document.getElementById(MODAL_ID) as HTMLDivElement | null, - styles: document.getElementById(MODAL_STYLE_ID) as HTMLStyleElement | null, - }; - }, + activeEmbed: null, + activeModal: null, }; +const readScriptAttribute = (name: string): string | null => document.currentScript?.getAttribute(name) ?? null; + const isSimplePDFLink = (url: string) => { const regex = /^https:\/\/[^.]+\.simplepdf\.com(\/[^\/]+)?\/(form|documents)\/.+/; return regex.test(url); }; -const isPDFLink = (url: string) => url.endsWith('.pdf'); +const isPDFLink = (url: string): boolean => { + if (url.toLowerCase().endsWith('.pdf')) { + return true; + } + + try { + return new URL(url).pathname.toLowerCase().endsWith('.pdf'); + } catch { + return false; + } +}; + +const isLocale = (value: string): value is Locale => Object.prototype.hasOwnProperty.call(SUPPORTED_LOCALES, value); const getLocale = (): Locale => { const languageCode = (() => { @@ -49,66 +68,67 @@ const getLocale = (): Locale => { } })(); - const inputLocale = (editorContext.getFromConfig('locale') ?? - document.currentScript?.getAttribute('locale') ?? - languageCode ?? - 'en') as Locale; - - switch (inputLocale) { - case 'en': - case 'de': - case 'es': - case 'fr': - case 'it': - case 'pt': - return inputLocale; + const inputLocale = window.simplePDF?.config?.locale ?? readScriptAttribute('locale') ?? languageCode ?? 'en'; + + return isLocale(inputLocale) ? inputLocale : 'en'; +}; + +const readWebMCPAttribute = (): WebMCPOptions | null => { + const webMCPAttribute = readScriptAttribute('webmcp'); + switch (webMCPAttribute) { + case null: + return null; + case '': + case 'true': + return { enabled: true }; + case 'false': + return { enabled: false }; default: - inputLocale satisfies never; - return 'en'; + console.error( + `@simplepdf/web-embed-pdf: webmcp="${webMCPAttribute}" is neither "true" nor "false", WebMCP is turned off`, + ); + return { enabled: false }; } }; +const scriptBaseDomain = readScriptAttribute('baseDomain'); +const scriptWebMCP = readWebMCPAttribute(); + export const config: EditorConfig = { locale: getLocale(), - companyIdentifier: - editorContext.getFromConfig('companyIdentifier') ?? - document.currentScript?.getAttribute('companyIdentifier') ?? - 'embed', + companyIdentifier: window.simplePDF?.config?.companyIdentifier ?? readScriptAttribute('companyIdentifier') ?? 'embed', autoOpen: false, + ...(scriptBaseDomain !== null ? { baseDomain: scriptBaseDomain } : {}), + ...(scriptWebMCP !== null ? { webMCP: scriptWebMCP } : {}), }; export const setConfig: ConfigSetter = (params) => { - let config = window.simplePDF?.config; - - if (!config) { + if (!window.simplePDF) { throw Error(UNEXPECTED_ERROR_INITIALIZATION); } - Object.keys(params).forEach((paramKey) => { - const configKey = paramKey as keyof EditorConfig; - const configValue = params[configKey]; - - if (configValue === undefined) { - return; - } - - editorContext.log('Update config', { configKey, configValue }); + editorContext.log('Update config', params); - switch (configKey) { - case 'autoOpen': { - if (configValue === true) { - enableAutoOpen(); - } else { - disableAutoOpen(); - } - break; - } - default: - break; + if (params.autoOpen !== undefined) { + if (params.autoOpen) { + enableAutoOpen(); + } else { + disableAutoOpen(); } - - (config[configKey] as any) = configValue as any; - }); + config.autoOpen = params.autoOpen; + } + if (params.companyIdentifier !== undefined) { + config.companyIdentifier = params.companyIdentifier; + } + if (params.locale !== undefined) { + config.locale = params.locale; + } + if (params.baseDomain !== undefined) { + config.baseDomain = params.baseDomain; + } + if (params.webMCP !== undefined) { + config.webMCP = params.webMCP; + } return config; }; @@ -139,116 +159,90 @@ export const getSimplePDFElements = (document: Document): Element[] => { return [...getNonAnchors(), ...getAnchors()]; }; -export const closeEditor = (): void => { - removeIframe(); +const removeModal = (): void => { + document.getElementById(MODAL_ID)?.remove(); + document.getElementById(MODAL_STYLE_ID)?.remove(); document.body.style.overflow = 'initial'; }; -const eventsListener = (event: MessageEvent) => { - const { getEditor, outgoingEventsQueue, getFromConfig, log } = editorContext; - const iframe = getEditor().iframe; - const editorDomain = `https://${getFromConfig('companyIdentifier')}.simplepdf.com`; - - const eventOrigin = new URL(event.origin).origin; - const iframeOrigin = new URL(editorDomain).origin; - - if (eventOrigin !== iframeOrigin) { - log('Incoming message from untrusted origin', { eventOrigin, iframeOrigin }); - return; - } - - const isTrustedIframe = event.source === iframe?.contentWindow; - - if (!isTrustedIframe) { - log('Incoming message from untrusted iframe', { eventOrigin, iframeOrigin }); - return; - } - - const payload: IncomingIframeEvent | null = (() => { - try { - return JSON.parse(event.data); - } catch (e) { - console.error('Failed to parse iFrame event payload'); - return null; - } - })(); - - switch (payload?.type) { - case 'EDITOR_READY': - editorContext.isIframeReady = true; - outgoingEventsQueue.forEach((queuedEvent) => { - sendEventToIframe(queuedEvent); - }); - outgoingEventsQueue.length = 0; - return; - case 'DOCUMENT_LOADED': - case 'SUBMISSION_SENT': - default: - return; - } -}; - -const onIframeLoaded = () => { - window.addEventListener('message', eventsListener); +export const closeEditor = (): void => { + editorContext.activeEmbed?.lifecycle.dispose(); + editorContext.activeEmbed = null; + editorContext.activeModal = null; + removeModal(); }; -const removeIframe = () => { - window.removeEventListener('message', eventsListener); - editorContext.getEditor().modal?.remove(); - editorContext.getEditor().styles?.remove(); - editorContext.isIframeReady = false; - editorContext.outgoingEventsQueue.length = 0; -}; +// The script tag and openEditor take untyped input; the core accepts absolute http(s) URLs, data +// URLs and Blobs, so a relative, data: or blob: href is turned into the matching document. +const resolveEmbedDocument = (href: string | null): Promise => + Promise.resolve().then((): EmbedDocument | undefined | Promise => { + if (!href) { + return undefined; + } -function sendEventToIframe(event: OutgoingIframeEvent) { - const { outgoingEventsQueue, log, isIframeReady } = editorContext; - const editorDomainURL = new URL(editorContext.getEditor().iframe?.src ?? ''); + const documentUrl = new URL(href, document.baseURI); + switch (documentUrl.protocol) { + case 'data:': + return { dataUrl: documentUrl.href }; + case 'blob:': + return fetch(href) + .then((response) => response.blob()) + .then((file) => ({ file })); + case 'http:': + case 'https:': { + const name = documentUrl.pathname.substring(documentUrl.pathname.lastIndexOf('/') + 1); + return name === '' ? { url: documentUrl.href } : { url: documentUrl.href, name }; + } + default: + // A `simplepdf` element whose href is not a document (javascript:, mailto:) opens an empty editor. + return undefined; + } + }); - if (isIframeReady) { - log('Send iframe event', { event }); - editorContext.getEditor().iframe?.contentWindow?.postMessage(JSON.stringify(event), editorDomainURL.origin); - return; +// The core compares the editor's messages against the origin it builds from `baseDomain`, so the +// value is canonicalized the way the browser writes the iframe's origin (lowercase, default and +// zero-padded ports dropped) and anything beyond a host and a port is refused. +const normalizeBaseDomain = ( + baseDomain: unknown, +): + | { success: true; data: string | null } + | { success: false; error: { code: 'not_a_domain_name'; message: string } } => { + if (baseDomain === undefined || baseDomain === null) { + return { success: true, data: null }; } - log('Push event to queue', { event }); - outgoingEventsQueue.push(event); -} - -export const openEditor = ({ href, context }: { href: string | null; context?: Record }): void => { - const { getFromConfig, log, getEditor } = editorContext; - - if (getEditor().iframe) { - log('Editor already opened', {}); - return; + const notADomainName = (): { success: false; error: { code: 'not_a_domain_name'; message: string } } => ({ + success: false, + error: { code: 'not_a_domain_name', message: `baseDomain '${String(baseDomain)}' is not a domain name` }, + }); + if (typeof baseDomain !== 'string') { + return notADomainName(); } - const companyIdentifier = getFromConfig('companyIdentifier'); - const locale = getFromConfig('locale'); - - const encodedContext = (() => { - if (!context) { - return null; - } - + const parsedBaseDomain = ((): URL | null => { try { - return encodeURIComponent(btoa(JSON.stringify(context))); - } catch (e) { - log(`Failed to encode the context: ${JSON.stringify(e)}`, { context }); + return new URL(`https://${baseDomain}`); + } catch { return null; } })(); - - const iframeURL = new URL(`/${locale}/editor`, `https://${companyIdentifier}.simplepdf.com`); - - if (href) { - iframeURL.searchParams.set('loadingPlaceholder', 'true'); + const isHostOnly = + parsedBaseDomain !== null && + parsedBaseDomain.username === '' && + parsedBaseDomain.password === '' && + parsedBaseDomain.pathname === '/' && + parsedBaseDomain.search === '' && + parsedBaseDomain.hash === '' && + HOST_NAME_PATTERN.test(parsedBaseDomain.hostname); + if (parsedBaseDomain === null || !isHostOnly) { + return notADomainName(); } - if (encodedContext) { - iframeURL.searchParams.set('context', encodedContext); - } + const port = parsedBaseDomain.port === '80' ? '' : parsedBaseDomain.port; + return { success: true, data: port === '' ? parsedBaseDomain.hostname : `${parsedBaseDomain.hostname}:${port}` }; +}; - const modal = ` +const MODAL_HTML = `