From 9a9595a115672fc47bb2249ff60323f81f87a38c Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 21 Jul 2026 23:47:15 +0000 Subject: [PATCH 01/10] feat: add @devframes/json-render and @devframes/json-render-ui packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce JSON-render as an opt-in capability a devframe app adds, rather than a cost the hub or a plain app pays. - @devframes/json-render: framework-neutral protocol layer — base catalog + per-component Zod prop schemas, serializable JsonRenderViewRef, curated ./core re-exports of @json-render/core, and a ./node createJsonRenderView runtime factory (scoped stable ids, patch-enabled shared state, JSON-Pointer state patches, ingress prop + serializability validation, disposal). A ./hub subpath contributes the json-render dock type to the hub's open dock union without making the hub depend on json-render. - @devframes/json-render-ui: official Vue reference frontend on @antfu/design — the fourteen base components, base registry, renderer shell with reset semantics, an unrestricted action bridge that tracks per-action loading and surfaces failures, render-time prop isolation, a hub dock renderer, and Storybook coverage. - Hub: open the dock union via an augmentable registry and drop the closed json-render variant, createJsonRenderer, and the hand-written json-render types; add registerRenderer routing (with dispose-on-deactivation) to the client host. - devframe core: add RpcSharedStateHost.delete, load immer patches for patch-enabled shared state, and remove stale createJsonRenderer references. - Docs: error pages DF0038-DF0041. tsnapi snapshots + exports tests updated. Created with the help of an agent. --- alias.ts | 6 + docs/errors/DF0038.md | 37 ++ docs/errors/DF0039.md | 34 ++ docs/errors/DF0040.md | 29 ++ docs/errors/DF0041.md | 33 ++ .../devframe/src/client/rpc-shared-state.ts | 13 +- packages/devframe/src/node/context.ts | 4 +- .../devframe/src/node/rpc-shared-state.ts | 12 +- packages/devframe/src/types/context.ts | 4 +- packages/devframe/src/types/rpc.ts | 7 + packages/devframe/src/utils/shared-state.ts | 5 + .../src/client/__tests__/renderers.test.ts | 110 ++++++ packages/hub/src/client/docks.ts | 7 + packages/hub/src/client/host.ts | 61 ++++ packages/hub/src/client/index.ts | 1 + packages/hub/src/client/renderers.ts | 53 +++ packages/hub/src/define.ts | 5 - packages/hub/src/node/context.ts | 34 +- packages/hub/src/types/docks.ts | 35 +- packages/hub/src/types/index.ts | 1 - packages/hub/src/types/json-render.ts | 29 -- packages/json-render-ui/.storybook/main.ts | 23 ++ packages/json-render-ui/.storybook/preview.ts | 41 +++ packages/json-render-ui/package.json | 64 ++++ .../json-render-ui/src/JsonRender.stories.ts | 76 ++++ packages/json-render-ui/src/action-bridge.ts | 82 +++++ .../json-render-ui/src/components/_error.ts | 13 + .../json-render-ui/src/components/_shared.ts | 18 + .../json-render-ui/src/components/controls.ts | 111 ++++++ .../json-render-ui/src/components/data.ts | 124 +++++++ .../json-render-ui/src/components/icon.ts | 57 +++ .../json-render-ui/src/components/index.ts | 6 + .../json-render-ui/src/components/layout.ts | 88 +++++ .../src/components/typography.ts | 91 +++++ packages/json-render-ui/src/dock-renderer.ts | 80 +++++ packages/json-render-ui/src/index.ts | 14 + packages/json-render-ui/src/registry.ts | 49 +++ packages/json-render-ui/src/renderer.ts | 150 ++++++++ .../json-render-ui/test/action-bridge.test.ts | 52 +++ packages/json-render-ui/test/renderer.test.ts | 27 ++ packages/json-render-ui/tsconfig.json | 9 + packages/json-render-ui/tsdown.config.ts | 19 + packages/json-render-ui/uno.config.ts | 40 +++ packages/json-render/package.json | 58 +++ packages/json-render/src/catalog.ts | 64 ++++ packages/json-render/src/core.ts | 41 +++ packages/json-render/src/hub.ts | 36 ++ packages/json-render/src/index.ts | 39 +++ packages/json-render/src/node/create-view.ts | 164 +++++++++ packages/json-render/src/node/diagnostics.ts | 44 +++ packages/json-render/src/node/index.ts | 3 + packages/json-render/src/prop-schemas.ts | 152 ++++++++ packages/json-render/src/types.ts | 47 +++ packages/json-render/src/view-ref.ts | 31 ++ packages/json-render/test/catalog.test.ts | 49 +++ packages/json-render/test/create-view.test.ts | 115 ++++++ packages/json-render/tsconfig.json | 9 + packages/json-render/tsdown.config.ts | 15 + pnpm-lock.yaml | 245 ++++++++++--- pnpm-workspace.yaml | 3 + .../@devframes/hub/client.snapshot.d.ts | 17 + .../tsnapi/@devframes/hub/index.snapshot.d.ts | 6 +- .../tsnapi/@devframes/hub/index.snapshot.js | 1 - .../tsnapi/@devframes/hub/types.snapshot.d.ts | 5 +- .../json-render-ui/components.snapshot.d.ts | 20 ++ .../json-render-ui/components.snapshot.js | 19 + .../json-render-ui/index.snapshot.d.ts | 149 ++++++++ .../json-render-ui/index.snapshot.js | 32 ++ .../@devframes/json-render/core.snapshot.d.ts | 24 ++ .../@devframes/json-render/core.snapshot.js | 8 + .../@devframes/json-render/hub.snapshot.d.ts | 13 + .../@devframes/json-render/hub.snapshot.js | 6 + .../json-render/index.snapshot.d.ts | 331 ++++++++++++++++++ .../@devframes/json-render/index.snapshot.js | 27 ++ .../@devframes/json-render/node.snapshot.d.ts | 47 +++ .../@devframes/json-render/node.snapshot.js | 10 + tsconfig.base.json | 18 + turbo.json | 10 + vitest.config.ts | 2 + 79 files changed, 3471 insertions(+), 143 deletions(-) create mode 100644 docs/errors/DF0038.md create mode 100644 docs/errors/DF0039.md create mode 100644 docs/errors/DF0040.md create mode 100644 docs/errors/DF0041.md create mode 100644 packages/hub/src/client/__tests__/renderers.test.ts create mode 100644 packages/hub/src/client/renderers.ts delete mode 100644 packages/hub/src/types/json-render.ts create mode 100644 packages/json-render-ui/.storybook/main.ts create mode 100644 packages/json-render-ui/.storybook/preview.ts create mode 100644 packages/json-render-ui/package.json create mode 100644 packages/json-render-ui/src/JsonRender.stories.ts create mode 100644 packages/json-render-ui/src/action-bridge.ts create mode 100644 packages/json-render-ui/src/components/_error.ts create mode 100644 packages/json-render-ui/src/components/_shared.ts create mode 100644 packages/json-render-ui/src/components/controls.ts create mode 100644 packages/json-render-ui/src/components/data.ts create mode 100644 packages/json-render-ui/src/components/icon.ts create mode 100644 packages/json-render-ui/src/components/index.ts create mode 100644 packages/json-render-ui/src/components/layout.ts create mode 100644 packages/json-render-ui/src/components/typography.ts create mode 100644 packages/json-render-ui/src/dock-renderer.ts create mode 100644 packages/json-render-ui/src/index.ts create mode 100644 packages/json-render-ui/src/registry.ts create mode 100644 packages/json-render-ui/src/renderer.ts create mode 100644 packages/json-render-ui/test/action-bridge.test.ts create mode 100644 packages/json-render-ui/test/renderer.test.ts create mode 100644 packages/json-render-ui/tsconfig.json create mode 100644 packages/json-render-ui/tsdown.config.ts create mode 100644 packages/json-render-ui/uno.config.ts create mode 100644 packages/json-render/package.json create mode 100644 packages/json-render/src/catalog.ts create mode 100644 packages/json-render/src/core.ts create mode 100644 packages/json-render/src/hub.ts create mode 100644 packages/json-render/src/index.ts create mode 100644 packages/json-render/src/node/create-view.ts create mode 100644 packages/json-render/src/node/diagnostics.ts create mode 100644 packages/json-render/src/node/index.ts create mode 100644 packages/json-render/src/prop-schemas.ts create mode 100644 packages/json-render/src/types.ts create mode 100644 packages/json-render/src/view-ref.ts create mode 100644 packages/json-render/test/catalog.test.ts create mode 100644 packages/json-render/test/create-view.test.ts create mode 100644 packages/json-render/tsconfig.json create mode 100644 packages/json-render/tsdown.config.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render/core.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render/core.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.js create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts create mode 100644 tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.js diff --git a/alias.ts b/alias.ts index da4f75dd..66a230ae 100644 --- a/alias.ts +++ b/alias.ts @@ -46,6 +46,12 @@ export const alias = { '@devframes/hub': r('hub/src/index.ts'), '@devframes/nuxt/runtime/plugin.client': r('nuxt/src/runtime/plugin.client.ts'), '@devframes/nuxt': r('nuxt/src/index.ts'), + '@devframes/json-render/core': r('json-render/src/core.ts'), + '@devframes/json-render/hub': r('json-render/src/hub.ts'), + '@devframes/json-render/node': r('json-render/src/node/index.ts'), + '@devframes/json-render': r('json-render/src/index.ts'), + '@devframes/json-render-ui/components': r('json-render-ui/src/components/index.ts'), + '@devframes/json-render-ui': r('json-render-ui/src/index.ts'), '@devframes/plugin-code-server/client': p('code-server/src/client/index.ts'), '@devframes/plugin-code-server/node': p('code-server/src/node/index.ts'), '@devframes/plugin-code-server/constants': p('code-server/src/constants.ts'), diff --git a/docs/errors/DF0038.md b/docs/errors/DF0038.md new file mode 100644 index 00000000..e0da8e3b --- /dev/null +++ b/docs/errors/DF0038.md @@ -0,0 +1,37 @@ +--- +outline: deep +--- + +# DF0038: Invalid JSON-Render Element Props + +## Message + +> JSON-render view "`{id}`" received invalid props on element "`{key}`": `{issues}` + +## Cause + +`@devframes/json-render` validates every element's props against the base catalog's per-component Zod schema at spec ingress (`createJsonRenderView` / `view.update`). Upstream `@json-render/core` only checks component *names*, so this per-component prop check is the one validation Devframes adds. An element whose props don't match its component's schema is rejected here rather than failing silently at render. + +## Example + +```ts +// ✗ Bad — `variant` is not one of the Button variants +createJsonRenderView(ctx, { + id: 'toolbar', + spec: { root: 'a', elements: { a: { type: 'Button', props: { variant: 'nope' }, children: [] } } }, +}) + +// ✓ Good +createJsonRenderView(ctx, { + id: 'toolbar', + spec: { root: 'a', elements: { a: { type: 'Button', props: { variant: 'primary', label: 'Save' }, children: [] } } }, +}) +``` + +## Fix + +Match the element props to the base catalog's prop schema for that component. Dynamic `$state` / `$bindState` expressions are accepted wherever a scalar prop is expected, so a valid binding never triggers this. + +## Source + +- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts) — `validateElementProps()` throws this at ingress and on `update`. diff --git a/docs/errors/DF0039.md b/docs/errors/DF0039.md new file mode 100644 index 00000000..6617ac06 --- /dev/null +++ b/docs/errors/DF0039.md @@ -0,0 +1,34 @@ +--- +outline: deep +--- + +# DF0039: Duplicate JSON-Render View + +## Message + +> A JSON-render view with id "`{id}`" already exists in scope "`{scope}`". + +## Cause + +Each JSON-render view has a stable, author-supplied id that forms its shared-state key `devframe:json-render::`. Ids must be unique within a scope so the client keeps a stable subscription across reconnects. Creating a second view with the same id in the same scope throws instead of clobbering the first. + +## Example + +```ts +// ✗ Bad — same id twice in the same scope +createJsonRenderView(ctx, { id: 'metrics', spec }) +createJsonRenderView(ctx, { id: 'metrics', spec }) // DF0039 + +// ✓ Good — dispose the previous view first, or use a distinct id +const view = createJsonRenderView(ctx, { id: 'metrics', spec }) +view.dispose() +createJsonRenderView(ctx, { id: 'metrics', spec }) +``` + +## Fix + +Give each view a stable id unique within its scope, or dispose the previous view before recreating it. Scope defaults to the context namespace (or `global`); pass `scope` to isolate ids explicitly. + +## Source + +- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts) — `createJsonRenderView()` throws this when the scoped id is already live. diff --git a/docs/errors/DF0040.md b/docs/errors/DF0040.md new file mode 100644 index 00000000..a76095dc --- /dev/null +++ b/docs/errors/DF0040.md @@ -0,0 +1,29 @@ +--- +outline: deep +--- + +# DF0040: JSON-Render View Used After Disposal + +## Message + +> JSON-render view "`{id}`" was used after it was disposed. + +## Cause + +`view.dispose()` unregisters the view's shared state and its broadcast listeners. Calling `update` or `patchState` on a disposed handle is a lifecycle bug — the shared state it targeted no longer exists. + +## Example + +```ts +const view = createJsonRenderView(ctx, { id: 'metrics', spec }) +view.dispose() +view.update(nextSpec) // DF0040 +``` + +## Fix + +Create a fresh view with `createJsonRenderView` instead of reusing a disposed handle. The id is free to reuse once disposed. + +## Source + +- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts) — `update` / `patchState` throw this after `dispose()`. diff --git a/docs/errors/DF0041.md b/docs/errors/DF0041.md new file mode 100644 index 00000000..c400e9eb --- /dev/null +++ b/docs/errors/DF0041.md @@ -0,0 +1,33 @@ +--- +outline: deep +--- + +# DF0041: JSON-Render Spec Is Not JSON-Serializable + +## Message + +> JSON-render view "`{id}`" spec is not JSON-serializable: `{reason}` + +## Cause + +Specs and state travel across the RPC / static boundary as strict JSON. A spec containing functions, symbols, class instances, `Map`/`Set`, or circular references cannot cross that boundary, so it is rejected at ingress. + +## Example + +```ts +// ✗ Bad — circular reference +const spec: any = { root: 'a', elements: {} } +spec.self = spec +createJsonRenderView(ctx, { id: 'x', spec }) // DF0041 + +// ✓ Good — plain JSON data +createJsonRenderView(ctx, { id: 'x', spec: { root: 'a', elements: { a: { type: 'Text', props: { text: 'hi' }, children: [] } } } }) +``` + +## Fix + +Keep specs and state strict JSON — remove functions, symbols, class instances, `Map`/`Set`, or circular references. + +## Source + +- [`packages/json-render/src/node/create-view.ts`](https://github.com/devframes/devframe/blob/main/packages/json-render/src/node/create-view.ts) — `assertJsonSerializable()` throws this at ingress and on `update`. diff --git a/packages/devframe/src/client/rpc-shared-state.ts b/packages/devframe/src/client/rpc-shared-state.ts index dcbd6871..3e3ffb87 100644 --- a/packages/devframe/src/client/rpc-shared-state.ts +++ b/packages/devframe/src/client/rpc-shared-state.ts @@ -5,6 +5,7 @@ import { createSharedState } from 'devframe/utils/shared-state' export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcSharedStateHost { const sharedState = new Map>() + const stateDisposers = new Map void>() const initialValues = new Map() const keyAddedListeners = new Set<(key: string) => void>() const isStaticBackend = rpc.connectionMeta.backend === 'static' @@ -68,6 +69,14 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare keyAddedListeners.delete(fn) } }, + delete(key) { + const dispose = stateDisposers.get(key) + stateDisposers.delete(key) + const existed = sharedState.delete(key) + initialValues.delete(key) + dispose?.() + return existed + }, get: async (key: string, options?: RpcSharedStateGetOptions) => { if (options?.initialValue !== undefined) { initialValues.set(key, options.initialValue) @@ -97,7 +106,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare .catch((error) => { console.error('Error getting server state', error) }) - registerSharedState(key, state) + stateDisposers.set(key, registerSharedState(key, state)) return state } else { @@ -106,7 +115,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare sharedState.set(key, state) for (const fn of keyAddedListeners) fn(key) - registerSharedState(key, state) + stateDisposers.set(key, registerSharedState(key, state)) return state } } diff --git a/packages/devframe/src/node/context.ts b/packages/devframe/src/node/context.ts index 426786c1..5e3cd7ce 100644 --- a/packages/devframe/src/node/context.ts +++ b/packages/devframe/src/node/context.ts @@ -28,8 +28,8 @@ export interface CreateHostContextOptions { * Wires the RPC host, view (HTTP file-serving) host, diagnostics, and * agent subsystems. Host adapters can wrap this to augment `ctx` with * extra surfaces — for example, `@vitejs/devtools-kit`'s - * `createKitContext` attaches `docks`, `terminals`, `messages`, - * `commands`, and `createJsonRenderer` when mounted into Vite DevTools. + * `createKitContext` attaches `docks`, `terminals`, `messages`, and + * `commands` when mounted into Vite DevTools. */ export async function createHostContext(options: CreateHostContextOptions): Promise { const { cwd, workspaceRoot = cwd, mode, host, builtinRpcDeclarations = [] } = options diff --git a/packages/devframe/src/node/rpc-shared-state.ts b/packages/devframe/src/node/rpc-shared-state.ts index eb944223..27bdc4d8 100644 --- a/packages/devframe/src/node/rpc-shared-state.ts +++ b/packages/devframe/src/node/rpc-shared-state.ts @@ -11,6 +11,7 @@ export function createRpcSharedStateServerHost( rpc: RpcFunctionsHost, ): RpcSharedStateHost { const sharedState = new Map>() + const stateDisposers = new Map void>() const keyAddedListeners = new Set<(key: string) => void>() function registerSharedState(key: string, state: SharedState) { @@ -57,7 +58,7 @@ export function createRpcSharedStateServerHost( initialValue: options.initialValue as T, enablePatches: false, }) - registerSharedState(key, state) + stateDisposers.set(key, registerSharedState(key, state)) sharedState.set(key, state) for (const fn of keyAddedListeners) fn(key) @@ -72,6 +73,15 @@ export function createRpcSharedStateServerHost( keyAddedListeners.delete(fn) } }, + delete(key) { + const dispose = stateDisposers.get(key) + if (!dispose) + return false + dispose() + stateDisposers.delete(key) + sharedState.delete(key) + return true + }, } // Wire methods that the client-side `client/rpc-shared-state.ts` diff --git a/packages/devframe/src/types/context.ts b/packages/devframe/src/types/context.ts index 50d8a272..b2ed32c5 100644 --- a/packages/devframe/src/types/context.ts +++ b/packages/devframe/src/types/context.ts @@ -15,7 +15,9 @@ export interface DevframeCapabilities { * agent + the view-host (HTTP file-serving). Host adapters can wrap this * to add their own surfaces; for example, `@vitejs/devtools-kit`'s * `createKitContext` adds `docks`, `terminals`, `messages`, and - * `commands` when mounted into Vite DevTools. + * `commands` when mounted into Vite DevTools. JSON rendering is an opt-in + * integration (`@devframes/json-render`) layered on top, not part of this + * core surface. */ export interface DevframeNodeContext { readonly workspaceRoot: string diff --git a/packages/devframe/src/types/rpc.ts b/packages/devframe/src/types/rpc.ts index 79375a78..045b3a89 100644 --- a/packages/devframe/src/types/rpc.ts +++ b/packages/devframe/src/types/rpc.ts @@ -81,6 +81,13 @@ export interface RpcSharedStateHost { * as dynamic resources. */ onKeyAdded: (fn: (key: string) => void) => () => void + /** + * Unregister a shared state and drop its broadcast listeners. Returns + * `true` when a state was removed, `false` when the key was unknown. + * Used by short-lived states (e.g. a disposed JSON-render view) to avoid + * leaking listeners and lingering entries for the context lifetime. + */ + delete: (key: string) => boolean } /** diff --git a/packages/devframe/src/utils/shared-state.ts b/packages/devframe/src/utils/shared-state.ts index 942f27c1..8bda1f0f 100644 --- a/packages/devframe/src/utils/shared-state.ts +++ b/packages/devframe/src/utils/shared-state.ts @@ -94,6 +94,11 @@ export function createSharedState( enablePatches = false, } = options + // `mutate` uses `produceWithPatches` when patches are enabled, which requires + // Immer's Patches plugin to be loaded up front (not only on `patch()`). + if (enablePatches) + enableImmerPatches() + const events = createEventEmitter>() let state = options.initialValue const syncIds = new Set() diff --git a/packages/hub/src/client/__tests__/renderers.test.ts b/packages/hub/src/client/__tests__/renderers.test.ts new file mode 100644 index 00000000..2de4a134 --- /dev/null +++ b/packages/hub/src/client/__tests__/renderers.test.ts @@ -0,0 +1,110 @@ +import type { DevframeRpcClient } from 'devframe/client' +import type { SharedState } from 'devframe/utils/shared-state' +import type { DevframeDockEntry } from '../../types/docks' +import { createEventEmitter } from 'devframe/utils/events' +import { describe, expect, it, vi } from 'vitest' +import { createDevframeClientHost } from '../host' + +interface StubSharedState extends SharedState { + push: (next: T) => void +} + +function createStubSharedState(initial: T): StubSharedState { + let state = initial + const events = createEventEmitter() + return { + value: () => state as any, + on: events.on, + mutate: (fn) => { + fn(state) + events.emit('updated', state, undefined, 'test') + }, + patch: () => {}, + syncIds: new Set(), + push: (next) => { + state = next + events.emit('updated', state, undefined, 'test') + }, + } +} + +function createStubRpc() { + const states = new Map>() + const rpc = { + sharedState: { + async get(key: string, options?: { initialValue?: any }) { + if (!states.has(key)) + states.set(key, createStubSharedState(options?.initialValue)) + return states.get(key)! + }, + }, + call: async () => undefined, + client: { definitions: new Map(), register() {} }, + } as unknown as DevframeRpcClient + return { rpc, states } +} + +const jsonRenderEntry = { + id: 'metrics', + title: 'Metrics', + icon: 'ph:cube', + type: 'json-render', + view: { stateKey: 'devframe:json-render:global:metrics', upstreamVersion: '0.19.0' }, +} as unknown as DevframeDockEntry + +const container = {} as HTMLElement + +describe('client host renderer registry', () => { + it('registers renderers injected at boot', async () => { + const { rpc } = createStubRpc() + const host = await createDevframeClientHost({ rpc, renderers: { 'json-render': async () => ({}) } }) + expect(host.context.renderers.has('json-render')).toBe(true) + host.dispose() + }) + + it('routes a dock type to its renderer and returns a disposer', async () => { + const { rpc } = createStubRpc() + const renderer = vi.fn(async () => ({ dispose: vi.fn() })) + const host = await createDevframeClientHost({ rpc, renderers: { 'json-render': renderer } }) + + const dispose = await host.context.renderers.mount(jsonRenderEntry, container) + expect(renderer).toHaveBeenCalledWith(expect.objectContaining({ entry: jsonRenderEntry, container, context: host.context })) + dispose() + host.dispose() + }) + + it('warns and no-ops when no renderer is registered for the type', async () => { + const { rpc } = createStubRpc() + const host = await createDevframeClientHost({ rpc }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const dispose = await host.context.renderers.mount(jsonRenderEntry, container) + expect(warn).toHaveBeenCalled() + expect(() => dispose()).not.toThrow() + warn.mockRestore() + host.dispose() + }) + + it('disposes a mounted renderer when the dock deactivates', async () => { + const { rpc, states } = createStubRpc() + const disposeSpy = vi.fn() + const host = await createDevframeClientHost({ rpc, renderers: { 'json-render': async () => ({ dispose: disposeSpy }) } }) + + // Seed the dock so the entry state exists (needed for deactivation hooks). + states.get('devframe:docks')!.push([jsonRenderEntry]) + + await host.context.renderers.mount(jsonRenderEntry, container) + await host.context.docks.switchEntry('metrics') + await host.context.docks.switchEntry(null) + expect(disposeSpy).toHaveBeenCalledTimes(1) + host.dispose() + }) + + it('disposes live mounts on host teardown', async () => { + const { rpc } = createStubRpc() + const disposeSpy = vi.fn() + const host = await createDevframeClientHost({ rpc, renderers: { 'json-render': async () => ({ dispose: disposeSpy }) } }) + await host.context.renderers.mount(jsonRenderEntry, container) + host.dispose() + expect(disposeSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts index ae34c340..7cbd29c2 100644 --- a/packages/hub/src/client/docks.ts +++ b/packages/hub/src/client/docks.ts @@ -5,6 +5,7 @@ import type { WhenContext } from 'devframe/utils/when' import type { DevframeClientCommand, DevframeCommandEntry, DevframeCommandKeybinding } from '../types/commands' import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeDockUserEntry } from '../types/docks' import type { DevframeDocksUserSettings } from '../types/settings' +import type { DockRenderersContext } from './renderers' export type { DevframeClientRpcHost, RpcClientEvents } from 'devframe/client' @@ -51,6 +52,12 @@ export interface DocksContext extends DevframeRpcContext { * instead of each plugin surfacing its own. */ readonly connection: DocksConnectionContext + /** + * The dock-renderer registry. Routes a dock `type` to a host-registered + * renderer (e.g. `@devframes/json-render-ui` for `'json-render'`). The hub + * itself ships no renderers. + */ + readonly renderers: DockRenderersContext } export interface DocksConnectionContext { diff --git a/packages/hub/src/client/host.ts b/packages/hub/src/client/host.ts index ec69058f..2a118493 100644 --- a/packages/hub/src/client/host.ts +++ b/packages/hub/src/client/host.ts @@ -22,6 +22,7 @@ import type { DocksPanelContext, WhenClauseContext, } from './docks' +import type { DockRenderer, DockRenderersContext } from './renderers' import { connectDevframe } from 'devframe/client' import { createEventEmitter } from 'devframe/utils/events' import { DEFAULT_CATEGORIES_ORDER, DEFAULT_STATE_USER_SETTINGS } from '../constants' @@ -53,6 +54,13 @@ export interface DevframeClientHostOptions { * `custom-render`, and iframe `clientScript`). Default `true`. */ loadClientScripts?: boolean + /** + * Dock renderers to register at boot, keyed by dock `type`. The host + * application injects the ones it wants (e.g. + * `{ 'json-render': createJsonRenderDockRenderer() }` from + * `@devframes/json-render-ui`). The hub ships none by default. + */ + renderers?: Record } export interface DevframeClientHost { @@ -77,6 +85,8 @@ export async function createDevframeClientHost( ): Promise { const clientType: DockClientType = options.clientType ?? 'standalone' const rpc = options.rpc ?? await connectDevframe(options.connect) + // Set by createRenderersContext(); teardown disposes every live mount. + let mountedRenderers: Set<() => void> | undefined const [docksState, commandsState, settings] = await Promise.all([ rpc.sharedState.get(DOCKS_STATE_KEY, { initialValue: [] }), @@ -92,6 +102,7 @@ export async function createDevframeClientHost( const panel = createPanelContext(clientType) const docks = createDocksContext() const commands = createCommandsContext() + const renderers = createRenderersContext() const when: WhenClauseContext = { get context(): WhenContext { return { @@ -109,6 +120,7 @@ export async function createDevframeClientHost( panel, docks, commands, + renderers, when, connection: { get status() { @@ -169,6 +181,9 @@ export async function createDevframeClientHost( context, dispose() { for (const off of disposers.splice(0)) off() + if (mountedRenderers) { + for (const disposeMount of [...mountedRenderers]) disposeMount() + } if (getDevframeClientContext() === context) setDevframeClientContext(undefined) }, @@ -293,6 +308,52 @@ export async function createDevframeClientHost( return ctx } + // ── renderers ──────────────────────────────────────────────────────────── + + function createRenderersContext(): DockRenderersContext { + const rendererMap = new Map() + for (const [type, renderer] of Object.entries(options.renderers ?? {})) + rendererMap.set(type, renderer) + // Every live mount's disposer, so host teardown cleans them all up. + const mountedDisposers = new Set<() => void>() + mountedRenderers = mountedDisposers + + return { + register(type, renderer) { + rendererMap.set(type, renderer) + return () => { + if (rendererMap.get(type) === renderer) + rendererMap.delete(type) + } + }, + get: type => rendererMap.get(type), + has: type => rendererMap.has(type), + async mount(entry, container) { + const renderer = rendererMap.get(entry.type) + if (!renderer) { + console.warn(`[@devframes/hub] no renderer registered for dock type "${entry.type}" (entry "${entry.id}")`) + return () => {} + } + const instance = await renderer({ entry, container, context }) + let disposed = false + let offDeactivate: (() => void) | undefined + const dispose = (): void => { + if (disposed) + return + disposed = true + mountedDisposers.delete(dispose) + offDeactivate?.() + instance.dispose?.() + } + mountedDisposers.add(dispose) + // Dispose when the dock deactivates — the Vite viewer leaked here by + // never unsubscribing the renderer's shared-state listeners. + offDeactivate = entryToStateMap.get(entry.id)?.events.on('entry:deactivated', dispose) + return dispose + }, + } + } + // ── client scripts ─────────────────────────────────────────────────────── function clientScriptOf(entry: DevframeDockEntry): ClientScriptEntry | undefined { diff --git a/packages/hub/src/client/index.ts b/packages/hub/src/client/index.ts index 87f8edf2..2c91c629 100644 --- a/packages/hub/src/client/index.ts +++ b/packages/hub/src/client/index.ts @@ -4,4 +4,5 @@ export * from './docks' export * from './host' export * from './messages' export * from './remote' +export * from './renderers' export * from 'devframe/client' diff --git a/packages/hub/src/client/renderers.ts b/packages/hub/src/client/renderers.ts new file mode 100644 index 00000000..856ad975 --- /dev/null +++ b/packages/hub/src/client/renderers.ts @@ -0,0 +1,53 @@ +import type { DevframeDockEntry } from '../types/docks' +import type { DevframeClientContext } from './docks' + +/** + * Options handed to a dock renderer when the client host mounts a dock entry. + */ +export interface DockRendererMountOptions { + /** The dock entry being rendered (carries the entry's serializable payload). */ + entry: DevframeDockEntry + /** The DOM element the renderer should mount into. */ + container: HTMLElement + /** The assembled client host context (rpc, docks, commands, …). */ + context: DevframeClientContext +} + +/** A mounted renderer instance the host can tear down. */ +export interface DockRendererInstance { + /** Tear down the mounted UI and release its subscriptions. */ + dispose?: () => void +} + +/** + * A renderer for a dock `type`. The headless hub is renderer-agnostic — a + * host application registers renderers at boot (e.g. injecting + * `@devframes/json-render-ui` for the `'json-render'` type). The renderer + * owns its framework (Vue, React, …); the hub only routes a dock type to it + * and disposes it on deactivation. + */ +export type DockRenderer = ( + options: DockRendererMountOptions, +) => DockRendererInstance | Promise + +/** + * The dock-renderer registry surfaced on the client host context. A viewer + * calls {@link DockRenderersContext.mount} to render a dock whose `type` has a + * registered renderer into a container it owns; the host tracks the instance + * and disposes it when the entry deactivates. + */ +export interface DockRenderersContext { + /** Register a renderer for a dock `type`. Returns an unregister function. */ + register: (type: string, renderer: DockRenderer) => () => void + /** Look up the renderer registered for a dock `type`, if any. */ + get: (type: string) => DockRenderer | undefined + /** Whether a renderer is registered for a dock `type`. */ + has: (type: string) => boolean + /** + * Mount the entry's registered renderer into `container`. Resolves to a + * disposer; the same instance is also disposed automatically when the entry + * deactivates. Warns and resolves to a no-op disposer when no renderer is + * registered for the entry's type. + */ + mount: (entry: DevframeDockEntry, container: HTMLElement) => Promise<() => void> +} diff --git a/packages/hub/src/define.ts b/packages/hub/src/define.ts index e69b87b8..3e763849 100644 --- a/packages/hub/src/define.ts +++ b/packages/hub/src/define.ts @@ -2,7 +2,6 @@ import type { WhenContext, WhenExpression } from 'devframe/utils/when' import type { DevframeHubContext } from './node/context' import type { DevframeServerCommandInput } from './types/commands' import type { DevframeDockUserEntry } from './types/docks' -import type { JsonRenderSpec } from './types/json-render' import { createDefineWrapperWithContext } from 'devframe/rpc' export const defineHubRpcFunction = createDefineWrapperWithContext() @@ -21,7 +20,3 @@ export function defineDockEntry< ): T { return entry as unknown as T } - -export function defineJsonRenderSpec(spec: JsonRenderSpec): JsonRenderSpec { - return spec -} diff --git a/packages/hub/src/node/context.ts b/packages/hub/src/node/context.ts index c6f14630..8489af4c 100644 --- a/packages/hub/src/node/context.ts +++ b/packages/hub/src/node/context.ts @@ -2,7 +2,6 @@ import type { CreateHostContextOptions } from 'devframe/node' import type { DevframeHost, DevframeNodeContext } from 'devframe/types' import type { DevframeCommandsHost } from '../types/commands' import type { DevframeDockActivation, DevframeDocksActiveState, DevframeDocksHost } from '../types/docks' -import type { JsonRenderer, JsonRenderSpec } from '../types/json-render' import type { DevframeMessageEntry, DevframeMessageEntryInput, DevframeMessagesHost } from '../types/messages' import type { DevframeTerminalsHost } from '../types/terminals' import { createHostContext } from 'devframe/node' @@ -85,13 +84,14 @@ declare module 'devframe/types' { /** * Hub-augmented node context — extends devframe's framework-neutral * `DevframeNodeContext` with the hub-level subsystems (`docks`, - * `terminals`, `messages`, `commands`) and the `createJsonRenderer` - * factory. + * `terminals`, `messages`, `commands`). * * Framework kits further extend this with their own slots (e.g. * `viteConfig`, `viteServer`). Host-specific capabilities (editor open, * filesystem reveal, etc.) ship as kit-registered RPC functions rather - * than as part of this surface. + * than as part of this surface. JSON-render is not part of the hub: it is + * an opt-in integration (`@devframes/json-render`) that augments any + * devframe context and contributes its own dock type. */ export interface DevframeHubContext extends DevframeNodeContext { readonly host: DevframeHost @@ -99,10 +99,6 @@ export interface DevframeHubContext extends DevframeNodeContext { terminals: DevframeTerminalsHost messages: DevframeMessagesHost commands: DevframeCommandsHost - /** - * Create a JsonRenderer handle for building json-render powered UIs. - */ - createJsonRenderer: (spec: JsonRenderSpec) => JsonRenderer } /** @@ -140,28 +136,6 @@ export async function createHubContext(options: CreateHubContextOptions): Promis await docks.init() - let jrCounter = 0 - context.createJsonRenderer = (initialSpec: JsonRenderSpec): JsonRenderer => { - const stateKey = `devframe:json-render:${jrCounter++}` - const statePromise = context.rpc.sharedState.get(stateKey as any, { - initialValue: initialSpec as any, - }) - - return { - _stateKey: stateKey, - async updateSpec(spec) { - const state = await statePromise - state.mutate(() => spec as any) - }, - async updateState(newState) { - const state = await statePromise - state.mutate((draft: any) => { - draft.state = { ...draft.state, ...newState } - }) - }, - } - } - const debounceMs = options.mode === 'build' ? 0 : 10 const docksSharedState = await context.rpc.sharedState.get('devframe:docks', { initialValue: [] }) diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index c3e5009b..c137241f 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -1,5 +1,4 @@ import type { ConnectionMeta, EventEmitter } from 'devframe/types' -import type { JsonRenderer } from './json-render' export interface DevframeDocksHost { readonly views: Map @@ -252,12 +251,6 @@ export interface DevframeViewBuiltin extends DevframeDockEntryBase { id: string } -export interface DevframeViewJsonRender extends DevframeDockEntryBase { - type: 'json-render' - /** JsonRenderer handle created by ctx.createJsonRenderer() */ - ui: JsonRenderer -} - /** * A dock group: a single dock-bar button that collapses every entry whose * {@link DevframeDockEntryBase.groupId} matches this group's `id`. @@ -280,7 +273,33 @@ export interface DevframeViewGroup extends DevframeDockEntryBase { defaultChildId?: string } -export type DevframeDockUserEntry = DevframeViewIframe | DevframeViewAction | DevframeViewCustomRender | DevframeViewLauncher | DevframeViewJsonRender | DevframeViewGroup | DevframeViewBuiltin +/** + * The **open** registry of dock entry variants, keyed by their `type` + * discriminator. The hub ships the framework-neutral built-ins; opt-in + * integrations contribute their own variants through declaration merging — + * e.g. `@devframes/json-render/hub` adds a `'json-render'` entry. The hub + * itself stays agnostic: it hard-codes no integration-specific variant. + * + * @example + * ```ts + * // in an opt-in integration package + * declare module '@devframes/hub/types' { + * interface DevframeDockEntryRegistry { + * 'my-view': MyDockEntry + * } + * } + * ``` + */ +export interface DevframeDockEntryRegistry { + 'iframe': DevframeViewIframe + 'action': DevframeViewAction + 'custom-render': DevframeViewCustomRender + 'launcher': DevframeViewLauncher + 'group': DevframeViewGroup + '~builtin': DevframeViewBuiltin +} + +export type DevframeDockUserEntry = DevframeDockEntryRegistry[keyof DevframeDockEntryRegistry] export type DevframeDockEntry = DevframeDockUserEntry diff --git a/packages/hub/src/types/index.ts b/packages/hub/src/types/index.ts index e97130e7..e81a1c1a 100644 --- a/packages/hub/src/types/index.ts +++ b/packages/hub/src/types/index.ts @@ -4,7 +4,6 @@ export type { CreateHubContextOptions, DevframeHubContext } from '../node/contex export * from './commands' export * from './docks' -export * from './json-render' export * from './messages' export * from './settings' export * from './terminals' diff --git a/packages/hub/src/types/json-render.ts b/packages/hub/src/types/json-render.ts deleted file mode 100644 index 336af7cc..00000000 --- a/packages/hub/src/types/json-render.ts +++ /dev/null @@ -1,29 +0,0 @@ -export interface JsonRenderElement { - type: string - props?: Record - children?: string[] - /** json-render event bindings (e.g. `{ press: { action: "my:action" } }`) */ - on?: Record - /** json-render visibility condition */ - visible?: unknown - /** json-render repeat binding */ - repeat?: unknown - /** Allow additional json-render element fields */ - [key: string]: unknown -} - -export interface JsonRenderSpec { - root: string - elements: Record - /** Initial client-side state model for $state/$bindState expressions */ - state?: Record -} - -export interface JsonRenderer { - /** Replace the entire spec */ - updateSpec: (spec: JsonRenderSpec) => void | Promise - /** Update json-render state values (shallow merge into spec.state) */ - updateState: (state: Record) => void | Promise - /** Internal: shared state key used by the client to subscribe */ - readonly _stateKey: string -} diff --git a/packages/json-render-ui/.storybook/main.ts b/packages/json-render-ui/.storybook/main.ts new file mode 100644 index 00000000..46f23d60 --- /dev/null +++ b/packages/json-render-ui/.storybook/main.ts @@ -0,0 +1,23 @@ +import type { StorybookConfig } from '@storybook/vue3-vite' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import { mergeConfig } from 'vite' +import { alias } from '../../../alias' + +const config: StorybookConfig = { + stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: ['@storybook/addon-docs'], + framework: { + name: '@storybook/vue3-vite', + options: {}, + }, + docs: {}, + async viteFinal(config) { + return mergeConfig(config, { + resolve: { alias }, + plugins: [vue(), UnoCSS()], + server: { allowedHosts: true }, + }) + }, +} +export default config diff --git a/packages/json-render-ui/.storybook/preview.ts b/packages/json-render-ui/.storybook/preview.ts new file mode 100644 index 00000000..01f1fcea --- /dev/null +++ b/packages/json-render-ui/.storybook/preview.ts @@ -0,0 +1,41 @@ +import type { Decorator, Preview } from '@storybook/vue3-vite' +import 'virtual:uno.css' +import '@antfu/design/styles.css' + +// Drive the shared `@antfu/design` tokens off the toolbar theme toggle: dark +// mode is the `.dark` class on ``, and the canvas takes the semantic +// `bg-base`/`color-base` surface — matching every other devframe surface. +function applyTheme(theme: string): void { + document.documentElement.classList.toggle('dark', theme !== 'light') + document.body.classList.add('bg-base', 'color-base', 'font-sans') +} + +const withTheme: Decorator = (story, context) => { + applyTheme(context.globals.theme ?? 'dark') + return { components: { story }, template: '
' } +} + +const preview: Preview = { + parameters: { + layout: 'fullscreen', + controls: { expanded: true }, + }, + globalTypes: { + theme: { + description: 'Color theme', + defaultValue: 'dark', + toolbar: { + title: 'Theme', + icon: 'contrast', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [withTheme], +} + +export default preview diff --git a/packages/json-render-ui/package.json b/packages/json-render-ui/package.json new file mode 100644 index 00000000..f86acd6f --- /dev/null +++ b/packages/json-render-ui/package.json @@ -0,0 +1,64 @@ +{ + "name": "@devframes/json-render-ui", + "type": "module", + "version": "0.7.5", + "description": "Official reference frontend for @devframes/json-render — a Vue renderer implementing the base catalog with @antfu/design.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "packages/json-render-ui", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devtools", + "devframe", + "json-render", + "vue" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./components": "./dist/components/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", + "storybook": "storybook dev -p 6014 --host 0.0.0.0", + "build-storybook": "storybook build", + "test": "vitest run", + "prepack": "pnpm run build" + }, + "peerDependencies": { + "@devframes/json-render": "workspace:*", + "vue": "^3.5.0" + }, + "dependencies": { + "@json-render/vue": "catalog:frontend", + "dompurify": "catalog:frontend" + }, + "devDependencies": { + "@antfu/design": "catalog:frontend", + "@devframes/json-render": "workspace:*", + "@iconify-json/ph": "catalog:frontend", + "@storybook/addon-docs": "catalog:storybook", + "@storybook/vue3-vite": "catalog:storybook", + "@unocss/preset-icons": "catalog:frontend", + "@vitejs/plugin-vue": "catalog:build", + "devframe": "workspace:*", + "storybook": "catalog:storybook", + "tsdown": "catalog:build", + "unocss": "catalog:frontend", + "vite": "catalog:build", + "vitest": "catalog:testing", + "vue": "catalog:frontend" + } +} diff --git a/packages/json-render-ui/src/JsonRender.stories.ts b/packages/json-render-ui/src/JsonRender.stories.ts new file mode 100644 index 00000000..6d578516 --- /dev/null +++ b/packages/json-render-ui/src/JsonRender.stories.ts @@ -0,0 +1,76 @@ +import type { Spec } from '@devframes/json-render' +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import { h } from 'vue' +import { JsonRenderView } from './renderer' + +// A no-op RPC — stories don't dispatch real actions. +const rpc = { call: async () => undefined } + +function story(spec: Spec, extra: Record = {}): StoryObj { + return { + render: () => ({ + setup: () => () => h(JsonRenderView, { spec, rpc, ...extra }), + }), + } +} + +const meta: Meta = { + title: 'JsonRender', +} +export default meta + +export const Gallery = story({ + root: 'root', + elements: { + root: { type: 'Stack', props: { gap: 12 }, children: ['title', 'row', 'card', 'progress', 'table', 'tree'] }, + title: { type: 'Text', props: { text: 'JSON-render gallery', variant: 'heading' }, children: [] }, + row: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['b1', 'b2', 'badge'] }, + b1: { type: 'Button', props: { label: 'Primary', variant: 'primary' }, children: [] }, + b2: { type: 'Button', props: { label: 'Ghost', variant: 'ghost', icon: 'plus' }, children: [] }, + badge: { type: 'Badge', props: { text: 'success', variant: 'success' }, children: [] }, + card: { type: 'Card', props: { title: 'Details', collapsible: true }, children: ['kv'] }, + kv: { type: 'KeyValueTable', props: { data: { name: 'devframe', version: '0.7.5' } }, children: [] }, + progress: { type: 'Progress', props: { value: 62, max: 100, label: 'Coverage' }, children: [] }, + table: { type: 'DataTable', props: { rows: [{ id: 1, name: 'a' }, { id: 2, name: 'b' }] }, children: [] }, + tree: { type: 'Tree', props: { data: { a: 1, b: [true, 'x'] } }, children: [] }, + }, +}) + +export const Controls = story({ + root: 'root', + elements: { + root: { type: 'Stack', props: { gap: 12 }, children: ['input', 'toggle', 'divider', 'code'] }, + input: { type: 'TextInput', props: { label: 'Name', placeholder: 'Type…', value: { $bindState: '/name' } }, children: [] }, + toggle: { type: 'Switch', props: { label: 'Enabled', value: { $bindState: '/enabled' } }, children: [] }, + divider: { type: 'Divider', props: { label: 'code' }, children: [] }, + code: { type: 'CodeBlock', props: { filename: 'hello.ts', language: 'ts', code: 'export const x = 1' }, children: [] }, + }, + state: { name: '', enabled: true }, +}) + +export const Loading: StoryObj = story({ root: 'a', elements: { a: { type: 'Text', props: {}, children: [] } } }, { loading: true }) + +export const ConnectionError: StoryObj = story( + { root: 'a', elements: { a: { type: 'Text', props: {}, children: [] } } }, + { connectionError: 'Disconnected from server' }, +) + +export const StaticOutput: StoryObj = story( + { + root: 'root', + elements: { + root: { type: 'Stack', props: { gap: 8 }, children: ['btn'] }, + btn: { type: 'Button', props: { label: 'Run', variant: 'primary' }, children: [] }, + }, + }, + { interactive: false }, +) + +export const InvalidElement: StoryObj = story({ + root: 'root', + elements: { + root: { type: 'Stack', props: { gap: 8 }, children: ['ok', 'bad'] }, + ok: { type: 'Text', props: { text: 'valid' }, children: [] }, + bad: { type: 'Badge', props: { variant: 'purple' }, children: [] }, + }, +}) diff --git a/packages/json-render-ui/src/action-bridge.ts b/packages/json-render-ui/src/action-bridge.ts new file mode 100644 index 00000000..522d8212 --- /dev/null +++ b/packages/json-render-ui/src/action-bridge.ts @@ -0,0 +1,82 @@ +import { reactive, shallowRef } from 'vue' + +/** Minimal RPC surface the bridge needs. */ +export interface ActionBridgeRpc { + call: (method: string, ...args: unknown[]) => Promise +} + +export interface JsonRenderActionError { + action: string + error: unknown +} + +export interface JsonRenderActionBridge { + /** Handlers object handed to `JSONUIProvider` (a Proxy over all action names). */ + handlers: Record) => Promise> + /** Reactive per-action loading flags. */ + loading: Record + /** The most recent action failure, or `null`. */ + error: { value: JsonRenderActionError | null } +} + +// Built-ins handled inside upstream's ActionProvider — never bridged to RPC. +const RESERVED = new Set(['setState', 'pushState', 'removeState', 'validateForm']) +// Promise-probe / symbol keys must not resolve to a phantom action. +const PROBES = new Set(['then', 'catch', 'finally']) + +/** + * The unrestricted action bridge: any spec action name is dispatched as an RPC + * call of the same name (the current `vitejs/devtools` behavior — no + * allowlist, no param schema). Unlike that proxy, this bridge tracks per-action + * loading state and surfaces failures to the view instead of swallowing them to + * the console. + * + * In static output (`interactive: false`) no RPC exists, so handlers reject + * with a clear "unavailable in static output" error rather than hanging. + */ +export function createActionBridge( + rpc: ActionBridgeRpc, + options: { interactive?: boolean } = {}, +): JsonRenderActionBridge { + const interactive = options.interactive ?? true + const loading = reactive>({}) + const error = shallowRef(null) + const cache = new Map) => Promise>() + + function makeHandler(action: string) { + let fn = cache.get(action) + if (fn) + return fn + fn = async (params?: Record) => { + if (!interactive) { + const err = new Error(`Action "${action}" is unavailable in static output`) + error.value = { action, error: err } + throw err + } + loading[action] = true + try { + return await rpc.call(action, params) + } + catch (err) { + error.value = { action, error: err } + throw err + } + finally { + loading[action] = false + } + } + cache.set(action, fn) + return fn + } + + const handlers = new Proxy({} as Record) => Promise>, { + has: (_t, prop) => typeof prop === 'string' && !RESERVED.has(prop) && !PROBES.has(prop), + get: (_t, prop) => { + if (typeof prop !== 'string' || RESERVED.has(prop) || PROBES.has(prop)) + return undefined + return makeHandler(prop) + }, + }) + + return { handlers, loading, error } +} diff --git a/packages/json-render-ui/src/components/_error.ts b/packages/json-render-ui/src/components/_error.ts new file mode 100644 index 00000000..35b5147d --- /dev/null +++ b/packages/json-render-ui/src/components/_error.ts @@ -0,0 +1,13 @@ +import type { JrComponent } from './_shared' +import { h } from 'vue' + +/** + * Internal placeholder rendered in place of an element whose props failed + * render-time validation, so one bad element is isolated rather than breaking + * the whole view. Not part of the public catalog. + */ +export const JsonRenderError: JrComponent<{ message?: string }> = ({ props }) => + h('div', { + class: 'rounded border border-red bg-red:10 color-red text-xs font-mono px2 py1', + role: 'alert', + }, props.message ?? 'Invalid element') diff --git a/packages/json-render-ui/src/components/_shared.ts b/packages/json-render-ui/src/components/_shared.ts new file mode 100644 index 00000000..e2a90854 --- /dev/null +++ b/packages/json-render-ui/src/components/_shared.ts @@ -0,0 +1,18 @@ +import type { BaseComponentProps } from '@json-render/vue' +import type { VNodeChild } from 'vue' + +/** + * A base-catalog component render function. Receives the upstream + * {@link BaseComponentProps} contract (`props`, `children`, `emit`, `on`, + * `bindings`, `loading`) and returns Vue VNodes. Ported components are plain + * functions so they need no SFC compiler and can be imported individually. + */ +export type JrComponent

