diff --git a/packages/ts/sdk/README.md b/packages/ts/sdk/README.md new file mode 100644 index 00000000..b1923ac3 --- /dev/null +++ b/packages/ts/sdk/README.md @@ -0,0 +1,67 @@ +# @gonext/sdk + +Browser-side TypeScript SDK for GoNext plugins. + +`@gonext/sdk` is what a plugin's browser bundle imports to talk to the GoNext +host. It auto-detects the plugin's slug from the import-map URL, exposes +namespaced REST shims, lets a plugin register client-side blocks, and wraps +the host's translation endpoint. + +The SDK is served by the host (typically at `/_/runtime/sdk.mjs`) and pinned +in the admin's import map under the `@gonext/sdk` specifier. Plugin authors +never install it from npm — they import it as a bare specifier and the +import map resolves to the host-served bundle: + +```ts +import { defineBlock, host, i18n, setHTML } from '@gonext/sdk'; + +defineBlock({ + name: 'acme/quote', + title: 'Acme Quote', + edit: ({ attributes, setAttributes }) => { + const me = await host.users.me(); + return
{i18n.t('quote.lead', { name: me.name })}; + }, +}); +``` + +That's the whole hello-world. The SDK figures out the plugin slug from +the URL the host loaded the bundle from, and `host.users.me()` calls the +same-origin REST surface with the operator's session cookie. + +## Module surface + +| Export | Purpose | +| --- | --- | +| `getSlug()` | Returns the auto-detected slug, or `null`. | +| `setSlug(slug)` | Explicit override for tests / SSR. | +| `host.posts.{list,get}` | Reads from `/wp-json/wp/v2/posts`. | +| `host.users.{list,get,me}` | Reads from `/wp-json/wp/v2/users`. | +| `host.media.{list,get}` | Reads from `/wp-json/wp/v2/media`. | +| `host.cache.invalidate(tags)` | Plugin-scoped cache invalidation. | +| `defineBlock(spec)` | Forwards to the editor's `BLOCK_REGISTRY`. | +| `i18n.t(key, args)` | Translation lookup with `{placeholder}` interp. | +| `i18n.load(locale)` | Preloads a catalogue. | +| `setHTML(el, html)` | Trusted-Types-safe `innerHTML` setter. | + +## Trusted Types + +The admin enforces `require-trusted-types-for 'script'`. Every assignment +to `innerHTML` / `outerHTML` / `script.src` would throw at runtime in +plugin code that didn't go through a registered policy. The SDK ships a +`setHTML(el, html)` helper that routes through the host-installed +`gn-plugin` policy, so plugin code stays portable to environments with or +without TT enforcement. + +## Build outputs + +`pnpm --filter @gonext/sdk run build` (via `tsup`) produces: + +- `dist/index.mjs` — primary ESM bundle (what the import map loads) +- `dist/index.cjs` — CJS fallback for Node test harnesses +- `dist/index.d.ts` — rolled-up TypeScript declarations + +## License + +Apache-2.0. Plugin authors are explicitly free to ship plugins under any +license — the SDK does not impose viral terms. diff --git a/packages/ts/sdk/package.json b/packages/ts/sdk/package.json index 9d7af94b..2747be40 100644 --- a/packages/ts/sdk/package.json +++ b/packages/ts/sdk/package.json @@ -1,15 +1,42 @@ { "name": "@gonext/sdk", - "version": "0.0.0", + "version": "0.1.0", "private": false, - "description": "Plugin SDK for GoNext. Shared between WASM plugin guests (via Javy or AssemblyScript) and frontend ES modules loaded by the host. Licensed under Apache-2.0 from day 1 so plugin authors are unencumbered.", + "description": "Browser-side TypeScript SDK for GoNext plugins. Auto-detects the loading plugin's slug from the import-map URL, exposes namespaced REST shims (posts, users, media, cache), block registration, and an i18n wrapper. Trusted-Types compatible by design — plays nicely with the gn-plugin policy the admin installs at boot. Apache-2.0 from day 1 so plugin authors are unencumbered.", "license": "Apache-2.0", - "main": "./src/index.ts", - "types": "./src/index.ts", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist", + "src", + "README.md" + ], "scripts": { - "build": "echo '@gonext/sdk build: not yet implemented (see issue #237/#239/#242)'", + "build": "tsup", + "dev": "tsup --watch", "lint": "echo '@gonext/sdk lint: not yet implemented'", - "test": "echo '@gonext/sdk test: not yet implemented'", - "typecheck": "echo '@gonext/sdk typecheck: not yet implemented'" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@gonext/test-config": "workspace:*", + "@vitest/coverage-v8": "^1.6.0", + "jsdom": "^24.0.0", + "tsup": "^8.3.0", + "typescript": "^5.6.0", + "vitest": "^1.6.0" } } diff --git a/packages/ts/sdk/src/blocks.test.ts b/packages/ts/sdk/src/blocks.test.ts new file mode 100644 index 00000000..c11ca823 --- /dev/null +++ b/packages/ts/sdk/src/blocks.test.ts @@ -0,0 +1,87 @@ +/** + * defineBlock tests. + * + * Two modes: forwarding to a registry the editor already + * published, and capturing into the local map when the registry + * has not booted yet. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + __drainCapturedBlocks, + __getCapturedBlocks, + defineBlock, +} from './blocks'; + +beforeEach(() => { + delete (globalThis as { __GN_BLOCK_REGISTRY__?: unknown }).__GN_BLOCK_REGISTRY__; + delete (globalThis as { BLOCK_REGISTRY?: unknown }).BLOCK_REGISTRY; + __drainCapturedBlocks(); +}); + +afterEach(() => { + __drainCapturedBlocks(); +}); + +describe('defineBlock', () => { + it('forwards to __GN_BLOCK_REGISTRY__ when present', () => { + const register = vi.fn(); + (globalThis as { __GN_BLOCK_REGISTRY__?: unknown }).__GN_BLOCK_REGISTRY__ = { + register, + }; + defineBlock({ + name: 'acme/hello', + title: 'Hello', + attributes: { greeting: 'Hi' }, + }); + expect(register).toHaveBeenCalledTimes(1); + expect(register.mock.calls[0]![0]).toMatchObject({ name: 'acme/hello' }); + expect(__getCapturedBlocks()).toHaveLength(0); + }); + + it('falls back to BLOCK_REGISTRY when only that is present', () => { + const register = vi.fn(); + (globalThis as { BLOCK_REGISTRY?: unknown }).BLOCK_REGISTRY = { register }; + defineBlock({ name: 'acme/old', attributes: {} }); + expect(register).toHaveBeenCalledTimes(1); + }); + + it('captures locally when no registry is installed yet', () => { + defineBlock({ name: 'acme/captured', attributes: {} }); + expect(__getCapturedBlocks()).toHaveLength(1); + expect(__getCapturedBlocks()[0]!.name).toBe('acme/captured'); + }); + + it('drain returns and clears the capture map', () => { + defineBlock({ name: 'acme/a', attributes: {} }); + defineBlock({ name: 'acme/b', attributes: {} }); + const drained = __drainCapturedBlocks(); + expect(drained.map((s) => s.name)).toEqual(['acme/a', 'acme/b']); + expect(__getCapturedBlocks()).toHaveLength(0); + }); + + it('replaces a captured block on re-registration', () => { + defineBlock({ name: 'acme/dupe', title: 'First', attributes: {} }); + defineBlock({ name: 'acme/dupe', title: 'Second', attributes: {} }); + const captured = __getCapturedBlocks(); + expect(captured).toHaveLength(1); + expect(captured[0]!.title).toBe('Second'); + }); + + it('rejects an invalid name', () => { + expect(() => + defineBlock({ name: 'BadName', attributes: {} } as never), + ).toThrow(TypeError); + expect(() => + defineBlock({ name: 'no-slash', attributes: {} } as never), + ).toThrow(TypeError); + expect(() => + defineBlock({ name: 'acme/Bad Slug', attributes: {} } as never), + ).toThrow(TypeError); + }); + + it('ignores a registry that lacks register()', () => { + (globalThis as { __GN_BLOCK_REGISTRY__?: unknown }).__GN_BLOCK_REGISTRY__ = {}; + defineBlock({ name: 'acme/ok', attributes: {} }); + expect(__getCapturedBlocks()).toHaveLength(1); + }); +}); diff --git a/packages/ts/sdk/src/blocks.ts b/packages/ts/sdk/src/blocks.ts new file mode 100644 index 00000000..970515ad --- /dev/null +++ b/packages/ts/sdk/src/blocks.ts @@ -0,0 +1,168 @@ +/** + * Client-side block registration. + * + * `defineBlock(spec)` is how a plugin's browser bundle registers a + * block component the editor + the public theme can render. The + * concrete registry — the global object the host writes blocks into + * — is owned by the editor package (`@gonext/blocks-editor`) and + * mounted on the page before plugin bundles evaluate. + * + * This module forwards to that registry when it exists, and + * silently captures the registration into a local Map otherwise. + * The capture is observable via `__getCapturedBlocks()` so tests + * (and the editor, once it boots) can flush the pending list. + * + * Forward-compat: the `BLOCK_REGISTRY` shape will probably grow + * lifecycle hooks (mount / unmount / capability gates). We pass + * the full spec through verbatim — the SDK does not normalize or + * validate beyond a name check — so a registry update doesn't + * require an SDK rev. + */ + +import type { ComponentType } from './react-types'; + +/** + * Block-spec shape. Mirrors the contract the editor's + * `BLOCK_REGISTRY.register` expects. + * + * `name` is the block identifier and follows the WP convention of + * `
` callable shape gives plugin authors the type + * affordance without the runtime / peer-dep coupling. + * + * The shape mirrors React's: a function from `props` to + * `ReactElement | null`, plus optional `displayName`. We use + * `unknown` for the return type so a JSX-emitting function and a + * pre-rendered element factory both satisfy the contract. + */ + +/** + * Open-typed React element. The block registry treats whatever the + * component returns as opaque — it's the renderer (editor or theme) + * that types it tightly. + */ +export type ReactElementLike = unknown; + +/** + * Subset of `React.ComponentType
` that the SDK needs. Matches + * the real `@types/react` declaration structurally so a plugin can + * pass a real React component and get the right inference. + */ +export interface ComponentType
{ + (props: P): ReactElementLike; + displayName?: string; +} diff --git a/packages/ts/sdk/src/slug.test.ts b/packages/ts/sdk/src/slug.test.ts new file mode 100644 index 00000000..2001795a --- /dev/null +++ b/packages/ts/sdk/src/slug.test.ts @@ -0,0 +1,178 @@ +/** + * Slug auto-detection tests. + * + * Each strategy gets its own table-driven block. We reset the + * module-scoped cache in `beforeEach` so detection re-runs against + * the fixture state for the current test. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + __resetSlugCache, + SlugRequiredError, + getSlug, + requireSlug, + setSlug, +} from './slug'; + +beforeEach(() => { + __resetSlugCache(); + // Clear any global from a prior test. + delete (globalThis as { __GN_PLUGIN_SLUG__?: unknown }).__GN_PLUGIN_SLUG__; + // Strip any plugin