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 + * `/` (e.g. `acme/quote`). The SDK enforces the + * shape so a typo (`'quote'`) doesn't silently shadow another + * plugin's block. + * + * `edit` is the editor-side React component; `save` is the public + * theme's render component. Either or both may be omitted — server- + * rendered blocks have neither, and edit-only blocks have no save. + * The registry tolerates `undefined` for both. + * + * `attributes` is the block's attribute schema, in the same shape + * Gutenberg's `registerBlockType` expects. The SDK does not validate + * it; the editor performs full validation at registration time and + * surfaces errors to the developer. + */ +export interface BlockSpec> { + name: string; + title?: string; + icon?: string; + category?: string; + description?: string; + keywords?: ReadonlyArray; + attributes?: Attrs; + edit?: ComponentType>; + save?: ComponentType>; + /** Free-form pass-through for future registry fields. */ + [extra: string]: unknown; +} + +/** + * Props the block's `edit` and `save` components receive. The + * concrete shape is owned by the editor; we declare just the two + * fields the SDK needs to expose for type-checking. + */ +export interface BlockProps> { + attributes: Attrs; + setAttributes?: (next: Partial) => void; +} + +/** + * Shape of the global block registry the editor mounts. We declare + * it via an interface and treat the global at call time as + * potentially-undefined; the SDK is the upstream package, so it + * cannot statically depend on the editor exporting one. + */ +interface BlockRegistry { + register: (spec: BlockSpec>) => void; + unregister?: (name: string) => void; +} + +/** + * Module-scoped capture for the "registry not present yet" path. + * Keyed by block name so a re-registration replaces the previous + * entry instead of stacking duplicates. + */ +const captured = new Map>>(); + +/** + * Block-name validation pattern. Lower-case namespace, slash, + * kebab-case slug. Matches the WP block-type rules so a plugin + * authored against live WP doesn't need to rename blocks. + */ +const BLOCK_NAME_PATTERN = /^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$/; + +/** + * Registers a block. If the editor has already published a + * `window.__GN_BLOCK_REGISTRY__` (or the equivalent + * `globalThis.BLOCK_REGISTRY` constant the editor exposes), the + * call forwards directly. Otherwise the spec is captured locally + * and the editor will pull captured specs at boot. + * + * Throws `TypeError` for an invalid `name`. NOT thrown for missing + * `edit` / `save` — server-rendered blocks have neither. + */ +export function defineBlock>( + spec: BlockSpec, +): void { + if (typeof spec.name !== 'string' || !BLOCK_NAME_PATTERN.test(spec.name)) { + throw new TypeError( + `[@gonext/sdk] defineBlock: name ${JSON.stringify(spec.name)} ` + + 'must be "/" with lower-case ASCII.', + ); + } + // Cast Attrs → unknown for the registry surface. The block + // registry is generic-erased; we recover the type at the call + // sites where the component is actually rendered. + const registry = getBlockRegistry(); + const erased = spec as unknown as BlockSpec>; + if (registry !== null) { + registry.register(erased); + return; + } + captured.set(spec.name, erased); +} + +/** + * Reads the block registry off the global, if present. The editor + * publishes the registry under the conventional name + * `__GN_BLOCK_REGISTRY__`; we ALSO accept a `BLOCK_REGISTRY` + * camel-cased export for the very-old editor builds that shipped + * before the convention firmed up. + */ +function getBlockRegistry(): BlockRegistry | null { + if (typeof globalThis === 'undefined') { + return null; + } + const g = globalThis as { + __GN_BLOCK_REGISTRY__?: unknown; + BLOCK_REGISTRY?: unknown; + }; + const candidate = g.__GN_BLOCK_REGISTRY__ ?? g.BLOCK_REGISTRY; + if (candidate === undefined || candidate === null) { + return null; + } + if (typeof (candidate as { register?: unknown }).register !== 'function') { + return null; + } + return candidate as BlockRegistry; +} + +/** + * Drains captured specs. Called by the editor on boot once its + * registry is ready, OR by tests to assert what was registered. + * + * Returns an array of specs in insertion order. Subsequent calls + * see an empty list — drain is destructive on purpose so the + * editor doesn't double-register. + */ +export function __drainCapturedBlocks(): Array>> { + const list = Array.from(captured.values()); + captured.clear(); + return list; +} + +/** + * Read-only peek into the captured map. Distinct from `__drain` + * because tests often want to assert WITHOUT clearing. + */ +export function __getCapturedBlocks(): ReadonlyArray>> { + return Array.from(captured.values()); +} diff --git a/packages/ts/sdk/src/host.test.ts b/packages/ts/sdk/src/host.test.ts new file mode 100644 index 00000000..0495cc46 --- /dev/null +++ b/packages/ts/sdk/src/host.test.ts @@ -0,0 +1,246 @@ +/** + * REST-shim tests. + * + * We mock `globalThis.fetch` with `vi.fn` and pin the request URL, + * method, headers, and the body shape we send. The response side + * is constructed via the real `Response` constructor so the SDK's + * parser exercises the same path it would in the browser. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + HostFetchError, + host, + __test_buildQuery, + __test_hostFetch, +} from './host'; +import { __resetSlugCache, setSlug, SlugRequiredError } from './slug'; + +let fetchMock: ReturnType; + +beforeEach(() => { + __resetSlugCache(); + setSlug('test-plugin'); + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; +}); + +afterEach(() => { + __resetSlugCache(); +}); + +/** + * Helper: builds an okay JSON response. + */ +function jsonResponse(body: unknown, init: ResponseInit = {}): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + ...init, + }); +} + +describe('host.posts', () => { + it('lists posts via GET /wp-json/wp/v2/posts', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 1 }, { id: 2 }])); + const result = await host.posts.list(); + expect(result).toHaveLength(2); + const call = fetchMock.mock.calls[0]!; + expect(call[0]).toBe('/wp-json/wp/v2/posts'); + expect(call[1].method).toBe('GET'); + }); + + it('forwards ListOptions as snake_case query params', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([])); + await host.posts.list({ perPage: 5, search: 'hello' }); + const url = fetchMock.mock.calls[0]![0] as string; + expect(url).toContain('per_page=5'); + expect(url).toContain('search=hello'); + }); + + it('reads a single post via GET /wp-json/wp/v2/posts/{id}', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ id: 42, slug: 'p' })); + const post = await host.posts.get(42); + expect(post.id).toBe(42); + expect(fetchMock.mock.calls[0]![0]).toBe('/wp-json/wp/v2/posts/42'); + }); + + it('throws HostFetchError on a 5xx', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ code: 'rest_error' }), { + status: 500, + headers: { 'content-type': 'application/json' }, + }), + ); + await expect(host.posts.get(1)).rejects.toMatchObject({ + name: 'HostFetchError', + status: 500, + }); + }); + + it('throws HostFetchError with status 0 on transport error', async () => { + fetchMock.mockRejectedValueOnce(new TypeError('network failed')); + await expect(host.posts.list()).rejects.toMatchObject({ + name: 'HostFetchError', + status: 0, + }); + }); + + it('forwards the abort signal', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([])); + const ctrl = new AbortController(); + await host.posts.list(undefined, { signal: ctrl.signal }); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect(init.signal).toBe(ctrl.signal); + }); +}); + +describe('host.users', () => { + it('resolves the current user via /users/me', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ id: 7, name: 'Ada' })); + const me = await host.users.me(); + expect(me.name).toBe('Ada'); + expect(fetchMock.mock.calls[0]![0]).toBe('/wp-json/wp/v2/users/me'); + }); + + it('lists users', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 1, name: 'A' }])); + const users = await host.users.list({ perPage: 10 }); + expect(users).toHaveLength(1); + }); + + it('gets a single user by id', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ id: 9, name: 'X' })); + const u = await host.users.get(9); + expect(u.id).toBe(9); + expect(fetchMock.mock.calls[0]![0]).toBe('/wp-json/wp/v2/users/9'); + }); +}); + +describe('host.media', () => { + it('lists media', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse([{ id: 1, mime_type: 'image/png' }])); + const media = await host.media.list(); + expect(media[0]!.mime_type).toBe('image/png'); + }); + + it('gets a single media item by id', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ id: 5 })); + const m = await host.media.get(5); + expect(m.id).toBe(5); + }); +}); + +describe('host.cache.invalidate', () => { + it('POSTs to /api/plugins/{slug}/cache/invalidate with the tags', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ invalidated: 3 })); + const count = await host.cache.invalidate(['posts', 'menus']); + expect(count).toBe(3); + const call = fetchMock.mock.calls[0]!; + expect(call[0]).toBe('/api/plugins/test-plugin/cache/invalidate'); + expect(call[1].method).toBe('POST'); + const body = JSON.parse(call[1].body as string) as { tags: string[] }; + expect(body.tags).toEqual(['posts', 'menus']); + }); + + it('accepts a single string tag', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ invalidated: 1 })); + await host.cache.invalidate('posts'); + const body = JSON.parse(fetchMock.mock.calls[0]![1].body as string) as { + tags: string[]; + }; + expect(body.tags).toEqual(['posts']); + }); + + it('defaults to 0 when the server omits a count', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({})); + const count = await host.cache.invalidate('posts'); + expect(count).toBe(0); + }); + + it('rejects empty / non-string tags before fetch', async () => { + await expect(host.cache.invalidate('')).rejects.toThrow(TypeError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('throws SlugRequiredError when slug missing', async () => { + setSlug(null); + await expect(host.cache.invalidate('posts')).rejects.toThrow(SlugRequiredError); + }); + + it('throws HostFetchError with the parsed body on 403', async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ code: 'forbidden' }), { + status: 403, + headers: { 'content-type': 'application/json' }, + }), + ); + try { + await host.cache.invalidate('posts'); + throw new Error('expected throw'); + } catch (err) { + expect(err).toBeInstanceOf(HostFetchError); + expect((err as HostFetchError).status).toBe(403); + expect((err as HostFetchError).responseBody).toEqual({ code: 'forbidden' }); + } + }); +}); + +describe('hostFetch transport', () => { + it('sets Accept: application/json by default', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + await __test_hostFetch('/foo', { method: 'GET' }, {}); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + const headers = init.headers as Headers; + expect(headers.get('Accept')).toBe('application/json'); + }); + + it('sets Content-Type: application/json for bodied requests', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + await __test_hostFetch('/foo', { method: 'POST', body: '{}' }, {}); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + const headers = init.headers as Headers; + expect(headers.get('Content-Type')).toBe('application/json'); + }); + + it('sends credentials: include', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true })); + await __test_hostFetch('/foo', { method: 'GET' }, {}); + const init = fetchMock.mock.calls[0]![1] as RequestInit; + expect(init.credentials).toBe('include'); + }); + + it('parses a 204 as null', async () => { + fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 })); + const out = await __test_hostFetch('/foo', { method: 'DELETE' }, {}); + expect(out).toBeNull(); + }); + + it('passes non-JSON content type through as text', async () => { + fetchMock.mockResolvedValueOnce( + new Response('hello', { + status: 200, + headers: { 'content-type': 'text/plain' }, + }), + ); + const out = await __test_hostFetch('/foo', { method: 'GET' }, {}); + expect(out).toBe('hello'); + }); +}); + +describe('buildQuery', () => { + it('returns empty string for undefined', () => { + expect(__test_buildQuery(undefined)).toBe(''); + }); + + it('camelCase keys become snake_case', () => { + expect(__test_buildQuery({ perPage: 5 })).toBe('?per_page=5'); + }); + + it('skips null/undefined values', () => { + expect(__test_buildQuery({ a: null, b: undefined, c: 1 })).toBe('?c=1'); + }); + + it('joins arrays with commas', () => { + expect(__test_buildQuery({ tags: ['a', 'b'] })).toBe('?tags=a%2Cb'); + }); +}); diff --git a/packages/ts/sdk/src/host.ts b/packages/ts/sdk/src/host.ts new file mode 100644 index 00000000..eae11573 --- /dev/null +++ b/packages/ts/sdk/src/host.ts @@ -0,0 +1,431 @@ +/** + * Browser-side host calls. + * + * The `host` object is the SDK's main namespace for plugin code that + * needs to talk back to the GoNext server. Each sub-namespace + * (`host.posts`, `host.users`, `host.media`, `host.cache`) is a thin + * shim over the existing REST surfaces the admin already exposes — + * the WP-compat read/write shim under `/wp-json/wp/v2/...` for + * posts, users, and media (see apps/api/internal/wprest/shim.go), + * and the per-plugin sub-tree under `/api/plugins/{slug}/...` for + * plugin-scoped endpoints like cache invalidation. + * + * Design rules: + * + * 1. Every call is `same-origin` + `credentials: 'include'`. The + * admin authenticates the plugin's browser bundle the same way + * it authenticates its own UI: session cookie + nonce. Plugin + * code MUST NOT need to forge an Authorization header. + * + * 2. Errors throw `HostFetchError` (typed) — never silently + * resolve to `undefined`. Plugin authors get a clear stack and + * can `instanceof` to branch on network vs. server-side + * failures. + * + * 3. No client-side state. Each method is a pure function over the + * arguments; caching / invalidation is the host's job (and + * `host.cache.invalidate` exists precisely so plugins can + * participate in it). + * + * 4. The slug is read lazily through `requireSlug()` on the FIRST + * call, not at module import. Plugins that wrap the SDK in a + * lazy-initialized singleton therefore don't need to also wrap + * the slug-detection bootstrap. + * + * The shims are intentionally narrow. Anything the plugin's own + * routes (#136) handle is out of scope here — this object exposes + * the COMMON host surface that nearly every plugin needs. + */ + +import { requireSlug } from './slug'; + +/** + * Base path for the WP-compat shim. Mirrors `wprest.BasePath` on the + * Go side. We avoid importing a JSON config so this single file is + * the wire-level contract — if the host ever moves the prefix, this + * is the one place to update. + */ +const WP_BASE = '/wp-json/wp/v2'; + +/** + * Base path for plugin-scoped sub-routes. The host mounts each + * plugin's manifest-declared HTTP routes under + * `/api/plugins/{slug}/...`, and the SDK uses the same prefix for + * SDK-defined plugin operations (cache invalidation, …) so a future + * extension can land without a new prefix. + */ +const PLUGIN_API_BASE = '/api/plugins'; + +/** + * One post in the WP REST v2 shape. Only the fields the SDK + * exposes are typed; the underlying payload may contain more + * fields, which the helpers preserve so consumers can cast. + */ +export interface Post { + id: number; + date: string; + date_gmt?: string; + modified?: string; + slug: string; + status: string; + type?: string; + title: { rendered: string }; + content?: { rendered: string }; + excerpt?: { rendered: string }; + author?: number; + featured_media?: number; + [extra: string]: unknown; +} + +/** + * Subset of the user shape relevant to client code. The host's + * `/wp-json/wp/v2/users` returns more fields, which pass through + * via the index signature. + */ +export interface User { + id: number; + name: string; + slug?: string; + description?: string; + avatar_urls?: Record; + [extra: string]: unknown; +} + +/** + * One media item. The shim returns the WP REST v2 attachment + * shape — same as live WP — so plugin authors with prior WP + * experience can use the existing field names directly. + */ +export interface Media { + id: number; + date?: string; + slug?: string; + type?: string; + title?: { rendered: string }; + source_url?: string; + mime_type?: string; + [extra: string]: unknown; +} + +/** + * Common collection-listing arguments. `perPage` maps to WP's + * `per_page`; we expose the camelCase form for ergonomic plugin + * code and translate at the wire. + * + * Any extra fields are passed through to the query string so + * future server-side filters work without an SDK rev. + */ +export interface ListOptions { + page?: number; + perPage?: number; + search?: string; + /** Free-form pass-through. Stringified into the query string. */ + [extra: string]: unknown; +} + +/** + * Strong-typed error thrown by every host call. `status` is `0` + * when the failure is a transport-level error (CORS, offline, + * fetch abort); otherwise it carries the HTTP status code. + * + * Plugin code branches on: + * + * if (err instanceof HostFetchError && err.status === 0) {…} + * + * to distinguish "no network" from "server said no". The + * `responseBody` field is the parsed JSON body when one exists, + * else `null` — same shape the WP REST error envelope uses. + */ +export class HostFetchError extends Error { + override readonly name = 'HostFetchError'; + readonly status: number; + readonly url: string; + readonly responseBody: unknown; + constructor(message: string, status: number, url: string, responseBody: unknown) { + super(message); + this.status = status; + this.url = url; + this.responseBody = responseBody; + } +} + +/** + * Options accepted by every host-side call. Exposed so a plugin can + * forward an `AbortSignal` from its own component lifecycle and + * cancel in-flight requests on unmount. + */ +export interface HostCallOptions { + signal?: AbortSignal; +} + +/** + * Issues a host fetch. Owns the canonical request shape — same- + * origin + cookies, JSON Accept, JSON Content-Type when a body is + * supplied, an AbortSignal forwarded from the caller. + * + * The body parsing is best-effort: a 204 / empty response resolves + * to `null`; a non-JSON content-type passes through the raw text + * (cast); a JSON body parses to whatever shape the caller's + * generic claims. Errors throw `HostFetchError` with the parsed + * body (if any) attached so callers can render server-side validation + * messages without re-parsing. + */ +async function hostFetch( + url: string, + init: RequestInit, + callOpts: HostCallOptions, +): Promise { + const headers = new Headers(init.headers ?? undefined); + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json'); + } + if (init.body !== undefined && init.body !== null && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + let response: Response; + try { + response = await fetch(url, { + credentials: 'include', + ...init, + headers, + signal: callOpts.signal, + }); + } catch (err) { + // Network-level failure: AbortError, TypeError ("Failed to + // fetch"), CORS, offline. Re-raise as HostFetchError so consumers + // can `instanceof`-check uniformly. + const message = err instanceof Error ? err.message : String(err); + throw new HostFetchError(`network error: ${message}`, 0, url, null); + } + const parsed = await safeParseBody(response); + if (!response.ok) { + throw new HostFetchError( + `host call failed: ${response.status} ${response.statusText}`, + response.status, + url, + parsed, + ); + } + return parsed as T; +} + +/** + * Reads the response body without throwing on an empty / non-JSON + * payload. Returns `null` when there is no body. + */ +async function safeParseBody(response: Response): Promise { + if (response.status === 204) { + return null; + } + const contentType = response.headers.get('content-type') ?? ''; + // Read text first; many error envelopes from middleware (rate + // limit, CSP) come back as text/plain. + const text = await response.text(); + if (text === '') { + return null; + } + if (contentType.includes('application/json')) { + try { + return JSON.parse(text) as unknown; + } catch { + return text; + } + } + return text; +} + +/** + * Builds the query string from a ListOptions-like object. Returns + * the prefix '?' included when there is at least one parameter, + * otherwise an empty string. Keys map camelCase → snake_case for + * WP-compat; unknown keys pass through as-is. + * + * Boolean values serialize as 'true'/'false' (WP convention). + * Array values join with commas (WP convention). + * Nullable values are skipped, so callers can build an options + * object with conditional fields without manual cleanup. + */ +function buildQuery(opts: ListOptions | undefined): string { + if (opts === undefined) { + return ''; + } + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(opts)) { + if (value === undefined || value === null) continue; + const wire = camelToSnake(key); + if (Array.isArray(value)) { + params.set(wire, value.map(String).join(',')); + continue; + } + params.set(wire, String(value)); + } + const s = params.toString(); + return s === '' ? '' : `?${s}`; +} + +/** + * Quick camelCase → snake_case translation. Used for query keys + * (`perPage` → `per_page`). Naïve on purpose; the surface is small. + */ +function camelToSnake(s: string): string { + return s.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); +} + +/** + * `host.posts` — read access to published posts. + * + * Writes (create / update / delete) deliberately are NOT exposed + * here. Plugin browser code that needs to author content does so + * through the host's own admin UI components (``), + * which run with the operator's session cookie + nonce. Letting a + * plugin's browser bundle mint arbitrary posts would bypass the + * admin's capability checks. + */ +const posts = { + /** + * Lists posts. Pages and per-page caps mirror the WP REST shim. + * Returns the raw array — the host emits the totals via the + * `X-WP-Total` header which plugin code can read via the + * `listResponse` helper if needed. + */ + list(options?: ListOptions, callOpts: HostCallOptions = {}): Promise { + const url = `${WP_BASE}/posts${buildQuery(options)}`; + return hostFetch(url, { method: 'GET' }, callOpts); + }, + + /** + * Reads a single post by id. The id is encoded into the path so + * a plugin that builds the id from user input doesn't need its + * own validation — the host route's `{id}` matcher rejects + * malformed values. + */ + get(id: number, callOpts: HostCallOptions = {}): Promise { + const url = `${WP_BASE}/posts/${encodeURIComponent(String(id))}`; + return hostFetch(url, { method: 'GET' }, callOpts); + }, +} as const; + +/** + * `host.users` — read access to the user directory plus a special + * `me()` helper that resolves the currently-authenticated user. + * + * `me()` calls `/wp-json/wp/v2/users/me`, which is the same + * endpoint the live WP REST surface uses. It returns the calling + * user's profile (and only that user's; the host enforces it + * server-side). + */ +const users = { + list(options?: ListOptions, callOpts: HostCallOptions = {}): Promise { + const url = `${WP_BASE}/users${buildQuery(options)}`; + return hostFetch(url, { method: 'GET' }, callOpts); + }, + get(id: number, callOpts: HostCallOptions = {}): Promise { + const url = `${WP_BASE}/users/${encodeURIComponent(String(id))}`; + return hostFetch(url, { method: 'GET' }, callOpts); + }, + me(callOpts: HostCallOptions = {}): Promise { + return hostFetch(`${WP_BASE}/users/me`, { method: 'GET' }, callOpts); + }, +} as const; + +/** + * `host.media` — read access to the media library. + * + * Upload is intentionally not exposed: the WP REST media endpoint + * accepts multipart bodies and requires a nonce + capability check + * the admin UI handles via its own ``. Plugin code + * that wants to attach uploads should drive the admin's media + * picker via the (forthcoming) `host.ui` namespace. + */ +const media = { + list(options?: ListOptions, callOpts: HostCallOptions = {}): Promise { + const url = `${WP_BASE}/media${buildQuery(options)}`; + return hostFetch(url, { method: 'GET' }, callOpts); + }, + get(id: number, callOpts: HostCallOptions = {}): Promise { + const url = `${WP_BASE}/media/${encodeURIComponent(String(id))}`; + return hostFetch(url, { method: 'GET' }, callOpts); + }, +} as const; + +/** + * `host.cache` — plugin-scoped cache invalidation. + * + * Posts under `/api/plugins/{slug}/cache/invalidate` with the + * supplied tag list. The host's invalidator (see + * packages/go/cache/invalidator) then propagates the invalidation + * across the cluster via the shared Redis channel. + * + * The capability gate is the same as the WASM-side + * `gn_cache_invalidate`: the plugin must hold the + * `cache.invalidate` grant. Plugins without the grant get a 403 + * back, which surfaces as a `HostFetchError` with status 403. + * + * Tag validation is server-side; the client only checks that each + * tag is a string so a typo throws TYPE-side rather than HTTP-side. + */ +const cache = { + /** + * Invalidates one or more cache tags. The host accepts either a + * single string or an array; the SDK normalizes to an array on + * the wire so the server-side schema is unambiguous. + * + * Returns the (server-supplied) count of invalidated keys, or 0 + * if the server didn't echo one back. + */ + async invalidate( + tags: string | ReadonlyArray, + callOpts: HostCallOptions = {}, + ): Promise { + const slug = requireSlug(); + const tagList = Array.isArray(tags) ? Array.from(tags) : [tags as string]; + for (const t of tagList) { + if (typeof t !== 'string' || t === '') { + throw new TypeError( + `[@gonext/sdk] host.cache.invalidate: tag must be a non-empty string (got ${JSON.stringify(t)})`, + ); + } + } + const url = `${PLUGIN_API_BASE}/${encodeURIComponent(slug)}/cache/invalidate`; + const body = JSON.stringify({ tags: tagList }); + const result = await hostFetch<{ invalidated?: number } | null>( + url, + { method: 'POST', body }, + callOpts, + ); + if (result === null || typeof result !== 'object') { + return 0; + } + return typeof result.invalidated === 'number' ? result.invalidated : 0; + }, +} as const; + +/** + * The `host` namespace. Exported as a single frozen object so + * plugin code that destructures (`const { posts } = host`) gets a + * stable reference and can't accidentally monkey-patch a method on + * another plugin's namespace. + */ +export const host = Object.freeze({ + posts, + users, + media, + cache, +}) as { + readonly posts: typeof posts; + readonly users: typeof users; + readonly media: typeof media; + readonly cache: typeof cache; +}; + +/** + * Internal hook for tests. Not exported from the public barrel. + * Exposes the low-level fetch helper so tests can pin transport + * behaviour (header set, abort plumbing, error shape) without + * needing to hit one of the namespace shims. + * + * @internal + */ +export const __test_hostFetch = hostFetch; +/** @internal */ +export const __test_buildQuery = buildQuery; diff --git a/packages/ts/sdk/src/i18n.test.ts b/packages/ts/sdk/src/i18n.test.ts new file mode 100644 index 00000000..4618161a --- /dev/null +++ b/packages/ts/sdk/src/i18n.test.ts @@ -0,0 +1,113 @@ +/** + * i18n.t / i18n.load tests. + * + * Catalogue fetch is mocked; we exercise the fallback, the + * interpolation, the cache reuse, and the malformed-input + * resilience. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { i18n } from './i18n'; +import { __resetSlugCache, setSlug } from './slug'; + +let fetchMock: ReturnType; + +beforeEach(() => { + __resetSlugCache(); + setSlug('demo'); + i18n.__reset(); + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof fetch; + document.documentElement.setAttribute('lang', 'en'); +}); + +afterEach(() => { + i18n.__reset(); + __resetSlugCache(); +}); + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +describe('i18n.load', () => { + it('fetches the catalogue and caches the result', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ 'hello.world': 'Hello!' })); + const cat = await i18n.load(); + expect(cat['hello.world']).toBe('Hello!'); + // Second call: served from cache, no second fetch. + await i18n.load(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]![0]).toBe('/api/plugins/demo/i18n/en.json'); + }); + + it('uses an explicit locale when provided', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ 'hi': 'Bonjour' })); + await i18n.load('fr-FR'); + expect(fetchMock.mock.calls[0]![0]).toBe('/api/plugins/demo/i18n/fr-FR.json'); + }); + + it('resolves to an empty catalogue on fetch failure', async () => { + fetchMock.mockRejectedValueOnce(new Error('boom')); + const cat = await i18n.load(); + expect(cat).toEqual({}); + }); + + it('filters non-string catalogue entries', async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ valid: 'ok', invalid: 42, also: null }), + ); + const cat = await i18n.load(); + expect(cat).toEqual({ valid: 'ok' }); + }); + + it('shares the in-flight promise', async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ k: 'v' })); + const [a, b] = await Promise.all([i18n.load(), i18n.load()]); + expect(a).toEqual({ k: 'v' }); + expect(b).toEqual({ k: 'v' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('i18n.t', () => { + it('returns the key while the catalogue is loading', () => { + fetchMock.mockResolvedValue(jsonResponse({ greeting: 'Hello' })); + // Synchronous call — first t() kicks off async load. + const out = i18n.t('greeting'); + expect(out).toBe('greeting'); + }); + + it('returns the translation once the catalogue resolves', async () => { + fetchMock.mockResolvedValue(jsonResponse({ greeting: 'Hello' })); + await i18n.load(); + expect(i18n.t('greeting')).toBe('Hello'); + }); + + it('falls back to the key when the entry is missing', async () => { + fetchMock.mockResolvedValue(jsonResponse({ other: 'present' })); + await i18n.load(); + expect(i18n.t('missing.key')).toBe('missing.key'); + }); + + it('interpolates {name} placeholders', async () => { + fetchMock.mockResolvedValue(jsonResponse({ welcome: 'Hi, {name}!' })); + await i18n.load(); + expect(i18n.t('welcome', { name: 'Ada' })).toBe('Hi, Ada!'); + }); + + it('leaves unknown placeholders intact', async () => { + fetchMock.mockResolvedValue(jsonResponse({ tpl: 'Hi {name} and {other}' })); + await i18n.load(); + expect(i18n.t('tpl', { name: 'A' })).toBe('Hi A and {other}'); + }); + + it('returns the key when no slug is detected', () => { + setSlug(null); + // No fetch should be issued because we have no slug to address. + expect(i18n.t('any.key')).toBe('any.key'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/ts/sdk/src/i18n.ts b/packages/ts/sdk/src/i18n.ts new file mode 100644 index 00000000..218ce369 Binary files /dev/null and b/packages/ts/sdk/src/i18n.ts differ diff --git a/packages/ts/sdk/src/index.ts b/packages/ts/sdk/src/index.ts new file mode 100644 index 00000000..6f8142da --- /dev/null +++ b/packages/ts/sdk/src/index.ts @@ -0,0 +1,50 @@ +/** + * @gonext/sdk — browser-side SDK plugin authors import. + * + * This is the canonical bundle the host's import map (see + * packages/ts/plugin-frontend-host/src/import-map.ts) resolves + * `@gonext/sdk` to. A plugin's browser code writes: + * + * import { host, defineBlock, getSlug, i18n, setHTML } from '@gonext/sdk'; + * + * and the import map points that specifier at + * `/_/runtime/sdk.mjs` (the ESM emit from this package's tsup + * build). The host serves the file with an SRI hash pinned in + * the import map, so a tampered SDK bundle is rejected by the + * browser before any plugin code runs. + * + * Public surface: + * + * - `getSlug()` / `setSlug()` / `SlugRequiredError` — plugin + * identity, auto-detected from the import-map URL. + * + * - `host.{posts, users, media, cache}` — REST shims over the + * existing WP-compat + plugin-scoped surfaces. + * + * - `HostFetchError` — typed error for the REST shims. + * + * - `defineBlock(spec)` — register a client-side block; + * forwards to the editor's `BLOCK_REGISTRY` when present. + * + * - `i18n.t(key, args)` / `i18n.load(locale)` — translation + * lookup against the host's per-plugin catalogue endpoint. + * + * - `setHTML(el, html)` / `getTrustedTypesPolicy()` — Trusted + * Types-safe DOM injection that re-uses the host's + * `gn-plugin` policy. + * + * Stability: post-1.0 we promise backwards compatibility on every + * named export. Pre-1.0 (current) we may rename internals; the + * public surface listed above is intentionally narrow so most + * plugin code survives a 0.x → 1.0 bump untouched. + */ + +export { getSlug, setSlug, SlugRequiredError } from './slug'; +export { host, HostFetchError } from './host'; +export type { Post, User, Media, ListOptions, HostCallOptions } from './host'; +export { defineBlock } from './blocks'; +export type { BlockSpec, BlockProps } from './blocks'; +export type { ComponentType } from './react-types'; +export { i18n } from './i18n'; +export type { Catalogue } from './i18n'; +export { setHTML, getTrustedTypesPolicy } from './trusted-types'; diff --git a/packages/ts/sdk/src/react-types.ts b/packages/ts/sdk/src/react-types.ts new file mode 100644 index 00000000..40277796 --- /dev/null +++ b/packages/ts/sdk/src/react-types.ts @@ -0,0 +1,33 @@ +/** + * Minimal `ComponentType` declaration. + * + * The SDK exposes block specs that carry React components, but + * adding `@types/react` as a dependency would (a) bloat the SDK's + * type surface, (b) couple us to a specific React major, and (c) + * make the SDK harder to consume from non-React block runtimes + * (Preact, future Solid integration). Declaring just the + * `ComponentType

` 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