@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/dist/chunk-5RM2VYAM.js +150 -0
  4. package/dist/chunk-5RM2VYAM.js.map +1 -0
  5. package/dist/cli.cjs +303 -0
  6. package/dist/cli.cjs.map +1 -0
  7. package/dist/cli.d.cts +1 -0
  8. package/dist/cli.d.ts +1 -0
  9. package/dist/cli.js +138 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/index.cjs +219 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.cts +374 -0
  14. package/dist/index.d.ts +374 -0
  15. package/dist/index.js +182 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/plugin.cjs +185 -0
  18. package/dist/plugin.cjs.map +1 -0
  19. package/dist/plugin.d.cts +11 -0
  20. package/dist/plugin.d.ts +11 -0
  21. package/dist/plugin.js +11 -0
  22. package/dist/plugin.js.map +1 -0
  23. package/package.json +95 -0
  24. package/src/cli/index.ts +160 -0
  25. package/src/client/App.tsx +310 -0
  26. package/src/client/Canvas.tsx +358 -0
  27. package/src/client/Inspector.tsx +586 -0
  28. package/src/client/Sidebar.tsx +108 -0
  29. package/src/client/bridge.ts +124 -0
  30. package/src/client/chrome.css +1966 -0
  31. package/src/client/icons.tsx +110 -0
  32. package/src/client/index.ts +15 -0
  33. package/src/client/internal-types.ts +147 -0
  34. package/src/client/persist.ts +38 -0
  35. package/src/define-config.ts +54 -0
  36. package/src/descriptor.test.ts +59 -0
  37. package/src/descriptor.ts +185 -0
  38. package/src/globals.d.ts +9 -0
  39. package/src/index.ts +54 -0
  40. package/src/plugin/index.ts +181 -0
  41. package/src/preview/ctx.ts +43 -0
  42. package/src/preview/index.ts +283 -0
  43. package/src/preview/preview.css +81 -0
  44. package/src/preview/registry.ts +159 -0
  45. package/src/preview/render.tsx +90 -0
  46. package/src/preview/virtual.d.ts +8 -0
  47. package/src/protocol.ts +59 -0
  48. 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
+ }
@@ -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'