diff --git a/alias.ts b/alias.ts index 90ba9797..a7a63e24 100644 --- a/alias.ts +++ b/alias.ts @@ -46,6 +46,13 @@ 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'), + '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/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 8d2c716b..0580692d 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` }, @@ -77,6 +78,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/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/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/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/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..d064fae7 --- /dev/null +++ b/docs/guide/json-render.md @@ -0,0 +1,189 @@ +--- +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. + +### 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 + +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. + +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 +`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..64f92696 --- /dev/null +++ b/examples/minimal-json-render/README.md @@ -0,0 +1,44 @@ +# 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. 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 + 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. + +## 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. + +## 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-json-render/bin.mjs b/examples/minimal-json-render/bin.mjs new file mode 100755 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..5a6f69b7 --- /dev/null +++ b/examples/minimal-json-render/package.json @@ -0,0 +1,36 @@ +{ + "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", + "exports": { + ".": "./src/devframe.ts", + "./dashboard": "./src/dashboard.ts" + }, + "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..02e6de67 --- /dev/null +++ b/examples/minimal-json-render/src/client/shims.d.ts @@ -0,0 +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 new file mode 100644 index 00000000..56bbf6fa --- /dev/null +++ b/examples/minimal-json-render/src/client/vite.config.ts @@ -0,0 +1,19 @@ +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()], + // `@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/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 new file mode 100644 index 00000000..81af62de --- /dev/null +++ b/examples/minimal-json-render/src/devframe.ts @@ -0,0 +1,30 @@ +import { fileURLToPath } from 'node:url' +import { defineDevframe } from 'devframe/types' +import pkg from '../package.json' with { type: 'json' } +import { createDashboardView } from './dashboard.ts' + +const BASE_PATH = '/__minimal-json-render/' +const distDir = fileURLToPath(new URL('../dist/client', import.meta.url)) + +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 RPCs without an OTP round-trip. + auth: false, + }, + spa: { loader: 'none' }, + setup(ctx) { + createDashboardView(ctx) + }, +}) diff --git a/examples/minimal-json-render/src/shared.ts b/examples/minimal-json-render/src/shared.ts new file mode 100644 index 00000000..b3a571e1 --- /dev/null +++ b/examples/minimal-json-render/src/shared.ts @@ -0,0 +1,18 @@ +// Shared constants used by both the node devframe and the browser SPA. + +/** Author-supplied, stable view id. */ +export const VIEW_ID = 'dashboard' + +/** + * 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}` + +// 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/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..5709e9f4 --- /dev/null +++ b/examples/minimal-json-render/uno.config.ts @@ -0,0 +1,40 @@ +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}' }], + // `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]', + '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/examples/minimal-next-devframe-hub/package.json b/examples/minimal-next-devframe-hub/package.json index b06e7ed3..73ce29e3 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/layout.tsx b/examples/minimal-next-devframe-hub/src/client/app/layout.tsx index 9ba841f9..1ef0c233 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/layout.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/layout.tsx @@ -13,11 +13,11 @@ const themeScript = `(function(){try{if(window.matchMedia('(prefers-color-scheme export default function RootLayout({ children }: { children: ReactNode }) { return ( - +