Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/ts/sdk/README.md
Original file line number Diff line number Diff line change
@@ -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 <blockquote>{i18n.t('quote.lead', { name: me.name })}</blockquote>;
},
});
```

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.
41 changes: 34 additions & 7 deletions packages/ts/sdk/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
87 changes: 87 additions & 0 deletions packages/ts/sdk/src/blocks.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
168 changes: 168 additions & 0 deletions packages/ts/sdk/src/blocks.ts
Original file line number Diff line number Diff line change
@@ -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
* `<namespace>/<slug>` (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<Attrs = Record<string, unknown>> {
name: string;
title?: string;
icon?: string;
category?: string;
description?: string;
keywords?: ReadonlyArray<string>;
attributes?: Attrs;
edit?: ComponentType<BlockProps<Attrs>>;
save?: ComponentType<BlockProps<Attrs>>;
/** 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<Attrs = Record<string, unknown>> {
attributes: Attrs;
setAttributes?: (next: Partial<Attrs>) => 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<Record<string, unknown>>) => 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<string, BlockSpec<Record<string, unknown>>>();

/**
* 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<Attrs extends Record<string, unknown>>(
spec: BlockSpec<Attrs>,
): 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 "<namespace>/<slug>" 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<Record<string, unknown>>;
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<BlockSpec<Record<string, unknown>>> {
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<BlockSpec<Record<string, unknown>>> {
return Array.from(captured.values());
}
Loading
Loading