@westopp/windo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/dist/chunk-5RM2VYAM.js +150 -0
- package/dist/chunk-5RM2VYAM.js.map +1 -0
- package/dist/cli.cjs +303 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +138 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.cjs +219 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +374 -0
- package/dist/index.d.ts +374 -0
- package/dist/index.js +182 -0
- package/dist/index.js.map +1 -0
- package/dist/plugin.cjs +185 -0
- package/dist/plugin.cjs.map +1 -0
- package/dist/plugin.d.cts +11 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.js +11 -0
- package/dist/plugin.js.map +1 -0
- package/package.json +95 -0
- package/src/cli/index.ts +160 -0
- package/src/client/App.tsx +310 -0
- package/src/client/Canvas.tsx +358 -0
- package/src/client/Inspector.tsx +586 -0
- package/src/client/Sidebar.tsx +108 -0
- package/src/client/bridge.ts +124 -0
- package/src/client/chrome.css +1966 -0
- package/src/client/icons.tsx +110 -0
- package/src/client/index.ts +15 -0
- package/src/client/internal-types.ts +147 -0
- package/src/client/persist.ts +38 -0
- package/src/define-config.ts +54 -0
- package/src/descriptor.test.ts +59 -0
- package/src/descriptor.ts +185 -0
- package/src/globals.d.ts +9 -0
- package/src/index.ts +54 -0
- package/src/plugin/index.ts +181 -0
- package/src/preview/ctx.ts +43 -0
- package/src/preview/index.ts +283 -0
- package/src/preview/preview.css +81 -0
- package/src/preview/registry.ts +159 -0
- package/src/preview/render.tsx +90 -0
- package/src/preview/virtual.d.ts +8 -0
- package/src/protocol.ts +59 -0
- package/src/types.ts +319 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Inline SVG glyphs for the chrome UI, ported from the mock workbench
|
|
2
|
+
// (app.jsx WbIcons, inspector.jsx InspIcons, canvas.jsx toolbar svgs).
|
|
3
|
+
// camelCase SVG attributes throughout; consumers read Icons[name].
|
|
4
|
+
|
|
5
|
+
import type { ReactElement } from 'react'
|
|
6
|
+
|
|
7
|
+
export const Icons: Record<string, ReactElement> = {
|
|
8
|
+
sun: (
|
|
9
|
+
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
10
|
+
<circle cx="12" cy="12" r="4" />
|
|
11
|
+
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
|
12
|
+
</svg>
|
|
13
|
+
),
|
|
14
|
+
moon: (
|
|
15
|
+
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
16
|
+
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
|
|
17
|
+
</svg>
|
|
18
|
+
),
|
|
19
|
+
search: (
|
|
20
|
+
<svg aria-hidden="true" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
21
|
+
<circle cx="11" cy="11" r="8" />
|
|
22
|
+
<path d="m21 21-4.35-4.35" />
|
|
23
|
+
</svg>
|
|
24
|
+
),
|
|
25
|
+
chevsLeft: (
|
|
26
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
27
|
+
<path d="m11 17-5-5 5-5" />
|
|
28
|
+
<path d="m18 17-5-5 5-5" />
|
|
29
|
+
</svg>
|
|
30
|
+
),
|
|
31
|
+
chevsRight: (
|
|
32
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
33
|
+
<path d="m6 17 5-5-5-5" />
|
|
34
|
+
<path d="m13 17 5-5-5-5" />
|
|
35
|
+
</svg>
|
|
36
|
+
),
|
|
37
|
+
chevDown: (
|
|
38
|
+
<svg aria-hidden="true" width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
39
|
+
<path d="m6 9 6 6 6-6" />
|
|
40
|
+
</svg>
|
|
41
|
+
),
|
|
42
|
+
chevsUp: (
|
|
43
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
44
|
+
<path d="m17 18-5-5-5 5" />
|
|
45
|
+
<path d="m17 11-5-5-5 5" />
|
|
46
|
+
</svg>
|
|
47
|
+
),
|
|
48
|
+
chevsDown: (
|
|
49
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
50
|
+
<path d="m7 6 5 5 5-5" />
|
|
51
|
+
<path d="m7 13 5 5 5-5" />
|
|
52
|
+
</svg>
|
|
53
|
+
),
|
|
54
|
+
copy: (
|
|
55
|
+
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
56
|
+
<rect x="9" y="9" width="11" height="11" rx="2" />
|
|
57
|
+
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
|
58
|
+
</svg>
|
|
59
|
+
),
|
|
60
|
+
check: (
|
|
61
|
+
<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
62
|
+
<path d="m5 13 4 4L19 7" />
|
|
63
|
+
</svg>
|
|
64
|
+
),
|
|
65
|
+
grid: (
|
|
66
|
+
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
|
67
|
+
<circle cx="5" cy="5" r="1.6" />
|
|
68
|
+
<circle cx="12" cy="5" r="1.6" />
|
|
69
|
+
<circle cx="19" cy="5" r="1.6" />
|
|
70
|
+
<circle cx="5" cy="12" r="1.6" />
|
|
71
|
+
<circle cx="12" cy="12" r="1.6" />
|
|
72
|
+
<circle cx="19" cy="12" r="1.6" />
|
|
73
|
+
<circle cx="5" cy="19" r="1.6" />
|
|
74
|
+
<circle cx="12" cy="19" r="1.6" />
|
|
75
|
+
<circle cx="19" cy="19" r="1.6" />
|
|
76
|
+
</svg>
|
|
77
|
+
),
|
|
78
|
+
zoomIn: (
|
|
79
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
80
|
+
<path d="M12 5v14M5 12h14" />
|
|
81
|
+
</svg>
|
|
82
|
+
),
|
|
83
|
+
zoomOut: (
|
|
84
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
85
|
+
<path d="M5 12h14" />
|
|
86
|
+
</svg>
|
|
87
|
+
),
|
|
88
|
+
settings: (
|
|
89
|
+
<svg aria-hidden="true" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
90
|
+
<path d="M4 8h10M18 8h2M4 16h2M10 16h10" />
|
|
91
|
+
<circle cx="16" cy="8" r="2" />
|
|
92
|
+
<circle cx="8" cy="16" r="2" />
|
|
93
|
+
</svg>
|
|
94
|
+
),
|
|
95
|
+
minus: (
|
|
96
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
97
|
+
<path d="M5 12h14" />
|
|
98
|
+
</svg>
|
|
99
|
+
),
|
|
100
|
+
plus: (
|
|
101
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
102
|
+
<path d="M12 5v14M5 12h14" />
|
|
103
|
+
</svg>
|
|
104
|
+
),
|
|
105
|
+
close: (
|
|
106
|
+
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
107
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
108
|
+
</svg>
|
|
109
|
+
),
|
|
110
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Chrome mount entry. Boots the workbench shell into #root.
|
|
2
|
+
|
|
3
|
+
import './chrome.css'
|
|
4
|
+
import { createElement } from 'react'
|
|
5
|
+
import { createRoot } from 'react-dom/client'
|
|
6
|
+
import { App } from './App'
|
|
7
|
+
|
|
8
|
+
let root = document.getElementById('root')
|
|
9
|
+
if (!root) {
|
|
10
|
+
root = document.createElement('div')
|
|
11
|
+
root.id = 'root'
|
|
12
|
+
document.body.appendChild(root)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
createRoot(root).render(createElement(App))
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Internal contracts shared across the chrome UI. App owns global state and the
|
|
2
|
+
// bridge; Sidebar / Canvas / Inspector are pure-ish views driven by these props.
|
|
3
|
+
// Authored up front so the components can be built independently without drift.
|
|
4
|
+
|
|
5
|
+
import type { RefObject } from 'react'
|
|
6
|
+
import type { WindoActionMeta, WindoContextMeta, WindoEnvState, WindoFieldError, WindoGroup, WindoLogEntry, WindoManifestEntry, WindoPropDoc, WindoSchemaDescriptor, WindoVariantMeta } from '../types'
|
|
7
|
+
|
|
8
|
+
/** A log entry plus a monotonic key for stable React list rendering (the wire entry carries no unique id). */
|
|
9
|
+
export interface WindoLogRow extends WindoLogEntry {
|
|
10
|
+
seq: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The "describe" payload for the currently-selected windo. */
|
|
14
|
+
export interface WindoDescribe {
|
|
15
|
+
id: string
|
|
16
|
+
descriptor: WindoSchemaDescriptor
|
|
17
|
+
props: WindoPropDoc[]
|
|
18
|
+
variants: WindoVariantMeta[]
|
|
19
|
+
defaults: unknown
|
|
20
|
+
code: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Host-side bridge returned by `useWindoBridge(iframeRef)`. It owns the
|
|
25
|
+
* postMessage conversation with the preview iframe: sends host messages,
|
|
26
|
+
* subscribes to preview messages, and surfaces the latest state.
|
|
27
|
+
*/
|
|
28
|
+
export interface BridgeApi {
|
|
29
|
+
ready: boolean
|
|
30
|
+
/** Increments each time the preview (re)signals ready — e.g. after an HMR or dep-optimize reload. The chrome re-syncs selection/props/env when it changes. */
|
|
31
|
+
readyNonce: number
|
|
32
|
+
title: string
|
|
33
|
+
groups: WindoGroup[]
|
|
34
|
+
contexts: WindoContextMeta[]
|
|
35
|
+
manifest: WindoManifestEntry[]
|
|
36
|
+
/** describe payload for the most recently selected id (null until it arrives). */
|
|
37
|
+
describe: WindoDescribe | null
|
|
38
|
+
parseErrors: WindoFieldError[]
|
|
39
|
+
renderError: { message: string; stack?: string } | null
|
|
40
|
+
logs: WindoLogRow[]
|
|
41
|
+
clearLogs: () => void
|
|
42
|
+
/** Latest component-local state snapshot per windo id, echoed from the preview. */
|
|
43
|
+
stateValues: Record<string, Record<string, unknown>>
|
|
44
|
+
/** Live `disabled` flag per action id (keyed windo id → action id), echoed from the preview. */
|
|
45
|
+
actionDisabled: Record<string, Record<string, boolean>>
|
|
46
|
+
// outbound
|
|
47
|
+
select: (id: string) => void
|
|
48
|
+
setProps: (id: string, json: string) => void
|
|
49
|
+
setEnv: (env: WindoEnvState) => void
|
|
50
|
+
invokeAction: (id: string, actionId: string) => void
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type InspectorPosition = 'right' | 'bottom'
|
|
54
|
+
|
|
55
|
+
export type ThemeMode = 'light' | 'dark'
|
|
56
|
+
|
|
57
|
+
/** Cosmetic canvas chrome (drawn behind a transparent iframe), persisted to localStorage. */
|
|
58
|
+
export interface CanvasGridOpts {
|
|
59
|
+
on: boolean
|
|
60
|
+
style: 'dots' | 'lines'
|
|
61
|
+
dotSize: number
|
|
62
|
+
gridOpacity: number
|
|
63
|
+
light: { bg: string; dot: string }
|
|
64
|
+
dark: { bg: string; dot: string }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const CANVAS_GRID_DEFAULTS: CanvasGridOpts = {
|
|
68
|
+
on: true,
|
|
69
|
+
style: 'dots',
|
|
70
|
+
dotSize: 20,
|
|
71
|
+
gridOpacity: 100,
|
|
72
|
+
light: { bg: '#ffffff', dot: '#e2e2e4' },
|
|
73
|
+
dark: { bg: '#161618', dot: '#2c2c30' },
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Global ambient environment edited from the chrome (Context tab + topbar theme toggle). */
|
|
77
|
+
export interface ChromeEnv {
|
|
78
|
+
colorScheme: ThemeMode
|
|
79
|
+
reducedMotion: boolean
|
|
80
|
+
direction: 'ltr' | 'rtl'
|
|
81
|
+
locale: string
|
|
82
|
+
/** Per-context control values, keyed by context name then control key. */
|
|
83
|
+
contexts: Record<string, Record<string, unknown>>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SidebarProps {
|
|
87
|
+
groups: WindoGroup[]
|
|
88
|
+
manifest: WindoManifestEntry[]
|
|
89
|
+
selected: string | null
|
|
90
|
+
onSelect: (id: string) => void
|
|
91
|
+
query: string
|
|
92
|
+
setQuery: (q: string) => void
|
|
93
|
+
collapsed: boolean
|
|
94
|
+
onToggle: () => void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CanvasProps {
|
|
98
|
+
iframeRef: RefObject<HTMLIFrameElement | null>
|
|
99
|
+
iframeSrc: string
|
|
100
|
+
theme: ThemeMode
|
|
101
|
+
width: number
|
|
102
|
+
setWidth: (w: number) => void
|
|
103
|
+
/** null = fill available height. */
|
|
104
|
+
height: number | null
|
|
105
|
+
setHeight: (h: number | null) => void
|
|
106
|
+
zoom: number
|
|
107
|
+
setZoom: (z: number) => void
|
|
108
|
+
grid: CanvasGridOpts
|
|
109
|
+
/** patch === null resets to defaults. */
|
|
110
|
+
setGrid: (patch: Partial<CanvasGridOpts> | null) => void
|
|
111
|
+
/** `click` actions for the selected windo, rendered as toolbar buttons. */
|
|
112
|
+
actions: WindoActionMeta[]
|
|
113
|
+
/** Live `disabled` flag per action id for the selected windo. */
|
|
114
|
+
actionDisabled: Record<string, boolean>
|
|
115
|
+
onAction: (actionId: string) => void
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface InspectorProps {
|
|
119
|
+
entry: WindoManifestEntry | null
|
|
120
|
+
describe: WindoDescribe | null
|
|
121
|
+
/** Preview iframe URL — variant cells each load their own to render live. */
|
|
122
|
+
iframeSrc: string
|
|
123
|
+
/** Current draft JSON in the Controls editor (App owns the value, per selected id). */
|
|
124
|
+
valuesJson: string
|
|
125
|
+
setValuesJson: (json: string) => void
|
|
126
|
+
/** True when the draft differs from what was last pushed to the preview (gates Save). */
|
|
127
|
+
dirty: boolean
|
|
128
|
+
/** Commit the current draft (host → preview → parse). */
|
|
129
|
+
onSave: () => void
|
|
130
|
+
onReset: () => void
|
|
131
|
+
parseErrors: WindoFieldError[]
|
|
132
|
+
renderError: { message: string; stack?: string } | null
|
|
133
|
+
logs: WindoLogRow[]
|
|
134
|
+
clearLogs: () => void
|
|
135
|
+
/** Live component-local state snapshot for the selected windo (drives the read-only State strip). */
|
|
136
|
+
state: Record<string, unknown>
|
|
137
|
+
/** Context metadata filtered to this windo (its `uses` providers + all ambient). */
|
|
138
|
+
contexts: WindoContextMeta[]
|
|
139
|
+
env: ChromeEnv
|
|
140
|
+
setEnv: (patch: Partial<ChromeEnv>) => void
|
|
141
|
+
setContextValue: (contextName: string, key: string, value: unknown) => void
|
|
142
|
+
/** Apply a variant patch into the editor + commit. */
|
|
143
|
+
onApplyVariant: (props: Record<string, unknown>) => void
|
|
144
|
+
position: InspectorPosition
|
|
145
|
+
collapsed: boolean
|
|
146
|
+
onToggle: () => void
|
|
147
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// localStorage helpers. Every access is try/catch wrapped so corrupted JSON or
|
|
2
|
+
// an unavailable storage backend (private mode, quota) degrades to the fallback
|
|
3
|
+
// instead of throwing. Callers pass keys already prefixed with `windo:`.
|
|
4
|
+
|
|
5
|
+
export function loadJSON<T>(key: string, fallback: T): T {
|
|
6
|
+
try {
|
|
7
|
+
const raw = localStorage.getItem(key)
|
|
8
|
+
if (raw === null) return fallback
|
|
9
|
+
return JSON.parse(raw) as T
|
|
10
|
+
} catch {
|
|
11
|
+
return fallback
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function saveJSON(key: string, value: unknown): void {
|
|
16
|
+
try {
|
|
17
|
+
localStorage.setItem(key, JSON.stringify(value))
|
|
18
|
+
} catch {
|
|
19
|
+
/* unavailable or over quota — ignore */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadString(key: string, fallback: string): string {
|
|
24
|
+
try {
|
|
25
|
+
const raw = localStorage.getItem(key)
|
|
26
|
+
return raw === null ? fallback : raw
|
|
27
|
+
} catch {
|
|
28
|
+
return fallback
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveString(key: string, value: string): void {
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(key, value)
|
|
35
|
+
} catch {
|
|
36
|
+
/* unavailable or over quota — ignore */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The authoring API. `defineWindoConfig` is the single entry point: it captures
|
|
2
|
+
// the project's groups + contexts and hands back a `windo` factory whose `group`
|
|
3
|
+
// field is type-checked against the configured slugs.
|
|
4
|
+
|
|
5
|
+
import type { ComponentType, ReactNode } from 'react'
|
|
6
|
+
import type { z } from 'zod'
|
|
7
|
+
import type { WindoConfig, WindoContextDefinition, WindoContextMap, WindoControlMap, WindoControlValues, WindoDefinition, WindoFactoryArg, WindoGroup, WindoModule, WindoRenderContext } from './types'
|
|
8
|
+
|
|
9
|
+
export interface DefineWindoConfigResult<Groups extends readonly WindoGroup[], Contexts extends WindoContextMap> {
|
|
10
|
+
config: WindoConfig<Groups, Contexts>
|
|
11
|
+
windo: <Props, State = Record<string, never>>(factory: (w: WindoFactoryArg<Groups, Contexts>) => WindoDefinition<Props, State, Groups[number]['slug']>) => WindoModule<Props, State>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function defineWindoConfig<const Groups extends readonly WindoGroup[], Contexts extends WindoContextMap = Record<string, never>>(
|
|
15
|
+
config: WindoConfig<Groups, Contexts>
|
|
16
|
+
): DefineWindoConfigResult<Groups, Contexts> {
|
|
17
|
+
function windo<Props, State = Record<string, never>>(factory: (w: WindoFactoryArg<Groups, Contexts>) => WindoDefinition<Props, State, Groups[number]['slug']>): WindoModule<Props, State> {
|
|
18
|
+
return {
|
|
19
|
+
__windo: true,
|
|
20
|
+
resolve: w => factory(w as unknown as WindoFactoryArg<Groups, Contexts>),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return { config, windo }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Define a named context. A context can contribute ambient `controls` (values +
|
|
28
|
+
* UI), a `provider` (mounted inside the iframe for components that opt in via
|
|
29
|
+
* `uses`), or both.
|
|
30
|
+
*
|
|
31
|
+
* No cast: `def` is already structurally a `WindoContextDefinition<WindoControlValues<C>,
|
|
32
|
+
* Provided>` (only `controls` widens, covariantly). The unavoidable variance — TS has
|
|
33
|
+
* no existential type to hold contexts heterogeneous in `C` — is absorbed once, in
|
|
34
|
+
* `WindoContextMap`, not here and not at call sites.
|
|
35
|
+
*/
|
|
36
|
+
export function defineContext<C extends WindoControlMap, Provided = WindoControlValues<C>>(def: {
|
|
37
|
+
label?: string
|
|
38
|
+
description?: string
|
|
39
|
+
controls?: C
|
|
40
|
+
provider?: ComponentType<{ children: ReactNode; values: WindoControlValues<C>; ctx: WindoRenderContext }>
|
|
41
|
+
resolve?: (values: WindoControlValues<C>, ctx: WindoRenderContext) => Provided
|
|
42
|
+
}): WindoContextDefinition<WindoControlValues<C>, Provided> {
|
|
43
|
+
return def
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Bind a zod schema to a component's props. The generic carries the component's
|
|
48
|
+
* prop type so the schema's output stays a subset of it; the returned schema is
|
|
49
|
+
* the runtime validator + parser (`z.input` is the JSON edit surface, `z.output`
|
|
50
|
+
* is what the component receives).
|
|
51
|
+
*/
|
|
52
|
+
export function configurableProps<P>() {
|
|
53
|
+
return <S extends z.ZodType<Partial<P>>>(schema: S): S => schema
|
|
54
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { describeSchema } from './descriptor'
|
|
4
|
+
import type { WindoControlDescriptor } from './types'
|
|
5
|
+
|
|
6
|
+
function field(fields: WindoControlDescriptor[], key: string): WindoControlDescriptor {
|
|
7
|
+
const found = fields.find(f => f.key === key)
|
|
8
|
+
if (!found) throw new Error(`no field "${key}"`)
|
|
9
|
+
return found
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('describeSchema', () => {
|
|
13
|
+
it('returns no fields for a missing schema', () => {
|
|
14
|
+
expect(describeSchema(undefined).fields).toEqual([])
|
|
15
|
+
expect(describeSchema(null).fields).toEqual([])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('describes a z.enum as kind enum with the right options', () => {
|
|
19
|
+
const { fields } = describeSchema(z.object({ variant: z.enum(['primary', 'secondary', 'ghost']) }))
|
|
20
|
+
const variant = field(fields, 'variant')
|
|
21
|
+
expect(variant.kind).toBe('enum')
|
|
22
|
+
expect(variant.options).toEqual(['primary', 'secondary', 'ghost'])
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('describes a z.boolean as kind boolean', () => {
|
|
26
|
+
const { fields } = describeSchema(z.object({ disabled: z.boolean() }))
|
|
27
|
+
expect(field(fields, 'disabled').kind).toBe('boolean')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('describes a bounded z.number with min and max', () => {
|
|
31
|
+
const { fields } = describeSchema(z.object({ count: z.number().min(0).max(10) }))
|
|
32
|
+
const count = field(fields, 'count')
|
|
33
|
+
expect(count.kind).toBe('number')
|
|
34
|
+
expect(count.min).toBe(0)
|
|
35
|
+
expect(count.max).toBe(10)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('marks an optional field as optional', () => {
|
|
39
|
+
const { fields } = describeSchema(z.object({ label: z.string().optional() }))
|
|
40
|
+
const label = field(fields, 'label')
|
|
41
|
+
expect(label.kind).toBe('string')
|
|
42
|
+
expect(label.optional).toBe(true)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('marks a required field as not optional', () => {
|
|
46
|
+
const { fields } = describeSchema(z.object({ label: z.string() }))
|
|
47
|
+
expect(field(fields, 'label').optional).toBe(false)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('describes a z.coerce.date as kind date', () => {
|
|
51
|
+
const { fields } = describeSchema(z.object({ when: z.coerce.date() }))
|
|
52
|
+
expect(field(fields, 'when').kind).toBe('date')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('describes a z.array as kind array', () => {
|
|
56
|
+
const { fields } = describeSchema(z.object({ tags: z.array(z.string()) }))
|
|
57
|
+
expect(field(fields, 'tags').kind).toBe('array')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Walk a zod schema into a serialisable descriptor that can cross the iframe
|
|
2
|
+
// boundary and render the Controls/Schema UI. We lean on `z.toJSONSchema` (zod
|
|
3
|
+
// v4) for the input shape (enum options, min/max, optionality), then enrich the
|
|
4
|
+
// kind from the zod node's own type tag — some types (Date, Map, Set) are
|
|
5
|
+
// unrepresentable in JSON Schema and come back as `{}`, but their zod `def.type`
|
|
6
|
+
// is exact. The live schema (with transforms/coerce/refine) stays in the iframe
|
|
7
|
+
// and does the actual parsing — only this descriptor travels.
|
|
8
|
+
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import type { WindoControlDescriptor, WindoControlKind, WindoSchemaDescriptor } from './types'
|
|
11
|
+
|
|
12
|
+
interface JsonNode {
|
|
13
|
+
type?: string | string[]
|
|
14
|
+
enum?: unknown[]
|
|
15
|
+
const?: unknown
|
|
16
|
+
format?: string
|
|
17
|
+
properties?: Record<string, JsonNode>
|
|
18
|
+
required?: string[]
|
|
19
|
+
items?: JsonNode
|
|
20
|
+
anyOf?: JsonNode[]
|
|
21
|
+
oneOf?: JsonNode[]
|
|
22
|
+
allOf?: JsonNode[]
|
|
23
|
+
minimum?: number
|
|
24
|
+
maximum?: number
|
|
25
|
+
description?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function describeSchema(schema: z.ZodType | undefined | null): WindoSchemaDescriptor {
|
|
29
|
+
if (!schema) return { fields: [] }
|
|
30
|
+
let json: JsonNode
|
|
31
|
+
try {
|
|
32
|
+
json = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' }) as JsonNode
|
|
33
|
+
} catch {
|
|
34
|
+
return { fields: [] }
|
|
35
|
+
}
|
|
36
|
+
const root = unwrap(json)
|
|
37
|
+
const properties = root.properties ?? {}
|
|
38
|
+
const required = new Set(root.required ?? [])
|
|
39
|
+
const zodKinds = shapeKinds(schema)
|
|
40
|
+
const fields: WindoControlDescriptor[] = Object.keys(properties).map(key => fieldFrom(key, properties[key], required.has(key), zodKinds[key]))
|
|
41
|
+
return { fields }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// JSON Schema often wraps the object in anyOf (for optional/nullable). Find the
|
|
45
|
+
// node that actually carries the object's properties.
|
|
46
|
+
function unwrap(node: JsonNode): JsonNode {
|
|
47
|
+
if (node.properties) return node
|
|
48
|
+
const branches = node.anyOf ?? node.oneOf ?? node.allOf
|
|
49
|
+
if (branches) {
|
|
50
|
+
const withProps = branches.find(b => b.properties)
|
|
51
|
+
if (withProps) return withProps
|
|
52
|
+
}
|
|
53
|
+
return node
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fieldFrom(key: string, node: JsonNode, isRequired: boolean, zodKind?: WindoControlKind): WindoControlDescriptor {
|
|
57
|
+
const inner = collapseNullable(node)
|
|
58
|
+
let kind = kindOf(inner)
|
|
59
|
+
// The zod tag wins for types JSON Schema can't express (date/array/object that
|
|
60
|
+
// came back as {}), and as a fallback whenever the JSON kind is unknown.
|
|
61
|
+
if (zodKind && (kind === 'unknown' || zodKind === 'date')) kind = zodKind
|
|
62
|
+
const descriptor: WindoControlDescriptor = {
|
|
63
|
+
key,
|
|
64
|
+
kind,
|
|
65
|
+
optional: !isRequired || isNullable(node),
|
|
66
|
+
}
|
|
67
|
+
if (inner.description) descriptor.description = inner.description
|
|
68
|
+
const options = enumOptions(inner)
|
|
69
|
+
if (options) descriptor.options = options
|
|
70
|
+
if (typeof inner.minimum === 'number') descriptor.min = inner.minimum
|
|
71
|
+
if (typeof inner.maximum === 'number') descriptor.max = inner.maximum
|
|
72
|
+
return descriptor
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isNullable(node: JsonNode): boolean {
|
|
76
|
+
const branches = node.anyOf ?? node.oneOf
|
|
77
|
+
if (!branches) return false
|
|
78
|
+
return branches.some(b => b.type === 'null')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Strip a `{ anyOf: [T, null] }` wrapper down to T.
|
|
82
|
+
function collapseNullable(node: JsonNode): JsonNode {
|
|
83
|
+
const branches = node.anyOf ?? node.oneOf
|
|
84
|
+
if (!branches) return node
|
|
85
|
+
const nonNull = branches.filter(b => b.type !== 'null')
|
|
86
|
+
if (nonNull.length === 1) return nonNull[0]
|
|
87
|
+
return node
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function enumOptions(node: JsonNode): string[] | undefined {
|
|
91
|
+
if (Array.isArray(node.enum)) return node.enum.map(v => String(v))
|
|
92
|
+
const branches = node.anyOf ?? node.oneOf
|
|
93
|
+
if (branches?.every(b => b.const !== undefined)) {
|
|
94
|
+
return branches.map(b => String(b.const))
|
|
95
|
+
}
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function kindOf(node: JsonNode): WindoControlKind {
|
|
100
|
+
if (Array.isArray(node.enum) || (node.anyOf?.every(b => b.const !== undefined) ?? false)) return 'enum'
|
|
101
|
+
if (node.format === 'date-time' || node.format === 'date') return 'date'
|
|
102
|
+
const type = Array.isArray(node.type) ? node.type.find(t => t !== 'null') : node.type
|
|
103
|
+
switch (type) {
|
|
104
|
+
case 'string':
|
|
105
|
+
return 'string'
|
|
106
|
+
case 'number':
|
|
107
|
+
case 'integer':
|
|
108
|
+
return 'number'
|
|
109
|
+
case 'boolean':
|
|
110
|
+
return 'boolean'
|
|
111
|
+
case 'array':
|
|
112
|
+
return 'array'
|
|
113
|
+
case 'object':
|
|
114
|
+
return 'object'
|
|
115
|
+
default:
|
|
116
|
+
return 'unknown'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* ------------------------------------------------------------------ *
|
|
121
|
+
* zod node introspection (the kind source of truth for unrepresentable types)
|
|
122
|
+
* ------------------------------------------------------------------ */
|
|
123
|
+
|
|
124
|
+
interface ZodDefLike {
|
|
125
|
+
type?: string
|
|
126
|
+
innerType?: unknown
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function zodDef(node: unknown): ZodDefLike | undefined {
|
|
130
|
+
const n = node as { def?: ZodDefLike; _zod?: { def?: ZodDefLike } } | null
|
|
131
|
+
if (!n || typeof n !== 'object') return undefined
|
|
132
|
+
return n.def ?? n._zod?.def
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const ZOD_WRAPPERS = new Set(['optional', 'nullable', 'default', 'prefault', 'catch', 'readonly', 'nonoptional', 'lazy'])
|
|
136
|
+
|
|
137
|
+
// Unwrap optional/nullable/default/... down to the meaningful inner node.
|
|
138
|
+
function unwrapZodDef(node: unknown): ZodDefLike | undefined {
|
|
139
|
+
let def = zodDef(node)
|
|
140
|
+
let guard = 0
|
|
141
|
+
while (def && def.innerType && ZOD_WRAPPERS.has(def.type ?? '') && guard++ < 16) {
|
|
142
|
+
def = zodDef(def.innerType)
|
|
143
|
+
}
|
|
144
|
+
return def
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mapZodType(type: string | undefined): WindoControlKind | undefined {
|
|
148
|
+
switch (type) {
|
|
149
|
+
case 'date':
|
|
150
|
+
return 'date'
|
|
151
|
+
case 'string':
|
|
152
|
+
return 'string'
|
|
153
|
+
case 'number':
|
|
154
|
+
case 'int':
|
|
155
|
+
case 'bigint':
|
|
156
|
+
return 'number'
|
|
157
|
+
case 'boolean':
|
|
158
|
+
return 'boolean'
|
|
159
|
+
case 'array':
|
|
160
|
+
case 'set':
|
|
161
|
+
case 'tuple':
|
|
162
|
+
return 'array'
|
|
163
|
+
case 'object':
|
|
164
|
+
case 'record':
|
|
165
|
+
case 'map':
|
|
166
|
+
return 'object'
|
|
167
|
+
case 'enum':
|
|
168
|
+
case 'literal':
|
|
169
|
+
return 'enum'
|
|
170
|
+
default:
|
|
171
|
+
return undefined
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Per-field kind read straight from the zod object's shape.
|
|
176
|
+
function shapeKinds(schema: z.ZodType): Record<string, WindoControlKind> {
|
|
177
|
+
const shape = (schema as { shape?: Record<string, unknown> }).shape
|
|
178
|
+
if (!shape || typeof shape !== 'object') return {}
|
|
179
|
+
const out: Record<string, WindoControlKind> = {}
|
|
180
|
+
for (const key of Object.keys(shape)) {
|
|
181
|
+
const kind = mapZodType(unwrapZodDef(shape[key])?.type)
|
|
182
|
+
if (kind) out[key] = kind
|
|
183
|
+
}
|
|
184
|
+
return out
|
|
185
|
+
}
|
package/src/globals.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export {}
|
|
2
|
+
|
|
3
|
+
declare global {
|
|
4
|
+
// Injected by the windo Vite plugin via `define`. Tells the chrome where to
|
|
5
|
+
// load the preview iframe from: a routeless path in dev (the dev middleware
|
|
6
|
+
// serves it), a real `.html` file in a static build. Undefined when the chrome
|
|
7
|
+
// is bundled without the plugin.
|
|
8
|
+
const __WINDO_IFRAME_SRC__: string | undefined
|
|
9
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Public authoring API. This is the isomorphic entry point users import from
|
|
2
|
+
// their `*.windo.tsx` and `windo.config.ts` files. No DOM or Node code lives
|
|
3
|
+
// here — the chrome (`/client`), preview (`/preview`), plugin (`/plugin`), and
|
|
4
|
+
// CLI (`bin`) are separate entry points.
|
|
5
|
+
|
|
6
|
+
export type { DefineWindoConfigResult } from './define-config'
|
|
7
|
+
export { configurableProps, defineContext, defineWindoConfig } from './define-config'
|
|
8
|
+
export { describeSchema } from './descriptor'
|
|
9
|
+
export type { WindoHostMessage, WindoMessage, WindoPreviewMessage } from './protocol'
|
|
10
|
+
export {
|
|
11
|
+
isHostMessage,
|
|
12
|
+
isPreviewMessage,
|
|
13
|
+
isWindoMessage,
|
|
14
|
+
WINDO_MSG,
|
|
15
|
+
} from './protocol'
|
|
16
|
+
export type {
|
|
17
|
+
WindoAction,
|
|
18
|
+
WindoActionMeta,
|
|
19
|
+
WindoActionTrigger,
|
|
20
|
+
WindoConfig,
|
|
21
|
+
WindoContextControlMeta,
|
|
22
|
+
WindoContextDefinition,
|
|
23
|
+
WindoContextMap,
|
|
24
|
+
WindoContextMeta,
|
|
25
|
+
WindoControlDescriptor,
|
|
26
|
+
WindoControlKind,
|
|
27
|
+
WindoControlMap,
|
|
28
|
+
WindoControlSpec,
|
|
29
|
+
WindoControlType,
|
|
30
|
+
WindoControlValues,
|
|
31
|
+
WindoDefaultProps,
|
|
32
|
+
WindoDefinition,
|
|
33
|
+
WindoEnvState,
|
|
34
|
+
WindoFactoryArg,
|
|
35
|
+
WindoFieldError,
|
|
36
|
+
WindoGroup,
|
|
37
|
+
WindoLogEntry,
|
|
38
|
+
WindoLogger,
|
|
39
|
+
WindoManifestEntry,
|
|
40
|
+
WindoModule,
|
|
41
|
+
WindoPlacement,
|
|
42
|
+
WindoPropDoc,
|
|
43
|
+
WindoRenderContext,
|
|
44
|
+
WindoSchemaDescriptor,
|
|
45
|
+
WindoStatus,
|
|
46
|
+
WindoVariant,
|
|
47
|
+
WindoVariantMeta,
|
|
48
|
+
WindoViewport,
|
|
49
|
+
} from './types'
|
|
50
|
+
export {
|
|
51
|
+
WINDO_ACTION_TRIGGERS,
|
|
52
|
+
WINDO_PLACEMENTS,
|
|
53
|
+
WINDO_STATUSES,
|
|
54
|
+
} from './types'
|