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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const alias = {
'@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/spa': r('json-render-ui/src/spa.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'),
Expand Down
13 changes: 6 additions & 7 deletions docs/examples/minimal-json-render.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ 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.
`@json-render/core` spec once, and the prebuilt `@devframes/json-render-ui` SPA
renders it — the app ships no client build of its own.

Package: `minimal-json-render` · framework: **Vue + Vite**
Package: `minimal-json-render` · frontend: **prebuilt `@devframes/json-render-ui/spa`**

## What it shows

Expand All @@ -20,17 +20,16 @@ Package: `minimal-json-render` · framework: **Vue + Vite**
- **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`.
- **Out-of-box SPA** — `createJsonRenderDevframe` points `cli.distDir` at the
prebuilt `@devframes/json-render-ui/spa`, which discovers the view from the
view index and renders it — no client build in the example.
- **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
```

Expand Down
46 changes: 44 additions & 2 deletions docs/guide/json-render.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,44 @@ validation.

## 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`:
### Out-of-box SPA (no client build)

`@devframes/json-render-ui/spa` ships a prebuilt renderer, so an app serves a
JSON-render UI without authoring or building any client. Wrap the definition
with `createJsonRenderDevframe` — it points `cli.distDir` at the shipped SPA
(`jsonRenderSpaDir`) and sets `spa.loader: 'none'`:

```ts
import { createJsonRenderDevframe } from '@devframes/json-render-ui/spa'
import { createJsonRenderView } from '@devframes/json-render/node'

export default createJsonRenderDevframe({
id: 'my-app',
name: 'My App',
version,
packageName,
homepage,
description,
cli: { command: 'my-app', port: 9800 },
setup(ctx) {
createJsonRenderView(ctx, { id: 'main', title: 'Dashboard', spec })
},
})
```

The SPA discovers views from the **view index** (`JSON_RENDER_INDEX_KEY`), a
shared state the node factory maintains as views are created and disposed. A
single view renders full-bleed on its own; once more than one view is
registered, a top bar appears with the shared segmented switcher, labelling each
view with its `title` (defaulting to the view id). The
`@devframes/json-render-ui/spa` entry is node-safe — it exposes only the asset
path and the wiring helper, pulling in no Vue.

### Custom frontend

To render with your own client, supply the frontend lib and let devframe serve
its SPA. Connect, read the view's shared state, and render it with
`JsonRenderView`:

```ts
import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
Expand Down Expand Up @@ -185,5 +221,11 @@ A third party replaces the whole registry — pass a custom `registry` to
`@devframes/json-render-ui` is the reference implementation, not a hard
dependency of the protocol; the hub acquires no Vue.

A frontend need not implement every component. When a spec references a
component the active registry lacks, the renderer isolates that element behind a
placeholder — showing the component type and a gist of its prop keys (`{ label,
onPress }`) — and logs a `console.warn`, while the rest of the view renders
normally.

See the [`minimal-json-render` example](/examples/minimal-json-render) for a
runnable end-to-end app.
8 changes: 4 additions & 4 deletions examples/minimal-json-render/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ dashboard** that exercises **every base-catalog component** — `Stack`, `Card`,
`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.
- **`@devframes/json-render-ui/spa`** — the prebuilt out-of-box SPA. This example
has **no client build**: `createJsonRenderDevframe(...)` points `cli.distDir` at
the shipped renderer, which discovers the view from the JSON-render view index
and renders it with `JsonRenderView`.

## 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)
```

Expand Down
10 changes: 2 additions & 8 deletions examples/minimal-json-render/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,17 @@
"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"
"devframe": "workspace:*"
},
"devDependencies": {
"@vitejs/plugin-vue": "catalog:build",
"get-port-please": "catalog:deps",
"unocss": "catalog:frontend",
"vite": "catalog:build"
"get-port-please": "catalog:deps"
}
}
54 changes: 0 additions & 54 deletions examples/minimal-json-render/src/client/main.ts

This file was deleted.

19 changes: 0 additions & 19 deletions examples/minimal-json-render/src/client/vite.config.ts

This file was deleted.

2 changes: 1 addition & 1 deletion examples/minimal-json-render/src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export const dashboardSpec: DevframeJsonRenderSpec = {
* 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 })
const view = createJsonRenderView(ctx, { id: VIEW_ID, title: 'Dashboard', spec: dashboardSpec })

// The action bridge dispatches a spec action as an RPC call of the same
// name. `Refresh` re-samples the metrics.
Expand Down
15 changes: 6 additions & 9 deletions examples/minimal-json-render/src/devframe.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,26 @@
import { fileURLToPath } from 'node:url'
import { defineDevframe } from 'devframe/types'
import { createJsonRenderDevframe } from '@devframes/json-render-ui/spa'
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({
// `createJsonRenderDevframe` presets `spa.loader: 'none'` and points
// `cli.distDir` at the prebuilt `@devframes/json-render-ui` SPA, so this
// example serves the out-of-box renderer with no client build of its own.
export default createJsonRenderDevframe({
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,
basePath: '/__minimal-json-render/',
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)
},
Expand Down
11 changes: 2 additions & 9 deletions examples/minimal-json-render/src/shared.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
// Shared constants used by both the node devframe and the browser SPA.
// Shared constants for the node devframe. The prebuilt SPA discovers the view
// from the JSON-render view index, so no client-side view id/key is needed.

/** Author-supplied, stable view id. */
export const VIEW_ID = 'dashboard'

/**
* The view's shared-state key. `createJsonRenderView` derives it as
* `devframe:json-render:<scope>:<id>`; 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'
Expand Down
3 changes: 2 additions & 1 deletion packages/json-render-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@
"exports": {
".": "./dist/index.mjs",
"./components": "./dist/components/index.mjs",
"./spa": "./dist/spa.mjs",
"./package.json": "./package.json"
},
"types": "./dist/index.d.mts",
"files": [
"dist"
],
"scripts": {
"build": "tsdown",
"build": "tsdown && vite build --config src/spa/vite.config.ts",
"watch": "tsdown --watch",
"typecheck": "tsc --noEmit",
"storybook": "storybook dev -p 6014 --host 0.0.0.0",
Expand Down
29 changes: 29 additions & 0 deletions packages/json-render-ui/src/JsonRender.stories.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Spec } from '@devframes/json-render'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { h } from 'vue'
import { baseRegistry } from './registry'
import { JsonRenderView } from './renderer'

// A no-op RPC — stories don't dispatch real actions.
Expand Down Expand Up @@ -74,3 +75,31 @@ export const InvalidElement: StoryObj = story({
bad: { type: 'Badge', props: { variant: 'purple' }, children: [] },
},
})

// An element whose `type` is absent from the registry renders behind the
// unsupported placeholder (type + prop-keys gist), while its siblings render.
export const UnsupportedComponent: StoryObj = story({
root: 'root',
elements: {
root: { type: 'Stack', props: { gap: 8 }, children: ['ok', 'chart', 'more'] },
ok: { type: 'Text', props: { text: 'This view uses a component the frontend does not ship.' }, children: [] },
chart: { type: 'Fancy3DChart', props: { data: [1, 2, 3], title: 'Revenue', animate: true }, children: [] },
more: { type: 'Badge', props: { text: 'still renders', variant: 'success' }, children: [] },
},
})

// A frontend that supports only a subset of the catalog: rendering with a
// registry missing `DataTable` placeholders that element, everything else
// renders normally.
const { DataTable: _omitted, ...subsetRegistry } = baseRegistry as Record<string, unknown>
export const SubsetRegistry: StoryObj = story(
{
root: 'root',
elements: {
root: { type: 'Stack', props: { gap: 8 }, children: ['title', 'table'] },
title: { type: 'Text', props: { text: 'Registry without DataTable', variant: 'heading' }, children: [] },
table: { type: 'DataTable', props: { rows: [{ id: 1, name: 'a' }], height: 120 }, children: [] },
},
},
{ registry: subsetRegistry },
)
25 changes: 25 additions & 0 deletions packages/json-render-ui/src/components/_unsupported.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { JrComponent } from './_shared'
import { h } from 'vue'

/** Format a list of prop keys as a compact gist, e.g. `{ foo, bar }`. */
export function formatPropKeys(keys: readonly string[]): string {
return keys.length ? `{ ${keys.join(', ')} }` : '{}'
}

/**
* Internal placeholder rendered in place of an element whose `type` is not in
* the active registry — i.e. a component this frontend does not support. The
* rest of the view still renders. Not part of the public catalog.
*
* Shows three lines using the design system's semantic tokens: a label, the
* unsupported component type, and a gist of the element's prop keys.
*/
export const JsonRenderUnsupported: JrComponent<{ type?: string, keys?: string[] }> = ({ props }) =>
h('div', {
class: 'flex flex-wrap gap-x-2 gap-y-1 gap-0.5 rounded text-center border-2 border-dashed border-orange:50 bg-orange:5 text-xs px2 py1',
role: 'note',
}, [
h('div', { class: 'text-orange' }, 'Unsupported component:'),
h('div', { class: 'font-semibold text-orange font-mono' }, props.type ?? 'unknown'),
h('div', { class: 'color-faint font-mono' }, formatPropKeys(props.keys ?? [])),
])
2 changes: 1 addition & 1 deletion packages/json-render-ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ export type {
JsonRenderDockRendererOptions,
} from './dock-renderer'

export { baseRegistry, ERROR_COMPONENT_TYPE } from './registry'
export { baseRegistry, ERROR_COMPONENT_TYPE, UNSUPPORTED_COMPONENT_TYPE } from './registry'
export { createRenderer, JsonRenderView, sanitizeSpec } from './renderer'
export type { CreateRendererOptions } from './renderer'
5 changes: 5 additions & 0 deletions packages/json-render-ui/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ import {
Tree,
} from './components'
import { JsonRenderError } from './components/_error'
import { JsonRenderUnsupported } from './components/_unsupported'

/** Reserved component type used to isolate an element that fails validation. */
export const ERROR_COMPONENT_TYPE = '__jsonRenderError'

/** Reserved component type used to placeholder an element the registry lacks. */
export const UNSUPPORTED_COMPONENT_TYPE = '__jsonRenderUnsupported'

/**
* The base Vue registry: the fourteen catalog-v1 components ported onto
* `@antfu/design` semantic tokens, wrapped as Vue components via upstream
Expand All @@ -45,5 +49,6 @@ export const baseRegistry: ComponentRegistry = defineRegistry(baseCatalog as any
Progress,
Tree,
[ERROR_COMPONENT_TYPE]: JsonRenderError,
[UNSUPPORTED_COMPONENT_TYPE]: JsonRenderUnsupported,
} as any,
}).registry
Loading
Loading