> = ( + ctx: BaseComponentProps

, +) => VNodeChild + +/** Resolve a numeric prop that may arrive as a number or numeric string. */ +export function toNumber(value: unknown, fallback: number): number { + const n = typeof value === 'string' ? Number(value) : value + return typeof n === 'number' && Number.isFinite(n) ? n : fallback +} diff --git a/packages/json-render-ui/src/components/controls.ts b/packages/json-render-ui/src/components/controls.ts new file mode 100644 index 00000000..64a8b74f --- /dev/null +++ b/packages/json-render-ui/src/components/controls.ts @@ -0,0 +1,111 @@ +import type { JrComponent } from './_shared' +import { useBoundProp } from '@json-render/vue' +import { h } from 'vue' + +interface ButtonProps { + label?: string + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' + icon?: string + disabled?: boolean + loading?: boolean +} + +const buttonBase + = 'inline-flex items-center gap-1.5 rounded px2.5 py1 text-sm font-medium transition disabled:op-50 disabled:cursor-not-allowed' +const buttonVariant: Record = { + primary: 'bg-primary color-white hover:bg-primary/90', + secondary: 'bg-secondary color-base border border-base hover:bg-active', + ghost: 'color-base hover:bg-secondary', + danger: 'bg-red color-white hover:bg-red/90', +} + +export const Button: JrComponent = ({ props, on }) => { + const busy = !!props.loading + return h( + 'button', + { + type: 'button', + class: `${buttonBase} ${buttonVariant[props.variant ?? 'secondary'] ?? buttonVariant.secondary}`, + disabled: props.disabled || busy, + onClick: () => { + const handle = on('press') + handle.emit() + }, + }, + [ + busy + ? h('span', { class: 'i-ph:spinner animate-spin' }) + : props.icon + ? h('span', { class: `i-ph:${props.icon}` }) + : null, + props.label ? h('span', props.label) : null, + ], + ) +} + +interface TextInputProps { + value?: string + placeholder?: string + label?: string + disabled?: boolean + type?: 'text' | 'number' | 'password' | 'email' | 'search' + loading?: boolean +} + +const fieldBase + = 'w-full rounded border border-base bg-base color-base px2 py1 text-sm outline-none focus:border-primary disabled:op-50' + +export const TextInput: JrComponent = ({ props, on, bindings }) => { + const [value, setValue] = useBoundProp(props.value, bindings?.value) + const input = h('input', { + class: fieldBase, + type: props.type ?? 'text', + value: value ?? '', + placeholder: props.placeholder, + disabled: props.disabled || props.loading, + onInput: (e: Event) => { + // Carry the new value into bound state, then fire the `change` action — + // an improvement over the Vite bridge's payload-free `change`. + setValue((e.target as HTMLInputElement).value) + on('change').emit() + }, + }) + if (props.label) { + return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [ + h('span', props.label), + input, + ]) + } + return input +} + +interface SwitchProps { + value?: boolean + label?: string + disabled?: boolean +} + +export const Switch: JrComponent = ({ props, on, bindings }) => { + const [value, setValue] = useBoundProp(props.value, bindings?.value) + const checked = !!value + const toggle = h('button', { + 'type': 'button', + 'role': 'switch', + 'aria-checked': checked ? 'true' : 'false', + 'disabled': props.disabled, + 'class': `relative inline-flex h-5 w-9 items-center rounded-full transition disabled:op-50 ${checked ? 'bg-primary' : 'bg-active'}`, + 'onClick': () => { + setValue(!checked) + on('change').emit() + }, + }, [ + h('span', { class: `inline-block h-4 w-4 transform rounded-full bg-base shadow transition ${checked ? 'translate-x-4.5' : 'translate-x-0.5'}` }), + ]) + if (props.label) { + return h('label', { class: 'inline-flex items-center gap-2 text-sm color-base cursor-pointer' }, [ + toggle, + h('span', props.label), + ]) + } + return toggle +} diff --git a/packages/json-render-ui/src/components/data.ts b/packages/json-render-ui/src/components/data.ts new file mode 100644 index 00000000..34d63748 --- /dev/null +++ b/packages/json-render-ui/src/components/data.ts @@ -0,0 +1,124 @@ +import type { VNodeChild } from 'vue' +import type { JrComponent } from './_shared' +import { useBoundProp } from '@json-render/vue' +import { h } from 'vue' +import { toNumber } from './_shared' + +interface KeyValueTableProps { + data?: Record + loading?: boolean +} + +function formatValue(value: unknown): string { + if (value == null) + return '' + if (typeof value === 'object') + return JSON.stringify(value) + return String(value) +} + +export const KeyValueTable: JrComponent = ({ props, loading }) => { + if (loading || props.loading) + return h('div', { class: 'color-faint text-sm' }, 'Loading…') + const entries = Object.entries(props.data ?? {}) + return h('table', { class: 'w-full text-sm border-collapse' }, [ + h('tbody', entries.map(([key, value]) => + h('tr', { class: 'border-b border-base' }, [ + h('td', { class: 'py1 pr3 color-muted font-medium align-top' }, key), + h('td', { class: 'py1 color-base font-mono break-all' }, formatValue(value)), + ]))), + ]) +} + +interface DataTableColumn { key: string, label?: string } +interface DataTableProps { + columns?: (string | DataTableColumn)[] + rows?: Record[] + height?: number + loading?: boolean +} + +function normalizeColumns(columns: DataTableProps['columns'], rows: DataTableProps['rows']): DataTableColumn[] { + if (columns?.length) + return columns.map(c => (typeof c === 'string' ? { key: c } : c)) + const first = rows?.[0] + return first ? Object.keys(first).map(key => ({ key })) : [] +} + +export const DataTable: JrComponent = ({ props, on, bindings, loading }) => { + if (loading || props.loading) + return h('div', { class: 'color-faint text-sm' }, 'Loading…') + const rows = props.rows ?? [] + const columns = normalizeColumns(props.columns, rows) + const [, setSelected] = useBoundProp(undefined, bindings?.value) + const wrapperStyle = props.height != null ? { maxHeight: `${props.height}px` } : undefined + + return h('div', { class: 'rounded border border-base overflow-auto', style: wrapperStyle }, [ + h('table', { class: 'w-full text-sm border-collapse' }, [ + h('thead', { class: 'sticky top-0 bg-secondary' }, [ + h('tr', columns.map(col => + h('th', { class: 'text-left px2 py1.5 color-muted font-medium border-b border-base' }, col.label ?? col.key))), + ]), + h('tbody', rows.map((row, index) => + h('tr', { + class: 'border-b border-base hover:bg-secondary cursor-pointer', + onClick: () => { + // Deliver row + index into bound state (Vite's rowClick dropped + // them), then fire the action. + setSelected({ row, index }) + on('rowClick').emit() + }, + }, columns.map(col => + h('td', { class: 'px2 py1 color-base font-mono' }, formatValue(row[col.key])))))), + ]), + ]) +} + +interface ProgressProps { + value?: number + max?: number + label?: string +} + +export const Progress: JrComponent = ({ props }) => { + const max = toNumber(props.max, 100) + const value = toNumber(props.value, 0) + const pct = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0 + return h('div', { class: 'flex flex-col gap-1' }, [ + props.label ? h('div', { class: 'flex justify-between text-xs color-muted' }, [h('span', props.label), h('span', `${Math.round(pct)}%`)]) : null, + h('div', { class: 'h-2 w-full rounded-full bg-active overflow-hidden' }, [ + h('div', { class: 'h-full rounded-full bg-primary transition-all', style: { width: `${pct}%` } }), + ]), + ]) +} + +interface TreeProps { + data?: unknown + defaultExpanded?: boolean +} + +function renderNode(value: unknown, keyLabel: string | null, expanded: boolean): VNodeChild { + const isObject = value !== null && typeof value === 'object' + if (!isObject) { + const color = typeof value === 'number' + ? 'color-primary' + : typeof value === 'string' + ? 'color-green' + : 'color-muted' + return h('div', { class: 'flex gap-1 font-mono text-sm' }, [ + keyLabel != null ? h('span', { class: 'color-muted' }, `${keyLabel}:`) : null, + h('span', { class: color }, typeof value === 'string' ? `"${value}"` : String(value)), + ]) + } + const entries = Array.isArray(value) + ? value.map((v, i) => [String(i), v] as const) + : Object.entries(value as Record) + const summaryLabel = `${keyLabel != null ? `${keyLabel}: ` : ''}${Array.isArray(value) ? `Array(${entries.length})` : 'Object'}` + return h('details', { class: 'font-mono text-sm', open: expanded }, [ + h('summary', { class: 'cursor-pointer color-muted select-none' }, summaryLabel), + h('div', { class: 'pl3 border-l border-base ml1' }, entries.map(([k, v]) => renderNode(v, k, expanded))), + ]) +} + +export const Tree: JrComponent = ({ props }) => + h('div', { class: 'color-base' }, [renderNode(props.data, null, props.defaultExpanded !== false)]) diff --git a/packages/json-render-ui/src/components/icon.ts b/packages/json-render-ui/src/components/icon.ts new file mode 100644 index 00000000..5017a88b --- /dev/null +++ b/packages/json-render-ui/src/components/icon.ts @@ -0,0 +1,57 @@ +import type { JrComponent } from './_shared' +import DOMPurify from 'dompurify' +import { defineComponent, h, ref, watchEffect } from 'vue' + +const ICONIFY_API = 'https://api.iconify.design' +// Sanitize the spec-supplied name before it reaches a URL: Iconify names are +// `prefix:icon`, lowercase alphanumerics plus `-`/`:`. +const SAFE_NAME = /^[a-z0-9]+:[a-z0-9-]+$/ + +const cache = new Map>() + +async function fetchIcon(name: string): Promise { + if (!SAFE_NAME.test(name)) + return null + let promise = cache.get(name) + if (!promise) { + const [prefix, icon] = name.split(':') + promise = fetch(`${ICONIFY_API}/${prefix}/${icon}.svg`) + .then(r => (r.ok ? r.text() : null)) + .then(svg => (svg ? DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) : null)) + .catch(() => null) + cache.set(name, promise) + } + return promise +} + +/** + * Fully dynamic icon: resolves whatever (sanitized) icon name a spec supplies + * at runtime from the Iconify API, with no bundled/preferred set. A deliberate, + * documented deviation from the repo's Phosphor-first convention — that applies + * to a surface's own chrome, not to spec-driven content icons. + */ +const IconImpl = defineComponent({ + name: 'JsonRenderIcon', + props: { + name: { type: String, default: '' }, + size: { type: Number, default: 16 }, + }, + setup(props) { + const svg = ref(null) + watchEffect(async () => { + svg.value = props.name ? await fetchIcon(props.name) : null + }) + return () => h('span', { + 'class': 'inline-flex items-center justify-center color-base', + 'style': { width: `${props.size}px`, height: `${props.size}px` }, + 'role': 'img', + 'aria-label': props.name, + 'innerHTML': svg.value ?? '', + }) + }, +}) + +interface IconProps { name?: string, size?: number } + +export const Icon: JrComponent = ({ props }) => + h(IconImpl, { name: props.name ?? '', size: props.size ?? 16 }) diff --git a/packages/json-render-ui/src/components/index.ts b/packages/json-render-ui/src/components/index.ts new file mode 100644 index 00000000..c009cdff --- /dev/null +++ b/packages/json-render-ui/src/components/index.ts @@ -0,0 +1,6 @@ +export type { JrComponent } from './_shared' +export { Button, Switch, TextInput } from './controls' +export { DataTable, KeyValueTable, Progress, Tree } from './data' +export { Icon } from './icon' +export { Card, Divider, Stack } from './layout' +export { Badge, CodeBlock, Text } from './typography' diff --git a/packages/json-render-ui/src/components/layout.ts b/packages/json-render-ui/src/components/layout.ts new file mode 100644 index 00000000..426c0ce8 --- /dev/null +++ b/packages/json-render-ui/src/components/layout.ts @@ -0,0 +1,88 @@ +import type { JrComponent } from './_shared' +import { h } from 'vue' +import { toNumber } from './_shared' + +interface StackProps { + direction?: 'row' | 'column' + gap?: number + padding?: number + align?: 'start' | 'center' | 'end' | 'stretch' + justify?: 'start' | 'center' | 'end' | 'between' | 'around' + wrap?: boolean + flex?: number | string +} + +const alignMap: Record = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' } +const justifyMap: Record = { + start: 'flex-start', + center: 'center', + end: 'flex-end', + between: 'space-between', + around: 'space-around', +} + +export const Stack: JrComponent = ({ props, children }) => { + const style: Record = { + display: 'flex', + flexDirection: props.direction === 'row' ? 'row' : 'column', + gap: `${toNumber(props.gap, 8)}px`, + } + if (props.padding != null) + style.padding = `${toNumber(props.padding, 0)}px` + if (props.align) + style.alignItems = alignMap[props.align] ?? props.align + if (props.justify) + style.justifyContent = justifyMap[props.justify] ?? props.justify + if (props.wrap) + style.flexWrap = 'wrap' + if (props.flex != null) + style.flex = String(props.flex) + return h('div', { style }, children as any) +} + +interface CardProps { + title?: string + border?: boolean + collapsible?: boolean + defaultCollapsed?: boolean + loading?: boolean +} + +export const Card: JrComponent = ({ props, children, loading }) => { + const isLoading = loading || props.loading + const body = isLoading + ? h('div', { class: 'color-faint text-sm' }, 'Loading…') + : (children as any) + const outer = `rounded ${props.border === false ? '' : 'border border-base'} bg-base overflow-hidden` + + // Collapsible uses native

so open/closed state persists without a + // Vue ref (the registry re-invokes this render fn on every update). + if (props.collapsible) { + return h('details', { class: outer, open: !props.defaultCollapsed }, [ + h( + 'summary', + { class: 'flex items-center justify-between px2 py1.5 border-b border-base color-base font-medium text-sm cursor-pointer select-none list-none' }, + [h('span', props.title ?? ''), h('span', { class: 'i-ph:caret-down color-faint' })], + ), + h('div', { class: 'p2' }, [body]), + ]) + } + + return h('div', { class: outer }, [ + props.title + ? h('div', { class: 'px2 py1.5 border-b border-base color-base font-medium text-sm' }, props.title) + : null, + h('div', { class: 'p2' }, [body]), + ]) +} + +export const Divider: JrComponent<{ label?: string }> = ({ props }) => { + if (props.label) { + return h('div', { class: 'flex items-center gap-2 my-2 color-faint text-xs' }, [ + h('div', { class: 'flex-1 border-t border-base' }), + h('span', props.label), + h('div', { class: 'flex-1 border-t border-base' }), + ]) + } + return h('div', { class: 'my-2 border-t border-base' }) +} diff --git a/packages/json-render-ui/src/components/typography.ts b/packages/json-render-ui/src/components/typography.ts new file mode 100644 index 00000000..720cd357 --- /dev/null +++ b/packages/json-render-ui/src/components/typography.ts @@ -0,0 +1,91 @@ +import type { JrComponent } from './_shared' +import { h } from 'vue' + +interface TextProps { + text?: string + variant?: 'heading' | 'subheading' | 'body' | 'caption' | 'code' + weight?: 'normal' | 'medium' | 'bold' + color?: 'base' | 'muted' | 'faint' | 'primary' | 'success' | 'warning' | 'danger' +} + +const textVariant: Record = { + heading: { tag: 'h2', class: 'text-lg font-semibold' }, + subheading: { tag: 'h3', class: 'text-base font-medium' }, + body: { tag: 'p', class: 'text-sm' }, + caption: { tag: 'span', class: 'text-xs color-faint' }, + code: { tag: 'code', class: 'text-sm font-mono bg-secondary rounded px1 py0.5' }, +} + +const colorClass: Record = { + base: 'color-base', + muted: 'color-muted', + faint: 'color-faint', + primary: 'color-primary', + success: 'color-green', + warning: 'color-amber', + danger: 'color-red', +} + +export const Text: JrComponent = ({ props, children }) => { + const variant = textVariant[props.variant ?? 'body'] ?? textVariant.body + const classes = [variant.class] + if (props.weight === 'bold') + classes.push('font-bold') + else if (props.weight === 'medium') + classes.push('font-medium') + if (props.color) + classes.push(colorClass[props.color] ?? 'color-base') + else classes.push('color-base') + return h(variant.tag, { class: classes.join(' ') }, props.text ?? (children as any)) +} + +interface BadgeProps { + text?: string + variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' + minWidth?: number +} + +const badgeVariant: Record = { + default: 'bg-secondary color-muted', + success: 'bg-green:15 color-green', + warning: 'bg-amber:15 color-amber', + danger: 'bg-red:15 color-red', + info: 'bg-primary:15 color-primary', +} + +export const Badge: JrComponent = ({ props, children }) => { + const style = props.minWidth != null ? { minWidth: `${props.minWidth}px` } : undefined + return h( + 'span', + { + class: `inline-flex items-center justify-center rounded px1.5 py0.5 text-xs font-medium ${badgeVariant[props.variant ?? 'default'] ?? badgeVariant.default}`, + style, + }, + props.text ?? (children as any), + ) +} + +interface CodeBlockProps { + code?: string + language?: string + filename?: string + height?: number +} + +export const CodeBlock: JrComponent = ({ props }) => { + const header = props.filename || props.language + ? h('div', { class: 'flex items-center justify-between px2 py1 border-b border-base bg-secondary text-xs color-faint' }, [ + h('span', props.filename ?? ''), + props.language ? h('span', { class: 'font-mono uppercase' }, props.language) : null, + ]) + : null + const preStyle = props.height != null ? { maxHeight: `${props.height}px` } : undefined + return h('div', { 'class': 'rounded border border-base overflow-hidden bg-base', 'data-language': props.language }, [ + header, + h( + 'pre', + { class: 'p2 text-sm font-mono overflow-auto color-base', style: preStyle }, + h('code', props.code ?? ''), + ), + ]) +} diff --git a/packages/json-render-ui/src/dock-renderer.ts b/packages/json-render-ui/src/dock-renderer.ts new file mode 100644 index 00000000..d2d83665 --- /dev/null +++ b/packages/json-render-ui/src/dock-renderer.ts @@ -0,0 +1,80 @@ +import type { JsonRenderViewRef, Spec } from '@devframes/json-render' +import type { ComponentRegistry } from '@json-render/vue' +import type { ActionBridgeRpc } from './action-bridge' +import { createApp, h, shallowRef } from 'vue' +import { baseRegistry } from './registry' +import { JsonRenderView } from './renderer' + +/** + * The mount options the hub client host hands a renderer. Declared + * structurally here so `@devframes/json-render-ui` needs no dependency on + * `@devframes/hub` — the returned factory is still assignable to the hub's + * `DockRenderer` at the host's registration site. + */ +export interface JsonRenderDockMountOptions { + entry: unknown + container: HTMLElement + + context: { rpc: any } +} + +/** A hub-compatible dock renderer. */ +export type JsonRenderDockRenderer = ( + options: JsonRenderDockMountOptions, +) => Promise<{ dispose?: () => void }> + +export interface JsonRenderDockRendererOptions { + /** Registry to render with. Defaults to the base registry. */ + registry?: ComponentRegistry +} + +/** + * Build a hub dock renderer for `'json-render'` entries. Register it at + * `createDevframeClientHost` boot: + * + * ```ts + * createDevframeClientHost({ + * renderers: { 'json-render': createJsonRenderDockRenderer() }, + * }) + * ``` + * + * It subscribes to the view's shared state (`entry.view.stateKey`), mounts a + * Vue app rendering {@link JsonRenderView}, and disposes cleanly — unmounting + * the app and unsubscribing the shared-state listener — when the dock + * deactivates (the client host drives that). + */ +export function createJsonRenderDockRenderer( + options: JsonRenderDockRendererOptions = {}, +): JsonRenderDockRenderer { + const registry = options.registry ?? baseRegistry + return async ({ entry, container, context }) => { + const view = (entry as { view: JsonRenderViewRef }).view + const rpc = context.rpc + const interactive = rpc.connectionMeta?.backend !== 'static' + const state = await rpc.sharedState.get(view.stateKey, { initialValue: null }) + + const specRef = shallowRef(state.value() as Spec | null) + const off = state.on('updated', () => { + specRef.value = state.value() as Spec | null + }) + + const app = createApp({ + render: () => h(JsonRenderView, { + spec: specRef.value, + rpc: rpc as ActionBridgeRpc, + registry, + viewId: view.stateKey, + upstreamVersion: view.upstreamVersion, + interactive, + }), + }) + app.mount(container) + + return { + dispose() { + off() + app.unmount() + }, + } + } +} diff --git a/packages/json-render-ui/src/index.ts b/packages/json-render-ui/src/index.ts new file mode 100644 index 00000000..a1d45336 --- /dev/null +++ b/packages/json-render-ui/src/index.ts @@ -0,0 +1,14 @@ +export { createActionBridge } from './action-bridge' +export type { ActionBridgeRpc, JsonRenderActionBridge, JsonRenderActionError } from './action-bridge' + +export * from './components' +export { createJsonRenderDockRenderer } from './dock-renderer' +export type { + JsonRenderDockMountOptions, + JsonRenderDockRenderer, + JsonRenderDockRendererOptions, +} from './dock-renderer' + +export { baseRegistry, ERROR_COMPONENT_TYPE } from './registry' +export { createRenderer, JsonRenderView, sanitizeSpec } from './renderer' +export type { CreateRendererOptions } from './renderer' diff --git a/packages/json-render-ui/src/registry.ts b/packages/json-render-ui/src/registry.ts new file mode 100644 index 00000000..3d05fec5 --- /dev/null +++ b/packages/json-render-ui/src/registry.ts @@ -0,0 +1,49 @@ +import type { ComponentRegistry } from '@json-render/vue' +import { baseCatalog } from '@devframes/json-render' +import { defineRegistry } from '@json-render/vue' +import { + Badge, + Button, + Card, + CodeBlock, + DataTable, + Divider, + Icon, + KeyValueTable, + Progress, + Stack, + Switch, + Text, + TextInput, + Tree, +} from './components' +import { JsonRenderError } from './components/_error' + +/** Reserved component type used to isolate an element that fails validation. */ +export const ERROR_COMPONENT_TYPE = '__jsonRenderError' + +/** + * The base Vue registry: the fourteen catalog-v1 components ported onto + * `@antfu/design` semantic tokens, wrapped as Vue components via upstream + * `defineRegistry`. A third party replaces the whole registry (there is no + * incremental extension in v1). + */ +export const baseRegistry: ComponentRegistry = defineRegistry(baseCatalog as any, { + components: { + Stack, + Card, + Text, + Badge, + Button, + Icon, + Divider, + TextInput, + Switch, + KeyValueTable, + DataTable, + CodeBlock, + Progress, + Tree, + [ERROR_COMPONENT_TYPE]: JsonRenderError, + } as any, +}).registry diff --git a/packages/json-render-ui/src/renderer.ts b/packages/json-render-ui/src/renderer.ts new file mode 100644 index 00000000..d0c31d80 --- /dev/null +++ b/packages/json-render-ui/src/renderer.ts @@ -0,0 +1,150 @@ +import type { Spec } from '@devframes/json-render' +import type { ComponentRegistry } from '@json-render/vue' +import type { Component, PropType } from 'vue' +import type { ActionBridgeRpc } from './action-bridge' +import { basePropSchemas, JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' +import { JSONUIProvider, Renderer } from '@json-render/vue' +import { computed, defineComponent, h, watch } from 'vue' +import { createActionBridge } from './action-bridge' +import { baseRegistry, ERROR_COMPONENT_TYPE } from './registry' + +// Upstream ships these as heavily-typed `DefineComponent`s; render them through +// a loose alias so `h()` doesn't demand their full public-instance surface. + +const ProviderC = JSONUIProvider as any + +const RendererC = Renderer as any + +/** + * Render-time prop validation: parse every element's props against the base + * catalog schema and swap any element that fails for the reserved error + * component, so one bad element is isolated instead of breaking the view. + * Returns the effective spec (unchanged when everything validates). + */ +export function sanitizeSpec(spec: Spec): Spec { + let changed = false + const elements: Spec['elements'] = {} + for (const [key, element] of Object.entries(spec.elements ?? {})) { + const schema = basePropSchemas[element.type as keyof typeof basePropSchemas] + if (schema) { + const result = schema.safeParse(element.props ?? {}) + if (!result.success) { + changed = true + const issues = result.error.issues.map(i => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; ') + // Browser-only render failure — keep console.* per the plan. + console.warn(`[@devframes/json-render-ui] invalid props on element "${key}" (${element.type}): ${issues}`) + elements[key] = { ...element, type: ERROR_COMPONENT_TYPE, props: { message: `${element.type}: ${issues}` } } + continue + } + } + elements[key] = element + } + return changed ? { ...spec, elements } : spec +} + +const surface = 'flex items-center justify-center p4 text-sm color-faint' + +/** + * The reference renderer shell. Wires upstream `JSONUIProvider` + `Renderer` + * with the unrestricted {@link createActionBridge action bridge}, seeds + * `spec.state`, isolates invalid elements, surfaces action errors, and owns + * reset semantics: the provider is remounted (state reseeded) when the view + * identity or upstream version changes, and preserved across ordinary + * spec/state updates. + */ +export const JsonRenderView = defineComponent({ + name: 'JsonRenderView', + props: { + spec: { type: Object as PropType, default: null }, + rpc: { type: Object as PropType, required: true }, + registry: { type: Object as PropType, default: () => baseRegistry }, + viewId: { type: String, default: 'default' }, + upstreamVersion: { type: String, default: undefined }, + interactive: { type: Boolean, default: true }, + loading: { type: Boolean, default: false }, + connectionError: { type: String as PropType, default: null }, + }, + setup(props) { + const bridge = createActionBridge(props.rpc, { interactive: props.interactive }) + + // A renderer/upstream-version mismatch warns rather than blocking. + watch( + () => props.upstreamVersion, + (version) => { + if (version && version !== JSON_RENDER_UPSTREAM_VERSION) { + console.warn( + `[@devframes/json-render-ui] view "${props.viewId}" was authored against @json-render ${version}, ` + + `but this renderer bundles ${JSON_RENDER_UPSTREAM_VERSION}. Rendering anyway.`, + ) + } + }, + { immediate: true }, + ) + + // Reset the provider (reseed state) only on identity / version change. + const resetKey = computed(() => `${props.viewId}::${props.upstreamVersion ?? JSON_RENDER_UPSTREAM_VERSION}`) + const effectiveSpec = computed(() => (props.spec ? sanitizeSpec(props.spec) : null)) + + return () => { + if (props.loading) + return h('div', { class: surface }, 'Loading…') + if (props.connectionError) + return h('div', { class: `${surface} color-red` }, props.connectionError) + if (!props.spec) + return h('div', { class: surface }, 'No view to render.') + + const banner = bridge.error.value + ? h('div', { + class: 'rounded border border-red bg-red:10 color-red text-xs px2 py1 mb2', + role: 'alert', + }, `Action "${bridge.error.value.action}" failed: ${String((bridge.error.value.error as Error)?.message ?? bridge.error.value.error)}`) + : null + + const staticNote = !props.interactive + ? h('div', { + class: 'rounded border border-base bg-secondary color-faint text-xs px2 py1 mb2', + }, 'Interactive actions are unavailable in static output.') + : null + + return h('div', { class: 'color-base' }, [ + staticNote, + banner, + h( + ProviderC, + { + key: resetKey.value, + registry: props.registry, + handlers: bridge.handlers, + initialState: props.spec.state ?? {}, + }, + { + default: () => h(RendererC, { spec: effectiveSpec.value, registry: props.registry }), + }, + ), + ]) + } + }, +}) + +/** Options for {@link createRenderer}. */ +export interface CreateRendererOptions { + /** Component registry to render with. Defaults to the base registry. */ + registry?: ComponentRegistry +} + +/** + * Create a configured renderer component bound to a registry. The returned + * component is {@link JsonRenderView} with the registry defaulted, so a host + * can `createRenderer({ registry: myRegistry })` to swap the whole registry. + */ +export function createRenderer(options: CreateRendererOptions = {}) { + const registry = options.registry ?? baseRegistry + return defineComponent({ + name: 'ConfiguredJsonRenderView', + inheritAttrs: false, + setup(_props, { attrs }) { + // Default the registry; every other prop flows through via attrs. + return () => h(JsonRenderView as unknown as Component, { registry, ...attrs }) + }, + }) +} diff --git a/packages/json-render-ui/test/action-bridge.test.ts b/packages/json-render-ui/test/action-bridge.test.ts new file mode 100644 index 00000000..c63beab2 --- /dev/null +++ b/packages/json-render-ui/test/action-bridge.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from 'vitest' +import { createActionBridge } from '../src/action-bridge' + +describe('action bridge', () => { + it('dispatches any action name as an RPC call of the same name', async () => { + const call = vi.fn(async () => 'ok') + const bridge = createActionBridge({ call }) + const result = await bridge.handlers.refreshData({ id: 1 }) + expect(call).toHaveBeenCalledWith('refreshData', { id: 1 }) + expect(result).toBe('ok') + }) + + it('does not shadow upstream built-ins', () => { + const bridge = createActionBridge({ call: async () => undefined }) + expect(bridge.handlers.setState).toBeUndefined() + expect(bridge.handlers.pushState).toBeUndefined() + expect(bridge.handlers.validateForm).toBeUndefined() + expect((bridge.handlers as any).then).toBeUndefined() + }) + + it('tracks per-action loading state', async () => { + let resolve!: () => void + const call = vi.fn(() => new Promise((r) => { + resolve = r + })) + const bridge = createActionBridge({ call }) + const p = bridge.handlers.slow() + expect(bridge.loading.slow).toBe(true) + resolve() + await p + expect(bridge.loading.slow).toBe(false) + }) + + it('surfaces and rethrows RPC failures', async () => { + const err = new Error('boom') + const bridge = createActionBridge({ + call: async () => { + throw err + }, + }) + await expect(bridge.handlers.explode()).rejects.toThrow('boom') + expect(bridge.error.value).toEqual({ action: 'explode', error: err }) + }) + + it('rejects with an unavailable error in static (non-interactive) output', async () => { + const call = vi.fn() + const bridge = createActionBridge({ call }, { interactive: false }) + await expect(bridge.handlers.doThing()).rejects.toThrow(/unavailable in static output/) + expect(call).not.toHaveBeenCalled() + expect(bridge.error.value?.action).toBe('doThing') + }) +}) diff --git a/packages/json-render-ui/test/renderer.test.ts b/packages/json-render-ui/test/renderer.test.ts new file mode 100644 index 00000000..de1428e4 --- /dev/null +++ b/packages/json-render-ui/test/renderer.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { ERROR_COMPONENT_TYPE } from '../src/registry' +import { sanitizeSpec } from '../src/renderer' + +describe('sanitizeSpec (render-time validation)', () => { + it('leaves a valid spec unchanged', () => { + const spec = { + root: 'a', + elements: { a: { type: 'Button', props: { label: 'Go', variant: 'primary' }, children: [] } }, + } + expect(sanitizeSpec(spec)).toBe(spec) + }) + + it('isolates an element with invalid props behind the error component', () => { + const spec = { + root: 'a', + elements: { + a: { type: 'Button', props: { variant: 'nope' }, children: [] }, + b: { type: 'Text', props: { text: 'ok' }, children: [] }, + }, + } + const result = sanitizeSpec(spec) + expect(result).not.toBe(spec) + expect(result.elements.a.type).toBe(ERROR_COMPONENT_TYPE) + expect(result.elements.b.type).toBe('Text') + }) +}) diff --git a/packages/json-render-ui/tsconfig.json b/packages/json-render-ui/tsconfig.json new file mode 100644 index 00000000..9284a685 --- /dev/null +++ b/packages/json-render-ui/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom"], + "types": ["node"] + }, + "include": ["src", "test", "uno.config.ts", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/json-render-ui/tsdown.config.ts b/packages/json-render-ui/tsdown.config.ts new file mode 100644 index 00000000..2000ecef --- /dev/null +++ b/packages/json-render-ui/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown' + +// Browser-only library. Vue and the protocol package are peers, so they stay +// external (the consuming app / hub host provides them). Components are plain +// `ComponentFn` render functions in `.ts`, so no SFC compiler is needed. +export default defineConfig({ + entry: { + 'index': 'src/index.ts', + 'components/index': 'src/components/index.ts', + }, + outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), + clean: true, + tsconfig: '../../tsconfig.base.json', + dts: true, + platform: 'browser', + deps: { + neverBundle: ['vue', '@devframes/json-render', '@devframes/json-render/core'], + }, +}) diff --git a/packages/json-render-ui/uno.config.ts b/packages/json-render-ui/uno.config.ts new file mode 100644 index 00000000..6066cbb6 --- /dev/null +++ b/packages/json-render-ui/uno.config.ts @@ -0,0 +1,40 @@ +import { presetAnthonyDesign } from '@antfu/design/unocss' +import { + defineConfig, + presetIcons, + presetWebFonts, + presetWind4, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +// The reference frontend uses `@antfu/design` directly: its preset (tuned to +// devframe's sage green) over a Wind4 base, Phosphor icons, DM Sans/Mono and +// the directive/variant-group transformers. Component class strings are +// authored in `.ts` render functions, so `.ts` is opted into extraction. The +// named `z-*` layers are the app's to own (the preset blocks plain `z-`). +export default defineConfig({ + presets: [ + presetAnthonyDesign({ primary: '#3a6a45' }), + presetWind4(), + presetIcons({ scale: 1.1 }), + presetWebFonts({ provider: 'none', fonts: { sans: 'DM Sans', mono: 'DM Mono' } }), + ], + transformers: [transformerDirectives(), transformerVariantGroup()], + preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], + shortcuts: { + 'z-nav': 'z-[30]', + 'z-dropdown': 'z-[40]', + 'z-tooltip': 'z-[45]', + 'z-toast': 'z-[50]', + 'z-modal-backdrop': 'z-[60]', + 'z-modal-content': 'z-[70]', + 'z-drawer-backdrop': 'z-[80]', + 'z-drawer-content': 'z-[90]', + }, + content: { + pipeline: { + include: [/\.(?:vue|[cm]?[jt]sx?|html)($|\?)/], + }, + }, +}) diff --git a/packages/json-render/package.json b/packages/json-render/package.json new file mode 100644 index 00000000..335e28d3 --- /dev/null +++ b/packages/json-render/package.json @@ -0,0 +1,58 @@ +{ + "name": "@devframes/json-render", + "type": "module", + "version": "0.7.5", + "description": "Opt-in, framework-neutral JSON-render protocol layer for devframe — spec/catalog types, base catalog, and the node runtime factory.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "packages/json-render", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devtools", + "devframe", + "json-render" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./core": "./dist/core.mjs", + "./hub": "./dist/hub.mjs", + "./node": "./dist/node/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", + "prepack": "pnpm run build" + }, + "peerDependencies": { + "@devframes/hub": "workspace:*", + "devframe": "workspace:*" + }, + "peerDependenciesMeta": { + "@devframes/hub": { + "optional": true + } + }, + "dependencies": { + "@json-render/core": "catalog:deps", + "nostics": "catalog:deps", + "zod": "catalog:deps" + }, + "devDependencies": { + "@devframes/hub": "workspace:*", + "@types/node": "catalog:types", + "devframe": "workspace:*", + "tsdown": "catalog:build" + } +} diff --git a/packages/json-render/src/catalog.ts b/packages/json-render/src/catalog.ts new file mode 100644 index 00000000..55990ee1 --- /dev/null +++ b/packages/json-render/src/catalog.ts @@ -0,0 +1,64 @@ +import { defineCatalog, defineSchema } from '@json-render/core' +import { basePropSchemas } from './prop-schemas' + +/** + * The Devframes base-catalog schema. A Devframes spec is an + * `@json-render/core` `Spec` (flat `root` + `elements` map); the structural + * fields upstream's Vue schema omits (`state`, `on`, `repeat`, `watch`) pass + * through unchecked. The one validation Devframes adds is per-component prop + * validation (see {@link basePropSchemas}). + */ +export const baseSchema = defineSchema(s => ({ + spec: s.object({ + root: s.string(), + elements: s.record(s.object({ + type: s.ref('catalog.components'), + props: s.propsOf('catalog.components'), + children: s.array(s.string()), + })), + }), + catalog: s.object({ + components: s.map({ + props: s.zod(), + description: s.string(), + }), + actions: s.map({ + description: s.string(), + }), + }), +})) + +const componentDescriptions: Record = { + Stack: 'Flex row/column container with gap, padding, alignment and justification.', + Card: 'Bordered container with an optional title and collapsible body.', + Text: 'Typographic text — heading, subheading, body, caption or inline code.', + Badge: 'Small status pill with a semantic variant.', + Button: 'Clickable button with a variant, optional icon and loading state.', + Icon: 'Renders an icon resolved by name at runtime.', + Divider: 'Horizontal rule with an optional centered label.', + TextInput: 'Single-line text input with two-way state binding.', + Switch: 'Accessible on/off toggle bound to a boolean state value.', + KeyValueTable: 'Two-column table of key/value pairs.', + DataTable: 'Tabular data with columns, rows and optional scroll height.', + CodeBlock: 'Preformatted code block with a filename and language label.', + Progress: 'Determinate progress bar.', + Tree: 'Recursive object/array viewer with expandable nodes.', +} + +/** + * The Devframes base catalog (catalog v1): the fourteen canonical components + * with their Devframes-authored Zod prop schemas and descriptions, and an + * empty action set (actions are dispatched dynamically via the bridge, they + * are not declared here). Reference frontend libraries implement this set. + */ +export const baseCatalog = defineCatalog(baseSchema, { + components: Object.fromEntries( + (Object.keys(basePropSchemas) as (keyof typeof basePropSchemas)[]).map(name => [ + name, + { props: basePropSchemas[name], description: componentDescriptions[name] }, + ]), + ) as { + [K in keyof typeof basePropSchemas]: { props: typeof basePropSchemas[K], description: string } + }, + actions: {}, +}) diff --git a/packages/json-render/src/core.ts b/packages/json-render/src/core.ts new file mode 100644 index 00000000..a399a583 --- /dev/null +++ b/packages/json-render/src/core.ts @@ -0,0 +1,41 @@ +/** + * Curated, named re-exports of `@json-render/core` — the upstream wire + * contract Devframes builds on. This is an **explicit** allowlist (never + * `export *`): every name here is a Devframes semver commitment, so the + * surface stays small and reviewed. Streaming, prompt/generation, and + * devtools hooks are deliberately excluded from the base contract (a + * streaming subpath may be added later, see the plan). + * + * @see https://www.npmjs.com/package/@json-render/core + */ + +// ── Builders (values) ──────────────────────────────────────────────────── +export { + createStateStore, + defineCatalog, + defineSchema, +} from '@json-render/core' + +// ── Spec / element / state types ───────────────────────────────────────── +export type { + Spec, + StateModel, + StateStore, + UIElement, +} from '@json-render/core' + +// ── Catalog & schema typing (inference helpers) ────────────────────────── +export type { + Catalog, + InferActionParams, + InferCatalogActions, + InferCatalogComponents, + InferCatalogInput, + InferComponentProps, + InferSpec, + Schema, + SchemaBuilder, + SchemaDefinition, + SchemaOptions, + SchemaType, +} from '@json-render/core' diff --git a/packages/json-render/src/hub.ts b/packages/json-render/src/hub.ts new file mode 100644 index 00000000..81f7a71f --- /dev/null +++ b/packages/json-render/src/hub.ts @@ -0,0 +1,36 @@ +import type { DevframeDockEntryBase } from '@devframes/hub/types' +import type { JsonRenderView } from './types' +import type { JsonRenderViewRef } from './view-ref' + +/** + * A `json-render` dock entry. Contributed to the hub's **open** dock union + * (`DevframeDockEntryRegistry`) by this opt-in integration — the hub itself + * hard-codes no json-render variant. Carries only the serializable + * {@link JsonRenderViewRef}; no functions cross the wire. + */ +export interface DevframeJsonRenderDockEntry extends DevframeDockEntryBase { + type: 'json-render' + /** Serializable reference the client subscribes through. */ + view: JsonRenderViewRef +} + +// Contribute the `json-render` variant to the hub's open dock registry. This +// augmentation only loads when a consumer imports `@devframes/json-render/hub` +// (the hub-mounted path), so a standalone app never pulls a hub type. +declare module '@devframes/hub/types' { + interface DevframeDockEntryRegistry { + 'json-render': DevframeJsonRenderDockEntry + } +} + +/** + * Build a `json-render` dock entry from a {@link JsonRenderView} and the dock + * metadata (id, title, icon, …). Projects the view down to its serializable + * {@link JsonRenderViewRef} so it survives dock projection into shared state. + */ +export function toJsonRenderDockEntry( + view: JsonRenderView, + meta: Omit, +): DevframeJsonRenderDockEntry { + return { ...meta, type: 'json-render', view: view.ref } +} diff --git a/packages/json-render/src/index.ts b/packages/json-render/src/index.ts new file mode 100644 index 00000000..287c098e --- /dev/null +++ b/packages/json-render/src/index.ts @@ -0,0 +1,39 @@ +// ── Base catalog + Devframes-authored prop schemas ─────────────────────── +export { baseCatalog, baseSchema } from './catalog' +// ── Re-exported upstream protocol types (from the curated `./core` list) ── +export type { + Catalog, + InferComponentProps, + Spec, + StateModel, + StateStore, + UIElement, +} from './core' + +export type { BaseComponentName } from './prop-schemas' + +export { + BadgePropsSchema, + baseComponentNames, + basePropSchemas, + ButtonPropsSchema, + CardPropsSchema, + CodeBlockPropsSchema, + DataTablePropsSchema, + DividerPropsSchema, + IconPropsSchema, + KeyValueTablePropsSchema, + ProgressPropsSchema, + StackPropsSchema, + SwitchPropsSchema, + TextInputPropsSchema, + TextPropsSchema, + TreePropsSchema, +} from './prop-schemas' + +// ── Devframes-facing type names ────────────────────────────────────────── +export type { DevframeJsonRenderSpec, JsonRenderView } from './types' +// ── Serializable view reference ────────────────────────────────────────── +export { JSON_RENDER_UPSTREAM_VERSION } from './view-ref' + +export type { JsonRenderViewRef } from './view-ref' diff --git a/packages/json-render/src/node/create-view.ts b/packages/json-render/src/node/create-view.ts new file mode 100644 index 00000000..6d83315b --- /dev/null +++ b/packages/json-render/src/node/create-view.ts @@ -0,0 +1,164 @@ +import type { DevframeNodeContext, DevframeScopedNodeContext } from 'devframe/types' +import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state' +import type { DevframeJsonRenderSpec, JsonRenderStatePatch, JsonRenderView } from '../types' +import { createSharedState } from 'devframe/utils/shared-state' +import { basePropSchemas } from '../prop-schemas' +import { JSON_RENDER_UPSTREAM_VERSION } from '../view-ref' +import { diagnostics } from './diagnostics' + +/** Options for {@link createJsonRenderView}. */ +export interface CreateJsonRenderViewOptions { + /** + * Stable, author-supplied id, unique within the view's scope. Forms the + * shared-state key `devframe:json-render::` and never changes + * across updates, so a client keeps its subscription across reconnects. + */ + id: string + /** The initial spec. */ + spec: DevframeJsonRenderSpec + /** + * Override the scope segment of the view's stable id. Defaults to the + * context's namespace when created from a scoped context, otherwise + * `'global'`. + */ + scope?: string +} + +type AnyContext = DevframeNodeContext | DevframeScopedNodeContext + +function isScoped(ctx: AnyContext): ctx is DevframeScopedNodeContext { + return 'base' in ctx && 'namespace' in ctx +} + +// One registry of live view keys per base context, so a duplicate id within a +// scope is caught deterministically (not left to shared-state get() returning +// the pre-existing entry). +const registries = new WeakMap>() +function registryFor(ctx: DevframeNodeContext): Set { + let set = registries.get(ctx) + if (!set) { + set = new Set() + registries.set(ctx, set) + } + return set +} + +/** Parse an RFC 6901 JSON Pointer into path segments. */ +function parsePointer(pointer: string): string[] { + if (pointer === '' || pointer === '/') + return [] + return pointer + .replace(/^\//, '') + .split('/') + .map(seg => seg.replace(/~1/g, '/').replace(/~0/g, '~')) +} + +function assertJsonSerializable(id: string, spec: DevframeJsonRenderSpec): void { + try { + JSON.stringify(spec) + } + catch (error) { + throw diagnostics.DF0041({ id, reason: (error as Error).message }) + } +} + +function validateElementProps(id: string, spec: DevframeJsonRenderSpec): void { + for (const [key, element] of Object.entries(spec.elements ?? {})) { + const schema = basePropSchemas[element.type as keyof typeof basePropSchemas] + if (!schema) + continue + const result = schema.safeParse(element.props ?? {}) + if (!result.success) { + const issues = result.error.issues + .map(i => `${i.path.join('.') || '(root)'}: ${i.message}`) + .join('; ') + throw diagnostics.DF0038({ id, key, issues }) + } + } +} + +// Ensure the spec always carries a `state` object so JSON-Pointer patches +// into `/state/...` have a container to target. +function normalizeSpec(spec: DevframeJsonRenderSpec): DevframeJsonRenderSpec { + return spec.state ? spec : { ...spec, state: {} } +} + +/** + * Create a JSON-render view bound to a devframe context. Registers a + * server-side shared state (patches enabled) carrying the live spec + state, + * validates element props at ingress against the base catalog, and returns a + * handle plus the serializable {@link JsonRenderView.ref} a client subscribes + * through. + * + * @example + * ```ts + * const view = createJsonRenderView(ctx, { id: 'metrics', spec }) + * view.update(nextSpec) + * view.patchState([{ op: 'replace', path: '/count', value: 3 }]) + * view.dispose() + * ``` + */ +export function createJsonRenderView( + ctx: AnyContext, + options: CreateJsonRenderViewOptions, +): JsonRenderView { + const scoped = isScoped(ctx) + const baseCtx = scoped ? ctx.base : ctx + const scope = options.scope ?? (scoped ? ctx.namespace : 'global') + const { id } = options + const stateKey = `devframe:json-render:${scope}:${id}` + + const registry = registryFor(baseCtx) + if (registry.has(stateKey)) + throw diagnostics.DF0039({ id, scope }) + + const initial = normalizeSpec(options.spec) + validateElementProps(id, initial) + assertJsonSerializable(id, initial) + + const state: SharedState = createSharedState({ + initialValue: initial, + enablePatches: true, + }) + + registry.add(stateKey) + // Registering the state on the RPC host is synchronous up to its first + // await (there is none before the broadcast listener is attached), so + // subsequent `update`/`patchState` calls broadcast correctly. + void baseCtx.rpc.sharedState.get(stateKey, { sharedState: state as SharedState }) + + let disposed = false + function assertLive(): void { + if (disposed) + throw diagnostics.DF0040({ id }) + } + + return { + id, + ref: { stateKey, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION }, + value: () => state.value() as DevframeJsonRenderSpec, + update(spec) { + assertLive() + const next = normalizeSpec(spec) + validateElementProps(id, next) + assertJsonSerializable(id, next) + state.mutate(() => next) + }, + patchState(patches: JsonRenderStatePatch[]) { + assertLive() + const prefixed: SharedStatePatch[] = patches.map(p => ({ + op: p.op, + path: ['state', ...parsePointer(p.path)], + value: p.value, + })) + state.patch(prefixed) + }, + dispose() { + if (disposed) + return + disposed = true + registry.delete(stateKey) + baseCtx.rpc.sharedState.delete(stateKey) + }, + } +} diff --git a/packages/json-render/src/node/diagnostics.ts b/packages/json-render/src/node/diagnostics.ts new file mode 100644 index 00000000..ee0c8c4c --- /dev/null +++ b/packages/json-render/src/node/diagnostics.ts @@ -0,0 +1,44 @@ +import type { Diagnostic } from 'nostics' +import { colors as c } from 'devframe/utils/colors' +import { defineDiagnostics } from 'nostics' +import { ansiFormatter } from 'nostics/formatters/ansi' + +const formatAnsi = ansiFormatter(c) + +interface ReporterOptions { method?: 'log' | 'warn' | 'error' } + +function jsonRenderReporter(d: Diagnostic, { method = 'warn' }: ReporterOptions = {}): void { + // eslint-disable-next-line no-console + console[method](formatAnsi(d)) +} + +// `@devframes/json-render` protocol/runtime diagnostics. These share the +// `DF` prefix and live in the devframe core range (next free after the +// current highest `DF00xx`, DF0037). Browser-only render failures keep +// `console.*` in the UI package. +export const diagnostics = defineDiagnostics({ + docsBase: 'https://devfra.me/errors', + reporters: [jsonRenderReporter], + codes: { + DF0038: { + why: (p: { id: string, key: string, issues: string }) => + `JSON-render view "${p.id}" received invalid props on element "${p.key}": ${p.issues}`, + fix: 'Match the element props to the base catalog\'s prop schema for that component. See the component reference for the expected shape.', + }, + DF0039: { + why: (p: { id: string, scope: string }) => + `A JSON-render view with id "${p.id}" already exists in scope "${p.scope}".`, + fix: 'Give each view a stable id unique within its scope, or dispose the previous view before recreating it.', + }, + DF0040: { + why: (p: { id: string }) => + `JSON-render view "${p.id}" was used after it was disposed.`, + fix: 'Create a fresh view with `createJsonRenderView` instead of reusing a disposed handle.', + }, + DF0041: { + why: (p: { id: string, reason: string }) => + `JSON-render view "${p.id}" spec is not JSON-serializable: ${p.reason}`, + fix: 'Specs and state travel as strict JSON — remove functions, symbols, class instances, Map/Set, or circular references.', + }, + }, +}) diff --git a/packages/json-render/src/node/index.ts b/packages/json-render/src/node/index.ts new file mode 100644 index 00000000..770be867 --- /dev/null +++ b/packages/json-render/src/node/index.ts @@ -0,0 +1,3 @@ +export { createJsonRenderView } from './create-view' +export type { CreateJsonRenderViewOptions } from './create-view' +export { diagnostics as jsonRenderDiagnostics } from './diagnostics' diff --git a/packages/json-render/src/prop-schemas.ts b/packages/json-render/src/prop-schemas.ts new file mode 100644 index 00000000..5ec99b60 --- /dev/null +++ b/packages/json-render/src/prop-schemas.ts @@ -0,0 +1,152 @@ +import { z } from 'zod' + +/** + * Devframes-authored per-component prop schemas for the base catalog. + * + * Upstream `defineCatalog` collapses a multi-component `propsOf` to + * `Record`, so it validates component *names* but not + * per-component *props*. These schemas are the one validation Devframes + * adds: element props are parsed against the matching schema at both trust + * boundaries — spec ingress (server) and render time (client). + * + * Each schema validates the types of the documented props and tolerates + * extra keys (upstream directives like `$bindState` resolve to values at + * render time), so validation catches genuine authoring mistakes without + * rejecting valid dynamic expressions. + */ + +// Two-way bindings and other `$`-prefixed dynamic expressions resolve to a +// concrete value only at render time. Accept them anywhere a scalar prop is +// expected so ingress validation never rejects a valid binding. +const dynamic = z.looseObject({}).and(z.record(z.string(), z.unknown())) + +function scalar(schema: T) { + return z.union([schema, dynamic]) +} + +const str = scalar(z.string()) +const num = scalar(z.number()) +const bool = scalar(z.boolean()) + +export const StackPropsSchema = z.object({ + direction: z.enum(['row', 'column']).optional(), + gap: num.optional(), + padding: num.optional(), + align: z.enum(['start', 'center', 'end', 'stretch']).optional(), + justify: z.enum(['start', 'center', 'end', 'between', 'around']).optional(), + wrap: bool.optional(), + flex: scalar(z.union([z.number(), z.string()])).optional(), +}) + +export const CardPropsSchema = z.object({ + title: str.optional(), + border: bool.optional(), + collapsible: bool.optional(), + defaultCollapsed: bool.optional(), + loading: bool.optional(), +}) + +export const TextPropsSchema = z.object({ + text: str.optional(), + variant: z.enum(['heading', 'subheading', 'body', 'caption', 'code']).optional(), + weight: z.enum(['normal', 'medium', 'bold']).optional(), + color: z.enum(['base', 'muted', 'faint', 'primary', 'success', 'warning', 'danger']).optional(), +}) + +export const BadgePropsSchema = z.object({ + text: str.optional(), + variant: z.enum(['default', 'success', 'warning', 'danger', 'info']).optional(), + minWidth: num.optional(), +}) + +export const ButtonPropsSchema = z.object({ + label: str.optional(), + variant: z.enum(['primary', 'secondary', 'ghost', 'danger']).optional(), + icon: str.optional(), + disabled: bool.optional(), + loading: bool.optional(), +}) + +export const IconPropsSchema = z.object({ + name: str.optional(), + size: num.optional(), +}) + +export const DividerPropsSchema = z.object({ + label: str.optional(), +}) + +export const TextInputPropsSchema = z.object({ + value: str.optional(), + placeholder: str.optional(), + label: str.optional(), + disabled: bool.optional(), + type: z.enum(['text', 'number', 'password', 'email', 'search']).optional(), + loading: bool.optional(), +}) + +export const SwitchPropsSchema = z.object({ + value: bool.optional(), + label: str.optional(), + disabled: bool.optional(), +}) + +export const KeyValueTablePropsSchema = z.object({ + data: z.union([z.record(z.string(), z.unknown()), dynamic]).optional(), + loading: bool.optional(), +}) + +export const DataTablePropsSchema = z.object({ + columns: scalar(z.array(z.union([ + z.string(), + z.looseObject({ key: z.string(), label: z.string().optional() }), + ]))).optional(), + rows: scalar(z.array(z.unknown())).optional(), + height: num.optional(), + loading: bool.optional(), +}) + +export const CodeBlockPropsSchema = z.object({ + code: str.optional(), + language: str.optional(), + filename: str.optional(), + height: num.optional(), +}) + +export const ProgressPropsSchema = z.object({ + value: num.optional(), + max: num.optional(), + label: str.optional(), +}) + +export const TreePropsSchema = z.object({ + data: z.unknown().optional(), + defaultExpanded: bool.optional(), +}) + +/** + * Map of base-catalog component name → Zod prop schema. The keys are the + * canonical component set (catalog v1). + */ +export const basePropSchemas = { + Stack: StackPropsSchema, + Card: CardPropsSchema, + Text: TextPropsSchema, + Badge: BadgePropsSchema, + Button: ButtonPropsSchema, + Icon: IconPropsSchema, + Divider: DividerPropsSchema, + TextInput: TextInputPropsSchema, + Switch: SwitchPropsSchema, + KeyValueTable: KeyValueTablePropsSchema, + DataTable: DataTablePropsSchema, + CodeBlock: CodeBlockPropsSchema, + Progress: ProgressPropsSchema, + Tree: TreePropsSchema, +} as const satisfies Record + +/** Canonical base-catalog component name. */ +export type BaseComponentName = keyof typeof basePropSchemas + +/** The ordered list of base-catalog component names (catalog v1). */ +export const baseComponentNames = Object.keys(basePropSchemas) as BaseComponentName[] diff --git a/packages/json-render/src/types.ts b/packages/json-render/src/types.ts new file mode 100644 index 00000000..d182c200 --- /dev/null +++ b/packages/json-render/src/types.ts @@ -0,0 +1,47 @@ +import type { Spec } from '@json-render/core' +import type { JsonRenderViewRef } from './view-ref' + +/** + * A Devframes JSON-render spec **is** an `@json-render/core` `Spec`: a flat + * `root` key, an `elements` map, and optional initial `state`. This alias is + * the Devframes-facing name; it does not add or remove fields. + */ +export type DevframeJsonRenderSpec = Spec + +/** + * A single JSON-Pointer patch to a view's `state` model. `path` is an + * RFC 6901 JSON Pointer relative to the state root (e.g. `/count`, + * `/user/name`). Structural spec changes replace the whole spec via + * `update` instead. + */ +export interface JsonRenderStatePatch { + op: 'add' | 'remove' | 'replace' + /** JSON Pointer relative to the state root, e.g. `/count`. */ + path: string + value?: unknown +} + +/** + * A JSON-render view handle, returned by `createJsonRenderView`. Owns a + * server-side shared state carrying the live spec + state, and exposes the + * serializable {@link JsonRenderViewRef} that a hub dock (or any client + * transport) uses to locate it. + */ +export interface JsonRenderView { + /** Author-supplied stable id, unique within the view's scope. */ + readonly id: string + /** The serializable reference clients subscribe through. */ + readonly ref: JsonRenderViewRef + /** Replace the entire spec (a structural change replaces the whole spec). */ + update: (spec: DevframeJsonRenderSpec) => void + /** + * Apply JSON-Pointer patches to the view's `state`. Travels as a + * shared-state patch (not a whole-spec snapshot), so only the changed + * paths cross the wire. + */ + patchState: (patches: JsonRenderStatePatch[]) => void + /** Read the current spec (immutable). */ + value: () => DevframeJsonRenderSpec + /** Unregister the shared state and its listeners. */ + dispose: () => void +} diff --git a/packages/json-render/src/view-ref.ts b/packages/json-render/src/view-ref.ts new file mode 100644 index 00000000..7fe8c31f --- /dev/null +++ b/packages/json-render/src/view-ref.ts @@ -0,0 +1,31 @@ +/** + * The `@json-render/core` / `@json-render/vue` version this build of + * `@devframes/json-render` is written and tested against. It is the sole + * compatibility signal carried across the wire — there is no separate + * Devframes protocol/catalog version stamp. A renderer compares its own + * upstream version against a view's {@link JsonRenderViewRef.upstreamVersion} + * and warns (rather than blocking) on a mismatch. + * + * Kept paired with the caret range on `@json-render/core` / + * `@json-render/vue` in this package's manifest; the committed lockfile is + * the guard against a breaking upstream upgrade. + */ +export const JSON_RENDER_UPSTREAM_VERSION = '0.19.0' + +/** + * The serializable reference to a JSON-render view that crosses process / + * static boundaries — e.g. projected onto a hub dock entry. It carries **no + * functions** and no Devframes catalog version: just the shared-state key the + * client subscribes to for the live spec + state, and the upstream version + * the view was authored against. + * + * This is the corrected projection contract: the previous hub implementation + * leaked an accidental `_stateKey` field and a non-serializable renderer + * handle; a `JsonRenderViewRef` is a plain, fully-serializable object. + */ +export interface JsonRenderViewRef { + /** Shared-state key the client subscribes to for the live spec + state. */ + stateKey: string + /** Upstream `@json-render/*` version the view was authored against. */ + upstreamVersion: string +} diff --git a/packages/json-render/test/catalog.test.ts b/packages/json-render/test/catalog.test.ts new file mode 100644 index 00000000..6329df75 --- /dev/null +++ b/packages/json-render/test/catalog.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { baseCatalog, baseComponentNames, basePropSchemas } from '../src/index' + +describe('base catalog', () => { + it('exposes the fourteen catalog-v1 components', () => { + expect(baseComponentNames).toHaveLength(14) + expect(baseComponentNames).toEqual([ + 'Stack', + 'Card', + 'Text', + 'Badge', + 'Button', + 'Icon', + 'Divider', + 'TextInput', + 'Switch', + 'KeyValueTable', + 'DataTable', + 'CodeBlock', + 'Progress', + 'Tree', + ]) + }) + + it('registers the same component names on the upstream catalog', () => { + expect([...baseCatalog.componentNames].sort()).toEqual([...baseComponentNames].sort()) + }) + + it('declares no base actions (dispatched dynamically via the bridge)', () => { + expect(baseCatalog.actionNames).toEqual([]) + }) +}) + +describe('per-component prop validation', () => { + it('accepts valid props', () => { + expect(basePropSchemas.Button.safeParse({ label: 'Save', variant: 'primary' }).success).toBe(true) + expect(basePropSchemas.Progress.safeParse({ value: 40, max: 100 }).success).toBe(true) + }) + + it('rejects an out-of-set enum value', () => { + expect(basePropSchemas.Button.safeParse({ variant: 'nope' }).success).toBe(false) + expect(basePropSchemas.Badge.safeParse({ variant: 'purple' }).success).toBe(false) + }) + + it('tolerates dynamic `$bindState` expressions where a scalar is expected', () => { + expect(basePropSchemas.TextInput.safeParse({ value: { $bindState: '/name' } }).success).toBe(true) + expect(basePropSchemas.Switch.safeParse({ value: { $state: '/enabled' } }).success).toBe(true) + }) +}) diff --git a/packages/json-render/test/create-view.test.ts b/packages/json-render/test/create-view.test.ts new file mode 100644 index 00000000..ef893a0b --- /dev/null +++ b/packages/json-render/test/create-view.test.ts @@ -0,0 +1,115 @@ +import type { DevframeHost, DevframeNodeContext } from 'devframe/types' +import type { DevframeJsonRenderSpec } from '../src/types' +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHostContext } from 'devframe/node' +import { beforeEach, describe, expect, it } from 'vitest' +import { createJsonRenderView } from '../src/node/index' +import { JSON_RENDER_UPSTREAM_VERSION } from '../src/view-ref' + +function createHost(): DevframeHost { + const storageDir = mkdtempSync(join(tmpdir(), 'devframe-jr-')) + return { + mountStatic: () => {}, + resolveOrigin: () => 'http://localhost:5173', + getStorageDir: () => storageDir, + } as unknown as DevframeHost +} + +async function createContext(): Promise { + return createHostContext({ cwd: process.cwd(), mode: 'dev', host: createHost() }) +} + +const spec: DevframeJsonRenderSpec = { + root: 'a', + elements: { + a: { type: 'Text', props: { text: 'hi' }, children: [] }, + }, + state: { count: 1 }, +} + +let ctx: DevframeNodeContext +beforeEach(async () => { + ctx = await createContext() +}) + +describe('createJsonRenderView identity', () => { + it('uses a scoped, stable state key and serializable ref', () => { + const view = createJsonRenderView(ctx, { id: 'metrics', spec }) + expect(view.ref).toEqual({ + stateKey: 'devframe:json-render:global:metrics', + upstreamVersion: JSON_RENDER_UPSTREAM_VERSION, + }) + expect(JSON.parse(JSON.stringify(view.ref))).toEqual(view.ref) + }) + + it('derives the scope from a scoped context namespace', () => { + const scoped = ctx.scope('my-plugin') + const view = createJsonRenderView(scoped, { id: 'metrics', spec }) + expect(view.ref.stateKey).toBe('devframe:json-render:my-plugin:metrics') + }) + + it('throws DF0039 on a duplicate id within a scope', () => { + createJsonRenderView(ctx, { id: 'dup', spec }) + expect(() => createJsonRenderView(ctx, { id: 'dup', spec })).toThrow(expect.objectContaining({ code: 'DF0039' })) + }) +}) + +describe('createJsonRenderView state', () => { + it('registers a shared state carrying the spec', async () => { + const view = createJsonRenderView(ctx, { id: 'v', spec }) + expect(ctx.rpc.sharedState.keys()).toContain(view.ref.stateKey) + const state = await ctx.rpc.sharedState.get(view.ref.stateKey) + expect((state.value() as DevframeJsonRenderSpec).root).toBe('a') + }) + + it('normalizes a missing state to an empty object', () => { + const view = createJsonRenderView(ctx, { id: 'v', spec: { root: 'a', elements: {} } }) + expect(view.value().state).toEqual({}) + }) + + it('replaces the whole spec on update', () => { + const view = createJsonRenderView(ctx, { id: 'v', spec }) + view.update({ root: 'b', elements: { b: { type: 'Badge', props: { text: 'x' }, children: [] } } }) + expect(view.value().root).toBe('b') + }) + + it('applies JSON-Pointer patches into /state', () => { + const view = createJsonRenderView(ctx, { id: 'v', spec }) + view.patchState([{ op: 'replace', path: '/count', value: 3 }]) + expect((view.value().state as { count: number }).count).toBe(3) + }) +}) + +describe('createJsonRenderView validation', () => { + it('throws DF0038 on invalid element props at ingress', () => { + expect(() => createJsonRenderView(ctx, { + id: 'bad', + spec: { root: 'a', elements: { a: { type: 'Button', props: { variant: 'nope' }, children: [] } } }, + })).toThrow(expect.objectContaining({ code: 'DF0038' })) + }) + + it('throws DF0041 on a non-JSON-serializable spec', () => { + const circular: any = { root: 'a', elements: {} } + circular.self = circular + expect(() => createJsonRenderView(ctx, { id: 'circular', spec: circular })).toThrow(expect.objectContaining({ code: 'DF0041' })) + }) +}) + +describe('createJsonRenderView disposal', () => { + it('unregisters the shared state and frees the id', () => { + const view = createJsonRenderView(ctx, { id: 'v', spec }) + expect(ctx.rpc.sharedState.keys()).toContain(view.ref.stateKey) + view.dispose() + expect(ctx.rpc.sharedState.keys()).not.toContain(view.ref.stateKey) + // id is free again after disposal + expect(() => createJsonRenderView(ctx, { id: 'v', spec })).not.toThrow() + }) + + it('throws DF0040 when used after disposal', () => { + const view = createJsonRenderView(ctx, { id: 'v', spec }) + view.dispose() + expect(() => view.update(spec)).toThrow(expect.objectContaining({ code: 'DF0040' })) + }) +}) diff --git a/packages/json-render/tsconfig.json b/packages/json-render/tsconfig.json new file mode 100644 index 00000000..6646b7b4 --- /dev/null +++ b/packages/json-render/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext", "dom"], + "types": ["node"] + }, + "include": ["src", "tsdown.config.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/json-render/tsdown.config.ts b/packages/json-render/tsdown.config.ts new file mode 100644 index 00000000..235772d3 --- /dev/null +++ b/packages/json-render/tsdown.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { + 'index': 'src/index.ts', + 'core': 'src/core.ts', + 'hub': 'src/hub.ts', + 'node/index': 'src/node/index.ts', + }, + outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), + clean: true, + tsconfig: '../../tsconfig.base.json', + dts: true, + platform: 'neutral', +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5bb1adb5..02dfc07c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ catalogs: specifier: ^4.1.2 version: 4.1.2 deps: + '@json-render/core': + specifier: ^0.19.0 + version: 0.19.0 '@modelcontextprotocol/sdk': specifier: ^1.29.0 version: 1.29.0 @@ -128,6 +131,9 @@ catalogs: zigpty: specifier: ^0.2.1 version: 0.2.1 + zod: + specifier: ^4.3.6 + version: 4.4.3 docs: mermaid: specifier: ^11.16.0 @@ -151,6 +157,9 @@ catalogs: '@iconify-json/ph': specifier: ^1.2.2 version: 1.2.2 + '@json-render/vue': + specifier: ^0.19.0 + version: 0.19.0 '@pierre/diffs': specifier: ^1.2.12 version: 1.2.12 @@ -331,7 +340,7 @@ importers: devDependencies: '@antfu/eslint-config': specifier: catalog:tooling - version: 9.1.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.63.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.63.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))) + version: 9.1.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.63.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.63.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))) '@antfu/ni': specifier: catalog:build version: 30.2.0 @@ -818,6 +827,83 @@ importers: specifier: catalog:build version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + packages/json-render: + dependencies: + '@json-render/core': + specifier: catalog:deps + version: 0.19.0(zod@4.4.3) + nostics: + specifier: catalog:deps + version: 1.2.0 + zod: + specifier: catalog:deps + version: 4.4.3 + devDependencies: + '@devframes/hub': + specifier: workspace:* + version: link:../hub + '@types/node': + specifier: catalog:types + version: 26.1.1 + devframe: + specifier: workspace:* + version: link:../devframe + tsdown: + specifier: catalog:build + version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) + + packages/json-render-ui: + dependencies: + '@json-render/vue': + specifier: catalog:frontend + version: 0.19.0(vue@3.5.40(typescript@5.9.3))(zod@4.4.3) + dompurify: + specifier: catalog:frontend + version: 3.4.12 + devDependencies: + '@antfu/design': + specifier: catalog:frontend + version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@5.9.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@5.9.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@5.9.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@5.9.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3)) + '@devframes/json-render': + specifier: workspace:* + version: link:../json-render + '@iconify-json/ph': + specifier: catalog:frontend + version: 1.2.2 + '@storybook/addon-docs': + specifier: catalog:storybook + version: 10.5.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + '@storybook/vue3-vite': + specifier: catalog:storybook + version: 10.5.2(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) + '@unocss/preset-icons': + specifier: catalog:frontend + version: 66.7.5 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.8(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) + devframe: + specifier: workspace:* + version: link:../devframe + storybook: + specifier: catalog:storybook + version: 10.5.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + tsdown: + specifier: catalog:build + version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@5.9.3) + unocss: + specifier: catalog:frontend + version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + vite: + specifier: catalog:build + version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: catalog:testing + version: 4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + vue: + specifier: catalog:frontend + version: 3.5.40(typescript@5.9.3) + packages/nuxt: devDependencies: '@nuxt/kit': @@ -1108,7 +1194,7 @@ importers: dependencies: '@valibot/to-json-schema': specifier: catalog:deps - version: 1.7.1(valibot@1.4.2(typescript@5.9.3)) + version: 1.7.1(valibot@1.4.2(typescript@6.0.3)) cac: specifier: catalog:deps version: 7.0.0 @@ -1118,7 +1204,7 @@ importers: devDependencies: '@antfu/design': specifier: catalog:frontend - version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@5.9.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@5.9.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@5.9.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@5.9.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3)) + version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 @@ -1127,13 +1213,13 @@ importers: version: 10.5.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) '@storybook/vue3-vite': specifier: catalog:storybook - version: 10.5.2(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) + version: 10.5.2(esbuild@0.28.0)(rollup@4.60.3)(storybook@10.5.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) '@unocss/preset-icons': specifier: catalog:frontend version: 66.7.5 '@vitejs/plugin-vue': specifier: catalog:build - version: 6.0.8(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@5.9.3)) + version: 6.0.8(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) colorjs.io: specifier: catalog:frontend version: 0.7.0 @@ -1142,7 +1228,7 @@ importers: version: link:../../packages/devframe floating-vue: specifier: catalog:frontend - version: 5.2.2(vue@3.5.40(typescript@5.9.3)) + version: 5.2.2(vue@3.5.40(typescript@6.0.3)) get-port-please: specifier: catalog:deps version: 3.2.0 @@ -1151,13 +1237,13 @@ importers: version: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) reka-ui: specifier: catalog:frontend - version: 2.10.1(vue@3.5.40(typescript@5.9.3)) + version: 2.10.1(vue@3.5.40(typescript@6.0.3)) storybook: specifier: catalog:storybook version: 10.5.2(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) tsdown: specifier: catalog:build - version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@5.9.3) + version: 0.22.12(oxc-resolver@11.21.3)(tsx@4.23.1)(typescript@6.0.3) unocss: specifier: catalog:frontend version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) @@ -1169,7 +1255,7 @@ importers: version: 4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) vue: specifier: catalog:frontend - version: 3.5.40(typescript@5.9.3) + version: 3.5.40(typescript@6.0.3) ws: specifier: catalog:deps version: 8.21.1 @@ -2252,6 +2338,17 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@json-render/core@0.19.0': + resolution: {integrity: sha512-vvcyZ+10EDZKbEyB1J2kXOGfDaiZR2LurZGSqi2r5STHyKr+Te85DWaBxTwRGgM7U1LtIvNx85BzzjElRKoAIg==} + peerDependencies: + zod: ^4.0.0 + + '@json-render/vue@0.19.0': + resolution: {integrity: sha512-WWvjQnsYJsHvc61Y18z3n0feFIGDrL00ltezh4d2FGs9yrpNXhw3bZVvAi1jB7yq8/rB7nC2VF/CVu+Qg56Wgg==} + peerDependencies: + vue: ^3.5.0 + zod: ^4.0.0 + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -7499,9 +7596,6 @@ packages: nostics@0.2.0: resolution: {integrity: sha512-/WQpI46UMbqvy1okYb+V+9wW3J8/m6GJ33wm691n/tyi6YtJiZ6ssJjENAU7y4evfYrrgYN9HllKDzPvffil1w==} - nostics@1.1.4: - resolution: {integrity: sha512-U4FApICSLCQ0dYiN59pxUCEZC053palQXi57yLaNdMaL6TBY4C4q3qZrJKUGcor2dGv8EhEwt8y5KvU2bo2kFg==} - nostics@1.2.0: resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} @@ -9568,7 +9662,7 @@ snapshots: '@adobe/css-tools@4.5.0': {} - '@antfu/design@0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@5.9.3)))(@unocss/core@66.7.5)(colorjs.io@0.7.0)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@5.9.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@5.9.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@5.9.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3))': + '@antfu/design@0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@5.9.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@5.9.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@5.9.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@5.9.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@5.9.3))': dependencies: '@vueuse/core': 14.3.0(vue@3.5.40(typescript@5.9.3)) unocss: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) @@ -9578,7 +9672,7 @@ snapshots: '@iconify-json/catppuccin': 1.2.17 '@tanstack/vue-virtual': 3.13.30(vue@3.5.40(typescript@5.9.3)) '@unocss/core': 66.7.5 - colorjs.io: 0.7.0 + colorjs.io: 0.6.1 dompurify: 3.4.12 floating-vue: 5.2.2(vue@3.5.40(typescript@5.9.3)) playwright: 1.61.1 @@ -9636,7 +9730,7 @@ snapshots: reka-ui: 2.10.1(vue@3.5.40(typescript@6.0.3)) splitpanes: 4.1.2(vue@3.5.40(typescript@6.0.3)) - '@antfu/eslint-config@9.1.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.63.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.63.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))': + '@antfu/eslint-config@9.1.0(@typescript-eslint/rule-tester@8.59.2(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/typescript-estree@8.63.0(supports-color@10.2.2)(typescript@6.0.3))(@typescript-eslint/utils@8.63.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(@vue/compiler-sfc@3.5.40)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.10(@types/node@26.1.1)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))': dependencies: '@antfu/install-pkg': 1.1.0 '@clack/prompts': 1.7.0 @@ -9668,7 +9762,7 @@ snapshots: eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)) eslint-plugin-vue: 10.9.2(@stylistic/eslint-plugin@5.10.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)))(@typescript-eslint/parser@8.63.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(vue-eslint-parser@10.4.1(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)) eslint-plugin-yml: 3.6.0(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)) - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)) + eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.40)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)) globals: 17.7.0 local-pkg: 1.2.1 parse-gitignore: 2.0.0 @@ -9717,14 +9811,14 @@ snapshots: '@babel/core@7.29.0(supports-color@10.2.2)': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.2 '@babel/parser': 7.29.7 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0(supports-color@10.2.2) + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 @@ -9772,7 +9866,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.2 + browserslist: 4.28.6 lru-cache: 5.1.1 semver: 7.8.5 @@ -9806,7 +9900,7 @@ snapshots: '@babel/helper-module-imports@7.28.6(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.0(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -9816,7 +9910,7 @@ snapshots: '@babel/core': 7.29.0(supports-color@10.2.2) '@babel/helper-module-imports': 7.28.6(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.0(supports-color@10.2.2) + '@babel/traverse': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -9856,7 +9950,7 @@ snapshots: '@babel/helpers@7.29.2': dependencies: - '@babel/template': 7.28.6 + '@babel/template': 7.29.7 '@babel/types': 7.29.7 '@babel/parser@7.29.7': @@ -9870,7 +9964,7 @@ snapshots: '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0(supports-color@10.2.2))': dependencies: '@babel/core': 7.29.0(supports-color@10.2.2) - '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.0(supports-color@10.2.2))': dependencies: @@ -9982,14 +10076,24 @@ snapshots: '@colordx/core@5.4.3': {} - '@devframes/hub@0.5.4(devframe@0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(typescript@6.0.3))': + '@devframes/hub@0.5.4(devframe@0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: birpc: 4.0.0 - devframe: 0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(typescript@6.0.3) - nostics: 0.2.0 + devframe: 0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + nostics: 0.2.0(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) pathe: 2.0.3 perfect-debounce: 2.1.0 tinyexec: 1.2.4 + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - vite + - webpack '@discoveryjs/discovery@1.0.0-beta.99': dependencies: @@ -10304,6 +10408,7 @@ snapshots: transitivePeerDependencies: - '@vue/composition-api' - vue + optional: true '@floating-ui/vue@1.1.11(vue@3.5.40(typescript@6.0.3))': dependencies: @@ -10506,6 +10611,16 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@json-render/core@0.19.0(zod@4.4.3)': + dependencies: + zod: 4.4.3 + + '@json-render/vue@0.19.0(vue@3.5.40(typescript@5.9.3))(zod@4.4.3)': + dependencies: + '@json-render/core': 0.19.0(zod@4.4.3) + vue: 3.5.40(typescript@5.9.3) + zod: 4.4.3 + '@kwsites/file-exists@1.1.1(supports-color@10.2.2)': dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -10780,7 +10895,7 @@ snapshots: jiti: 2.7.0 klona: 2.0.6 mlly: 1.8.2 - nostics: 1.1.4 + nostics: 1.2.0 ohash: 2.0.11 pathe: 2.0.3 pkg-types: 2.3.1 @@ -10816,7 +10931,7 @@ snapshots: klona: 2.0.6 mocked-exports: 0.1.1 nitropack: 2.13.4(oxc-parser@0.140.0)(rolldown@1.2.0)(srvx@0.11.22)(supports-color@10.2.2) - nostics: 1.1.4 + nostics: 1.2.0 nuxt: 4.5.0(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0(supports-color@10.2.2)))(@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.0(supports-color@10.2.2)))(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(@parcel/watcher@2.5.6)(@types/node@26.1.1)(@vue/compiler-sfc@3.5.40)(cac@6.7.14)(crossws@0.4.10(srvx@0.11.22))(db0@0.3.4)(esbuild@0.28.0)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2))(ioredis@5.10.1(supports-color@10.2.2))(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(oxc-parser@0.140.0)(rollup-plugin-visualizer@7.0.1(rolldown@1.2.0)(rollup@4.60.3))(rollup@4.60.3)(srvx@0.11.22)(supports-color@10.2.2)(terser@5.47.1)(tsx@4.23.1)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(yaml@2.9.0) nypm: 0.6.8 ohash: 2.0.11 @@ -10889,7 +11004,7 @@ snapshots: dependencies: '@vue/shared': 3.5.40 defu: 6.1.7 - nostics: 1.1.4 + nostics: 1.2.0 pathe: 2.0.3 pkg-types: 2.3.1 std-env: 4.2.0 @@ -12225,6 +12340,7 @@ snapshots: dependencies: '@tanstack/virtual-core': 3.17.2 vue: 3.5.40(typescript@5.9.3) + optional: true '@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3))': dependencies: @@ -12670,7 +12786,7 @@ snapshots: '@unhead/bundler@3.2.1(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.0)(lightningcss@1.32.0)(rolldown@1.2.0)(rollup@4.60.3)(typescript@6.0.3)(unhead@3.2.1(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: - '@vitejs/devtools-kit': 0.3.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + '@vitejs/devtools-kit': 0.3.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) magic-string: 1.0.0 oxc-parser: 0.140.0 oxc-walker: 1.0.0(oxc-parser@0.140.0)(rolldown@1.2.0) @@ -12872,10 +12988,6 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@5.9.3))': - dependencies: - valibot: 1.4.2(typescript@5.9.3) - '@valibot/to-json-schema@1.7.1(valibot@1.4.2(typescript@6.0.3))': dependencies: valibot: 1.4.2(typescript@6.0.3) @@ -12899,23 +13011,31 @@ snapshots: - rollup - supports-color - '@vitejs/devtools-kit@0.3.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))': + '@vitejs/devtools-kit@0.3.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: - '@devframes/hub': 0.5.4(devframe@0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(typescript@6.0.3)) + '@devframes/hub': 0.5.4(devframe@0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) birpc: 4.0.0 - devframe: 0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(typescript@6.0.3) + devframe: 0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) mlly: 1.8.2 - nostics: 1.1.4 + nostics: 1.2.0 pathe: 2.0.3 perfect-debounce: 2.1.0 tinyexec: 1.2.4 vite: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) transitivePeerDependencies: + - '@farmfe/core' - '@modelcontextprotocol/sdk' + - '@rspack/core' - bufferutil + - bun-types-no-globals - crossws + - esbuild + - rolldown + - rollup - typescript + - unloader - utf-8-validate + - webpack '@vitejs/plugin-react-oxc@0.4.3(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))': dependencies: @@ -14152,24 +14272,33 @@ snapshots: devalue@5.8.1: {} - devframe@0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(typescript@6.0.3): + devframe@0.5.4(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(crossws@0.4.10(srvx@0.11.22))(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(typescript@6.0.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)): dependencies: '@valibot/to-json-schema': 1.7.1(valibot@1.4.2(typescript@6.0.3)) birpc: 4.0.0 cac: 7.0.0 h3: 2.0.1-rc.22(crossws@0.4.10(srvx@0.11.22)) mrmime: 2.0.1 - nostics: 0.2.0 + nostics: 0.2.0(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) pathe: 2.0.3 valibot: 1.4.2(typescript@6.0.3) ws: 8.21.1 optionalDependencies: '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3) transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' - bufferutil + - bun-types-no-globals - crossws + - esbuild + - rolldown + - rollup - typescript + - unloader - utf-8-validate + - vite + - webpack devlop@1.1.0: dependencies: @@ -14509,9 +14638,9 @@ snapshots: natural-compare: 1.4.0 yaml-eslint-parser: 2.0.0 - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.39)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)): + eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.40)(eslint@10.7.0(jiti@2.7.0)(supports-color@10.2.2)): dependencies: - '@vue/compiler-sfc': 3.5.39 + '@vue/compiler-sfc': 3.5.40 eslint: 10.7.0(jiti@2.7.0)(supports-color@10.2.2) eslint-scope@9.1.2: @@ -14770,6 +14899,7 @@ snapshots: '@floating-ui/dom': 1.1.1 vue: 3.5.40(typescript@5.9.3) vue-resize: 2.0.0-alpha.1(vue@3.5.40(typescript@5.9.3)) + optional: true floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)): dependencies: @@ -16029,13 +16159,21 @@ snapshots: normalize-path@3.0.0: {} - nostics@0.2.0: + nostics@0.2.0(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)): dependencies: magic-string: 0.30.21 oxc-parser: 0.132.0 - unplugin: 3.0.0 - - nostics@1.1.4: {} + unplugin: 3.3.0(esbuild@0.28.0)(rolldown@1.2.0)(rollup@4.60.3)(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + transitivePeerDependencies: + - '@farmfe/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - vite + - webpack nostics@1.2.0: {} @@ -16083,7 +16221,7 @@ snapshots: magic-string: 1.0.0 mlly: 1.8.2 nanotar: 0.3.0 - nostics: 1.1.4 + nostics: 1.2.0 nypm: 0.6.8 object-identity: 0.2.3 ofetch: 1.5.1 @@ -16960,6 +17098,7 @@ snapshots: vue: 3.5.40(typescript@5.9.3) transitivePeerDependencies: - '@vue/composition-api' + optional: true reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)): dependencies: @@ -18043,10 +18182,6 @@ snapshots: uuid@11.1.1: {} - valibot@1.4.2(typescript@5.9.3): - optionalDependencies: - typescript: 5.9.3 - valibot@1.4.2(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -18307,6 +18442,7 @@ snapshots: vue-demi@0.14.10(vue@3.5.40(typescript@5.9.3)): dependencies: vue: 3.5.40(typescript@5.9.3) + optional: true vue-demi@0.14.10(vue@3.5.40(typescript@6.0.3)): dependencies: @@ -18369,6 +18505,7 @@ snapshots: vue-resize@2.0.0-alpha.1(vue@3.5.40(typescript@5.9.3)): dependencies: vue: 3.5.40(typescript@5.9.3) + optional: true vue-resize@2.0.0-alpha.1(vue@3.5.40(typescript@6.0.3)): dependencies: @@ -18386,7 +18523,7 @@ snapshots: magic-string: 0.30.21 mlly: 1.8.2 muggle-string: 0.4.1 - nostics: 1.1.4 + nostics: 1.2.0 pathe: 2.0.3 picomatch: 4.0.5 scule: 1.3.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3f54d812..cfcbe3e0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -50,6 +50,7 @@ catalogs: vite: ^8.1.5 vite-plugin-solid: ^2.11.13 deps: + '@json-render/core': ^0.19.0 '@modelcontextprotocol/sdk': ^1.29.0 '@valibot/to-json-schema': ^1.7.1 birpc: ^4.0.0 @@ -76,6 +77,7 @@ catalogs: whenexpr: ^0.1.2 ws: ^8.21.1 zigpty: ^0.2.1 + zod: ^4.3.6 docs: mermaid: ^11.16.0 vitepress: ^2.0.0-alpha.18 @@ -85,6 +87,7 @@ catalogs: '@floating-ui/react': ^0.27.20 '@iconify-json/catppuccin': ^1.2.17 '@iconify-json/ph': ^1.2.2 + '@json-render/vue': ^0.19.0 '@pierre/diffs': ^1.2.12 '@radix-ui/react-scroll-area': ^1.2.15 '@radix-ui/react-separator': ^1.1.12 diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index b6755360..bbea0efa 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -20,6 +20,7 @@ export interface DevframeClientHostOptions { connect?: DevframeRpcClientOptions; clientType?: DockClientType; loadClientScripts?: boolean; + renderers?: Record; } export interface DockClientScriptContext extends DocksContext { current: DockEntryState; @@ -51,6 +52,20 @@ export interface DockPanelStorage { open: boolean; inactiveTimeout: number; } +export interface DockRendererInstance { + dispose?: () => void; +} +export interface DockRendererMountOptions { + entry: DevframeDockEntry; + container: HTMLElement; + context: DevframeClientContext; +} +export interface DockRenderersContext { + register: (_: string, _: DockRenderer) => () => void; + get: (_: string) => DockRenderer | undefined; + has: (_: string) => boolean; + mount: (_: DevframeDockEntry, _: HTMLElement) => Promise<() => void>; +} export interface DocksConnectionContext { readonly status: DevframeConnectionStatus; readonly error: Error | null; @@ -63,6 +78,7 @@ export interface DocksContext extends DevframeRpcContext { readonly commands: CommandsContext; readonly when: WhenClauseContext; readonly connection: DocksConnectionContext; + readonly renderers: DockRenderersContext; } export interface DocksEntriesContext { selectedId: string | null; @@ -93,6 +109,7 @@ export interface WhenClauseContext { export type ConnectRemoteDevframeOptions = Omit; export type DevframeClientContext = DocksContext; export type DockClientType = 'embedded' | 'standalone'; +export type DockRenderer = (_: DockRendererMountOptions) => DockRendererInstance | Promise; // #endregion // #region Functions diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 65578b82..6b0d8a58 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -8,7 +8,6 @@ export declare function defineCommand(_: Omit(_: Omit & { when?: WhenExpression; }): T; -export declare function defineJsonRenderSpec(_: JsonRenderSpec): JsonRenderSpec; // #endregion // #region Variables @@ -41,6 +40,7 @@ export { DevframeDockEntry } export { DevframeDockEntryBase } export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } +export { DevframeDockEntryRegistry } export { DevframeDocksActiveState } export { DevframeDocksHost } export { DevframeDocksUserSettings } @@ -77,16 +77,12 @@ export { DevframeViewCustomRender } export { DevframeViewGroup } export { DevframeViewHost } export { DevframeViewIframe } -export { DevframeViewJsonRender } export { DevframeViewLauncher } export { DevframeViewLauncherStatus } export { EntriesToObject } export { EventEmitter } export { EventsMap } export { EventUnsubscribe } -export { JsonRenderElement } -export { JsonRenderer } -export { JsonRenderSpec } export { PartialWithoutId } export { RemoteConnectionInfo } export { RemoteDockOptions } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js index b50f2b5b..1900d65b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.js @@ -5,5 +5,4 @@ export { defineCommand } export { defineDockEntry } export { defineHubRpcFunction } -export { defineJsonRenderSpec } // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 19f93f45..e9a95e3d 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -27,6 +27,7 @@ export { DevframeDockEntry } export { DevframeDockEntryBase } export { DevframeDockEntryCategory } export { DevframeDockEntryIcon } +export { DevframeDockEntryRegistry } export { DevframeDocksActiveState } export { DevframeDocksHost } export { DevframeDocksUserSettings } @@ -63,16 +64,12 @@ export { DevframeViewCustomRender } export { DevframeViewGroup } export { DevframeViewHost } export { DevframeViewIframe } -export { DevframeViewJsonRender } export { DevframeViewLauncher } export { DevframeViewLauncherStatus } export { EntriesToObject } export { EventEmitter } export { EventsMap } export { EventUnsubscribe } -export { JsonRenderElement } -export { JsonRenderer } -export { JsonRenderSpec } export { PartialWithoutId } export { RemoteConnectionInfo } export { RemoteDockOptions } diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts new file mode 100644 index 00000000..b994a352 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.d.ts @@ -0,0 +1,20 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render-ui/components` + */ +// #region Other +export { Badge } +export { Button } +export { Card } +export { CodeBlock } +export { DataTable } +export { Divider } +export { Icon } +export { JrComponent } +export { KeyValueTable } +export { Progress } +export { Stack } +export { Switch } +export { Text } +export { TextInput } +export { Tree } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js new file mode 100644 index 00000000..d5fac876 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/components.snapshot.js @@ -0,0 +1,19 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render-ui/components` + */ +// #region Other +export { Badge } +export { Button } +export { Card } +export { CodeBlock } +export { DataTable } +export { Divider } +export { Icon } +export { KeyValueTable } +export { Progress } +export { Stack } +export { Switch } +export { Text } +export { TextInput } +export { Tree } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts new file mode 100644 index 00000000..a7f04978 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts @@ -0,0 +1,149 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render-ui` + */ +// #region Interfaces +export interface ActionBridgeRpc { + call: (_: string, ..._: unknown[]) => Promise; +} +export interface CreateRendererOptions { + registry?: ComponentRegistry; +} +export interface JsonRenderActionBridge { + handlers: Record) => Promise>; + loading: Record; + error: { + value: JsonRenderActionError | null; + }; +} +export interface JsonRenderActionError { + action: string; + error: unknown; +} +export interface JsonRenderDockMountOptions { + entry: unknown; + container: HTMLElement; + context: { + rpc: any; + }; +} +export interface JsonRenderDockRendererOptions { + registry?: ComponentRegistry; +} +// #endregion + +// #region Types +export type JsonRenderDockRenderer = (_: JsonRenderDockMountOptions) => Promise<{ + dispose?: () => void; +}>; +// #endregion + +// #region Functions +export declare function createActionBridge(_: ActionBridgeRpc, _?: { + interactive?: boolean; +}): JsonRenderActionBridge; +export declare function createJsonRenderDockRenderer(_?: JsonRenderDockRendererOptions): JsonRenderDockRenderer; +export declare function createRenderer(_?: CreateRendererOptions): import("vue").DefineComponent<{}, () => import("vue").VNode, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; +export declare function sanitizeSpec(_: Spec): Spec; +// #endregion + +// #region Variables +export declare const baseRegistry: ComponentRegistry; +export declare const ERROR_COMPONENT_TYPE: string; +export declare const JsonRenderView: import("vue").DefineComponent; + default: null; + }; + rpc: { + type: PropType; + required: true; + }; + registry: { + type: PropType; + default: () => ComponentRegistry; + }; + viewId: { + type: StringConstructor; + default: string; + }; + upstreamVersion: { + type: StringConstructor; + default: undefined; + }; + interactive: { + type: BooleanConstructor; + default: boolean; + }; + loading: { + type: BooleanConstructor; + default: boolean; + }; + connectionError: { + type: PropType; + default: null; + }; +}>, () => import("vue").VNode, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly; + default: null; + }; + rpc: { + type: PropType; + required: true; + }; + registry: { + type: PropType; + default: () => ComponentRegistry; + }; + viewId: { + type: StringConstructor; + default: string; + }; + upstreamVersion: { + type: StringConstructor; + default: undefined; + }; + interactive: { + type: BooleanConstructor; + default: boolean; + }; + loading: { + type: BooleanConstructor; + default: boolean; + }; + connectionError: { + type: PropType; + default: null; + }; +}>> & Readonly<{}>, { + spec: Spec | null; + registry: ComponentRegistry; + viewId: string; + upstreamVersion: string; + interactive: boolean; + loading: boolean; + connectionError: string | null; +}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; +// #endregion + +// #region Other +export { Badge } +export { Button } +export { Card } +export { CodeBlock } +export { DataTable } +export { Divider } +export { Icon } +export { JrComponent } +export { KeyValueTable } +export { Progress } +export { Stack } +export { Switch } +export { Text } +export { TextInput } +export { Tree } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js new file mode 100644 index 00000000..b4926446 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.js @@ -0,0 +1,32 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render-ui` + */ +// #region Functions +export function createActionBridge(_, _) {} +export function createJsonRenderDockRenderer(_) {} +export function createRenderer(_) {} +export function sanitizeSpec(_) {} +// #endregion + +// #region Variables +export var baseRegistry /* const */ +export var ERROR_COMPONENT_TYPE /* const */ +export var JsonRenderView /* const */ +// #endregion + +// #region Other +export { Badge } +export { Button } +export { Card } +export { CodeBlock } +export { DataTable } +export { Divider } +export { Icon } +export { KeyValueTable } +export { Progress } +export { Stack } +export { Switch } +export { Text } +export { TextInput } +export { Tree } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/core.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/core.snapshot.d.ts new file mode 100644 index 00000000..7d84cdad --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/core.snapshot.d.ts @@ -0,0 +1,24 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render/core` + */ +// #region Other +export { Catalog } +export { createStateStore } +export { defineCatalog } +export { defineSchema } +export { InferActionParams } +export { InferCatalogActions } +export { InferCatalogComponents } +export { InferCatalogInput } +export { InferComponentProps } +export { InferSpec } +export { Schema } +export { SchemaBuilder } +export { SchemaDefinition } +export { SchemaOptions } +export { SchemaType } +export { Spec } +export { StateModel } +export { StateStore } +export { UIElement } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/core.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render/core.snapshot.js new file mode 100644 index 00000000..2ecf25cb --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/core.snapshot.js @@ -0,0 +1,8 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render/core` + */ +// #region Other +export { createStateStore } +export { defineCatalog } +export { defineSchema } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.d.ts new file mode 100644 index 00000000..27a34dc9 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.d.ts @@ -0,0 +1,13 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render/hub` + */ +// #region Interfaces +export interface DevframeJsonRenderDockEntry extends DevframeDockEntryBase { + type: 'json-render'; + view: JsonRenderViewRef; +} +// #endregion + +// #region Functions +export declare function toJsonRenderDockEntry(_: JsonRenderView, _: Omit): DevframeJsonRenderDockEntry; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.js new file mode 100644 index 00000000..cd6638b8 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/hub.snapshot.js @@ -0,0 +1,6 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render/hub` + */ +// #region Functions +export function toJsonRenderDockEntry(_, _) {} +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts new file mode 100644 index 00000000..f3e75329 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.d.ts @@ -0,0 +1,331 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render` + */ +// #region Types +export type BaseComponentName = keyof typeof basePropSchemas; +// #endregion + +// #region Variables +export declare const BadgePropsSchema: z.ZodObject<{ + text: z.ZodOptional, z.ZodRecord>]>>; + variant: z.ZodOptional>; + minWidth: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const baseCatalog: import("@json-render/core").Catalog<{ + spec: import("@json-render/core").SchemaType<"object", { + root: import("@json-render/core").SchemaType<"string", unknown>; + elements: import("@json-render/core").SchemaType<"record", import("@json-render/core").SchemaType<"object", { + type: import("@json-render/core").SchemaType<"ref", string>; + props: import("@json-render/core").SchemaType<"propsOf", string>; + children: import("@json-render/core").SchemaType<"array", import("@json-render/core").SchemaType<"string", unknown>>; + }>>; + }>; + catalog: import("@json-render/core").SchemaType<"object", { + components: import("@json-render/core").SchemaType<"map", { + props: import("@json-render/core").SchemaType<"zod", unknown>; + description: import("@json-render/core").SchemaType<"string", unknown>; + }>; + actions: import("@json-render/core").SchemaType<"map", { + description: import("@json-render/core").SchemaType<"string", unknown>; + }>; + }>; +}, { + components: { [K in keyof typeof basePropSchemas]: { + props: (typeof basePropSchemas)[K]; + description: string; + }; }; + actions: {}; +}>; +export declare const baseComponentNames: BaseComponentName[]; +export declare const basePropSchemas: { + readonly Stack: z.ZodObject<{ + direction: z.ZodOptional>; + gap: z.ZodOptional, z.ZodRecord>]>>; + padding: z.ZodOptional, z.ZodRecord>]>>; + align: z.ZodOptional>; + justify: z.ZodOptional>; + wrap: z.ZodOptional, z.ZodRecord>]>>; + flex: z.ZodOptional, z.ZodIntersection, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly Card: z.ZodObject<{ + title: z.ZodOptional, z.ZodRecord>]>>; + border: z.ZodOptional, z.ZodRecord>]>>; + collapsible: z.ZodOptional, z.ZodRecord>]>>; + defaultCollapsed: z.ZodOptional, z.ZodRecord>]>>; + loading: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly Text: z.ZodObject<{ + text: z.ZodOptional, z.ZodRecord>]>>; + variant: z.ZodOptional>; + weight: z.ZodOptional>; + color: z.ZodOptional>; + }, z.core.$strip>; + readonly Badge: z.ZodObject<{ + text: z.ZodOptional, z.ZodRecord>]>>; + variant: z.ZodOptional>; + minWidth: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly Button: z.ZodObject<{ + label: z.ZodOptional, z.ZodRecord>]>>; + variant: z.ZodOptional>; + icon: z.ZodOptional, z.ZodRecord>]>>; + disabled: z.ZodOptional, z.ZodRecord>]>>; + loading: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly Icon: z.ZodObject<{ + name: z.ZodOptional, z.ZodRecord>]>>; + size: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly Divider: z.ZodObject<{ + label: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly TextInput: z.ZodObject<{ + value: z.ZodOptional, z.ZodRecord>]>>; + placeholder: z.ZodOptional, z.ZodRecord>]>>; + label: z.ZodOptional, z.ZodRecord>]>>; + disabled: z.ZodOptional, z.ZodRecord>]>>; + type: z.ZodOptional>; + loading: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly Switch: z.ZodObject<{ + value: z.ZodOptional, z.ZodRecord>]>>; + label: z.ZodOptional, z.ZodRecord>]>>; + disabled: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly KeyValueTable: z.ZodObject<{ + data: z.ZodOptional, z.ZodIntersection, z.ZodRecord>]>>; + loading: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly DataTable: z.ZodObject<{ + columns: z.ZodOptional; + }, z.core.$loose>]>>, z.ZodIntersection, z.ZodRecord>]>>; + rows: z.ZodOptional, z.ZodIntersection, z.ZodRecord>]>>; + height: z.ZodOptional, z.ZodRecord>]>>; + loading: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly CodeBlock: z.ZodObject<{ + code: z.ZodOptional, z.ZodRecord>]>>; + language: z.ZodOptional, z.ZodRecord>]>>; + filename: z.ZodOptional, z.ZodRecord>]>>; + height: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly Progress: z.ZodObject<{ + value: z.ZodOptional, z.ZodRecord>]>>; + max: z.ZodOptional, z.ZodRecord>]>>; + label: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; + readonly Tree: z.ZodObject<{ + data: z.ZodOptional; + defaultExpanded: z.ZodOptional, z.ZodRecord>]>>; + }, z.core.$strip>; +}; +export declare const baseSchema: import("@json-render/core").Schema<{ + spec: import("@json-render/core").SchemaType<"object", { + root: import("@json-render/core").SchemaType<"string", unknown>; + elements: import("@json-render/core").SchemaType<"record", import("@json-render/core").SchemaType<"object", { + type: import("@json-render/core").SchemaType<"ref", string>; + props: import("@json-render/core").SchemaType<"propsOf", string>; + children: import("@json-render/core").SchemaType<"array", import("@json-render/core").SchemaType<"string", unknown>>; + }>>; + }>; + catalog: import("@json-render/core").SchemaType<"object", { + components: import("@json-render/core").SchemaType<"map", { + props: import("@json-render/core").SchemaType<"zod", unknown>; + description: import("@json-render/core").SchemaType<"string", unknown>; + }>; + actions: import("@json-render/core").SchemaType<"map", { + description: import("@json-render/core").SchemaType<"string", unknown>; + }>; + }>; +}>; +export declare const ButtonPropsSchema: z.ZodObject<{ + label: z.ZodOptional, z.ZodRecord>]>>; + variant: z.ZodOptional>; + icon: z.ZodOptional, z.ZodRecord>]>>; + disabled: z.ZodOptional, z.ZodRecord>]>>; + loading: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const CardPropsSchema: z.ZodObject<{ + title: z.ZodOptional, z.ZodRecord>]>>; + border: z.ZodOptional, z.ZodRecord>]>>; + collapsible: z.ZodOptional, z.ZodRecord>]>>; + defaultCollapsed: z.ZodOptional, z.ZodRecord>]>>; + loading: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const CodeBlockPropsSchema: z.ZodObject<{ + code: z.ZodOptional, z.ZodRecord>]>>; + language: z.ZodOptional, z.ZodRecord>]>>; + filename: z.ZodOptional, z.ZodRecord>]>>; + height: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const DataTablePropsSchema: z.ZodObject<{ + columns: z.ZodOptional; + }, z.core.$loose>]>>, z.ZodIntersection, z.ZodRecord>]>>; + rows: z.ZodOptional, z.ZodIntersection, z.ZodRecord>]>>; + height: z.ZodOptional, z.ZodRecord>]>>; + loading: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const DividerPropsSchema: z.ZodObject<{ + label: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const IconPropsSchema: z.ZodObject<{ + name: z.ZodOptional, z.ZodRecord>]>>; + size: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const KeyValueTablePropsSchema: z.ZodObject<{ + data: z.ZodOptional, z.ZodIntersection, z.ZodRecord>]>>; + loading: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const ProgressPropsSchema: z.ZodObject<{ + value: z.ZodOptional, z.ZodRecord>]>>; + max: z.ZodOptional, z.ZodRecord>]>>; + label: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const StackPropsSchema: z.ZodObject<{ + direction: z.ZodOptional>; + gap: z.ZodOptional, z.ZodRecord>]>>; + padding: z.ZodOptional, z.ZodRecord>]>>; + align: z.ZodOptional>; + justify: z.ZodOptional>; + wrap: z.ZodOptional, z.ZodRecord>]>>; + flex: z.ZodOptional, z.ZodIntersection, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const SwitchPropsSchema: z.ZodObject<{ + value: z.ZodOptional, z.ZodRecord>]>>; + label: z.ZodOptional, z.ZodRecord>]>>; + disabled: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const TextInputPropsSchema: z.ZodObject<{ + value: z.ZodOptional, z.ZodRecord>]>>; + placeholder: z.ZodOptional, z.ZodRecord>]>>; + label: z.ZodOptional, z.ZodRecord>]>>; + disabled: z.ZodOptional, z.ZodRecord>]>>; + type: z.ZodOptional>; + loading: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +export declare const TextPropsSchema: z.ZodObject<{ + text: z.ZodOptional, z.ZodRecord>]>>; + variant: z.ZodOptional>; + weight: z.ZodOptional>; + color: z.ZodOptional>; +}, z.core.$strip>; +export declare const TreePropsSchema: z.ZodObject<{ + data: z.ZodOptional; + defaultExpanded: z.ZodOptional, z.ZodRecord>]>>; +}, z.core.$strip>; +// #endregion + +// #region Other +export { Catalog } +export { DevframeJsonRenderSpec } +export { InferComponentProps } +export { JSON_RENDER_UPSTREAM_VERSION } +export { JsonRenderView } +export { JsonRenderViewRef } +export { Spec } +export { StateModel } +export { StateStore } +export { UIElement } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.js new file mode 100644 index 00000000..986c18bf --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/index.snapshot.js @@ -0,0 +1,27 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render` + */ +// #region Variables +export var baseCatalog /* const */ +export var baseSchema /* const */ +// #endregion + +// #region Other +export { BadgePropsSchema } +export { baseComponentNames } +export { basePropSchemas } +export { ButtonPropsSchema } +export { CardPropsSchema } +export { CodeBlockPropsSchema } +export { DataTablePropsSchema } +export { DividerPropsSchema } +export { IconPropsSchema } +export { JSON_RENDER_UPSTREAM_VERSION } +export { KeyValueTablePropsSchema } +export { ProgressPropsSchema } +export { StackPropsSchema } +export { SwitchPropsSchema } +export { TextInputPropsSchema } +export { TextPropsSchema } +export { TreePropsSchema } +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts new file mode 100644 index 00000000..9731f5f2 --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.d.ts @@ -0,0 +1,47 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render/node` + */ +// #region Interfaces +export interface CreateJsonRenderViewOptions { + id: string; + spec: DevframeJsonRenderSpec; + scope?: string; +} +// #endregion + +// #region Functions +export declare function createJsonRenderView(_: AnyContext, _: CreateJsonRenderViewOptions): JsonRenderView; +// #endregion + +// #region Variables +export declare const jsonRenderDiagnostics: import("nostics").Diagnostics<{ + readonly DF0038: { + readonly why: (p: { + id: string; + key: string; + issues: string; + }) => string; + readonly fix: "Match the element props to the base catalog's prop schema for that component. See the component reference for the expected shape."; + }; + readonly DF0039: { + readonly why: (p: { + id: string; + scope: string; + }) => string; + readonly fix: "Give each view a stable id unique within its scope, or dispose the previous view before recreating it."; + }; + readonly DF0040: { + readonly why: (p: { + id: string; + }) => string; + readonly fix: "Create a fresh view with `createJsonRenderView` instead of reusing a disposed handle."; + }; + readonly DF0041: { + readonly why: (p: { + id: string; + reason: string; + }) => string; + readonly fix: "Specs and state travel as strict JSON — remove functions, symbols, class instances, Map/Set, or circular references."; + }; +}, readonly [typeof jsonRenderReporter]>; +// #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.js new file mode 100644 index 00000000..a5fc4c5d --- /dev/null +++ b/tests/__snapshots__/tsnapi/@devframes/json-render/node.snapshot.js @@ -0,0 +1,10 @@ +/** + * Generated by tsnapi — public API snapshot of `@devframes/json-render/node` + */ +// #region Functions +export function createJsonRenderView(_, _) {} +// #endregion + +// #region Variables +export var jsonRenderDiagnostics /* const */ +// #endregion \ No newline at end of file diff --git a/tsconfig.base.json b/tsconfig.base.json index da139680..aa7b8b76 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -124,6 +124,24 @@ "@devframes/nuxt": [ "./packages/nuxt/src/index.ts" ], + "@devframes/json-render/core": [ + "./packages/json-render/src/core.ts" + ], + "@devframes/json-render/hub": [ + "./packages/json-render/src/hub.ts" + ], + "@devframes/json-render/node": [ + "./packages/json-render/src/node/index.ts" + ], + "@devframes/json-render": [ + "./packages/json-render/src/index.ts" + ], + "@devframes/json-render-ui/components": [ + "./packages/json-render-ui/src/components/index.ts" + ], + "@devframes/json-render-ui": [ + "./packages/json-render-ui/src/index.ts" + ], "@devframes/plugin-code-server/client": [ "./plugins/code-server/src/client/index.ts" ], diff --git a/turbo.json b/turbo.json index 8cfd0b72..fc3a3030 100644 --- a/turbo.json +++ b/turbo.json @@ -18,6 +18,16 @@ "outputLogs": "new-only", "outputs": ["dist/**"] }, + "@devframes/json-render#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build", "@devframes/hub#build"], + "outputs": ["dist/**"] + }, + "@devframes/json-render-ui#build": { + "outputLogs": "new-only", + "dependsOn": ["@devframes/json-render#build"], + "outputs": ["dist/**"] + }, "@devframes/plugin-code-server#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build"], diff --git a/vitest.config.ts b/vitest.config.ts index 7d9e4e67..6522d0b4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ projects: [ 'packages/devframe', 'packages/hub', + 'packages/json-render', + 'packages/json-render-ui', 'plugins/code-server', 'plugins/data-inspector', 'plugins/terminals', From 6cd18c810305f5b69a2e79f9d59ca645641a8204 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 00:05:15 +0000 Subject: [PATCH 02/10] docs: add minimal-json-render example and JSON-render guide - examples/minimal-json-render: a standalone Vue + Vite devframe that authors a JSON-render view server-side (createJsonRenderView), ticks live state, bridges a Button action to RPC, and renders it with @devframes/json-render-ui. - docs/guide/json-render.md: authoring, base catalog, actions/state, standalone and hub rendering, and swapping the frontend; linked from the guide index. - docs/examples/minimal-json-render.md + examples index/sidebar entry. - hub + client-context guides updated for the open dock union and the client host renderer registry (no more hub createJsonRenderer). Created with the help of an agent. --- docs/.vitepress/config.ts | 2 + docs/examples/index.md | 1 + docs/examples/minimal-json-render.md | 41 +++++ docs/guide/client-context.md | 2 + docs/guide/hub.md | 6 +- docs/guide/index.md | 1 + docs/guide/json-render.md | 170 ++++++++++++++++++ examples/minimal-json-render/README.md | 28 +++ examples/minimal-json-render/bin.mjs | 14 ++ examples/minimal-json-render/package.json | 32 ++++ .../minimal-json-render/src/client/index.html | 13 ++ .../minimal-json-render/src/client/main.ts | 54 ++++++ .../minimal-json-render/src/client/shims.d.ts | 3 + .../src/client/vite.config.ts | 16 ++ examples/minimal-json-render/src/devframe.ts | 86 +++++++++ examples/minimal-json-render/src/shared.ts | 15 ++ examples/minimal-json-render/tsconfig.json | 12 ++ examples/minimal-json-render/uno.config.ts | 37 ++++ pnpm-lock.yaml | 34 ++++ turbo.json | 5 + 20 files changed, 569 insertions(+), 3 deletions(-) create mode 100644 docs/examples/minimal-json-render.md create mode 100644 docs/guide/json-render.md create mode 100644 examples/minimal-json-render/README.md create mode 100644 examples/minimal-json-render/bin.mjs create mode 100644 examples/minimal-json-render/package.json create mode 100644 examples/minimal-json-render/src/client/index.html create mode 100644 examples/minimal-json-render/src/client/main.ts create mode 100644 examples/minimal-json-render/src/client/shims.d.ts create mode 100644 examples/minimal-json-render/src/client/vite.config.ts create mode 100644 examples/minimal-json-render/src/devframe.ts create mode 100644 examples/minimal-json-render/src/shared.ts create mode 100644 examples/minimal-json-render/tsconfig.json create mode 100644 examples/minimal-json-render/uno.config.ts diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 23213484..320cd39d 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -24,6 +24,7 @@ function guideItems(prefix: string) { { text: 'Cross-Plugin Services', link: `${prefix}/guide/services` }, { text: 'RPC', link: `${prefix}/guide/rpc` }, { text: 'Shared State', link: `${prefix}/guide/shared-state` }, + { text: 'JSON-Render', link: `${prefix}/guide/json-render` }, { text: 'Streaming', link: `${prefix}/guide/streaming` }, { text: 'When Clauses', link: `${prefix}/guide/when-clauses` }, { text: 'Structured Diagnostics', link: `${prefix}/guide/diagnostics` }, @@ -76,6 +77,7 @@ function examplesItems(prefix: string) { { text: 'Overview', link: `${prefix}/examples/` }, { text: 'Built with Devframe', link: `${prefix}/examples/built-with` }, { text: 'files-inspector', link: `${prefix}/examples/files-inspector` }, + { text: 'minimal-json-render', link: `${prefix}/examples/minimal-json-render` }, { text: 'streaming-chat', link: `${prefix}/examples/streaming-chat` }, { text: 'next-runtime-snapshot', link: `${prefix}/examples/next-runtime-snapshot` }, { text: 'minimal-vite-devframe-hub', link: `${prefix}/examples/minimal-vite-devframe-hub` }, diff --git a/docs/examples/index.md b/docs/examples/index.md index 7cccf3c5..9655d837 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -9,6 +9,7 @@ End-to-end examples that exercise the full adapter surface, each a runnable app | Example | UI framework | What it shows | |---------|--------------|---------------| | [files-inspector](./files-inspector) | Preact | Lists files in the cwd via RPC; exercises the CLI dev / build / spa surfaces. | +| [minimal-json-render](./minimal-json-render) | Vue | A server-authored JSON-render view rendered by `@devframes/json-render-ui`, with live state and an action bridge. | | [streaming-chat](./streaming-chat) | Preact | Streams synthetic chat tokens server → client, with history kept in shared state. | | [next-runtime-snapshot](./next-runtime-snapshot) | React (Next.js) | A Next.js App Router SPA over RPC, surfacing the host Node runtime. | | [minimal-vite-devframe-hub](./minimal-vite-devframe-hub) | Vanilla TypeScript (Vite) | A ~120-line Vite host wiring `@devframes/hub` end to end. | diff --git a/docs/examples/minimal-json-render.md b/docs/examples/minimal-json-render.md new file mode 100644 index 00000000..9e9f843b --- /dev/null +++ b/docs/examples/minimal-json-render.md @@ -0,0 +1,41 @@ +--- +outline: deep +--- + +# minimal-json-render + +A standalone devframe that serves a **JSON-render view**: the server authors an +`@json-render/core` spec once, and the browser renders it with the official +`@devframes/json-render-ui` Vue frontend. + +Package: `minimal-json-render` · framework: **Vue + Vite** + +## What it shows + +- **`createJsonRenderView`** — registers the spec as shared state, validates + element props at ingress, and returns a handle with `update` / `patchState` / + `dispose`. +- **Live state** — the server ticks `uptime` every second via `patchState`, so + the view updates without replacing the whole spec. +- **Action bridge** — the `Refresh` button's `press` action is dispatched as an + RPC call of the same name; the handler bumps a counter and patches state, with + per-action loading and error surfacing. +- **Standalone rendering** — the app supplies the frontend lib + (`@devframes/json-render-ui`); devframe serves the SPA, which subscribes to + the view's shared state and renders it with `JsonRenderView`. +- **Static output** — `cli:build` snapshots the spec + state as a read-only + render; the action bridge reports actions as unavailable (no live RPC). + +## Run it + +```sh +pnpm --filter minimal-json-render dev # CLI dev server (live RPC) +pnpm --filter minimal-json-render build # build the Vue client +pnpm --filter minimal-json-render cli:build # static deploy → dist/static +``` + +The dev server serves the SPA at `/__minimal-json-render/`. + +## Source + +[`examples/minimal-json-render`](https://github.com/devframes/devframe/tree/main/examples/minimal-json-render) diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index b4e226fa..94386ea7 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -38,6 +38,7 @@ Viewers with an HTML pipeline layer injection on top: `@vitejs/devtools` wraps t | `connect` | Options forwarded to `connectDevframe` when `rpc` is not supplied — pass `baseURL` to point at the hub's connection-meta mount (e.g. `/__hub/`). | | `clientType` | `'standalone'` (default) — the runtime owns the whole page (a hub UI). `'embedded'` — the runtime lives inside a user app alongside a panel. | | `loadClientScripts` | Import and run dock entries' client scripts. Default `true`. | +| `renderers` | Dock renderers to register at boot, keyed by dock `type` (e.g. `{ 'json-render': createJsonRenderDockRenderer() }` from `@devframes/json-render-ui`). The hub ships none. | Boot the host once per page: a second boot replaces the published context and logs a warning. `dispose()` tears down its listeners and unpublishes the context it owns. @@ -52,6 +53,7 @@ Boot the host once per page: a second boot replaces the published context and lo | `docks` | Dock entries and selection — `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`. | | `panel` | Dock panel state: position, size, drag/resize flags. | | `commands` | The command palette: `register()`, `execute()`, `getKeybindings()`. | +| `renderers` | Dock-renderer registry — `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a host-registered renderer (e.g. [JSON-Render](./json-render)); the hub ships none. | | `when` | The [when-clause](./when-clauses) evaluation context. | | `connection` | The client's live [connection status](./client#handling-connection-and-auth-errors) — `status`, `error`, and `events` — so a viewer can render one central connection indicator for every docked plugin. | diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 45d681c0..95cc8e0a 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -12,12 +12,12 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi | Subsystem | Surface | Purpose | |---|---|---| -| `ctx.docks` | `register / update / values / activate` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. `activate(dockId, params?)` steers which dock the viewer shows — see [Cross-iframe dock activation](#cross-iframe-dock-activation). | +| `ctx.docks` | `register / update / values / activate` | Multi-tool dock entries (iframes, launchers, custom-render) and groups that collapse them under one button. The dock union is **open**, so opt-in integrations contribute their own entry types. `activate(dockId, params?)` steers which dock the viewer shows — see [Cross-iframe dock activation](#cross-iframe-dock-activation). | | `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. | | `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). | | `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. | -Plus a `createJsonRenderer(spec)` factory for building remote-UI panels via the framework-neutral json-render DSL. +The hub itself is JSON-render-agnostic. Data-driven UI panels are an opt-in integration — see [JSON-Render](/guide/json-render), which contributes a `json-render` dock type to the open dock union and a client-host renderer, with no JSON-render dependency in the hub. ## Built-in RPC @@ -197,7 +197,7 @@ ctx.docks.register({ }) ``` -`groupId` lives on every entry kind, so iframes, launchers, json-render panels, and custom-render views all join groups the same way. The group and its members stay independent top-level entries in `devframe:docks`; a downstream UI derives the visual collapse by matching each member's `groupId` to the group's `id` and renders members in a popover or sub-navigation. `defaultChildId` names the member opened when the group button is activated. +`groupId` lives on every entry kind, so iframes, launchers, custom-render views, and integration-contributed types (e.g. json-render panels) all join groups the same way. The group and its members stay independent top-level entries in `devframe:docks`; a downstream UI derives the visual collapse by matching each member's `groupId` to the group's `id` and renders members in a popover or sub-navigation. `defaultChildId` names the member opened when the group button is activated. Grouping is one level deep: members join a group, and a group is always a top-level button. A member whose group is never registered renders as a normal top-level entry, so registration order is free. diff --git a/docs/guide/index.md b/docs/guide/index.md index fc13211d..78ad0a37 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -26,6 +26,7 @@ Devframe keeps its surface focused on one tool, so the same definition stays por | **[Devframe Definition](./devframe-definition)** | One `defineDevframe` call describes your tool once; the adapters deploy it anywhere. | | **[RPC](./rpc)** | Type-safe bidirectional calls built on birpc + valibot. Supports `query`, `static`, `action`, and `event` types. | | **[Shared State](./shared-state)** | Observable, patch-synced state that survives reconnects and bridges server ↔ browser. | +| **[JSON-Render](./json-render)** | Opt-in data-driven UI — author a view as a serializable spec, render it standalone or in a hub dock with a replaceable frontend. | | **[Diagnostics](./diagnostics)** | Coded warnings/errors via `nostics` — registered into the host's shared lookup so adapters and consumers share the same surface. | | **[Streaming](./streaming)** | One-way (RPC streaming) and two-way (uploads) channel primitives for long-running data. | | **[When Clauses](./when-clauses)** | VS Code-style conditional expressions for docks, commands, and custom UI. | diff --git a/docs/guide/json-render.md b/docs/guide/json-render.md new file mode 100644 index 00000000..189e7a96 --- /dev/null +++ b/docs/guide/json-render.md @@ -0,0 +1,170 @@ +--- +outline: deep +--- + +# JSON-Render + +JSON-render lets a devframe describe a UI as **data** — a serializable spec of +components — and have any compatible frontend render it. It is an **opt-in** +capability: a plain devframe app pulls zero JSON-render dependencies, and the +hub stays JSON-render-agnostic. You add it by depending on two packages: + +- **`@devframes/json-render`** — the framework-neutral protocol layer. It owns + the spec/catalog types, the base catalog and its per-component prop schemas, + the serializable view reference, and the node runtime factory. It builds on + [`@json-render/core`](https://www.npmjs.com/package/@json-render/core) as its + wire contract and has no Vue or DOM code. +- **`@devframes/json-render-ui`** — the official reference frontend: a Vue + renderer implementing the base catalog with [`@antfu/design`](https://github.com/antfu/design). + Any compatible frontend library can replace it. + +A single view authored once renders standalone (the app supplies a frontend +lib) and inside a hub dock (the hub supplies one), live or static. + +## Authoring a view + +`createJsonRenderView` augments any devframe context. It registers the spec as +shared state, validates every element's props against the base catalog at +ingress, and returns a handle: + +```ts +import { createJsonRenderView } from '@devframes/json-render/node' + +export default defineDevframe({ + // … + setup(ctx) { + const view = createJsonRenderView(ctx, { + id: 'metrics', // stable, unique within the scope + spec: { + root: 'root', + elements: { + root: { type: 'Card', props: { title: 'Live metrics' }, children: ['count'] }, + count: { type: 'Text', props: { text: { $state: '/count' } }, children: [] }, + }, + state: { count: 0 }, + }, + }) + + // A structural change replaces the whole spec… + view.update(nextSpec) + // …while state travels as JSON-Pointer patches (only the changed path crosses the wire). + view.patchState([{ op: 'replace', path: '/count', value: 3 }]) + + // Unregisters the shared state and its listeners. + // view.dispose() + }, +}) +``` + +The view has a stable, scoped id — `devframe:json-render::` — so a +client keeps its subscription across reconnects. `scope` defaults to the +context's namespace (from `ctx.scope('my-plugin')`) or `global`. Element props +are validated at ingress ([DF0038](/errors/DF0038)); a duplicate id +([DF0039](/errors/DF0039)), a disposed-view use ([DF0040](/errors/DF0040)), and +a non-JSON-serializable spec ([DF0041](/errors/DF0041)) each raise a diagnostic. + +## The base catalog + +Catalog v1 ships fourteen components — `Stack`, `Card`, `Text`, `Badge`, +`Button`, `Icon`, `Divider`, `TextInput`, `Switch`, `KeyValueTable`, +`DataTable`, `CodeBlock`, `Progress`, `Tree`. A Devframes spec **is** an +`@json-render/core` `Spec`; the one validation Devframes adds is a per-component +Zod prop schema (`basePropSchemas`), applied at both boundaries — spec ingress +(server) and render time (client). Dynamic `$state` / `$bindState` expressions +are accepted wherever a scalar prop is expected, so a valid binding never fails +validation. + +## Actions and state + +- **State** is a JSON-serializable `Record` addressed by JSON + Pointer. State updates travel as patches; a structural change replaces the + whole spec. +- **Actions** are unrestricted: an element event maps to an action whose name is + dispatched as an RPC call of the same name. There is no allowlist — a spec + can invoke any RPC method the client can reach. The reference bridge tracks + per-action loading state and surfaces RPC failures to the view rather than + swallowing them. +- **Reserved built-ins** (`setState`, `pushState`, `removeState`, + `validateForm`) are handled client-side and are never bridged to RPC. + +## Rendering standalone + +The app supplies the frontend lib and devframe serves its SPA. Connect, read the +view's shared state, and render it with `JsonRenderView`: + +```ts +import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' +import { JsonRenderView } from '@devframes/json-render-ui' +import { connectDevframe } from 'devframe/client' +import { createApp, h, shallowRef } from 'vue' + +const rpc = await connectDevframe() +const state = await rpc.sharedState.get('devframe:json-render:global:metrics', { initialValue: null }) +const spec = shallowRef(state.value()) +state.on('updated', () => { + spec.value = state.value() +}) + +createApp({ + render: () => h(JsonRenderView, { + spec: spec.value, + rpc, + upstreamVersion: JSON_RENDER_UPSTREAM_VERSION, + interactive: rpc.connectionMeta.backend !== 'static', + }), +}).mount('#app') +``` + +In a **static** build the spec + state are snapshotted as a read-only render; +there is no live RPC, so the action bridge reports actions as unavailable and +`interactive: false` renders a static-output notice. Local state and bindings +still work. + +The reference components author their class strings in `.ts`, so a consuming +app must opt `.ts` into UnoCSS extraction (`content.pipeline.include` for Vite), +and add the lib to the scanned sources when it lives in `node_modules`. + +## Rendering inside a hub + +The hub is JSON-render-agnostic; its dock union is **open**. The +`@devframes/json-render/hub` subpath contributes the `json-render` dock type, +and the hub's client host routes it to a **registered renderer**: + +```ts +// server — register a dock carrying the view's serializable reference +import { toJsonRenderDockEntry } from '@devframes/json-render/hub' + +ctx.docks.register(toJsonRenderDockEntry(view, { + id: 'metrics', + title: 'Metrics', + icon: 'ph:chart-bar-duotone', +})) +``` + +```ts +// host page — inject the frontend lib as the renderer for the dock type +import { createDevframeClientHost } from '@devframes/hub/client' +import { createJsonRenderDockRenderer } from '@devframes/json-render-ui' + +const host = await createDevframeClientHost({ + renderers: { 'json-render': createJsonRenderDockRenderer() }, +}) + +// the viewer mounts the active dock into a container it owns +const dispose = await host.context.renderers.mount(entry, container) +``` + +The dock carries only a serializable `JsonRenderViewRef` (`{ stateKey, +upstreamVersion }`) — no functions cross the wire. The client host disposes the +renderer when the dock deactivates. A renderer/upstream-version mismatch logs a +warning rather than blocking. + +## Swapping the frontend + +A third party replaces the whole registry — pass a custom `registry` to +`createRenderer({ registry })` or `createJsonRenderDockRenderer({ registry })`. +`@devframes/json-render-ui` is the reference implementation, not a hard +dependency of the protocol; the hub acquires no Vue. + +See the [`minimal-json-render` example](/examples/minimal-json-render) for a +runnable end-to-end app. diff --git a/examples/minimal-json-render/README.md b/examples/minimal-json-render/README.md new file mode 100644 index 00000000..08cd0aec --- /dev/null +++ b/examples/minimal-json-render/README.md @@ -0,0 +1,28 @@ +# minimal-json-render + +A standalone devframe that serves a **JSON-render view**: the server authors an +`@json-render/core` spec once, and the browser renders it with the official +`@devframes/json-render-ui` Vue frontend. It exercises the whole opt-in +JSON-render stack end to end: + +- **`@devframes/json-render/node`** — `createJsonRenderView(ctx, { id, spec })` + registers the spec as shared state, validates element props at ingress, and + hands back a handle with `update` / `patchState` / `dispose`. +- **live state** — the server ticks `uptime` every second via `patchState`, so + the view updates without replacing the whole spec. +- **action bridge** — the `Refresh` button's `press` action is dispatched as an + RPC call of the same name; the handler bumps a counter and patches state. +- **`@devframes/json-render-ui`** — the SPA (`src/client`) subscribes to the + view's shared state and renders it with `JsonRenderView`. The app supplies the + frontend lib; devframe serves the SPA. + +## Run + +```sh +pnpm --filter minimal-json-render dev # live dev server (http://localhost:9877/__minimal-json-render/) +pnpm --filter minimal-json-render build # build the SPA +node bin.mjs build --out-dir dist/static # static snapshot (read-only; actions render disabled) +``` + +In the static build, the spec + state are snapshotted as a read-only render and +the action bridge reports actions as unavailable — there is no live RPC. diff --git a/examples/minimal-json-render/bin.mjs b/examples/minimal-json-render/bin.mjs new file mode 100644 index 00000000..d35b5779 --- /dev/null +++ b/examples/minimal-json-render/bin.mjs @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import process from 'node:process' +import { createCac } from 'devframe/adapters/cac' +import devframe from './src/devframe.ts' + +async function main() { + const cli = createCac(devframe) + await cli.parse() +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/examples/minimal-json-render/package.json b/examples/minimal-json-render/package.json new file mode 100644 index 00000000..2c84188c --- /dev/null +++ b/examples/minimal-json-render/package.json @@ -0,0 +1,32 @@ +{ + "name": "minimal-json-render", + "type": "module", + "version": "0.7.5", + "private": true, + "description": "Standalone devframe that serves a JSON-render view — a server-authored spec rendered by @devframes/json-render-ui, with live state and an action bridge.", + "homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-json-render", + "main": "src/devframe.ts", + "bin": { + "minimal-json-render": "./bin.mjs" + }, + "scripts": { + "build": "vite build --config src/client/vite.config.ts", + "dev": "node bin.mjs", + "cli:build": "node bin.mjs build --out-dir dist/static", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@antfu/design": "catalog:frontend", + "@devframes/json-render": "workspace:*", + "@devframes/json-render-ui": "workspace:*", + "cac": "catalog:deps", + "devframe": "workspace:*", + "vue": "catalog:frontend" + }, + "devDependencies": { + "@vitejs/plugin-vue": "catalog:build", + "get-port-please": "catalog:deps", + "unocss": "catalog:frontend", + "vite": "catalog:build" + } +} diff --git a/examples/minimal-json-render/src/client/index.html b/examples/minimal-json-render/src/client/index.html new file mode 100644 index 00000000..15131896 --- /dev/null +++ b/examples/minimal-json-render/src/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Minimal JSON-Render + + +
+ + + diff --git a/examples/minimal-json-render/src/client/main.ts b/examples/minimal-json-render/src/client/main.ts new file mode 100644 index 00000000..705ae28b --- /dev/null +++ b/examples/minimal-json-render/src/client/main.ts @@ -0,0 +1,54 @@ +import type { DevframeJsonRenderSpec } from '@devframes/json-render' +import type { ActionBridgeRpc } from '@devframes/json-render-ui' +import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' +import { JsonRenderView } from '@devframes/json-render-ui' +import { connectDevframe } from 'devframe/client' +import { createApp, h, shallowRef } from 'vue' +import { STATE_KEY } from '../shared.ts' +import 'virtual:uno.css' +import '@antfu/design/styles.css' + +// Shared design tokens flip on the `.dark` class; mirror the OS preference. +const mq = window.matchMedia('(prefers-color-scheme: dark)') +function applyScheme(dark: boolean): void { + document.documentElement.classList.toggle('dark', dark) +} +applyScheme(mq.matches) +mq.addEventListener('change', e => applyScheme(e.matches)) + +async function main(): Promise { + const root = document.getElementById('app') + if (!root) + throw new Error('#app mount node missing') + + // The app supplies the compatible frontend lib (@devframes/json-render-ui); + // devframe serves this SPA. Connect, subscribe to the view's shared state, + // and render — new server snapshots/patches re-render the view live. + const rpc = await connectDevframe() + const interactive = rpc.connectionMeta.backend !== 'static' + const state = await rpc.sharedState.get(STATE_KEY, { + initialValue: null as unknown as DevframeJsonRenderSpec, + }) + + const specRef = shallowRef(state.value() as DevframeJsonRenderSpec | null) + state.on('updated', () => { + specRef.value = state.value() as DevframeJsonRenderSpec | null + }) + + createApp({ + render: () => h('div', { class: 'min-h-screen bg-base color-base font-sans p6' }, [ + h(JsonRenderView, { + spec: specRef.value, + rpc: rpc as unknown as ActionBridgeRpc, + viewId: STATE_KEY, + upstreamVersion: JSON_RENDER_UPSTREAM_VERSION, + interactive, + }), + ]), + }).mount(root) +} + +main().catch((error) => { + console.error(error) + document.body.textContent = `Failed to start: ${(error as Error).message}` +}) diff --git a/examples/minimal-json-render/src/client/shims.d.ts b/examples/minimal-json-render/src/client/shims.d.ts new file mode 100644 index 00000000..1182fc16 --- /dev/null +++ b/examples/minimal-json-render/src/client/shims.d.ts @@ -0,0 +1,3 @@ +// Side-effect style imports used by the SPA entry. +declare module '*.css' {} +declare module 'virtual:uno.css' {} diff --git a/examples/minimal-json-render/src/client/vite.config.ts b/examples/minimal-json-render/src/client/vite.config.ts new file mode 100644 index 00000000..ede7a56a --- /dev/null +++ b/examples/minimal-json-render/src/client/vite.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from 'node:url' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' +import { defineConfig } from 'vite' +import { alias } from '../../../../alias' + +export default defineConfig({ + base: './', + root: fileURLToPath(new URL('.', import.meta.url)), + resolve: { alias }, + plugins: [UnoCSS(), vue()], + build: { + outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), + emptyOutDir: true, + }, +}) diff --git a/examples/minimal-json-render/src/devframe.ts b/examples/minimal-json-render/src/devframe.ts new file mode 100644 index 00000000..94a9fa9c --- /dev/null +++ b/examples/minimal-json-render/src/devframe.ts @@ -0,0 +1,86 @@ +import type { DevframeJsonRenderSpec } from '@devframes/json-render' +import { fileURLToPath } from 'node:url' +import { createJsonRenderView } from '@devframes/json-render/node' +import { defineRpcFunction } from 'devframe' +import { defineDevframe } from 'devframe/types' +import pkg from '../package.json' with { type: 'json' } +import { REFRESH_ACTION, VIEW_ID } from './shared.ts' + +const BASE_PATH = '/__minimal-json-render/' +const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) + +// A server-authored spec. Values marked `{ $state: '/...' }` resolve from the +// view's live state at render time, so patching state updates the UI without a +// whole-spec replacement. +const spec: DevframeJsonRenderSpec = { + root: 'root', + elements: { + root: { type: 'Stack', props: { gap: 14 }, children: ['title', 'card', 'actions'] }, + title: { type: 'Text', props: { text: 'JSON-render demo', variant: 'heading' }, children: [] }, + card: { type: 'Card', props: { title: 'Live metrics' }, children: ['metrics'] }, + metrics: { type: 'Stack', props: { gap: 8 }, children: ['uptimeRow', 'refreshRow'] }, + uptimeRow: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['uptimeLabel', 'uptimeValue'] }, + uptimeLabel: { type: 'Text', props: { text: 'Uptime (s)', variant: 'caption' }, children: [] }, + uptimeValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'body' }, children: [] }, + refreshRow: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['refreshLabel', 'refreshValue'] }, + refreshLabel: { type: 'Text', props: { text: 'Manual refreshes', variant: 'caption' }, children: [] }, + refreshValue: { type: 'Text', props: { text: { $state: '/refreshes' }, variant: 'body' }, children: [] }, + actions: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['refresh'] }, + refresh: { + type: 'Button', + props: { label: 'Refresh', variant: 'primary', icon: 'arrow-clockwise' }, + on: { press: { action: REFRESH_ACTION } }, + children: [], + }, + }, + state: { uptime: 0, refreshes: 0 }, +} + +export default defineDevframe({ + id: 'minimal-json-render', + name: 'Minimal JSON-Render', + version: pkg.version, + packageName: pkg.name, + homepage: pkg.homepage, + description: pkg.description, + icon: 'ph:layout-duotone', + basePath: BASE_PATH, + cli: { + command: 'minimal-json-render', + port: 9877, + distDir, + // Single-user localhost demo — skip the trust handshake so the served SPA + // can call the action RPC without an OTP round-trip. + auth: false, + }, + spa: { loader: 'none' }, + setup(ctx) { + const view = createJsonRenderView(ctx, { id: VIEW_ID, spec }) + + // The action bridge dispatches a spec action as an RPC call of the same + // name. Register the handler for the Button's `press` action; it bumps a + // counter and patches the view state (which broadcasts to the SPA). + let refreshes = 0 + ctx.rpc.register(defineRpcFunction({ + name: REFRESH_ACTION, + type: 'query', + jsonSerializable: true, + handler: () => { + refreshes += 1 + view.patchState([{ op: 'replace', path: '/refreshes', value: refreshes }]) + return { refreshes } + }, + })) + + // Live, server-driven state: tick uptime every second in dev. Unref the + // timer so it never keeps a one-shot `build` run alive. + if (ctx.mode === 'dev') { + let uptime = 0 + const timer = setInterval(() => { + uptime += 1 + view.patchState([{ op: 'replace', path: '/uptime', value: uptime }]) + }, 1000) + timer.unref?.() + } + }, +}) diff --git a/examples/minimal-json-render/src/shared.ts b/examples/minimal-json-render/src/shared.ts new file mode 100644 index 00000000..df7c2342 --- /dev/null +++ b/examples/minimal-json-render/src/shared.ts @@ -0,0 +1,15 @@ +// Shared constants used by both the node devframe and the browser SPA. + +/** Author-supplied, stable view id. */ +export const VIEW_ID = 'demo' + +/** + * The view's shared-state key. `createJsonRenderView` derives it as + * `devframe:json-render::`; the base context's scope is `global`. + * The SPA subscribes to this key directly. (A view's serializable + * `JsonRenderViewRef` carries the same `stateKey`.) + */ +export const STATE_KEY = `devframe:json-render:global:${VIEW_ID}` + +/** The action the demo Button dispatches — an RPC method the server registers. */ +export const REFRESH_ACTION = 'minimal-json-render:refresh' diff --git a/examples/minimal-json-render/tsconfig.json b/examples/minimal-json-render/tsconfig.json new file mode 100644 index 00000000..039dd77d --- /dev/null +++ b/examples/minimal-json-render/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"], + "noEmit": true, + "isolatedDeclarations": false + }, + "include": ["src", "bin.mjs"] +} diff --git a/examples/minimal-json-render/uno.config.ts b/examples/minimal-json-render/uno.config.ts new file mode 100644 index 00000000..5aa901ee --- /dev/null +++ b/examples/minimal-json-render/uno.config.ts @@ -0,0 +1,37 @@ +import { presetAnthonyDesign } from '@antfu/design/unocss' +import { + defineConfig, + presetIcons, + presetWebFonts, + presetWind4, + transformerDirectives, + transformerVariantGroup, +} from 'unocss' + +// The SPA renders `@devframes/json-render-ui`, whose components author class +// strings in `.ts` render functions — so `.ts` is opted into extraction. Same +// `@antfu/design` stack (sage-green preset, Wind4, Phosphor, DM Sans/Mono) as +// every other devframe surface. +export default defineConfig({ + presets: [ + presetAnthonyDesign({ primary: '#3a6a45' }), + presetWind4(), + presetIcons({ scale: 1.1 }), + presetWebFonts({ provider: 'none', fonts: { sans: 'DM Sans', mono: 'DM Mono' } }), + ], + transformers: [transformerDirectives(), transformerVariantGroup()], + preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], + shortcuts: { + 'z-nav': 'z-[30]', + 'z-dropdown': 'z-[40]', + 'z-tooltip': 'z-[45]', + 'z-toast': 'z-[50]', + 'z-modal-backdrop': 'z-[60]', + 'z-modal-content': 'z-[70]', + 'z-drawer-backdrop': 'z-[80]', + 'z-drawer-content': 'z-[90]', + }, + content: { + pipeline: { include: [/\.(?:vue|[cm]?[jt]sx?|html)($|\?)/] }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02dfc07c..647d9501 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -454,6 +454,40 @@ importers: specifier: catalog:deps version: 8.21.1 + examples/minimal-json-render: + dependencies: + '@antfu/design': + specifier: catalog:frontend + version: 0.3.2(@antfu/utils@9.3.0)(@iconify-json/catppuccin@1.2.17)(@tanstack/vue-virtual@3.13.30(vue@3.5.40(typescript@6.0.3)))(@unocss/core@66.7.5)(colorjs.io@0.6.1)(dompurify@3.4.12)(floating-vue@5.2.2(vue@3.5.40(typescript@6.0.3)))(playwright@1.61.1)(reka-ui@2.10.1(vue@3.5.40(typescript@6.0.3)))(splitpanes@4.1.2(vue@3.5.40(typescript@6.0.3)))(unocss@66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)))(vue@3.5.40(typescript@6.0.3)) + '@devframes/json-render': + specifier: workspace:* + version: link:../../packages/json-render + '@devframes/json-render-ui': + specifier: workspace:* + version: link:../../packages/json-render-ui + cac: + specifier: catalog:deps + version: 7.0.0 + devframe: + specifier: workspace:* + version: link:../../packages/devframe + vue: + specifier: catalog:frontend + version: 3.5.40(typescript@6.0.3) + devDependencies: + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.8(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) + get-port-please: + specifier: catalog:deps + version: 3.2.0 + unocss: + specifier: catalog:frontend + version: 66.7.5(@unocss/postcss@66.7.5(postcss@8.5.19))(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0)) + vite: + specifier: catalog:build + version: 8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0) + examples/minimal-next-devframe-hub: dependencies: '@antfu/design': diff --git a/turbo.json b/turbo.json index fc3a3030..392f76c4 100644 --- a/turbo.json +++ b/turbo.json @@ -102,6 +102,11 @@ "dependsOn": ["devframe#build"], "outputs": ["dist/**"] }, + "minimal-json-render#build": { + "outputLogs": "new-only", + "dependsOn": ["devframe#build", "@devframes/json-render#build", "@devframes/json-render-ui#build"], + "outputs": ["dist/**"] + }, "@devframes/plugin-git#build": { "outputLogs": "new-only", "dependsOn": ["devframe#build"], From 2ae38ee14d344576ffd21f92a350ecdc6b982432 Mon Sep 17 00:00:00 2001 From: Anthony Fu Date: Wed, 22 Jul 2026 09:39:35 +0900 Subject: [PATCH 03/10] chore: update --- examples/minimal-json-render/bin.mjs | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 examples/minimal-json-render/bin.mjs diff --git a/examples/minimal-json-render/bin.mjs b/examples/minimal-json-render/bin.mjs old mode 100644 new mode 100755 From 74a41da3507d5ec1b6fab76e8d6fb45614ae678f Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 00:40:48 +0000 Subject: [PATCH 04/10] docs: expand minimal-json-render into an all-components dashboard - Rework the example spec into a project dashboard that exercises every base-catalog component (Stack, Card, Text, Badge, Button, Icon, Divider, TextInput, Switch, KeyValueTable, DataTable, CodeBlock, Progress, Tree), with live state, two-way bindings, and three actions (Refresh re-samples the Progress bars, Deploy toggles DataTable loading and appends a module, Save sends bound form params the server writes back). - Make Button icons dependency-free: render the runtime-resolved Icon and a CSS spinner instead of static `i-ph:*` classes; use a text caret in Card so neither component requires an icon font in the consuming app. Created with the help of an agent. --- examples/minimal-json-render/README.md | 17 +- examples/minimal-json-render/src/devframe.ts | 177 +++++++++++++++--- examples/minimal-json-render/src/shared.ts | 7 +- .../json-render-ui/src/components/controls.ts | 8 +- .../json-render-ui/src/components/layout.ts | 2 +- 5 files changed, 176 insertions(+), 35 deletions(-) diff --git a/examples/minimal-json-render/README.md b/examples/minimal-json-render/README.md index 08cd0aec..6f0445df 100644 --- a/examples/minimal-json-render/README.md +++ b/examples/minimal-json-render/README.md @@ -2,16 +2,23 @@ A standalone devframe that serves a **JSON-render view**: the server authors an `@json-render/core` spec once, and the browser renders it with the official -`@devframes/json-render-ui` Vue frontend. It exercises the whole opt-in -JSON-render stack end to end: +`@devframes/json-render-ui` Vue frontend. The spec is a small **project +dashboard** that exercises **every base-catalog component** — `Stack`, `Card`, +`Text`, `Badge`, `Button`, `Icon`, `Divider`, `TextInput`, `Switch`, +`KeyValueTable`, `DataTable`, `CodeBlock`, `Progress`, and `Tree` — end to end: - **`@devframes/json-render/node`** — `createJsonRenderView(ctx, { id, spec })` registers the spec as shared state, validates element props at ingress, and hands back a handle with `update` / `patchState` / `dispose`. - **live state** — the server ticks `uptime` every second via `patchState`, so - the view updates without replacing the whole spec. -- **action bridge** — the `Refresh` button's `press` action is dispatched as an - RPC call of the same name; the handler bumps a counter and patches state. + bound values (`{ $state: '/…' }`) update without replacing the whole spec. +- **two-way bindings** — the `Display name` input and the two switches write back + into state via `{ $bindState: '/form/…' }`. +- **action bridge** — `Refresh` re-samples the coverage/bundle `Progress` bars; + `Deploy` flips the `DataTable` into a loading state and appends a module; + `Save` sends the bound form values as action params and the server writes the + name into the `KeyValueTable`. Each is dispatched as an RPC call of the same + name, with per-action loading and error surfacing. - **`@devframes/json-render-ui`** — the SPA (`src/client`) subscribes to the view's shared state and renders it with `JsonRenderView`. The app supplies the frontend lib; devframe serves the SPA. diff --git a/examples/minimal-json-render/src/devframe.ts b/examples/minimal-json-render/src/devframe.ts index 94a9fa9c..eb835c34 100644 --- a/examples/minimal-json-render/src/devframe.ts +++ b/examples/minimal-json-render/src/devframe.ts @@ -4,36 +4,123 @@ import { createJsonRenderView } from '@devframes/json-render/node' import { defineRpcFunction } from 'devframe' import { defineDevframe } from 'devframe/types' import pkg from '../package.json' with { type: 'json' } -import { REFRESH_ACTION, VIEW_ID } from './shared.ts' +import { DEPLOY_ACTION, REFRESH_ACTION, SAVE_ACTION, VIEW_ID } from './shared.ts' const BASE_PATH = '/__minimal-json-render/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) -// A server-authored spec. Values marked `{ $state: '/...' }` resolve from the -// view's live state at render time, so patching state updates the UI without a -// whole-spec replacement. +const VITE_CONFIG = `import { defineConfig } from 'vite' + +export default defineConfig({ + base: './', + build: { outDir: 'dist/client' }, +}) +` + +// Initial state model. Every value the spec reads via \`{ $state: '/…' }\` +// resolves from here and updates live as the server patches it. +const initialState = { + project: { name: 'acme-app', version: '1.4.2', license: 'MIT', repository: 'github.com/acme/app' }, + metrics: { coverage: 82, bundle: 64 }, + modules: [ + { name: '@acme/core', size: '42 kB', status: 'ok' }, + { name: '@acme/ui', size: '88 kB', status: 'ok' }, + { name: '@acme/cli', size: '17 kB', status: 'stale' }, + ], + deps: { + runtime: { vue: '^3.5', birpc: '^4.0' }, + dev: { vite: '^8.1', tsdown: '^0.22', vitest: '^4.1' }, + }, + form: { name: '', darkMode: true, notifications: false }, + building: false, + uptime: 0, +} + +// A server-authored spec exercising every base-catalog component. Values marked +// `{ $state: '/…' }` resolve from live state; `{ $bindState: '/…' }` are +// two-way inputs that write back into it. const spec: DevframeJsonRenderSpec = { root: 'root', elements: { - root: { type: 'Stack', props: { gap: 14 }, children: ['title', 'card', 'actions'] }, - title: { type: 'Text', props: { text: 'JSON-render demo', variant: 'heading' }, children: [] }, - card: { type: 'Card', props: { title: 'Live metrics' }, children: ['metrics'] }, - metrics: { type: 'Stack', props: { gap: 8 }, children: ['uptimeRow', 'refreshRow'] }, - uptimeRow: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['uptimeLabel', 'uptimeValue'] }, - uptimeLabel: { type: 'Text', props: { text: 'Uptime (s)', variant: 'caption' }, children: [] }, - uptimeValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'body' }, children: [] }, - refreshRow: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['refreshLabel', 'refreshValue'] }, - refreshLabel: { type: 'Text', props: { text: 'Manual refreshes', variant: 'caption' }, children: [] }, - refreshValue: { type: 'Text', props: { text: { $state: '/refreshes' }, variant: 'body' }, children: [] }, - actions: { type: 'Stack', props: { direction: 'row', gap: 8 }, children: ['refresh'] }, - refresh: { + root: { type: 'Stack', props: { gap: 16 }, children: ['header', 'overview', 'settings', 'modules', 'config', 'tree', 'footerDivider', 'footer'] }, + + // ── Header ──────────────────────────────────────────────────────────── + header: { type: 'Stack', props: { direction: 'row', gap: 10, align: 'center', justify: 'between' }, children: ['headLeft', 'headRight'] }, + headLeft: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['logo', 'headTitle'] }, + logo: { type: 'Icon', props: { name: 'ph:cube-duotone', size: 26 }, children: [] }, + headTitle: { type: 'Text', props: { text: 'Acme Dashboard', variant: 'heading' }, children: [] }, + headRight: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['status', 'refreshBtn', 'deployBtn'] }, + status: { type: 'Badge', props: { text: 'healthy', variant: 'success' }, children: [] }, + refreshBtn: { type: 'Button', - props: { label: 'Refresh', variant: 'primary', icon: 'arrow-clockwise' }, + props: { label: 'Refresh', variant: 'ghost', icon: 'ph:arrow-clockwise' }, on: { press: { action: REFRESH_ACTION } }, children: [], }, + deployBtn: { + type: 'Button', + props: { label: 'Deploy', variant: 'primary', icon: 'ph:rocket-launch' }, + on: { press: { action: DEPLOY_ACTION } }, + children: [], + }, + + // ── Overview: KeyValueTable + Progress ──────────────────────────────── + overview: { type: 'Card', props: { title: 'Overview' }, children: ['overviewBody'] }, + overviewBody: { type: 'Stack', props: { gap: 14 }, children: ['meta', 'coverage', 'bundle'] }, + meta: { type: 'KeyValueTable', props: { data: { $state: '/project' } }, children: [] }, + coverage: { type: 'Progress', props: { label: 'Test coverage', value: { $state: '/metrics/coverage' }, max: 100 }, children: [] }, + bundle: { type: 'Progress', props: { label: 'Bundle budget', value: { $state: '/metrics/bundle' }, max: 100 }, children: [] }, + + // ── Settings: TextInput + Switch + Divider + Button ─────────────────── + settings: { type: 'Card', props: { title: 'Settings', collapsible: true }, children: ['settingsBody'] }, + settingsBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greeting', 'prefsDivider', 'darkSwitch', 'notifSwitch', 'saveBtn'] }, + nameInput: { type: 'TextInput', props: { label: 'Display name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] }, + greeting: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'caption', color: 'primary' }, children: [] }, + prefsDivider: { type: 'Divider', props: { label: 'preferences' }, children: [] }, + darkSwitch: { type: 'Switch', props: { label: 'Dark mode', value: { $bindState: '/form/darkMode' } }, children: [] }, + notifSwitch: { type: 'Switch', props: { label: 'Email notifications', value: { $bindState: '/form/notifications' } }, children: [] }, + saveBtn: { + type: 'Button', + props: { label: 'Save settings', variant: 'secondary', icon: 'ph:floppy-disk' }, + // Bound values are resolved into the action params before dispatch, so the + // server receives whatever the user typed/toggled. + on: { press: { action: SAVE_ACTION, params: { name: { $state: '/form/name' } } } }, + children: [], + }, + + // ── Modules: DataTable (loading bound to /building) ─────────────────── + modules: { type: 'Card', props: { title: 'Modules' }, children: ['modulesTable'] }, + modulesTable: { + type: 'DataTable', + props: { + columns: [ + { key: 'name', label: 'Module' }, + { key: 'size', label: 'Size' }, + { key: 'status', label: 'Status' }, + ], + rows: { $state: '/modules' }, + height: 180, + loading: { $state: '/building' }, + }, + children: [], + }, + + // ── Config: CodeBlock ───────────────────────────────────────────────── + config: { type: 'Card', props: { title: 'Config' }, children: ['code'] }, + code: { type: 'CodeBlock', props: { filename: 'vite.config.ts', language: 'ts', code: VITE_CONFIG }, children: [] }, + + // ── Dependency tree: Tree ───────────────────────────────────────────── + tree: { type: 'Card', props: { title: 'Dependency tree', collapsible: true, defaultCollapsed: true }, children: ['depTree'] }, + depTree: { type: 'Tree', props: { data: { $state: '/deps' }, defaultExpanded: true }, children: [] }, + + // ── Footer ──────────────────────────────────────────────────────────── + footerDivider: { type: 'Divider', props: {}, children: [] }, + footer: { type: 'Stack', props: { direction: 'row', gap: 6 }, children: ['footerLabel', 'footerValue', 'footerSuffix'] }, + footerLabel: { type: 'Text', props: { text: 'Rendered from a JSON-render spec · uptime', variant: 'caption', color: 'faint' }, children: [] }, + footerValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'caption', color: 'faint' }, children: [] }, + footerSuffix: { type: 'Text', props: { text: 's', variant: 'caption', color: 'faint' }, children: [] }, }, - state: { uptime: 0, refreshes: 0 }, + state: initialState, } export default defineDevframe({ @@ -50,7 +137,7 @@ export default defineDevframe({ port: 9877, distDir, // Single-user localhost demo — skip the trust handshake so the served SPA - // can call the action RPC without an OTP round-trip. + // can call the action RPCs without an OTP round-trip. auth: false, }, spa: { loader: 'none' }, @@ -58,17 +145,57 @@ export default defineDevframe({ const view = createJsonRenderView(ctx, { id: VIEW_ID, spec }) // The action bridge dispatches a spec action as an RPC call of the same - // name. Register the handler for the Button's `press` action; it bumps a - // counter and patches the view state (which broadcasts to the SPA). - let refreshes = 0 + // name. `Refresh` re-samples the metrics. ctx.rpc.register(defineRpcFunction({ name: REFRESH_ACTION, type: 'query', jsonSerializable: true, handler: () => { - refreshes += 1 - view.patchState([{ op: 'replace', path: '/refreshes', value: refreshes }]) - return { refreshes } + const coverage = 70 + Math.floor(Math.random() * 30) + const bundle = 40 + Math.floor(Math.random() * 55) + view.patchState([ + { op: 'replace', path: '/metrics/coverage', value: coverage }, + { op: 'replace', path: '/metrics/bundle', value: bundle }, + ]) + return { coverage, bundle } + }, + })) + + // `Deploy` flips the DataTable into a loading state, then appends a module — + // demonstrating the per-action loading + a live spec/state change. + let deployed = 0 + ctx.rpc.register(defineRpcFunction({ + name: DEPLOY_ACTION, + type: 'query', + jsonSerializable: true, + handler: () => { + view.patchState([{ op: 'replace', path: '/building', value: true }]) + setTimeout(() => { + deployed += 1 + const modules = [ + ...(view.value().state?.modules as unknown[] ?? []), + { name: `@acme/edge-${deployed}`, size: '9 kB', status: 'ok' }, + ] + view.patchState([ + { op: 'replace', path: '/modules', value: modules }, + { op: 'replace', path: '/building', value: false }, + ]) + }, 1200) + return { building: true } + }, + })) + + // `Save` receives the bound form values as params (resolved client-side from + // `$state`) and writes the display name into the project metadata. + ctx.rpc.register(defineRpcFunction({ + name: SAVE_ACTION, + type: 'query', + jsonSerializable: true, + handler: (params?: { name?: string }) => { + const name = params?.name?.trim() + if (name) + view.patchState([{ op: 'replace', path: '/project/name', value: name }]) + return { saved: true } }, })) diff --git a/examples/minimal-json-render/src/shared.ts b/examples/minimal-json-render/src/shared.ts index df7c2342..b3a571e1 100644 --- a/examples/minimal-json-render/src/shared.ts +++ b/examples/minimal-json-render/src/shared.ts @@ -1,7 +1,7 @@ // Shared constants used by both the node devframe and the browser SPA. /** Author-supplied, stable view id. */ -export const VIEW_ID = 'demo' +export const VIEW_ID = 'dashboard' /** * The view's shared-state key. `createJsonRenderView` derives it as @@ -11,5 +11,8 @@ export const VIEW_ID = 'demo' */ export const STATE_KEY = `devframe:json-render:global:${VIEW_ID}` -/** The action the demo Button dispatches — an RPC method the server registers. */ +// Spec action names dispatched by the bridge — each is an RPC method the server +// registers. export const REFRESH_ACTION = 'minimal-json-render:refresh' +export const DEPLOY_ACTION = 'minimal-json-render:deploy' +export const SAVE_ACTION = 'minimal-json-render:save' diff --git a/packages/json-render-ui/src/components/controls.ts b/packages/json-render-ui/src/components/controls.ts index 64a8b74f..3fc15f8d 100644 --- a/packages/json-render-ui/src/components/controls.ts +++ b/packages/json-render-ui/src/components/controls.ts @@ -1,10 +1,12 @@ import type { JrComponent } from './_shared' import { useBoundProp } from '@json-render/vue' import { h } from 'vue' +import { Icon } from './icon' interface ButtonProps { label?: string variant?: 'primary' | 'secondary' | 'ghost' | 'danger' + /** Icon name resolved at runtime (e.g. `ph:arrow-clockwise`). */ icon?: string disabled?: boolean loading?: boolean @@ -33,10 +35,12 @@ export const Button: JrComponent = ({ props, on }) => { }, }, [ + // Dependency-free CSS spinner (no icon font needed); the dynamic Icon + // resolves the button's icon at runtime, like the standalone Icon. busy - ? h('span', { class: 'i-ph:spinner animate-spin' }) + ? h('span', { class: 'inline-block h-3.5 w-3.5 rounded-full border-2 border-current border-t-transparent animate-spin' }) : props.icon - ? h('span', { class: `i-ph:${props.icon}` }) + ? Icon({ props: { name: props.icon, size: 16 } } as Parameters[0]) : null, props.label ? h('span', props.label) : null, ], diff --git a/packages/json-render-ui/src/components/layout.ts b/packages/json-render-ui/src/components/layout.ts index 426c0ce8..0f5f78ab 100644 --- a/packages/json-render-ui/src/components/layout.ts +++ b/packages/json-render-ui/src/components/layout.ts @@ -62,7 +62,7 @@ export const Card: JrComponent = ({ props, children, loading }) => { h( 'summary', { class: 'flex items-center justify-between px2 py1.5 border-b border-base color-base font-medium text-sm cursor-pointer select-none list-none' }, - [h('span', props.title ?? ''), h('span', { class: 'i-ph:caret-down color-faint' })], + [h('span', props.title ?? ''), h('span', { class: 'color-faint text-xs' }, '▾')], ), h('div', { class: 'p2' }, [body]), ]) From 9dfaf6a85b1ffe0f8777d4a189a75261726736c7 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 01:16:00 +0000 Subject: [PATCH 05/10] refactor(json-render-ui): wrap @antfu/design components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the reference frontend on @antfu/design's Vue components directly instead of hand-rolled markup, so it tracks the shared design system and stops drifting. - Icon wraps DisplayIconifyRemoteIcon, which fetches with color=currentColor — fixing the reported bug where a button/text icon didn't inherit its color. - Button→ActionButton, Badge→DisplayBadge, Card→LayoutCard, Divider→ LayoutSeparator, TextInput→FormTextInput, Switch→FormSwitch, Progress→DisplayProgressBar, KeyValueTable→DisplayKeyValue. - Stack, Text, CodeBlock, the value-tree Tree, and the row-clickable/loadable DataTable stay bespoke where @antfu/design has no matching primitive. - @antfu/design becomes a peer dependency, kept external in the tsdown build (its .vue is compiled by the consumer's Vite); a *.vue shim resolves types. Consumers exclude it from optimizeDeps and safelist the runtime badge colors. Docs and the minimal-json-render example updated for the consumer setup. Created with the help of an agent. --- docs/guide/json-render.md | 21 ++++- .../minimal-json-render/src/client/shims.d.ts | 9 ++ .../src/client/vite.config.ts | 3 + examples/minimal-json-render/uno.config.ts | 3 + packages/json-render-ui/.storybook/main.ts | 2 + packages/json-render-ui/package.json | 4 +- .../json-render-ui/src/components/controls.ts | 92 ++++++++----------- .../json-render-ui/src/components/data.ts | 32 ++++--- .../json-render-ui/src/components/icon.ts | 74 +++++---------- .../json-render-ui/src/components/layout.ts | 46 ++++------ .../src/components/typography.ts | 26 +++--- packages/json-render-ui/src/shims.d.ts | 9 ++ packages/json-render-ui/tsdown.config.ts | 5 +- packages/json-render-ui/uno.config.ts | 3 + packages/json-render-ui/vitest.config.ts | 13 +++ pnpm-lock.yaml | 3 - 16 files changed, 178 insertions(+), 167 deletions(-) create mode 100644 packages/json-render-ui/src/shims.d.ts create mode 100644 packages/json-render-ui/vitest.config.ts diff --git a/docs/guide/json-render.md b/docs/guide/json-render.md index 189e7a96..3d04e0a3 100644 --- a/docs/guide/json-render.md +++ b/docs/guide/json-render.md @@ -120,9 +120,24 @@ there is no live RPC, so the action bridge reports actions as unavailable and `interactive: false` renders a static-output notice. Local state and bindings still work. -The reference components author their class strings in `.ts`, so a consuming -app must opt `.ts` into UnoCSS extraction (`content.pipeline.include` for Vite), -and add the lib to the scanned sources when it lives in `node_modules`. +### Consuming the reference frontend + +`@devframes/json-render-ui` wraps `@antfu/design`'s Vue components directly +(`ActionButton`, `DisplayBadge`, `LayoutCard`, `FormTextInput`, `FormSwitch`, +`DisplayProgressBar`, `DisplayKeyValue`, and `DisplayIconifyRemoteIcon` for +fully dynamic, `currentColor`-inheriting icons), so it looks and behaves like +the rest of the devframe surfaces. A few catalog components stay bespoke where +`@antfu/design` has no matching primitive — `Stack`, `Text`, `CodeBlock`, the +value-tree `Tree`, and the row-clickable/loadable `DataTable`. + +A consuming Vite app therefore: + +- installs `@antfu/design` (a peer dependency) and imports `@antfu/design/styles.css`; +- excludes it from dep pre-bundling so `@vitejs/plugin-vue` compiles its SFCs — + `optimizeDeps: { exclude: ['@antfu/design'] }`; +- composes the shared UnoCSS preset (`presetAnthonyDesign`) and safelists the + runtime-selected badge colors the base catalog can emit — + `safelist: ['badge-color-green', 'badge-color-amber', 'badge-color-red', 'badge-color-blue']`. ## Rendering inside a hub diff --git a/examples/minimal-json-render/src/client/shims.d.ts b/examples/minimal-json-render/src/client/shims.d.ts index 1182fc16..02e6de67 100644 --- a/examples/minimal-json-render/src/client/shims.d.ts +++ b/examples/minimal-json-render/src/client/shims.d.ts @@ -1,3 +1,12 @@ // Side-effect style imports used by the SPA entry. declare module '*.css' {} declare module 'virtual:uno.css' {} + +// @devframes/json-render-ui resolves to source in the workspace, and wraps +// `@antfu/design` `.vue` components — declare the module so `tsc` resolves them. +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent + export default component +} diff --git a/examples/minimal-json-render/src/client/vite.config.ts b/examples/minimal-json-render/src/client/vite.config.ts index ede7a56a..56bbf6fa 100644 --- a/examples/minimal-json-render/src/client/vite.config.ts +++ b/examples/minimal-json-render/src/client/vite.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ root: fileURLToPath(new URL('.', import.meta.url)), resolve: { alias }, plugins: [UnoCSS(), vue()], + // `@antfu/design` (pulled in by @devframes/json-render-ui) ships raw `.vue`; + // let @vitejs/plugin-vue compile its SFCs instead of esbuild pre-bundling. + optimizeDeps: { exclude: ['@antfu/design', '@devframes/json-render-ui'] }, build: { outDir: fileURLToPath(new URL('../../dist/client', import.meta.url)), emptyOutDir: true, diff --git a/examples/minimal-json-render/uno.config.ts b/examples/minimal-json-render/uno.config.ts index 5aa901ee..5709e9f4 100644 --- a/examples/minimal-json-render/uno.config.ts +++ b/examples/minimal-json-render/uno.config.ts @@ -21,6 +21,9 @@ export default defineConfig({ ], transformers: [transformerDirectives(), transformerVariantGroup()], preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], + // `Badge` in @devframes/json-render-ui picks a `badge-color-` at runtime + // from a fixed set, so those classes need safelisting. + safelist: ['badge-color-green', 'badge-color-amber', 'badge-color-red', 'badge-color-blue'], shortcuts: { 'z-nav': 'z-[30]', 'z-dropdown': 'z-[40]', diff --git a/packages/json-render-ui/.storybook/main.ts b/packages/json-render-ui/.storybook/main.ts index 46f23d60..05414b2b 100644 --- a/packages/json-render-ui/.storybook/main.ts +++ b/packages/json-render-ui/.storybook/main.ts @@ -16,6 +16,8 @@ const config: StorybookConfig = { return mergeConfig(config, { resolve: { alias }, plugins: [vue(), UnoCSS()], + // `@antfu/design` ships raw `.vue`; let plugin-vue compile its SFCs. + optimizeDeps: { exclude: ['@antfu/design'] }, server: { allowedHosts: true }, }) }, diff --git a/packages/json-render-ui/package.json b/packages/json-render-ui/package.json index f86acd6f..293c7781 100644 --- a/packages/json-render-ui/package.json +++ b/packages/json-render-ui/package.json @@ -38,12 +38,12 @@ "prepack": "pnpm run build" }, "peerDependencies": { + "@antfu/design": "^0.3.0", "@devframes/json-render": "workspace:*", "vue": "^3.5.0" }, "dependencies": { - "@json-render/vue": "catalog:frontend", - "dompurify": "catalog:frontend" + "@json-render/vue": "catalog:frontend" }, "devDependencies": { "@antfu/design": "catalog:frontend", diff --git a/packages/json-render-ui/src/components/controls.ts b/packages/json-render-ui/src/components/controls.ts index 3fc15f8d..6d8b5d95 100644 --- a/packages/json-render-ui/src/components/controls.ts +++ b/packages/json-render-ui/src/components/controls.ts @@ -1,4 +1,7 @@ import type { JrComponent } from './_shared' +import ActionButton from '@antfu/design/components/Action/ActionButton.vue' +import FormSwitch from '@antfu/design/components/Form/FormSwitch.vue' +import FormTextInput from '@antfu/design/components/Form/FormTextInput.vue' import { useBoundProp } from '@json-render/vue' import { h } from 'vue' import { Icon } from './icon' @@ -12,36 +15,32 @@ interface ButtonProps { loading?: boolean } -const buttonBase - = 'inline-flex items-center gap-1.5 rounded px2.5 py1 text-sm font-medium transition disabled:op-50 disabled:cursor-not-allowed' +// Catalog variant → `@antfu/design` ActionButton variant. `danger` has no +// upstream variant, so it renders as `primary` with a red override. const buttonVariant: Record = { - primary: 'bg-primary color-white hover:bg-primary/90', - secondary: 'bg-secondary color-base border border-base hover:bg-active', - ghost: 'color-base hover:bg-secondary', - danger: 'bg-red color-white hover:bg-red/90', + primary: 'primary', + secondary: 'action', + ghost: 'text', + danger: 'primary', } export const Button: JrComponent = ({ props, on }) => { - const busy = !!props.loading + const variant = props.variant ?? 'secondary' return h( - 'button', + ActionButton, { - type: 'button', - class: `${buttonBase} ${buttonVariant[props.variant ?? 'secondary'] ?? buttonVariant.secondary}`, - disabled: props.disabled || busy, - onClick: () => { - const handle = on('press') - handle.emit() - }, + variant: buttonVariant[variant] ?? 'action', + disabled: props.disabled, + loading: props.loading, + // `danger` isn't an ActionButton variant — override the primary tint. + class: variant === 'danger' ? 'bg-red! color-white! border-red! hover:bg-red/90!' : undefined, + onClick: () => on('press').emit(), }, - [ - // Dependency-free CSS spinner (no icon font needed); the dynamic Icon - // resolves the button's icon at runtime, like the standalone Icon. - busy - ? h('span', { class: 'inline-block h-3.5 w-3.5 rounded-full border-2 border-current border-t-transparent animate-spin' }) - : props.icon - ? Icon({ props: { name: props.icon, size: 16 } } as Parameters[0]) - : null, + // Render the dynamic Icon in the slot (ActionButton's own `icon` prop is a + // static UnoCSS class, unsuited to spec-supplied names). Skip it while + // loading — ActionButton already shows its spinner. + () => [ + props.icon && !props.loading ? Icon({ props: { name: props.icon, size: 16 } } as Parameters[0]) : null, props.label ? h('span', props.label) : null, ], ) @@ -56,23 +55,18 @@ interface TextInputProps { loading?: boolean } -const fieldBase - = 'w-full rounded border border-base bg-base color-base px2 py1 text-sm outline-none focus:border-primary disabled:op-50' - export const TextInput: JrComponent = ({ props, on, bindings }) => { const [value, setValue] = useBoundProp(props.value, bindings?.value) - const input = h('input', { - class: fieldBase, - type: props.type ?? 'text', - value: value ?? '', - placeholder: props.placeholder, - disabled: props.disabled || props.loading, - onInput: (e: Event) => { - // Carry the new value into bound state, then fire the `change` action — - // an improvement over the Vite bridge's payload-free `change`. - setValue((e.target as HTMLInputElement).value) + const input = h(FormTextInput, { + 'modelValue': value ?? '', + 'onUpdate:modelValue': (next: string) => { + // Carry the new value into bound state, then fire the `change` action. + setValue(next) on('change').emit() }, + 'placeholder': props.placeholder, + 'type': props.type ?? 'text', + 'disabled': props.disabled || props.loading, }) if (props.label) { return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [ @@ -91,25 +85,13 @@ interface SwitchProps { export const Switch: JrComponent = ({ props, on, bindings }) => { const [value, setValue] = useBoundProp(props.value, bindings?.value) - const checked = !!value - const toggle = h('button', { - 'type': 'button', - 'role': 'switch', - 'aria-checked': checked ? 'true' : 'false', - 'disabled': props.disabled, - 'class': `relative inline-flex h-5 w-9 items-center rounded-full transition disabled:op-50 ${checked ? 'bg-primary' : 'bg-active'}`, - 'onClick': () => { - setValue(!checked) + return h(FormSwitch, { + 'modelValue': !!value, + 'onUpdate:modelValue': (next: boolean) => { + setValue(next) on('change').emit() }, - }, [ - h('span', { class: `inline-block h-4 w-4 transform rounded-full bg-base shadow transition ${checked ? 'translate-x-4.5' : 'translate-x-0.5'}` }), - ]) - if (props.label) { - return h('label', { class: 'inline-flex items-center gap-2 text-sm color-base cursor-pointer' }, [ - toggle, - h('span', props.label), - ]) - } - return toggle + 'label': props.label, + 'disabled': props.disabled, + }) } diff --git a/packages/json-render-ui/src/components/data.ts b/packages/json-render-ui/src/components/data.ts index 34d63748..f108bbce 100644 --- a/packages/json-render-ui/src/components/data.ts +++ b/packages/json-render-ui/src/components/data.ts @@ -1,5 +1,7 @@ import type { VNodeChild } from 'vue' import type { JrComponent } from './_shared' +import DisplayKeyValue from '@antfu/design/components/Display/DisplayKeyValue.vue' +import DisplayProgressBar from '@antfu/design/components/Display/DisplayProgressBar.vue' import { useBoundProp } from '@json-render/vue' import { h } from 'vue' import { toNumber } from './_shared' @@ -17,17 +19,13 @@ function formatValue(value: unknown): string { return String(value) } +// Renders each entry as an `@antfu/design` DisplayKeyValue row. export const KeyValueTable: JrComponent = ({ props, loading }) => { if (loading || props.loading) return h('div', { class: 'color-faint text-sm' }, 'Loading…') const entries = Object.entries(props.data ?? {}) - return h('table', { class: 'w-full text-sm border-collapse' }, [ - h('tbody', entries.map(([key, value]) => - h('tr', { class: 'border-b border-base' }, [ - h('td', { class: 'py1 pr3 color-muted font-medium align-top' }, key), - h('td', { class: 'py1 color-base font-mono break-all' }, formatValue(value)), - ]))), - ]) + return h('div', { class: 'flex flex-col gap-1' }, entries.map(([key, value]) => + h(DisplayKeyValue, { key, label: key, value: formatValue(value) }))) } interface DataTableColumn { key: string, label?: string } @@ -45,6 +43,9 @@ function normalizeColumns(columns: DataTableProps['columns'], rows: DataTablePro return first ? Object.keys(first).map(key => ({ key })) : [] } +// `@antfu/design` LayoutDataTable has no per-row click or loading state, both of +// which the catalog requires (`rowClick`, `loading`), so this stays custom on +// the shared tokens. (A candidate to contribute back upstream.) export const DataTable: JrComponent = ({ props, on, bindings, loading }) => { if (loading || props.loading) return h('div', { class: 'color-faint text-sm' }, 'Loading…') @@ -63,8 +64,7 @@ export const DataTable: JrComponent = ({ props, on, bindings, lo h('tr', { class: 'border-b border-base hover:bg-secondary cursor-pointer', onClick: () => { - // Deliver row + index into bound state (Vite's rowClick dropped - // them), then fire the action. + // Deliver row + index into bound state, then fire the action. setSelected({ row, index }) on('rowClick').emit() }, @@ -80,15 +80,17 @@ interface ProgressProps { label?: string } +// Wraps `@antfu/design` DisplayProgressBar (which takes a 0–1 ratio), adding the +// catalog's visible label + percentage row. export const Progress: JrComponent = ({ props }) => { const max = toNumber(props.max, 100) const value = toNumber(props.value, 0) - const pct = max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0 + const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0 return h('div', { class: 'flex flex-col gap-1' }, [ - props.label ? h('div', { class: 'flex justify-between text-xs color-muted' }, [h('span', props.label), h('span', `${Math.round(pct)}%`)]) : null, - h('div', { class: 'h-2 w-full rounded-full bg-active overflow-hidden' }, [ - h('div', { class: 'h-full rounded-full bg-primary transition-all', style: { width: `${pct}%` } }), - ]), + props.label + ? h('div', { class: 'flex justify-between text-xs color-muted' }, [h('span', props.label), h('span', `${Math.round(ratio * 100)}%`)]) + : null, + h(DisplayProgressBar, { value: ratio, label: props.label }), ]) } @@ -120,5 +122,7 @@ function renderNode(value: unknown, keyLabel: string | null, expanded: boolean): ]) } +// `@antfu/design` DisplayTree nests a flat path list (file/folder style), not an +// arbitrary JSON value, so the catalog's value-tree viewer stays custom. export const Tree: JrComponent = ({ props }) => h('div', { class: 'color-base' }, [renderNode(props.data, null, props.defaultExpanded !== false)]) diff --git a/packages/json-render-ui/src/components/icon.ts b/packages/json-render-ui/src/components/icon.ts index 5017a88b..25a9bac6 100644 --- a/packages/json-render-ui/src/components/icon.ts +++ b/packages/json-render-ui/src/components/icon.ts @@ -1,57 +1,29 @@ import type { JrComponent } from './_shared' -import DOMPurify from 'dompurify' -import { defineComponent, h, ref, watchEffect } from 'vue' +import DisplayIconifyRemoteIcon from '@antfu/design/components/Display/DisplayIconifyRemoteIcon.vue' +import { h } from 'vue' -const ICONIFY_API = 'https://api.iconify.design' -// Sanitize the spec-supplied name before it reaches a URL: Iconify names are -// `prefix:icon`, lowercase alphanumerics plus `-`/`:`. -const SAFE_NAME = /^[a-z0-9]+:[a-z0-9-]+$/ - -const cache = new Map>() - -async function fetchIcon(name: string): Promise { - if (!SAFE_NAME.test(name)) - return null - let promise = cache.get(name) - if (!promise) { - const [prefix, icon] = name.split(':') - promise = fetch(`${ICONIFY_API}/${prefix}/${icon}.svg`) - .then(r => (r.ok ? r.text() : null)) - .then(svg => (svg ? DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } }) : null)) - .catch(() => null) - cache.set(name, promise) - } - return promise +interface IconProps { + /** Icon name resolved at runtime, e.g. `ph:rocket-launch`. */ + name?: string + size?: number } /** - * Fully dynamic icon: resolves whatever (sanitized) icon name a spec supplies - * at runtime from the Iconify API, with no bundled/preferred set. A deliberate, - * documented deviation from the repo's Phosphor-first convention — that applies - * to a surface's own chrome, not to spec-driven content icons. + * Fully dynamic icon: wraps `@antfu/design`'s `DisplayIconifyRemoteIcon`, which + * fetches the (sanitized) SVG from the Iconify API with `color=currentColor` — + * so the icon inherits the surrounding text color (e.g. white inside a primary + * button). A deliberate, documented deviation from the repo's Phosphor-first + * convention, which governs a surface's own chrome, not spec-driven content + * icons. */ -const IconImpl = defineComponent({ - name: 'JsonRenderIcon', - props: { - name: { type: String, default: '' }, - size: { type: Number, default: 16 }, - }, - setup(props) { - const svg = ref(null) - watchEffect(async () => { - svg.value = props.name ? await fetchIcon(props.name) : null - }) - return () => h('span', { - 'class': 'inline-flex items-center justify-center color-base', - 'style': { width: `${props.size}px`, height: `${props.size}px` }, - 'role': 'img', - 'aria-label': props.name, - 'innerHTML': svg.value ?? '', - }) - }, -}) - -interface IconProps { name?: string, size?: number } - -export const Icon: JrComponent = ({ props }) => - h(IconImpl, { name: props.name ?? '', size: props.size ?? 16 }) +export const Icon: JrComponent = ({ props }) => { + const size = props.size ?? 16 + return h( + 'span', + { + class: 'inline-flex shrink-0 items-center justify-center', + style: { fontSize: `${size}px`, width: `${size}px`, height: `${size}px` }, + }, + props.name ? [h(DisplayIconifyRemoteIcon, { icon: props.name })] : [], + ) +} diff --git a/packages/json-render-ui/src/components/layout.ts b/packages/json-render-ui/src/components/layout.ts index 0f5f78ab..b3079470 100644 --- a/packages/json-render-ui/src/components/layout.ts +++ b/packages/json-render-ui/src/components/layout.ts @@ -1,4 +1,6 @@ import type { JrComponent } from './_shared' +import LayoutCard from '@antfu/design/components/Layout/LayoutCard.vue' +import LayoutSeparator from '@antfu/design/components/Layout/LayoutSeparator.vue' import { h } from 'vue' import { toNumber } from './_shared' @@ -21,6 +23,8 @@ const justifyMap: Record = { around: 'space-around', } +// A flex layout primitive — `@antfu/design` has no generic Stack, so this stays +// a thin custom component built on the shared tokens. export const Stack: JrComponent = ({ props, children }) => { const style: Record = { display: 'flex', @@ -48,41 +52,31 @@ interface CardProps { loading?: boolean } +const headerClass = 'flex items-center justify-between px4 py2.5 border-b border-base color-base font-medium text-sm' + +// Wraps `@antfu/design` LayoutCard for the bordered surface, composing the +// title/collapsible header on top (LayoutCard is a plain surface). export const Card: JrComponent = ({ props, children, loading }) => { const isLoading = loading || props.loading const body = isLoading ? h('div', { class: 'color-faint text-sm' }, 'Loading…') : (children as any) - const outer = `rounded ${props.border === false ? '' : 'border border-base'} bg-base overflow-hidden` - // Collapsible uses native
so open/closed state persists without a - // Vue ref (the registry re-invokes this render fn on every update). if (props.collapsible) { - return h('details', { class: outer, open: !props.defaultCollapsed }, [ - h( - 'summary', - { class: 'flex items-center justify-between px2 py1.5 border-b border-base color-base font-medium text-sm cursor-pointer select-none list-none' }, - [h('span', props.title ?? ''), h('span', { class: 'color-faint text-xs' }, '▾')], - ), - h('div', { class: 'p2' }, [body]), - ]) + return h(LayoutCard, { padding: false }, () => h('details', { open: !props.defaultCollapsed }, [ + h('summary', { class: `${headerClass} cursor-pointer select-none list-none` }, [ + h('span', props.title ?? ''), + h('span', { class: 'color-faint text-xs' }, '▾'), + ]), + h('div', { class: 'p4' }, [body]), + ])) } - return h('div', { class: outer }, [ - props.title - ? h('div', { class: 'px2 py1.5 border-b border-base color-base font-medium text-sm' }, props.title) - : null, - h('div', { class: 'p2' }, [body]), + return h(LayoutCard, { padding: false }, () => [ + props.title ? h('div', { class: headerClass }, props.title) : null, + h('div', { class: 'p4' }, [body]), ]) } -export const Divider: JrComponent<{ label?: string }> = ({ props }) => { - if (props.label) { - return h('div', { class: 'flex items-center gap-2 my-2 color-faint text-xs' }, [ - h('div', { class: 'flex-1 border-t border-base' }), - h('span', props.label), - h('div', { class: 'flex-1 border-t border-base' }), - ]) - } - return h('div', { class: 'my-2 border-t border-base' }) -} +export const Divider: JrComponent<{ label?: string }> = ({ props }) => + h(LayoutSeparator, { label: props.label }) diff --git a/packages/json-render-ui/src/components/typography.ts b/packages/json-render-ui/src/components/typography.ts index 720cd357..a2d771d2 100644 --- a/packages/json-render-ui/src/components/typography.ts +++ b/packages/json-render-ui/src/components/typography.ts @@ -1,4 +1,5 @@ import type { JrComponent } from './_shared' +import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue' import { h } from 'vue' interface TextProps { @@ -45,23 +46,24 @@ interface BadgeProps { minWidth?: number } -const badgeVariant: Record = { - default: 'bg-secondary color-muted', - success: 'bg-green:15 color-green', - warning: 'bg-amber:15 color-amber', - danger: 'bg-red:15 color-red', - info: 'bg-primary:15 color-primary', +// Map the catalog's semantic variants onto `@antfu/design` `DisplayBadge` +// palette color names (`false` = neutral/muted). The resulting +// `badge-color-` classes are safelisted (see the package uno.config and +// the JSON-Render docs) since a spec picks the variant at runtime. +const badgeColor: Record = { + default: false, + success: 'green', + warning: 'amber', + danger: 'red', + info: 'blue', } export const Badge: JrComponent = ({ props, children }) => { const style = props.minWidth != null ? { minWidth: `${props.minWidth}px` } : undefined return h( - 'span', - { - class: `inline-flex items-center justify-center rounded px1.5 py0.5 text-xs font-medium ${badgeVariant[props.variant ?? 'default'] ?? badgeVariant.default}`, - style, - }, - props.text ?? (children as any), + DisplayBadge, + { text: props.text, color: badgeColor[props.variant ?? 'default'], style }, + props.text ? undefined : () => children, ) } diff --git a/packages/json-render-ui/src/shims.d.ts b/packages/json-render-ui/src/shims.d.ts new file mode 100644 index 00000000..dce12e40 --- /dev/null +++ b/packages/json-render-ui/src/shims.d.ts @@ -0,0 +1,9 @@ +// `@antfu/design` ships its components as raw `.vue` source (consumed and +// compiled by the consumer's Vite / @vitejs/plugin-vue). This ambient shim +// lets `tsc` / the dts build resolve those imports to a Vue component type. +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent + export default component +} diff --git a/packages/json-render-ui/tsdown.config.ts b/packages/json-render-ui/tsdown.config.ts index 2000ecef..0883b81e 100644 --- a/packages/json-render-ui/tsdown.config.ts +++ b/packages/json-render-ui/tsdown.config.ts @@ -14,6 +14,9 @@ export default defineConfig({ dts: true, platform: 'browser', deps: { - neverBundle: ['vue', '@devframes/json-render', '@devframes/json-render/core'], + // Keep peers external; `@antfu/design` ships `.vue` source that the + // consumer's Vite (with @vitejs/plugin-vue) compiles, so it must not be + // bundled/parsed here. + neverBundle: ['vue', '@antfu/design', /^@antfu\/design\//, '@devframes/json-render', '@devframes/json-render/core'], }, }) diff --git a/packages/json-render-ui/uno.config.ts b/packages/json-render-ui/uno.config.ts index 6066cbb6..f66b064e 100644 --- a/packages/json-render-ui/uno.config.ts +++ b/packages/json-render-ui/uno.config.ts @@ -22,6 +22,9 @@ export default defineConfig({ ], transformers: [transformerDirectives(), transformerVariantGroup()], preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], + // `Badge` picks a `badge-color-` at runtime from a fixed set, so those + // classes can't be found by static extraction — safelist them. + safelist: ['badge-color-green', 'badge-color-amber', 'badge-color-red', 'badge-color-blue'], shortcuts: { 'z-nav': 'z-[30]', 'z-dropdown': 'z-[40]', diff --git a/packages/json-render-ui/vitest.config.ts b/packages/json-render-ui/vitest.config.ts new file mode 100644 index 00000000..ec76195d --- /dev/null +++ b/packages/json-render-ui/vitest.config.ts @@ -0,0 +1,13 @@ +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vitest/config' +import { alias } from '../../alias' + +// The component graph imports `@antfu/design` `.vue` SFCs, so tests need the Vue +// plugin to parse them. +export default defineConfig({ + plugins: [vue()], + resolve: { alias }, + test: { + name: '@devframes/json-render-ui', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 498f2601..0f4a7efe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -891,9 +891,6 @@ importers: '@json-render/vue': specifier: catalog:frontend version: 0.19.0(vue@3.5.40(typescript@5.9.3))(zod@4.4.3) - dompurify: - specifier: catalog:frontend - version: 3.4.12 devDependencies: '@antfu/design': specifier: catalog:frontend From ae2304eeb6d62d1a66043e79f1f5754f39694fe0 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 01:30:41 +0000 Subject: [PATCH 06/10] refactor(json-render-ui): one component per file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the grouped component modules (layout/controls/data/typography/icon) into one file per catalog component — Stack, Card, Divider, Text, Badge, CodeBlock, Button, Icon, TextInput, Switch, KeyValueTable, DataTable, Progress, Tree — so each is individually importable and easy to locate. Shared helpers (JrComponent, toNumber, formatValue) live in _shared; the internal error placeholder stays in _error. No behavior change. Created with the help of an agent. --- .../json-render-ui/src/components/Badge.ts | 30 ++++ .../json-render-ui/src/components/Button.ts | 44 ++++++ .../json-render-ui/src/components/Card.ts | 37 +++++ .../src/components/CodeBlock.ts | 27 ++++ .../src/components/DataTable.ts | 50 +++++++ .../json-render-ui/src/components/Divider.ts | 6 + .../src/components/{icon.ts => Icon.ts} | 0 .../src/components/KeyValueTable.ts | 18 +++ .../json-render-ui/src/components/Progress.ts | 24 ++++ .../json-render-ui/src/components/Stack.ts | 43 ++++++ .../json-render-ui/src/components/Switch.ts | 23 ++++ .../json-render-ui/src/components/Text.ts | 42 ++++++ .../src/components/TextInput.ts | 35 +++++ .../json-render-ui/src/components/Tree.ts | 36 +++++ .../json-render-ui/src/components/_shared.ts | 9 ++ .../json-render-ui/src/components/controls.ts | 97 ------------- .../json-render-ui/src/components/data.ts | 128 ------------------ .../json-render-ui/src/components/index.ts | 20 ++- .../json-render-ui/src/components/layout.ts | 82 ----------- .../src/components/typography.ts | 93 ------------- 20 files changed, 439 insertions(+), 405 deletions(-) create mode 100644 packages/json-render-ui/src/components/Badge.ts create mode 100644 packages/json-render-ui/src/components/Button.ts create mode 100644 packages/json-render-ui/src/components/Card.ts create mode 100644 packages/json-render-ui/src/components/CodeBlock.ts create mode 100644 packages/json-render-ui/src/components/DataTable.ts create mode 100644 packages/json-render-ui/src/components/Divider.ts rename packages/json-render-ui/src/components/{icon.ts => Icon.ts} (100%) create mode 100644 packages/json-render-ui/src/components/KeyValueTable.ts create mode 100644 packages/json-render-ui/src/components/Progress.ts create mode 100644 packages/json-render-ui/src/components/Stack.ts create mode 100644 packages/json-render-ui/src/components/Switch.ts create mode 100644 packages/json-render-ui/src/components/Text.ts create mode 100644 packages/json-render-ui/src/components/TextInput.ts create mode 100644 packages/json-render-ui/src/components/Tree.ts delete mode 100644 packages/json-render-ui/src/components/controls.ts delete mode 100644 packages/json-render-ui/src/components/data.ts delete mode 100644 packages/json-render-ui/src/components/layout.ts delete mode 100644 packages/json-render-ui/src/components/typography.ts diff --git a/packages/json-render-ui/src/components/Badge.ts b/packages/json-render-ui/src/components/Badge.ts new file mode 100644 index 00000000..5753ae6c --- /dev/null +++ b/packages/json-render-ui/src/components/Badge.ts @@ -0,0 +1,30 @@ +import type { JrComponent } from './_shared' +import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue' +import { h } from 'vue' + +interface BadgeProps { + text?: string + variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' + minWidth?: number +} + +// Map the catalog's semantic variants onto `@antfu/design` `DisplayBadge` +// palette color names (`false` = neutral/muted). The resulting +// `badge-color-` classes are safelisted (see the package uno.config and +// the JSON-Render docs) since a spec picks the variant at runtime. +const badgeColor: Record = { + default: false, + success: 'green', + warning: 'amber', + danger: 'red', + info: 'blue', +} + +export const Badge: JrComponent = ({ props, children }) => { + const style = props.minWidth != null ? { minWidth: `${props.minWidth}px` } : undefined + return h( + DisplayBadge, + { text: props.text, color: badgeColor[props.variant ?? 'default'], style }, + props.text ? undefined : () => children, + ) +} diff --git a/packages/json-render-ui/src/components/Button.ts b/packages/json-render-ui/src/components/Button.ts new file mode 100644 index 00000000..3dc21fad --- /dev/null +++ b/packages/json-render-ui/src/components/Button.ts @@ -0,0 +1,44 @@ +import type { JrComponent } from './_shared' +import ActionButton from '@antfu/design/components/Action/ActionButton.vue' +import { h } from 'vue' +import { Icon } from './Icon' + +interface ButtonProps { + label?: string + variant?: 'primary' | 'secondary' | 'ghost' | 'danger' + /** Icon name resolved at runtime (e.g. `ph:arrow-clockwise`). */ + icon?: string + disabled?: boolean + loading?: boolean +} + +// Catalog variant → `@antfu/design` ActionButton variant. `danger` has no +// upstream variant, so it renders as `primary` with a red override. +const buttonVariant: Record = { + primary: 'primary', + secondary: 'action', + ghost: 'text', + danger: 'primary', +} + +export const Button: JrComponent = ({ props, on }) => { + const variant = props.variant ?? 'secondary' + return h( + ActionButton, + { + variant: buttonVariant[variant] ?? 'action', + disabled: props.disabled, + loading: props.loading, + // `danger` isn't an ActionButton variant — override the primary tint. + class: variant === 'danger' ? 'bg-red! color-white! border-red! hover:bg-red/90!' : undefined, + onClick: () => on('press').emit(), + }, + // Render the dynamic Icon in the slot (ActionButton's own `icon` prop is a + // static UnoCSS class, unsuited to spec-supplied names). Skip it while + // loading — ActionButton already shows its spinner. + () => [ + props.icon && !props.loading ? Icon({ props: { name: props.icon, size: 16 } } as Parameters[0]) : null, + props.label ? h('span', props.label) : null, + ], + ) +} diff --git a/packages/json-render-ui/src/components/Card.ts b/packages/json-render-ui/src/components/Card.ts new file mode 100644 index 00000000..dbeeb8ee --- /dev/null +++ b/packages/json-render-ui/src/components/Card.ts @@ -0,0 +1,37 @@ +import type { JrComponent } from './_shared' +import LayoutCard from '@antfu/design/components/Layout/LayoutCard.vue' +import { h } from 'vue' + +interface CardProps { + title?: string + border?: boolean + collapsible?: boolean + defaultCollapsed?: boolean + loading?: boolean +} + +const headerClass = 'flex items-center justify-between px4 py2.5 border-b border-base color-base font-medium text-sm' + +// Wraps `@antfu/design` LayoutCard for the bordered surface, composing the +// title/collapsible header on top (LayoutCard is a plain surface). +export const Card: JrComponent = ({ props, children, loading }) => { + const isLoading = loading || props.loading + const body = isLoading + ? h('div', { class: 'color-faint text-sm' }, 'Loading…') + : (children as any) + + if (props.collapsible) { + return h(LayoutCard, { padding: false }, () => h('details', { open: !props.defaultCollapsed }, [ + h('summary', { class: `${headerClass} cursor-pointer select-none list-none` }, [ + h('span', props.title ?? ''), + h('span', { class: 'color-faint text-xs' }, '▾'), + ]), + h('div', { class: 'p4' }, [body]), + ])) + } + + return h(LayoutCard, { padding: false }, () => [ + props.title ? h('div', { class: headerClass }, props.title) : null, + h('div', { class: 'p4' }, [body]), + ]) +} diff --git a/packages/json-render-ui/src/components/CodeBlock.ts b/packages/json-render-ui/src/components/CodeBlock.ts new file mode 100644 index 00000000..d69fed11 --- /dev/null +++ b/packages/json-render-ui/src/components/CodeBlock.ts @@ -0,0 +1,27 @@ +import type { JrComponent } from './_shared' +import { h } from 'vue' + +interface CodeBlockProps { + code?: string + language?: string + filename?: string + height?: number +} + +export const CodeBlock: JrComponent = ({ props }) => { + const header = props.filename || props.language + ? h('div', { class: 'flex items-center justify-between px2 py1 border-b border-base bg-secondary text-xs color-faint' }, [ + h('span', props.filename ?? ''), + props.language ? h('span', { class: 'font-mono uppercase' }, props.language) : null, + ]) + : null + const preStyle = props.height != null ? { maxHeight: `${props.height}px` } : undefined + return h('div', { 'class': 'rounded border border-base overflow-hidden bg-base', 'data-language': props.language }, [ + header, + h( + 'pre', + { class: 'p2 text-sm font-mono overflow-auto color-base', style: preStyle }, + h('code', props.code ?? ''), + ), + ]) +} diff --git a/packages/json-render-ui/src/components/DataTable.ts b/packages/json-render-ui/src/components/DataTable.ts new file mode 100644 index 00000000..64cc7ac9 --- /dev/null +++ b/packages/json-render-ui/src/components/DataTable.ts @@ -0,0 +1,50 @@ +import type { JrComponent } from './_shared' +import { useBoundProp } from '@json-render/vue' +import { h } from 'vue' +import { formatValue } from './_shared' + +interface DataTableColumn { key: string, label?: string } +interface DataTableProps { + columns?: (string | DataTableColumn)[] + rows?: Record[] + height?: number + loading?: boolean +} + +function normalizeColumns(columns: DataTableProps['columns'], rows: DataTableProps['rows']): DataTableColumn[] { + if (columns?.length) + return columns.map(c => (typeof c === 'string' ? { key: c } : c)) + const first = rows?.[0] + return first ? Object.keys(first).map(key => ({ key })) : [] +} + +// `@antfu/design` LayoutDataTable has no per-row click or loading state, both of +// which the catalog requires (`rowClick`, `loading`), so this stays custom on +// the shared tokens. (A candidate to contribute back upstream.) +export const DataTable: JrComponent = ({ props, on, bindings, loading }) => { + if (loading || props.loading) + return h('div', { class: 'color-faint text-sm' }, 'Loading…') + const rows = props.rows ?? [] + const columns = normalizeColumns(props.columns, rows) + const [, setSelected] = useBoundProp(undefined, bindings?.value) + const wrapperStyle = props.height != null ? { maxHeight: `${props.height}px` } : undefined + + return h('div', { class: 'rounded border border-base overflow-auto', style: wrapperStyle }, [ + h('table', { class: 'w-full text-sm border-collapse' }, [ + h('thead', { class: 'sticky top-0 bg-secondary' }, [ + h('tr', columns.map(col => + h('th', { class: 'text-left px2 py1.5 color-muted font-medium border-b border-base' }, col.label ?? col.key))), + ]), + h('tbody', rows.map((row, index) => + h('tr', { + class: 'border-b border-base hover:bg-secondary cursor-pointer', + onClick: () => { + // Deliver row + index into bound state, then fire the action. + setSelected({ row, index }) + on('rowClick').emit() + }, + }, columns.map(col => + h('td', { class: 'px2 py1 color-base font-mono' }, formatValue(row[col.key])))))), + ]), + ]) +} diff --git a/packages/json-render-ui/src/components/Divider.ts b/packages/json-render-ui/src/components/Divider.ts new file mode 100644 index 00000000..2c371d77 --- /dev/null +++ b/packages/json-render-ui/src/components/Divider.ts @@ -0,0 +1,6 @@ +import type { JrComponent } from './_shared' +import LayoutSeparator from '@antfu/design/components/Layout/LayoutSeparator.vue' +import { h } from 'vue' + +export const Divider: JrComponent<{ label?: string }> = ({ props }) => + h(LayoutSeparator, { label: props.label }) diff --git a/packages/json-render-ui/src/components/icon.ts b/packages/json-render-ui/src/components/Icon.ts similarity index 100% rename from packages/json-render-ui/src/components/icon.ts rename to packages/json-render-ui/src/components/Icon.ts diff --git a/packages/json-render-ui/src/components/KeyValueTable.ts b/packages/json-render-ui/src/components/KeyValueTable.ts new file mode 100644 index 00000000..4737a3db --- /dev/null +++ b/packages/json-render-ui/src/components/KeyValueTable.ts @@ -0,0 +1,18 @@ +import type { JrComponent } from './_shared' +import DisplayKeyValue from '@antfu/design/components/Display/DisplayKeyValue.vue' +import { h } from 'vue' +import { formatValue } from './_shared' + +interface KeyValueTableProps { + data?: Record + loading?: boolean +} + +// Renders each entry as an `@antfu/design` DisplayKeyValue row. +export const KeyValueTable: JrComponent = ({ props, loading }) => { + if (loading || props.loading) + return h('div', { class: 'color-faint text-sm' }, 'Loading…') + const entries = Object.entries(props.data ?? {}) + return h('div', { class: 'flex flex-col gap-1' }, entries.map(([key, value]) => + h(DisplayKeyValue, { key, label: key, value: formatValue(value) }))) +} diff --git a/packages/json-render-ui/src/components/Progress.ts b/packages/json-render-ui/src/components/Progress.ts new file mode 100644 index 00000000..6b423ff5 --- /dev/null +++ b/packages/json-render-ui/src/components/Progress.ts @@ -0,0 +1,24 @@ +import type { JrComponent } from './_shared' +import DisplayProgressBar from '@antfu/design/components/Display/DisplayProgressBar.vue' +import { h } from 'vue' +import { toNumber } from './_shared' + +interface ProgressProps { + value?: number + max?: number + label?: string +} + +// Wraps `@antfu/design` DisplayProgressBar (which takes a 0–1 ratio), adding the +// catalog's visible label + percentage row. +export const Progress: JrComponent = ({ props }) => { + const max = toNumber(props.max, 100) + const value = toNumber(props.value, 0) + const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0 + return h('div', { class: 'flex flex-col gap-1' }, [ + props.label + ? h('div', { class: 'flex justify-between text-xs color-muted' }, [h('span', props.label), h('span', `${Math.round(ratio * 100)}%`)]) + : null, + h(DisplayProgressBar, { value: ratio, label: props.label }), + ]) +} diff --git a/packages/json-render-ui/src/components/Stack.ts b/packages/json-render-ui/src/components/Stack.ts new file mode 100644 index 00000000..6703fe13 --- /dev/null +++ b/packages/json-render-ui/src/components/Stack.ts @@ -0,0 +1,43 @@ +import type { JrComponent } from './_shared' +import { h } from 'vue' +import { toNumber } from './_shared' + +interface StackProps { + direction?: 'row' | 'column' + gap?: number + padding?: number + align?: 'start' | 'center' | 'end' | 'stretch' + justify?: 'start' | 'center' | 'end' | 'between' | 'around' + wrap?: boolean + flex?: number | string +} + +const alignMap: Record = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' } +const justifyMap: Record = { + start: 'flex-start', + center: 'center', + end: 'flex-end', + between: 'space-between', + around: 'space-around', +} + +// A flex layout primitive — `@antfu/design` has no generic Stack, so this stays +// a thin custom component built on the shared tokens. +export const Stack: JrComponent = ({ props, children }) => { + const style: Record = { + display: 'flex', + flexDirection: props.direction === 'row' ? 'row' : 'column', + gap: `${toNumber(props.gap, 8)}px`, + } + if (props.padding != null) + style.padding = `${toNumber(props.padding, 0)}px` + if (props.align) + style.alignItems = alignMap[props.align] ?? props.align + if (props.justify) + style.justifyContent = justifyMap[props.justify] ?? props.justify + if (props.wrap) + style.flexWrap = 'wrap' + if (props.flex != null) + style.flex = String(props.flex) + return h('div', { style }, children as any) +} diff --git a/packages/json-render-ui/src/components/Switch.ts b/packages/json-render-ui/src/components/Switch.ts new file mode 100644 index 00000000..2b9ed5f5 --- /dev/null +++ b/packages/json-render-ui/src/components/Switch.ts @@ -0,0 +1,23 @@ +import type { JrComponent } from './_shared' +import FormSwitch from '@antfu/design/components/Form/FormSwitch.vue' +import { useBoundProp } from '@json-render/vue' +import { h } from 'vue' + +interface SwitchProps { + value?: boolean + label?: string + disabled?: boolean +} + +export const Switch: JrComponent = ({ props, on, bindings }) => { + const [value, setValue] = useBoundProp(props.value, bindings?.value) + return h(FormSwitch, { + 'modelValue': !!value, + 'onUpdate:modelValue': (next: boolean) => { + setValue(next) + on('change').emit() + }, + 'label': props.label, + 'disabled': props.disabled, + }) +} diff --git a/packages/json-render-ui/src/components/Text.ts b/packages/json-render-ui/src/components/Text.ts new file mode 100644 index 00000000..56a8b5f9 --- /dev/null +++ b/packages/json-render-ui/src/components/Text.ts @@ -0,0 +1,42 @@ +import type { JrComponent } from './_shared' +import { h } from 'vue' + +interface TextProps { + text?: string + variant?: 'heading' | 'subheading' | 'body' | 'caption' | 'code' + weight?: 'normal' | 'medium' | 'bold' + color?: 'base' | 'muted' | 'faint' | 'primary' | 'success' | 'warning' | 'danger' +} + +const textVariant: Record = { + heading: { tag: 'h2', class: 'text-lg font-semibold' }, + subheading: { tag: 'h3', class: 'text-base font-medium' }, + body: { tag: 'p', class: 'text-sm' }, + caption: { tag: 'span', class: 'text-xs color-faint' }, + code: { tag: 'code', class: 'text-sm font-mono bg-secondary rounded px1 py0.5' }, +} + +const colorClass: Record = { + base: 'color-base', + muted: 'color-muted', + faint: 'color-faint', + primary: 'color-primary', + success: 'color-green', + warning: 'color-amber', + danger: 'color-red', +} + +// Generic typography — `@antfu/design` has no single equivalent, so it stays a +// thin custom component over the shared semantic tokens. +export const Text: JrComponent = ({ props, children }) => { + const variant = textVariant[props.variant ?? 'body'] ?? textVariant.body + const classes = [variant.class] + if (props.weight === 'bold') + classes.push('font-bold') + else if (props.weight === 'medium') + classes.push('font-medium') + if (props.color) + classes.push(colorClass[props.color] ?? 'color-base') + else classes.push('color-base') + return h(variant.tag, { class: classes.join(' ') }, props.text ?? (children as any)) +} diff --git a/packages/json-render-ui/src/components/TextInput.ts b/packages/json-render-ui/src/components/TextInput.ts new file mode 100644 index 00000000..cc284c6b --- /dev/null +++ b/packages/json-render-ui/src/components/TextInput.ts @@ -0,0 +1,35 @@ +import type { JrComponent } from './_shared' +import FormTextInput from '@antfu/design/components/Form/FormTextInput.vue' +import { useBoundProp } from '@json-render/vue' +import { h } from 'vue' + +interface TextInputProps { + value?: string + placeholder?: string + label?: string + disabled?: boolean + type?: 'text' | 'number' | 'password' | 'email' | 'search' + loading?: boolean +} + +export const TextInput: JrComponent = ({ props, on, bindings }) => { + const [value, setValue] = useBoundProp(props.value, bindings?.value) + const input = h(FormTextInput, { + 'modelValue': value ?? '', + 'onUpdate:modelValue': (next: string) => { + // Carry the new value into bound state, then fire the `change` action. + setValue(next) + on('change').emit() + }, + 'placeholder': props.placeholder, + 'type': props.type ?? 'text', + 'disabled': props.disabled || props.loading, + }) + if (props.label) { + return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [ + h('span', props.label), + input, + ]) + } + return input +} diff --git a/packages/json-render-ui/src/components/Tree.ts b/packages/json-render-ui/src/components/Tree.ts new file mode 100644 index 00000000..e5da0956 --- /dev/null +++ b/packages/json-render-ui/src/components/Tree.ts @@ -0,0 +1,36 @@ +import type { VNodeChild } from 'vue' +import type { JrComponent } from './_shared' +import { h } from 'vue' + +interface TreeProps { + data?: unknown + defaultExpanded?: boolean +} + +function renderNode(value: unknown, keyLabel: string | null, expanded: boolean): VNodeChild { + const isObject = value !== null && typeof value === 'object' + if (!isObject) { + const color = typeof value === 'number' + ? 'color-primary' + : typeof value === 'string' + ? 'color-green' + : 'color-muted' + return h('div', { class: 'flex gap-1 font-mono text-sm' }, [ + keyLabel != null ? h('span', { class: 'color-muted' }, `${keyLabel}:`) : null, + h('span', { class: color }, typeof value === 'string' ? `"${value}"` : String(value)), + ]) + } + const entries = Array.isArray(value) + ? value.map((v, i) => [String(i), v] as const) + : Object.entries(value as Record) + const summaryLabel = `${keyLabel != null ? `${keyLabel}: ` : ''}${Array.isArray(value) ? `Array(${entries.length})` : 'Object'}` + return h('details', { class: 'font-mono text-sm', open: expanded }, [ + h('summary', { class: 'cursor-pointer color-muted select-none' }, summaryLabel), + h('div', { class: 'pl3 border-l border-base ml1' }, entries.map(([k, v]) => renderNode(v, k, expanded))), + ]) +} + +// `@antfu/design` DisplayTree nests a flat path list (file/folder style), not an +// arbitrary JSON value, so the catalog's value-tree viewer stays custom. +export const Tree: JrComponent = ({ props }) => + h('div', { class: 'color-base' }, [renderNode(props.data, null, props.defaultExpanded !== false)]) diff --git a/packages/json-render-ui/src/components/_shared.ts b/packages/json-render-ui/src/components/_shared.ts index e2a90854..3673540b 100644 --- a/packages/json-render-ui/src/components/_shared.ts +++ b/packages/json-render-ui/src/components/_shared.ts @@ -16,3 +16,12 @@ export function toNumber(value: unknown, fallback: number): number { const n = typeof value === 'string' ? Number(value) : value return typeof n === 'number' && Number.isFinite(n) ? n : fallback } + +/** Render an arbitrary value as a display string for table cells. */ +export function formatValue(value: unknown): string { + if (value == null) + return '' + if (typeof value === 'object') + return JSON.stringify(value) + return String(value) +} diff --git a/packages/json-render-ui/src/components/controls.ts b/packages/json-render-ui/src/components/controls.ts deleted file mode 100644 index 6d8b5d95..00000000 --- a/packages/json-render-ui/src/components/controls.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { JrComponent } from './_shared' -import ActionButton from '@antfu/design/components/Action/ActionButton.vue' -import FormSwitch from '@antfu/design/components/Form/FormSwitch.vue' -import FormTextInput from '@antfu/design/components/Form/FormTextInput.vue' -import { useBoundProp } from '@json-render/vue' -import { h } from 'vue' -import { Icon } from './icon' - -interface ButtonProps { - label?: string - variant?: 'primary' | 'secondary' | 'ghost' | 'danger' - /** Icon name resolved at runtime (e.g. `ph:arrow-clockwise`). */ - icon?: string - disabled?: boolean - loading?: boolean -} - -// Catalog variant → `@antfu/design` ActionButton variant. `danger` has no -// upstream variant, so it renders as `primary` with a red override. -const buttonVariant: Record = { - primary: 'primary', - secondary: 'action', - ghost: 'text', - danger: 'primary', -} - -export const Button: JrComponent = ({ props, on }) => { - const variant = props.variant ?? 'secondary' - return h( - ActionButton, - { - variant: buttonVariant[variant] ?? 'action', - disabled: props.disabled, - loading: props.loading, - // `danger` isn't an ActionButton variant — override the primary tint. - class: variant === 'danger' ? 'bg-red! color-white! border-red! hover:bg-red/90!' : undefined, - onClick: () => on('press').emit(), - }, - // Render the dynamic Icon in the slot (ActionButton's own `icon` prop is a - // static UnoCSS class, unsuited to spec-supplied names). Skip it while - // loading — ActionButton already shows its spinner. - () => [ - props.icon && !props.loading ? Icon({ props: { name: props.icon, size: 16 } } as Parameters[0]) : null, - props.label ? h('span', props.label) : null, - ], - ) -} - -interface TextInputProps { - value?: string - placeholder?: string - label?: string - disabled?: boolean - type?: 'text' | 'number' | 'password' | 'email' | 'search' - loading?: boolean -} - -export const TextInput: JrComponent = ({ props, on, bindings }) => { - const [value, setValue] = useBoundProp(props.value, bindings?.value) - const input = h(FormTextInput, { - 'modelValue': value ?? '', - 'onUpdate:modelValue': (next: string) => { - // Carry the new value into bound state, then fire the `change` action. - setValue(next) - on('change').emit() - }, - 'placeholder': props.placeholder, - 'type': props.type ?? 'text', - 'disabled': props.disabled || props.loading, - }) - if (props.label) { - return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [ - h('span', props.label), - input, - ]) - } - return input -} - -interface SwitchProps { - value?: boolean - label?: string - disabled?: boolean -} - -export const Switch: JrComponent = ({ props, on, bindings }) => { - const [value, setValue] = useBoundProp(props.value, bindings?.value) - return h(FormSwitch, { - 'modelValue': !!value, - 'onUpdate:modelValue': (next: boolean) => { - setValue(next) - on('change').emit() - }, - 'label': props.label, - 'disabled': props.disabled, - }) -} diff --git a/packages/json-render-ui/src/components/data.ts b/packages/json-render-ui/src/components/data.ts deleted file mode 100644 index f108bbce..00000000 --- a/packages/json-render-ui/src/components/data.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { VNodeChild } from 'vue' -import type { JrComponent } from './_shared' -import DisplayKeyValue from '@antfu/design/components/Display/DisplayKeyValue.vue' -import DisplayProgressBar from '@antfu/design/components/Display/DisplayProgressBar.vue' -import { useBoundProp } from '@json-render/vue' -import { h } from 'vue' -import { toNumber } from './_shared' - -interface KeyValueTableProps { - data?: Record - loading?: boolean -} - -function formatValue(value: unknown): string { - if (value == null) - return '' - if (typeof value === 'object') - return JSON.stringify(value) - return String(value) -} - -// Renders each entry as an `@antfu/design` DisplayKeyValue row. -export const KeyValueTable: JrComponent = ({ props, loading }) => { - if (loading || props.loading) - return h('div', { class: 'color-faint text-sm' }, 'Loading…') - const entries = Object.entries(props.data ?? {}) - return h('div', { class: 'flex flex-col gap-1' }, entries.map(([key, value]) => - h(DisplayKeyValue, { key, label: key, value: formatValue(value) }))) -} - -interface DataTableColumn { key: string, label?: string } -interface DataTableProps { - columns?: (string | DataTableColumn)[] - rows?: Record[] - height?: number - loading?: boolean -} - -function normalizeColumns(columns: DataTableProps['columns'], rows: DataTableProps['rows']): DataTableColumn[] { - if (columns?.length) - return columns.map(c => (typeof c === 'string' ? { key: c } : c)) - const first = rows?.[0] - return first ? Object.keys(first).map(key => ({ key })) : [] -} - -// `@antfu/design` LayoutDataTable has no per-row click or loading state, both of -// which the catalog requires (`rowClick`, `loading`), so this stays custom on -// the shared tokens. (A candidate to contribute back upstream.) -export const DataTable: JrComponent = ({ props, on, bindings, loading }) => { - if (loading || props.loading) - return h('div', { class: 'color-faint text-sm' }, 'Loading…') - const rows = props.rows ?? [] - const columns = normalizeColumns(props.columns, rows) - const [, setSelected] = useBoundProp(undefined, bindings?.value) - const wrapperStyle = props.height != null ? { maxHeight: `${props.height}px` } : undefined - - return h('div', { class: 'rounded border border-base overflow-auto', style: wrapperStyle }, [ - h('table', { class: 'w-full text-sm border-collapse' }, [ - h('thead', { class: 'sticky top-0 bg-secondary' }, [ - h('tr', columns.map(col => - h('th', { class: 'text-left px2 py1.5 color-muted font-medium border-b border-base' }, col.label ?? col.key))), - ]), - h('tbody', rows.map((row, index) => - h('tr', { - class: 'border-b border-base hover:bg-secondary cursor-pointer', - onClick: () => { - // Deliver row + index into bound state, then fire the action. - setSelected({ row, index }) - on('rowClick').emit() - }, - }, columns.map(col => - h('td', { class: 'px2 py1 color-base font-mono' }, formatValue(row[col.key])))))), - ]), - ]) -} - -interface ProgressProps { - value?: number - max?: number - label?: string -} - -// Wraps `@antfu/design` DisplayProgressBar (which takes a 0–1 ratio), adding the -// catalog's visible label + percentage row. -export const Progress: JrComponent = ({ props }) => { - const max = toNumber(props.max, 100) - const value = toNumber(props.value, 0) - const ratio = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0 - return h('div', { class: 'flex flex-col gap-1' }, [ - props.label - ? h('div', { class: 'flex justify-between text-xs color-muted' }, [h('span', props.label), h('span', `${Math.round(ratio * 100)}%`)]) - : null, - h(DisplayProgressBar, { value: ratio, label: props.label }), - ]) -} - -interface TreeProps { - data?: unknown - defaultExpanded?: boolean -} - -function renderNode(value: unknown, keyLabel: string | null, expanded: boolean): VNodeChild { - const isObject = value !== null && typeof value === 'object' - if (!isObject) { - const color = typeof value === 'number' - ? 'color-primary' - : typeof value === 'string' - ? 'color-green' - : 'color-muted' - return h('div', { class: 'flex gap-1 font-mono text-sm' }, [ - keyLabel != null ? h('span', { class: 'color-muted' }, `${keyLabel}:`) : null, - h('span', { class: color }, typeof value === 'string' ? `"${value}"` : String(value)), - ]) - } - const entries = Array.isArray(value) - ? value.map((v, i) => [String(i), v] as const) - : Object.entries(value as Record) - const summaryLabel = `${keyLabel != null ? `${keyLabel}: ` : ''}${Array.isArray(value) ? `Array(${entries.length})` : 'Object'}` - return h('details', { class: 'font-mono text-sm', open: expanded }, [ - h('summary', { class: 'cursor-pointer color-muted select-none' }, summaryLabel), - h('div', { class: 'pl3 border-l border-base ml1' }, entries.map(([k, v]) => renderNode(v, k, expanded))), - ]) -} - -// `@antfu/design` DisplayTree nests a flat path list (file/folder style), not an -// arbitrary JSON value, so the catalog's value-tree viewer stays custom. -export const Tree: JrComponent = ({ props }) => - h('div', { class: 'color-base' }, [renderNode(props.data, null, props.defaultExpanded !== false)]) diff --git a/packages/json-render-ui/src/components/index.ts b/packages/json-render-ui/src/components/index.ts index c009cdff..c9baa6bd 100644 --- a/packages/json-render-ui/src/components/index.ts +++ b/packages/json-render-ui/src/components/index.ts @@ -1,6 +1,16 @@ export type { JrComponent } from './_shared' -export { Button, Switch, TextInput } from './controls' -export { DataTable, KeyValueTable, Progress, Tree } from './data' -export { Icon } from './icon' -export { Card, Divider, Stack } from './layout' -export { Badge, CodeBlock, Text } from './typography' + +export { Badge } from './Badge' +export { Button } from './Button' +export { Card } from './Card' +export { CodeBlock } from './CodeBlock' +export { DataTable } from './DataTable' +export { Divider } from './Divider' +export { Icon } from './Icon' +export { KeyValueTable } from './KeyValueTable' +export { Progress } from './Progress' +export { Stack } from './Stack' +export { Switch } from './Switch' +export { Text } from './Text' +export { TextInput } from './TextInput' +export { Tree } from './Tree' diff --git a/packages/json-render-ui/src/components/layout.ts b/packages/json-render-ui/src/components/layout.ts deleted file mode 100644 index b3079470..00000000 --- a/packages/json-render-ui/src/components/layout.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { JrComponent } from './_shared' -import LayoutCard from '@antfu/design/components/Layout/LayoutCard.vue' -import LayoutSeparator from '@antfu/design/components/Layout/LayoutSeparator.vue' -import { h } from 'vue' -import { toNumber } from './_shared' - -interface StackProps { - direction?: 'row' | 'column' - gap?: number - padding?: number - align?: 'start' | 'center' | 'end' | 'stretch' - justify?: 'start' | 'center' | 'end' | 'between' | 'around' - wrap?: boolean - flex?: number | string -} - -const alignMap: Record = { start: 'flex-start', center: 'center', end: 'flex-end', stretch: 'stretch' } -const justifyMap: Record = { - start: 'flex-start', - center: 'center', - end: 'flex-end', - between: 'space-between', - around: 'space-around', -} - -// A flex layout primitive — `@antfu/design` has no generic Stack, so this stays -// a thin custom component built on the shared tokens. -export const Stack: JrComponent = ({ props, children }) => { - const style: Record = { - display: 'flex', - flexDirection: props.direction === 'row' ? 'row' : 'column', - gap: `${toNumber(props.gap, 8)}px`, - } - if (props.padding != null) - style.padding = `${toNumber(props.padding, 0)}px` - if (props.align) - style.alignItems = alignMap[props.align] ?? props.align - if (props.justify) - style.justifyContent = justifyMap[props.justify] ?? props.justify - if (props.wrap) - style.flexWrap = 'wrap' - if (props.flex != null) - style.flex = String(props.flex) - return h('div', { style }, children as any) -} - -interface CardProps { - title?: string - border?: boolean - collapsible?: boolean - defaultCollapsed?: boolean - loading?: boolean -} - -const headerClass = 'flex items-center justify-between px4 py2.5 border-b border-base color-base font-medium text-sm' - -// Wraps `@antfu/design` LayoutCard for the bordered surface, composing the -// title/collapsible header on top (LayoutCard is a plain surface). -export const Card: JrComponent = ({ props, children, loading }) => { - const isLoading = loading || props.loading - const body = isLoading - ? h('div', { class: 'color-faint text-sm' }, 'Loading…') - : (children as any) - - if (props.collapsible) { - return h(LayoutCard, { padding: false }, () => h('details', { open: !props.defaultCollapsed }, [ - h('summary', { class: `${headerClass} cursor-pointer select-none list-none` }, [ - h('span', props.title ?? ''), - h('span', { class: 'color-faint text-xs' }, '▾'), - ]), - h('div', { class: 'p4' }, [body]), - ])) - } - - return h(LayoutCard, { padding: false }, () => [ - props.title ? h('div', { class: headerClass }, props.title) : null, - h('div', { class: 'p4' }, [body]), - ]) -} - -export const Divider: JrComponent<{ label?: string }> = ({ props }) => - h(LayoutSeparator, { label: props.label }) diff --git a/packages/json-render-ui/src/components/typography.ts b/packages/json-render-ui/src/components/typography.ts deleted file mode 100644 index a2d771d2..00000000 --- a/packages/json-render-ui/src/components/typography.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { JrComponent } from './_shared' -import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue' -import { h } from 'vue' - -interface TextProps { - text?: string - variant?: 'heading' | 'subheading' | 'body' | 'caption' | 'code' - weight?: 'normal' | 'medium' | 'bold' - color?: 'base' | 'muted' | 'faint' | 'primary' | 'success' | 'warning' | 'danger' -} - -const textVariant: Record = { - heading: { tag: 'h2', class: 'text-lg font-semibold' }, - subheading: { tag: 'h3', class: 'text-base font-medium' }, - body: { tag: 'p', class: 'text-sm' }, - caption: { tag: 'span', class: 'text-xs color-faint' }, - code: { tag: 'code', class: 'text-sm font-mono bg-secondary rounded px1 py0.5' }, -} - -const colorClass: Record = { - base: 'color-base', - muted: 'color-muted', - faint: 'color-faint', - primary: 'color-primary', - success: 'color-green', - warning: 'color-amber', - danger: 'color-red', -} - -export const Text: JrComponent = ({ props, children }) => { - const variant = textVariant[props.variant ?? 'body'] ?? textVariant.body - const classes = [variant.class] - if (props.weight === 'bold') - classes.push('font-bold') - else if (props.weight === 'medium') - classes.push('font-medium') - if (props.color) - classes.push(colorClass[props.color] ?? 'color-base') - else classes.push('color-base') - return h(variant.tag, { class: classes.join(' ') }, props.text ?? (children as any)) -} - -interface BadgeProps { - text?: string - variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' - minWidth?: number -} - -// Map the catalog's semantic variants onto `@antfu/design` `DisplayBadge` -// palette color names (`false` = neutral/muted). The resulting -// `badge-color-` classes are safelisted (see the package uno.config and -// the JSON-Render docs) since a spec picks the variant at runtime. -const badgeColor: Record = { - default: false, - success: 'green', - warning: 'amber', - danger: 'red', - info: 'blue', -} - -export const Badge: JrComponent = ({ props, children }) => { - const style = props.minWidth != null ? { minWidth: `${props.minWidth}px` } : undefined - return h( - DisplayBadge, - { text: props.text, color: badgeColor[props.variant ?? 'default'], style }, - props.text ? undefined : () => children, - ) -} - -interface CodeBlockProps { - code?: string - language?: string - filename?: string - height?: number -} - -export const CodeBlock: JrComponent = ({ props }) => { - const header = props.filename || props.language - ? h('div', { class: 'flex items-center justify-between px2 py1 border-b border-base bg-secondary text-xs color-faint' }, [ - h('span', props.filename ?? ''), - props.language ? h('span', { class: 'font-mono uppercase' }, props.language) : null, - ]) - : null - const preStyle = props.height != null ? { maxHeight: `${props.height}px` } : undefined - return h('div', { 'class': 'rounded border border-base overflow-hidden bg-base', 'data-language': props.language }, [ - header, - h( - 'pre', - { class: 'p2 text-sm font-mono overflow-auto color-base', style: preStyle }, - h('code', props.code ?? ''), - ), - ]) -} From e0189740e456d3e1fb34c95944330e80e736ecd5 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 02:17:52 +0000 Subject: [PATCH 07/10] feat(examples): render a json-render dock in the minimal-vite hub Verify the JSON-render hub integration end to end in the Vite hub shell: - Extract a reusable `createDashboardView(ctx)` + `dashboardSpec` from the minimal-json-render example (exported as `minimal-json-render/dashboard`), used by both the standalone devframe and the hub. - The Vite hub authors the view on its hub context and projects it onto a `json-render` dock (via `@devframes/json-render/hub`), and the client host registers `createJsonRenderDockRenderer()` and mounts the dock into a panel. - Wire the hub's Vite build for the Vue renderer: @vitejs/plugin-vue, optimizeDeps exclude, UnoCSS `.vue` scanning + badge-color safelist, and a `*.vue` type shim. Created with the help of an agent. --- alias.ts | 1 + examples/minimal-json-render/package.json | 4 + examples/minimal-json-render/src/dashboard.ts | 199 ++++++++++++++++++ examples/minimal-json-render/src/devframe.ts | 187 +--------------- examples/minimal-vite-devframe-hub/index.html | 2 + .../minimal-vite-devframe-hub/package.json | 7 +- .../src/client/env.d.ts | 10 + .../src/client/main.ts | 78 +++++-- .../src/minimal-vite-devframe-hub.ts | 8 + .../minimal-vite-devframe-hub/uno.config.ts | 6 +- .../minimal-vite-devframe-hub/vite.config.ts | 19 ++ pnpm-lock.yaml | 15 ++ tsconfig.base.json | 3 + 13 files changed, 340 insertions(+), 199 deletions(-) create mode 100644 examples/minimal-json-render/src/dashboard.ts diff --git a/alias.ts b/alias.ts index 66a230ae..704b51a9 100644 --- a/alias.ts +++ b/alias.ts @@ -52,6 +52,7 @@ export const alias = { '@devframes/json-render': r('json-render/src/index.ts'), '@devframes/json-render-ui/components': r('json-render-ui/src/components/index.ts'), '@devframes/json-render-ui': r('json-render-ui/src/index.ts'), + 'minimal-json-render/dashboard': fileURLToPath(new URL('./examples/minimal-json-render/src/dashboard.ts', import.meta.url)), '@devframes/plugin-code-server/client': p('code-server/src/client/index.ts'), '@devframes/plugin-code-server/node': p('code-server/src/node/index.ts'), '@devframes/plugin-code-server/constants': p('code-server/src/constants.ts'), diff --git a/examples/minimal-json-render/package.json b/examples/minimal-json-render/package.json index 2c84188c..5a6f69b7 100644 --- a/examples/minimal-json-render/package.json +++ b/examples/minimal-json-render/package.json @@ -5,6 +5,10 @@ "private": true, "description": "Standalone devframe that serves a JSON-render view — a server-authored spec rendered by @devframes/json-render-ui, with live state and an action bridge.", "homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-json-render", + "exports": { + ".": "./src/devframe.ts", + "./dashboard": "./src/dashboard.ts" + }, "main": "src/devframe.ts", "bin": { "minimal-json-render": "./bin.mjs" diff --git a/examples/minimal-json-render/src/dashboard.ts b/examples/minimal-json-render/src/dashboard.ts new file mode 100644 index 00000000..ff134d8a --- /dev/null +++ b/examples/minimal-json-render/src/dashboard.ts @@ -0,0 +1,199 @@ +import type { DevframeJsonRenderSpec, JsonRenderView } from '@devframes/json-render' +import type { DevframeNodeContext } from 'devframe/types' +import { createJsonRenderView } from '@devframes/json-render/node' +import { defineRpcFunction } from 'devframe' +import { DEPLOY_ACTION, REFRESH_ACTION, SAVE_ACTION, VIEW_ID } from './shared.ts' + +const VITE_CONFIG = `import { defineConfig } from 'vite' + +export default defineConfig({ + base: './', + build: { outDir: 'dist/client' }, +}) +` + +// Initial state model. Every value the spec reads via \`{ $state: '/…' }\` +// resolves from here and updates live as the server patches it. +export const dashboardState = { + project: { name: 'acme-app', version: '1.4.2', license: 'MIT', repository: 'github.com/acme/app' }, + metrics: { coverage: 82, bundle: 64 }, + modules: [ + { name: '@acme/core', size: '42 kB', status: 'ok' }, + { name: '@acme/ui', size: '88 kB', status: 'ok' }, + { name: '@acme/cli', size: '17 kB', status: 'stale' }, + ], + deps: { + runtime: { vue: '^3.5', birpc: '^4.0' }, + dev: { vite: '^8.1', tsdown: '^0.22', vitest: '^4.1' }, + }, + form: { name: '', darkMode: true, notifications: false }, + building: false, + uptime: 0, +} + +/** + * A server-authored spec exercising every base-catalog component. Values marked + * `{ $state: '/…' }` resolve from live state; `{ $bindState: '/…' }` are + * two-way inputs that write back into it. + */ +export const dashboardSpec: DevframeJsonRenderSpec = { + root: 'root', + elements: { + root: { type: 'Stack', props: { gap: 16 }, children: ['header', 'overview', 'settings', 'modules', 'config', 'tree', 'footerDivider', 'footer'] }, + + // ── Header ──────────────────────────────────────────────────────────── + header: { type: 'Stack', props: { direction: 'row', gap: 10, align: 'center', justify: 'between' }, children: ['headLeft', 'headRight'] }, + headLeft: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['logo', 'headTitle'] }, + logo: { type: 'Icon', props: { name: 'ph:cube-duotone', size: 26 }, children: [] }, + headTitle: { type: 'Text', props: { text: 'Acme Dashboard', variant: 'heading' }, children: [] }, + headRight: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['status', 'refreshBtn', 'deployBtn'] }, + status: { type: 'Badge', props: { text: 'healthy', variant: 'success' }, children: [] }, + refreshBtn: { + type: 'Button', + props: { label: 'Refresh', variant: 'ghost', icon: 'ph:arrow-clockwise' }, + on: { press: { action: REFRESH_ACTION } }, + children: [], + }, + deployBtn: { + type: 'Button', + props: { label: 'Deploy', variant: 'primary', icon: 'ph:rocket-launch' }, + on: { press: { action: DEPLOY_ACTION } }, + children: [], + }, + + // ── Overview: KeyValueTable + Progress ──────────────────────────────── + overview: { type: 'Card', props: { title: 'Overview' }, children: ['overviewBody'] }, + overviewBody: { type: 'Stack', props: { gap: 14 }, children: ['meta', 'coverage', 'bundle'] }, + meta: { type: 'KeyValueTable', props: { data: { $state: '/project' } }, children: [] }, + coverage: { type: 'Progress', props: { label: 'Test coverage', value: { $state: '/metrics/coverage' }, max: 100 }, children: [] }, + bundle: { type: 'Progress', props: { label: 'Bundle budget', value: { $state: '/metrics/bundle' }, max: 100 }, children: [] }, + + // ── Settings: TextInput + Switch + Divider + Button ─────────────────── + settings: { type: 'Card', props: { title: 'Settings', collapsible: true }, children: ['settingsBody'] }, + settingsBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greeting', 'prefsDivider', 'darkSwitch', 'notifSwitch', 'saveBtn'] }, + nameInput: { type: 'TextInput', props: { label: 'Display name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] }, + greeting: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'caption', color: 'primary' }, children: [] }, + prefsDivider: { type: 'Divider', props: { label: 'preferences' }, children: [] }, + darkSwitch: { type: 'Switch', props: { label: 'Dark mode', value: { $bindState: '/form/darkMode' } }, children: [] }, + notifSwitch: { type: 'Switch', props: { label: 'Email notifications', value: { $bindState: '/form/notifications' } }, children: [] }, + saveBtn: { + type: 'Button', + props: { label: 'Save settings', variant: 'secondary', icon: 'ph:floppy-disk' }, + // Bound values are resolved into the action params before dispatch, so the + // server receives whatever the user typed/toggled. + on: { press: { action: SAVE_ACTION, params: { name: { $state: '/form/name' } } } }, + children: [], + }, + + // ── Modules: DataTable (loading bound to /building) ─────────────────── + modules: { type: 'Card', props: { title: 'Modules' }, children: ['modulesTable'] }, + modulesTable: { + type: 'DataTable', + props: { + columns: [ + { key: 'name', label: 'Module' }, + { key: 'size', label: 'Size' }, + { key: 'status', label: 'Status' }, + ], + rows: { $state: '/modules' }, + height: 180, + loading: { $state: '/building' }, + }, + children: [], + }, + + // ── Config: CodeBlock ───────────────────────────────────────────────── + config: { type: 'Card', props: { title: 'Config' }, children: ['code'] }, + code: { type: 'CodeBlock', props: { filename: 'vite.config.ts', language: 'ts', code: VITE_CONFIG }, children: [] }, + + // ── Dependency tree: Tree ───────────────────────────────────────────── + tree: { type: 'Card', props: { title: 'Dependency tree', collapsible: true, defaultCollapsed: true }, children: ['depTree'] }, + depTree: { type: 'Tree', props: { data: { $state: '/deps' }, defaultExpanded: true }, children: [] }, + + // ── Footer ──────────────────────────────────────────────────────────── + footerDivider: { type: 'Divider', props: {}, children: [] }, + footer: { type: 'Stack', props: { direction: 'row', gap: 6 }, children: ['footerLabel', 'footerValue', 'footerSuffix'] }, + footerLabel: { type: 'Text', props: { text: 'Rendered from a JSON-render spec · uptime', variant: 'caption', color: 'faint' }, children: [] }, + footerValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'caption', color: 'faint' }, children: [] }, + footerSuffix: { type: 'Text', props: { text: 's', variant: 'caption', color: 'faint' }, children: [] }, + }, + state: dashboardState, +} + +/** + * Create the dashboard JSON-render view on a devframe context and register the + * actions its spec dispatches. Shared by the standalone devframe and the hub + * shells (which additionally project it onto a `json-render` dock). Returns the + * view handle so callers can build a dock ref from it. + */ +export function createDashboardView(ctx: DevframeNodeContext): JsonRenderView { + const view = createJsonRenderView(ctx, { id: VIEW_ID, spec: dashboardSpec }) + + // The action bridge dispatches a spec action as an RPC call of the same + // name. `Refresh` re-samples the metrics. + ctx.rpc.register(defineRpcFunction({ + name: REFRESH_ACTION, + type: 'query', + jsonSerializable: true, + handler: () => { + const coverage = 70 + Math.floor(Math.random() * 30) + const bundle = 40 + Math.floor(Math.random() * 55) + view.patchState([ + { op: 'replace', path: '/metrics/coverage', value: coverage }, + { op: 'replace', path: '/metrics/bundle', value: bundle }, + ]) + return { coverage, bundle } + }, + })) + + // `Deploy` flips the DataTable into a loading state, then appends a module — + // demonstrating the per-action loading + a live spec/state change. + let deployed = 0 + ctx.rpc.register(defineRpcFunction({ + name: DEPLOY_ACTION, + type: 'query', + jsonSerializable: true, + handler: () => { + view.patchState([{ op: 'replace', path: '/building', value: true }]) + setTimeout(() => { + deployed += 1 + const modules = [ + ...(view.value().state?.modules as unknown[] ?? []), + { name: `@acme/edge-${deployed}`, size: '9 kB', status: 'ok' }, + ] + view.patchState([ + { op: 'replace', path: '/modules', value: modules }, + { op: 'replace', path: '/building', value: false }, + ]) + }, 1200) + return { building: true } + }, + })) + + // `Save` receives the bound form values as params (resolved client-side from + // `$state`) and writes the display name into the project metadata. + ctx.rpc.register(defineRpcFunction({ + name: SAVE_ACTION, + type: 'query', + jsonSerializable: true, + handler: (params?: { name?: string }) => { + const name = params?.name?.trim() + if (name) + view.patchState([{ op: 'replace', path: '/project/name', value: name }]) + return { saved: true } + }, + })) + + // Live, server-driven state: tick uptime every second in dev. Unref the + // timer so it never keeps a one-shot `build` run alive. + if (ctx.mode === 'dev') { + let uptime = 0 + const timer = setInterval(() => { + uptime += 1 + view.patchState([{ op: 'replace', path: '/uptime', value: uptime }]) + }, 1000) + timer.unref?.() + } + + return view +} diff --git a/examples/minimal-json-render/src/devframe.ts b/examples/minimal-json-render/src/devframe.ts index eb835c34..81af62de 100644 --- a/examples/minimal-json-render/src/devframe.ts +++ b/examples/minimal-json-render/src/devframe.ts @@ -1,128 +1,11 @@ -import type { DevframeJsonRenderSpec } from '@devframes/json-render' import { fileURLToPath } from 'node:url' -import { createJsonRenderView } from '@devframes/json-render/node' -import { defineRpcFunction } from 'devframe' import { defineDevframe } from 'devframe/types' import pkg from '../package.json' with { type: 'json' } -import { DEPLOY_ACTION, REFRESH_ACTION, SAVE_ACTION, VIEW_ID } from './shared.ts' +import { createDashboardView } from './dashboard.ts' const BASE_PATH = '/__minimal-json-render/' const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) -const VITE_CONFIG = `import { defineConfig } from 'vite' - -export default defineConfig({ - base: './', - build: { outDir: 'dist/client' }, -}) -` - -// Initial state model. Every value the spec reads via \`{ $state: '/…' }\` -// resolves from here and updates live as the server patches it. -const initialState = { - project: { name: 'acme-app', version: '1.4.2', license: 'MIT', repository: 'github.com/acme/app' }, - metrics: { coverage: 82, bundle: 64 }, - modules: [ - { name: '@acme/core', size: '42 kB', status: 'ok' }, - { name: '@acme/ui', size: '88 kB', status: 'ok' }, - { name: '@acme/cli', size: '17 kB', status: 'stale' }, - ], - deps: { - runtime: { vue: '^3.5', birpc: '^4.0' }, - dev: { vite: '^8.1', tsdown: '^0.22', vitest: '^4.1' }, - }, - form: { name: '', darkMode: true, notifications: false }, - building: false, - uptime: 0, -} - -// A server-authored spec exercising every base-catalog component. Values marked -// `{ $state: '/…' }` resolve from live state; `{ $bindState: '/…' }` are -// two-way inputs that write back into it. -const spec: DevframeJsonRenderSpec = { - root: 'root', - elements: { - root: { type: 'Stack', props: { gap: 16 }, children: ['header', 'overview', 'settings', 'modules', 'config', 'tree', 'footerDivider', 'footer'] }, - - // ── Header ──────────────────────────────────────────────────────────── - header: { type: 'Stack', props: { direction: 'row', gap: 10, align: 'center', justify: 'between' }, children: ['headLeft', 'headRight'] }, - headLeft: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['logo', 'headTitle'] }, - logo: { type: 'Icon', props: { name: 'ph:cube-duotone', size: 26 }, children: [] }, - headTitle: { type: 'Text', props: { text: 'Acme Dashboard', variant: 'heading' }, children: [] }, - headRight: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['status', 'refreshBtn', 'deployBtn'] }, - status: { type: 'Badge', props: { text: 'healthy', variant: 'success' }, children: [] }, - refreshBtn: { - type: 'Button', - props: { label: 'Refresh', variant: 'ghost', icon: 'ph:arrow-clockwise' }, - on: { press: { action: REFRESH_ACTION } }, - children: [], - }, - deployBtn: { - type: 'Button', - props: { label: 'Deploy', variant: 'primary', icon: 'ph:rocket-launch' }, - on: { press: { action: DEPLOY_ACTION } }, - children: [], - }, - - // ── Overview: KeyValueTable + Progress ──────────────────────────────── - overview: { type: 'Card', props: { title: 'Overview' }, children: ['overviewBody'] }, - overviewBody: { type: 'Stack', props: { gap: 14 }, children: ['meta', 'coverage', 'bundle'] }, - meta: { type: 'KeyValueTable', props: { data: { $state: '/project' } }, children: [] }, - coverage: { type: 'Progress', props: { label: 'Test coverage', value: { $state: '/metrics/coverage' }, max: 100 }, children: [] }, - bundle: { type: 'Progress', props: { label: 'Bundle budget', value: { $state: '/metrics/bundle' }, max: 100 }, children: [] }, - - // ── Settings: TextInput + Switch + Divider + Button ─────────────────── - settings: { type: 'Card', props: { title: 'Settings', collapsible: true }, children: ['settingsBody'] }, - settingsBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greeting', 'prefsDivider', 'darkSwitch', 'notifSwitch', 'saveBtn'] }, - nameInput: { type: 'TextInput', props: { label: 'Display name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] }, - greeting: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'caption', color: 'primary' }, children: [] }, - prefsDivider: { type: 'Divider', props: { label: 'preferences' }, children: [] }, - darkSwitch: { type: 'Switch', props: { label: 'Dark mode', value: { $bindState: '/form/darkMode' } }, children: [] }, - notifSwitch: { type: 'Switch', props: { label: 'Email notifications', value: { $bindState: '/form/notifications' } }, children: [] }, - saveBtn: { - type: 'Button', - props: { label: 'Save settings', variant: 'secondary', icon: 'ph:floppy-disk' }, - // Bound values are resolved into the action params before dispatch, so the - // server receives whatever the user typed/toggled. - on: { press: { action: SAVE_ACTION, params: { name: { $state: '/form/name' } } } }, - children: [], - }, - - // ── Modules: DataTable (loading bound to /building) ─────────────────── - modules: { type: 'Card', props: { title: 'Modules' }, children: ['modulesTable'] }, - modulesTable: { - type: 'DataTable', - props: { - columns: [ - { key: 'name', label: 'Module' }, - { key: 'size', label: 'Size' }, - { key: 'status', label: 'Status' }, - ], - rows: { $state: '/modules' }, - height: 180, - loading: { $state: '/building' }, - }, - children: [], - }, - - // ── Config: CodeBlock ───────────────────────────────────────────────── - config: { type: 'Card', props: { title: 'Config' }, children: ['code'] }, - code: { type: 'CodeBlock', props: { filename: 'vite.config.ts', language: 'ts', code: VITE_CONFIG }, children: [] }, - - // ── Dependency tree: Tree ───────────────────────────────────────────── - tree: { type: 'Card', props: { title: 'Dependency tree', collapsible: true, defaultCollapsed: true }, children: ['depTree'] }, - depTree: { type: 'Tree', props: { data: { $state: '/deps' }, defaultExpanded: true }, children: [] }, - - // ── Footer ──────────────────────────────────────────────────────────── - footerDivider: { type: 'Divider', props: {}, children: [] }, - footer: { type: 'Stack', props: { direction: 'row', gap: 6 }, children: ['footerLabel', 'footerValue', 'footerSuffix'] }, - footerLabel: { type: 'Text', props: { text: 'Rendered from a JSON-render spec · uptime', variant: 'caption', color: 'faint' }, children: [] }, - footerValue: { type: 'Text', props: { text: { $state: '/uptime' }, variant: 'caption', color: 'faint' }, children: [] }, - footerSuffix: { type: 'Text', props: { text: 's', variant: 'caption', color: 'faint' }, children: [] }, - }, - state: initialState, -} - export default defineDevframe({ id: 'minimal-json-render', name: 'Minimal JSON-Render', @@ -142,72 +25,6 @@ export default defineDevframe({ }, spa: { loader: 'none' }, setup(ctx) { - const view = createJsonRenderView(ctx, { id: VIEW_ID, spec }) - - // The action bridge dispatches a spec action as an RPC call of the same - // name. `Refresh` re-samples the metrics. - ctx.rpc.register(defineRpcFunction({ - name: REFRESH_ACTION, - type: 'query', - jsonSerializable: true, - handler: () => { - const coverage = 70 + Math.floor(Math.random() * 30) - const bundle = 40 + Math.floor(Math.random() * 55) - view.patchState([ - { op: 'replace', path: '/metrics/coverage', value: coverage }, - { op: 'replace', path: '/metrics/bundle', value: bundle }, - ]) - return { coverage, bundle } - }, - })) - - // `Deploy` flips the DataTable into a loading state, then appends a module — - // demonstrating the per-action loading + a live spec/state change. - let deployed = 0 - ctx.rpc.register(defineRpcFunction({ - name: DEPLOY_ACTION, - type: 'query', - jsonSerializable: true, - handler: () => { - view.patchState([{ op: 'replace', path: '/building', value: true }]) - setTimeout(() => { - deployed += 1 - const modules = [ - ...(view.value().state?.modules as unknown[] ?? []), - { name: `@acme/edge-${deployed}`, size: '9 kB', status: 'ok' }, - ] - view.patchState([ - { op: 'replace', path: '/modules', value: modules }, - { op: 'replace', path: '/building', value: false }, - ]) - }, 1200) - return { building: true } - }, - })) - - // `Save` receives the bound form values as params (resolved client-side from - // `$state`) and writes the display name into the project metadata. - ctx.rpc.register(defineRpcFunction({ - name: SAVE_ACTION, - type: 'query', - jsonSerializable: true, - handler: (params?: { name?: string }) => { - const name = params?.name?.trim() - if (name) - view.patchState([{ op: 'replace', path: '/project/name', value: name }]) - return { saved: true } - }, - })) - - // Live, server-driven state: tick uptime every second in dev. Unref the - // timer so it never keeps a one-shot `build` run alive. - if (ctx.mode === 'dev') { - let uptime = 0 - const timer = setInterval(() => { - uptime += 1 - view.patchState([{ op: 'replace', path: '/uptime', value: uptime }]) - }, 1000) - timer.unref?.() - } + createDashboardView(ctx) }, }) diff --git a/examples/minimal-vite-devframe-hub/index.html b/examples/minimal-vite-devframe-hub/index.html index d34a1bbd..538f682e 100644 --- a/examples/minimal-vite-devframe-hub/index.html +++ b/examples/minimal-vite-devframe-hub/index.html @@ -30,6 +30,8 @@

Docks + + diff --git a/examples/minimal-vite-devframe-hub/package.json b/examples/minimal-vite-devframe-hub/package.json index d9825e2f..b6565583 100644 --- a/examples/minimal-vite-devframe-hub/package.json +++ b/examples/minimal-vite-devframe-hub/package.json @@ -13,6 +13,8 @@ "dependencies": { "@antfu/design": "catalog:frontend", "@devframes/hub": "workspace:*", + "@devframes/json-render": "workspace:*", + "@devframes/json-render-ui": "workspace:*", "@devframes/plugin-a11y": "workspace:*", "@devframes/plugin-code-server": "workspace:*", "@devframes/plugin-data-inspector": "workspace:*", @@ -21,10 +23,13 @@ "@devframes/plugin-messages": "workspace:*", "@devframes/plugin-terminals": "workspace:*", "colorjs.io": "catalog:frontend", - "devframe": "workspace:*" + "devframe": "workspace:*", + "minimal-json-render": "workspace:*", + "vue": "catalog:frontend" }, "devDependencies": { "@iconify-json/ph": "catalog:frontend", + "@vitejs/plugin-vue": "catalog:build", "get-port-please": "catalog:deps", "pathe": "catalog:deps", "unocss": "catalog:frontend", diff --git a/examples/minimal-vite-devframe-hub/src/client/env.d.ts b/examples/minimal-vite-devframe-hub/src/client/env.d.ts index 01b43943..2b1ad040 100644 --- a/examples/minimal-vite-devframe-hub/src/client/env.d.ts +++ b/examples/minimal-vite-devframe-hub/src/client/env.d.ts @@ -1,3 +1,13 @@ /// declare module 'virtual:uno.css' + +// The JSON-render dock renderer (@devframes/json-render-ui, resolved to source +// in the workspace) wraps `@antfu/design` `.vue` components — declare the module +// so `tsc` resolves them. +declare module '*.vue' { + import type { DefineComponent } from 'vue' + + const component: DefineComponent + export default component +} diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts index de05fc77..f4e6afc4 100644 --- a/examples/minimal-vite-devframe-hub/src/client/main.ts +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -5,6 +5,7 @@ import type { DevframeTerminalSession, } from '@devframes/hub/types' import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' +import { createJsonRenderDockRenderer } from '@devframes/json-render-ui' import { iconClass } from './icons' import 'virtual:uno.css' import '@antfu/design/styles.css' @@ -18,8 +19,11 @@ const messagesEl = document.querySelector('#messages')! const terminalsEl = document.querySelector('#terminals')! const pingBtn = document.querySelector('#ping')! const iframeEl = document.querySelector('#dock-iframe')! +const panelEl = document.querySelector('#dock-panel')! let selectedDockId: string | null = null +// Disposer for the currently mounted renderer dock (e.g. json-render). +let disposePanel: (() => void) | null = null function setStatus(text: string, kind?: 'ready' | 'error') { const dot = kind === 'ready' ? 'bg-success' : kind === 'error' ? 'bg-error' : 'bg-neutral-400' @@ -47,6 +51,13 @@ function isIframeDock(d: DevframeDockEntry): d is DevframeDockEntry & { type: 'i return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string' } +// A dock this shell can display: an iframe, or one with a registered renderer +// (e.g. the json-render dock, rendered by @devframes/json-render-ui). +const RENDERER_TYPES = new Set(['json-render']) +function isRenderableDock(d: DevframeDockEntry): boolean { + return isIframeDock(d) || RENDERER_TYPES.has(d.type) +} + async function main() { setStatus('Connecting…') @@ -57,7 +68,13 @@ async function main() { // and imports each dock's client script into this page — e.g. the a11y // inspector's in-page agent, which then scans this host live. The dock UI // below still reads the same shared state directly. - await createDevframeClientHost({ rpc }) + // + // Register the JSON-render dock renderer so the hub can display a + // `json-render` dock (authored server-side via @devframes/json-render). + const host = await createDevframeClientHost({ + rpc, + renderers: { 'json-render': createJsonRenderDockRenderer() }, + }) // 1. Docks — read from `devframe:docks` shared state. const docks = await rpc.sharedState.get( @@ -65,26 +82,63 @@ async function main() { { initialValue: [] }, ) + // The dock currently mounted into the viewport (iframe or renderer panel). + let mountedDockId: string | null = null + + async function applySelection(list: DevframeDockEntry[]): Promise { + if (selectedDockId === mountedDockId) + return + mountedDockId = selectedDockId + // Tear down any active renderer mount (json-render) before switching. + disposePanel?.() + disposePanel = null + + const entry = list.find(d => d.id === selectedDockId) ?? null + if (!entry) { + iframeEl.hidden = false + iframeEl.src = 'about:blank' + panelEl.hidden = true + return + } + if (isIframeDock(entry)) { + panelEl.hidden = true + panelEl.innerHTML = '' + iframeEl.hidden = false + iframeEl.src = entry.url + } + else { + // A renderer dock (json-render): mount it into the panel via the client + // host's renderer registry. The Vue app subscribes to the view's shared + // state and updates live. + iframeEl.hidden = true + iframeEl.src = 'about:blank' + panelEl.hidden = false + panelEl.innerHTML = '' + disposePanel = await host.context.renderers.mount(entry, panelEl) + } + } + const renderDocks = () => { - const iframeDocks = (docks.value() ?? []).filter(isIframeDock) + const list = (docks.value() ?? []).filter(isRenderableDock) - if (selectedDockId && !iframeDocks.some(d => d.id === selectedDockId)) + if (selectedDockId && !list.some(d => d.id === selectedDockId)) selectedDockId = null - if (!selectedDockId && iframeDocks.length > 0) - selectedDockId = iframeDocks[0].id - - if (!iframeDocks.length) { - docksEl.innerHTML = '
  • No iframe docks
  • ' + if (!selectedDockId && list.length > 0) + selectedDockId = list[0].id + + if (!list.length) { + docksEl.innerHTML = '
  • No docks
  • ' + mountedDockId = null + disposePanel?.() + disposePanel = null iframeEl.src = 'about:blank' return } - renderList(docksEl, iframeDocks, d => + renderList(docksEl, list, d => `
  • `) - const selected = iframeDocks.find(d => d.id === selectedDockId) - if (selected && iframeEl.getAttribute('src') !== selected.url) - iframeEl.src = selected.url + void applySelection(list) } docksEl.addEventListener('click', (event) => { diff --git a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts index 9d53c85b..1b3351ce 100644 --- a/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts +++ b/examples/minimal-vite-devframe-hub/src/minimal-vite-devframe-hub.ts @@ -24,6 +24,12 @@ export interface MinimalViteDevframeHubOptions { * (e.g. the a11y inspector's in-page agent). */ clientScripts?: Record + /** + * Called once the hub context is created (after devframes are mounted), + * before the server starts. Lets the composition register extra surfaces on + * the context — e.g. a `json-render` dock via `@devframes/json-render`. + */ + onContextReady?: (context: DevframeHubContext) => void | Promise } // Minimal hub-local RPCs — used by the UI for read-side data. A more @@ -158,6 +164,8 @@ export function minimalViteDevframeHub(options: MinimalViteDevframeHubOptions = await mountDevframe(context, def, clientScript ? { dock: { clientScript } } : undefined) } + await options.onContextReady?.(context) + started = await startHttpAndWs({ context, port, diff --git a/examples/minimal-vite-devframe-hub/uno.config.ts b/examples/minimal-vite-devframe-hub/uno.config.ts index 120a8807..03a09b2e 100644 --- a/examples/minimal-vite-devframe-hub/uno.config.ts +++ b/examples/minimal-vite-devframe-hub/uno.config.ts @@ -25,6 +25,9 @@ export default defineConfig({ // Wind4 leaves bare `border`/`border-b` at currentColor; restore the subtle // shared border color (matching `border-base`) for unqualified borders. preflights: [{ getCSS: () => '*,::before,::after{border-color:#8882}' }], + // The JSON-render dock renders @devframes/json-render-ui, whose `Badge` picks + // a `badge-color-` at runtime — safelist the fixed set. + safelist: ['badge-color-green', 'badge-color-amber', 'badge-color-red', 'badge-color-blue'], shortcuts: { 'z-nav': 'z-[30]', 'z-dropdown': 'z-[40]', @@ -35,5 +38,6 @@ export default defineConfig({ 'z-drawer-backdrop': 'z-[80]', 'z-drawer-content': 'z-[90]', }, - content: { pipeline: { include: [/\.(?:[cm]?[jt]sx?|html)($|\?)/] } }, + // Also scan `.vue` — the JSON-render dock pulls in @antfu/design SFCs. + content: { pipeline: { include: [/\.(?:vue|[cm]?[jt]sx?|html)($|\?)/] } }, }) diff --git a/examples/minimal-vite-devframe-hub/vite.config.ts b/examples/minimal-vite-devframe-hub/vite.config.ts index c8e1a732..a8611015 100644 --- a/examples/minimal-vite-devframe-hub/vite.config.ts +++ b/examples/minimal-vite-devframe-hub/vite.config.ts @@ -1,3 +1,4 @@ +import { toJsonRenderDockEntry } from '@devframes/json-render/hub' import a11yDevframe, { a11yAgentBundlePath } from '@devframes/plugin-a11y' import codeServerDevframe from '@devframes/plugin-code-server' import dataInspectorDevframe from '@devframes/plugin-data-inspector' @@ -6,6 +7,8 @@ import gitDevframe from '@devframes/plugin-git' import inspectDevframe from '@devframes/plugin-inspect' import messagesDevframe from '@devframes/plugin-messages' import terminalsDevframe from '@devframes/plugin-terminals' +import vue from '@vitejs/plugin-vue' +import { createDashboardView } from 'minimal-json-render/dashboard' import UnoCSS from 'unocss/vite' import { defineConfig } from 'vite' import { alias } from '../../alias' @@ -18,7 +21,11 @@ export default defineConfig({ // Dev tooling reached from arbitrary hostnames (LAN IPs, tunnels, tailnets): // accept any Host header and fall back to the next free port when busy. server: { allowedHosts: true, strictPort: false }, + // @antfu/design (pulled in by the JSON-render dock renderer) ships raw `.vue`; + // let @vitejs/plugin-vue compile its SFCs instead of esbuild pre-bundling. + optimizeDeps: { exclude: ['@antfu/design', '@devframes/json-render-ui'] }, plugins: [ + vue(), UnoCSS(), { // The host registers its own live objects as data-inspector sources — @@ -69,6 +76,18 @@ export default defineConfig({ clientScripts: { [a11yDevframe.id]: { importFrom: `/@fs/${a11yAgentBundlePath}` }, }, + // Dogfood the opt-in JSON-render hub integration: author a view on the + // hub context and project it onto a `json-render` dock. The client host + // (src/client/main.ts) renders it via @devframes/json-render-ui. + onContextReady: (context) => { + const view = createDashboardView(context) + context.docks.register(toJsonRenderDockEntry(view, { + id: 'minimal-json-render', + title: 'JSON Render', + icon: 'ph:layout-duotone', + category: 'app', + })) + }, }), ], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f4a7efe..abc9f126 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -566,6 +566,12 @@ importers: '@devframes/hub': specifier: workspace:* version: link:../../packages/hub + '@devframes/json-render': + specifier: workspace:* + version: link:../../packages/json-render + '@devframes/json-render-ui': + specifier: workspace:* + version: link:../../packages/json-render-ui '@devframes/plugin-a11y': specifier: workspace:* version: link:../../plugins/a11y @@ -593,10 +599,19 @@ importers: devframe: specifier: workspace:* version: link:../../packages/devframe + minimal-json-render: + specifier: workspace:* + version: link:../minimal-json-render + vue: + specifier: catalog:frontend + version: 3.5.40(typescript@6.0.3) devDependencies: '@iconify-json/ph': specifier: catalog:frontend version: 1.2.2 + '@vitejs/plugin-vue': + specifier: catalog:build + version: 6.0.8(vite@8.1.5(@types/node@26.1.1)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.1)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3)) get-port-please: specifier: catalog:deps version: 3.2.0 diff --git a/tsconfig.base.json b/tsconfig.base.json index aa7b8b76..fef22d36 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -142,6 +142,9 @@ "@devframes/json-render-ui": [ "./packages/json-render-ui/src/index.ts" ], + "minimal-json-render/dashboard": [ + "./examples/minimal-json-render/src/dashboard.ts" + ], "@devframes/plugin-code-server/client": [ "./plugins/code-server/src/client/index.ts" ], From 615ff68a3c7dc9541656816d72f35890928284ba Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Wed, 22 Jul 2026 02:50:06 +0000 Subject: [PATCH 08/10] feat(examples): render a json-render dock in the minimal-next hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify the JSON-render hub integration in the React/Next shell via registry replacement (the path a non-Vue host uses, per the plan): - The Next hub authors the shared minimal-json-render view on its hub context and projects it onto a `json-render` dock (via @devframes/json-render/hub). - A small in-example React registry (built on @json-render/react + the shared @antfu/design tokens) is registered as the `json-render` renderer on the client host; the page mounts the active dock into a panel via the renderer registry. Next.js can't compile Vue SFCs, so the Vue @devframes/json-render-ui isn't used here — this is registry replacement. - UnoCSS scans the new dir + safelists the badge colors. Docs: note the json-render dock in both hub example pages and the guide. Created with the help of an agent. --- docs/examples/minimal-next-devframe-hub.md | 1 + docs/examples/minimal-vite-devframe-hub.md | 1 + docs/guide/json-render.md | 4 + examples/minimal-json-render/README.md | 9 + .../minimal-next-devframe-hub/package.json | 3 + .../src/client/app/page.tsx | 80 +++- .../devframe/minimal-next-devframe-hub.ts | 13 + .../src/client/json-render/dock-renderer.tsx | 126 +++++++ .../src/client/json-render/registry.tsx | 357 ++++++++++++++++++ .../minimal-next-devframe-hub/uno.config.ts | 9 +- pnpm-lock.yaml | 24 ++ pnpm-workspace.yaml | 1 + 12 files changed, 611 insertions(+), 17 deletions(-) create mode 100644 examples/minimal-next-devframe-hub/src/client/json-render/dock-renderer.tsx create mode 100644 examples/minimal-next-devframe-hub/src/client/json-render/registry.tsx diff --git a/docs/examples/minimal-next-devframe-hub.md b/docs/examples/minimal-next-devframe-hub.md index eb93375d..6d42588b 100644 --- a/docs/examples/minimal-next-devframe-hub.md +++ b/docs/examples/minimal-next-devframe-hub.md @@ -15,6 +15,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)** - `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock. - The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. - The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json`, which starts the singleton host on demand. +- The [JSON-render](/guide/json-render) hub integration with **registry replacement**: the host authors a view and projects it onto a `json-render` dock, and the React client renders it with a small in-example React registry (rather than the Vue `@devframes/json-render-ui`) — the path a non-Vue host uses. ## Run it diff --git a/docs/examples/minimal-vite-devframe-hub.md b/docs/examples/minimal-vite-devframe-hub.md index e748eb96..1d27f4b8 100644 --- a/docs/examples/minimal-vite-devframe-hub.md +++ b/docs/examples/minimal-vite-devframe-hub.md @@ -15,6 +15,7 @@ Package: `minimal-vite-devframe-hub` · framework: **Vanilla TypeScript (Vite)** - `mountDevframe(ctx, def)` registers any `DevframeDefinition` as a dock. - The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. - The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the kit's `__connection.json` middleware. +- The opt-in [JSON-render](/guide/json-render) hub integration end to end: the host authors a view on its hub context and projects it onto a `json-render` dock, and the client host renders it via `@devframes/json-render-ui` (registered through `createDevframeClientHost({ renderers })`). ## Run it diff --git a/docs/guide/json-render.md b/docs/guide/json-render.md index 3d04e0a3..d064fae7 100644 --- a/docs/guide/json-render.md +++ b/docs/guide/json-render.md @@ -174,6 +174,10 @@ upstreamVersion }`) — no functions cross the wire. The client host disposes th renderer when the dock deactivates. A renderer/upstream-version mismatch logs a warning rather than blocking. +Both hub example shells dogfood this end to end: the [Vite hub](/examples/minimal-vite-devframe-hub) +registers `@devframes/json-render-ui` (Vue), and the [Next hub](/examples/minimal-next-devframe-hub) +registers a small in-example React registry — the same dock, two frontends. + ## Swapping the frontend A third party replaces the whole registry — pass a custom `registry` to diff --git a/examples/minimal-json-render/README.md b/examples/minimal-json-render/README.md index 6f0445df..64f92696 100644 --- a/examples/minimal-json-render/README.md +++ b/examples/minimal-json-render/README.md @@ -33,3 +33,12 @@ node bin.mjs build --out-dir dist/static # static snapshot (read-only; actions In the static build, the spec + state are snapshotted as a read-only render and the action bridge reports actions as unavailable — there is no live RPC. + +## Reused by the hub shells + +The view is factored into `src/dashboard.ts` and exported as +`minimal-json-render/dashboard` (`createDashboardView(ctx)` + `dashboardSpec`), +so the hub examples plug the very same view into their hub context and project +it onto a `json-render` dock — the [Vite hub](../minimal-vite-devframe-hub) +renders it with `@devframes/json-render-ui` (Vue), and the +[Next hub](../minimal-next-devframe-hub) renders it with a small React registry. diff --git a/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json index a43a13c0..4c788936 100644 --- a/examples/minimal-next-devframe-hub/package.json +++ b/examples/minimal-next-devframe-hub/package.json @@ -13,14 +13,17 @@ "dependencies": { "@antfu/design": "catalog:frontend", "@devframes/hub": "workspace:*", + "@devframes/json-render": "workspace:*", "@devframes/plugin-a11y": "workspace:*", "@devframes/plugin-code-server": "workspace:*", "@devframes/plugin-git": "workspace:*", "@devframes/plugin-inspect": "workspace:*", "@devframes/plugin-messages": "workspace:*", "@devframes/plugin-terminals": "workspace:*", + "@json-render/react": "catalog:frontend", "colorjs.io": "catalog:frontend", "devframe": "workspace:*", + "minimal-json-render": "workspace:*", "next": "catalog:frontend", "react": "catalog:frontend", "react-dom": "catalog:frontend" diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx index 5f23c4af..f72b6b63 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -9,6 +9,7 @@ import type { } from '@devframes/hub/types' import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' import { useEffect, useMemo, useRef, useState } from 'react' +import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer' import { iconClass } from './icons' const HUB_BASE = '/__hub/' @@ -20,11 +21,19 @@ interface Status { type IframeDock = DevframeDockEntry & { type: 'iframe', url: string } type TerminalSummary = Pick +type ClientHost = Awaited> function isIframeDock(d: DevframeDockEntry): d is IframeDock { return d.type === 'iframe' && typeof (d as { url?: unknown }).url === 'string' } +// A dock this shell can display: an iframe, or one with a registered renderer +// (the json-render dock, rendered by the mini React registry). +const RENDERER_TYPES = new Set(['json-render']) +function isRenderableDock(d: DevframeDockEntry): boolean { + return isIframeDock(d) || RENDERER_TYPES.has(d.type) +} + /** Render a dock icon, falling back to the title's initial when unmapped. */ function DockIcon({ entry }: { entry: DevframeDockEntry }) { const cls = iconClass(entry.icon) @@ -43,6 +52,8 @@ export default function Page() { const [pingResult, setPingResult] = useState('Run ping') const [selectedDockId, setSelectedDockId] = useState(null) const rpcRef = useRef(null) + const hostRef = useRef(null) + const panelRef = useRef(null) useEffect(() => { let cancelled = false @@ -60,7 +71,14 @@ export default function Page() { // Boot the framework-level client host: it builds the shared client // context and imports each dock's client script into this page — e.g. // the a11y inspector's in-page agent, which then scans this hub live. - const clientHost = await createDevframeClientHost({ rpc }) + // + // Register a mini React json-render renderer so the hub can display the + // `json-render` dock authored server-side via @devframes/json-render. + const clientHost = await createDevframeClientHost({ + rpc, + renderers: { 'json-render': createReactJsonRenderDockRenderer() }, + }) + hostRef.current = clientHost const docksState = await rpc.sharedState.get( 'devframe:docks', @@ -122,18 +140,41 @@ export default function Page() { } }, []) - const iframeDocks = useMemo(() => docks.filter(isIframeDock), [docks]) + const renderableDocks = useMemo(() => docks.filter(isRenderableDock), [docks]) useEffect(() => { - if (selectedDockId && !iframeDocks.some(d => d.id === selectedDockId)) { + if (selectedDockId && !renderableDocks.some(d => d.id === selectedDockId)) { setSelectedDockId(null) return } - if (!selectedDockId && iframeDocks.length > 0) - setSelectedDockId(iframeDocks[0].id) - }, [iframeDocks, selectedDockId]) + if (!selectedDockId && renderableDocks.length > 0) + setSelectedDockId(renderableDocks[0].id) + }, [renderableDocks, selectedDockId]) - const selectedDock = iframeDocks.find(d => d.id === selectedDockId) ?? null + const selectedDock = renderableDocks.find(d => d.id === selectedDockId) ?? null + const selectedIsIframe = selectedDock ? isIframeDock(selectedDock) : false + + // Mount a renderer dock (json-render) into the panel via the client host's + // renderer registry, disposing when the selection changes. Keyed by dock id + // so a live view-state update (its own shared state) doesn't remount. + useEffect(() => { + const host = hostRef.current + const dock = selectedDock + const container = panelRef.current + if (!host || !dock || isIframeDock(dock) || !container) + return + let alive = true + let dispose: (() => void) | undefined + void host.context.renderers.mount(dock, container).then((d) => { + if (alive) + dispose = d + else d() + }) + return () => { + alive = false + dispose?.() + } + }, [selectedDockId, selectedIsIframe]) async function ping() { if (!rpcRef.current) @@ -169,9 +210,9 @@ export default function Page() {