From 6f3a3d16a9aa5aea48d81c516724a053b3fbee36 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Tue, 22 Sep 2026 05:12:14 +0300 Subject: [PATCH 01/26] change --- .gitignore | 21 ++ .golangci.yml | 68 +++++ .lic.yaml | 3 + LICENSE | 28 ++ builder.go | 213 ++++++++++++++ builder_test.go | 36 +++ examples/dashboard/dashboard.go | 42 +++ examples/dashboard/dashboard_test.go | 20 ++ examples/htmlform/htmlform.go | 38 +++ examples/htmlform/htmlform_test.go | 16 + fixtures/invalid-layout.json | 13 + fixtures/valid-view.json | 32 ++ go.mod | 10 + go.sum | 6 + html.go | 114 ++++++++ html_test.go | 13 + mcp/register.go | 74 +++++ mcp/register_test.go | 23 ++ schema.go | 161 +++++++++++ schema_test.go | 175 +++++++++++ security_test.go | 29 ++ shadcn.go | 142 +++++++++ shadcn_test.go | 25 ++ validation.go | 418 +++++++++++++++++++++++++++ value.go | 161 +++++++++++ 25 files changed, 1881 insertions(+) create mode 100755 .gitignore create mode 100755 .golangci.yml create mode 100755 .lic.yaml create mode 100644 LICENSE create mode 100644 builder.go create mode 100644 builder_test.go create mode 100644 examples/dashboard/dashboard.go create mode 100644 examples/dashboard/dashboard_test.go create mode 100644 examples/htmlform/htmlform.go create mode 100644 examples/htmlform/htmlform_test.go create mode 100644 fixtures/invalid-layout.json create mode 100644 fixtures/valid-view.json create mode 100644 go.mod create mode 100644 go.sum create mode 100644 html.go create mode 100644 html_test.go create mode 100644 mcp/register.go create mode 100644 mcp/register_test.go create mode 100644 schema.go create mode 100644 schema_test.go create mode 100644 security_test.go create mode 100644 shadcn.go create mode 100644 shadcn_test.go create mode 100644 validation.go create mode 100644 value.go diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..62936fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.github/ +.tools/ +bin/ +vendor/ +build/ +.idea/ +.vscode/ +coverage.txt +coverage.out +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.db +*.db-journal +*.mmdb +*.test +*.out +.env +Makefile \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100755 index 0000000..f2b16a7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,68 @@ +version: "2" + +run: + go: "1.26" + timeout: 5m + tests: false + issues-exit-code: 1 + modules-download-mode: readonly + allow-parallel-runners: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + fix: false + +output: + formats: + text: + print-linter-name: true + print-issued-lines: true + +formatters: + exclusions: + paths: + - vendors/ + enable: + - gofmt + - goimports + +linters: + settings: + staticcheck: + checks: + - all + - -S1023 + - -ST1000 + - -ST1003 + - -ST1020 + gosec: + excludes: + - G104 + - G115 + - G301 + - G304 + - G306 + - G501 + - G505 + exclusions: + paths: + - vendors/ + default: none + enable: + - govet + - errcheck + - misspell + - gocyclo + - ineffassign + - unparam + - unused + - prealloc + - durationcheck + - staticcheck + - makezero + - nilerr + - errorlint + - bodyclose + - gosec diff --git a/.lic.yaml b/.lic.yaml new file mode 100755 index 0000000..ee29af9 --- /dev/null +++ b/.lic.yaml @@ -0,0 +1,3 @@ +author: "Mikhail Knyazhev " +lic_short: "BSD 3-Clause" +lic_file: LICENSE \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f55727 --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Mikhail Knyazhev + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/builder.go b/builder.go new file mode 100644 index 0000000..16a7695 --- /dev/null +++ b/builder.go @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package ui + +import "encoding/json" + +type App struct { + manifest Manifest + views map[string]ViewSchema +} + +type AppOption func(*App) + +func PluginID(value string) AppOption { return func(app *App) { app.manifest.Plugin.ID = value } } +func PluginTitle(value string) AppOption { return func(app *App) { app.manifest.Plugin.Title = value } } +func PluginVersion(value string) AppOption { + return func(app *App) { app.manifest.Plugin.Version = value } +} + +func New(options ...AppOption) *App { + app := &App{manifest: Manifest{ProtocolVersion: ProtocolVersion, Views: []UIViewDescriptor{}}, views: make(map[string]ViewSchema)} + for _, option := range options { + option(app) + } + return app +} + +func (app *App) AddView(view *ViewBuilder) error { + schema := view.Schema() + if err := schema.Validate(); err != nil { + return err + } + if _, exists := app.views[schema.ID]; exists { + return ErrInvalidSchema + } + app.views[schema.ID] = schema + app.manifest.Views = append(app.manifest.Views, UIViewDescriptor{ID: schema.ID, Title: schema.Title, Schema: "ui://views/" + schema.ID}) + return nil +} + +func (app *App) Manifest() Manifest { return app.manifest } + +func (app *App) ManifestJSON() ([]byte, error) { return json.Marshal(app.manifest) } + +func (app *App) View(id string) (ViewSchema, bool) { view, ok := app.views[id]; return view, ok } + +type ViewBuilder struct{ schema ViewSchema } +type ViewOption func(*ViewSchema) + +func View(id string, options ...ViewOption) *ViewBuilder { + builder := &ViewBuilder{schema: ViewSchema{ProtocolVersion: ProtocolVersion, ID: id, Regions: emptyRegions(), Sources: map[string]DataSource{}, Actions: map[string]Action{}}} + for _, option := range options { + option(&builder.schema) + } + return builder +} + +func emptyRegions() Regions { + return Regions{TopHeader: []Node{}, LeftPanel: []Node{}, Content: []Node{}, RightPanel: []Node{}, Bottom: []Node{}} +} + +func Title(value string) ViewOption { return func(schema *ViewSchema) { schema.Title = value } } +func State(value map[string]any) ViewOption { return func(schema *ViewSchema) { schema.State = value } } +func TopHeader(nodes ...*NodeBuilder) ViewOption { + return func(schema *ViewSchema) { schema.Regions.TopHeader = buildNodes(nodes) } +} +func LeftPanel(nodes ...*NodeBuilder) ViewOption { + return func(schema *ViewSchema) { schema.Regions.LeftPanel = buildNodes(nodes) } +} +func Content(nodes ...*NodeBuilder) ViewOption { + return func(schema *ViewSchema) { schema.Regions.Content = buildNodes(nodes) } +} +func RightPanel(nodes ...*NodeBuilder) ViewOption { + return func(schema *ViewSchema) { schema.Regions.RightPanel = buildNodes(nodes) } +} +func Bottom(nodes ...*NodeBuilder) ViewOption { + return func(schema *ViewSchema) { schema.Regions.Bottom = buildNodes(nodes) } +} +func (view *ViewBuilder) Action(name string, action *ActionBuilder) *ViewBuilder { + view.schema.Actions[name] = action.action + return view +} +func (view *ViewBuilder) Schema() ViewSchema { return view.schema } +func (view *ViewBuilder) Revision(value string) *ViewBuilder { + view.schema.Revision = value + return view +} +func (view *ViewBuilder) Source(name string, source *SourceBuilder) *ViewBuilder { + view.schema.Sources[name] = source.source + return view +} + +type NodeBuilder struct{ node Node } + +func node(component, id string) *NodeBuilder { + return &NodeBuilder{node: Node{Component: component, ID: id, Props: map[string]Value{}, Events: map[string]EventHandler{}}} +} +func (node *NodeBuilder) Row(row int) *NodeBuilder { + node.ensureLayout() + node.node.Layout.Row = row + return node +} +func (node *NodeBuilder) Cols(cols int) *NodeBuilder { + node.ensureLayout() + node.node.Layout.Cols = cols + return node +} +func (node *NodeBuilder) Offset(offset int) *NodeBuilder { + node.ensureLayout() + node.node.Layout.Offset = offset + return node +} +func (node *NodeBuilder) Prop(name string, value any) *NodeBuilder { + node.node.Props[name] = toValue(value) + return node +} +func (node *NodeBuilder) On(name string, handler EventHandler) *NodeBuilder { + node.node.Events[name] = handler + return node +} +func (node *NodeBuilder) Children(children ...*NodeBuilder) *NodeBuilder { + node.node.Children = buildNodes(children) + return node +} +func (node *NodeBuilder) Slots(name string, children ...*NodeBuilder) *NodeBuilder { + if node.node.Slots == nil { + node.node.Slots = map[string][]Node{} + } + node.node.Slots[name] = buildNodes(children) + return node +} +func (node *NodeBuilder) When(value Value) *NodeBuilder { node.node.When = &value; return node } +func (node *NodeBuilder) Build() Node { return node.node } +func (node *NodeBuilder) ensureLayout() { + if node.node.Layout == nil { + node.node.Layout = &Layout{} + } +} + +func buildNodes(nodes []*NodeBuilder) []Node { + result := make([]Node, 0, len(nodes)) + for _, node := range nodes { + result = append(result, node.Build()) + } + return result +} + +type SourceBuilder struct{ source DataSource } + +func Source(name string, source *SourceBuilder) ViewOption { + return func(schema *ViewSchema) { + if schema.Sources == nil { + schema.Sources = map[string]DataSource{} + } + schema.Sources[name] = source.source + } +} +func Tool(name string) *SourceBuilder { + return &SourceBuilder{source: DataSource{Type: "tool", Tool: name, Policy: SourceManual, Input: map[string]Value{}}} +} +func (source *SourceBuilder) Input(name string, value any) *SourceBuilder { + source.source.Input[name] = toValue(value) + return source +} +func (source *SourceBuilder) OnMount() *SourceBuilder { + source.source.Policy = SourceOnMount + return source +} +func (source *SourceBuilder) TTL(value int64) *SourceBuilder { + source.source.Cache = &CachePolicy{TTL: value} + return source +} + +type ActionBuilder struct{ action Action } + +func CallTool(name string) *ActionBuilder { + return &ActionBuilder{action: Action{Type: "tool", Tool: name, Input: map[string]Value{}}} +} +func SetState(path string, value any) *ActionBuilder { + return &ActionBuilder{action: Action{Type: "set-state", Path: path, Value: ptrValue(value)}} +} +func MergeState(path string, value any) *ActionBuilder { + return &ActionBuilder{action: Action{Type: "merge-state", Path: path, Value: ptrValue(value)}} +} +func (action *ActionBuilder) Input(name string, value any) *ActionBuilder { + action.action.Input[name] = toValue(value) + return action +} +func (action *ActionBuilder) Then(effects ...Effect) *ActionBuilder { + action.action.Effects = append(action.action.Effects, effects...) + return action +} +func ActionRef(name string) EventHandler { return EventHandler{Action: name} } + +func Invalidate(source string) Effect { return Effect{Type: "invalidate", Source: source} } +func RefreshSource(source string) Effect { return Effect{Type: "refresh-source", Source: source} } +func ToastSuccess(message string) Effect { + return Effect{Type: "toast", Variant: "success", Message: message} +} +func SetStateEffect(path string, value any) Effect { + return Effect{Type: "set-state", Path: path, Value: ptrValue(value)} +} + +func toValue(value any) Value { + if typed, ok := value.(Value); ok { + return typed + } + return Literal(value) +} +func ptrValue(value any) *Value { result := toValue(value); return &result } diff --git a/builder_test.go b/builder_test.go new file mode 100644 index 0000000..6c042b4 --- /dev/null +++ b/builder_test.go @@ -0,0 +1,36 @@ +package ui + +import ( + "encoding/json" + "testing" +) + +func TestUnit_BuilderCreatesView(t *testing.T) { + view := View("users.list", Title("Users"), Source("users", Tool("users.list").OnMount()), Content( + DataTable("users-table").Row(1).Cols(12).Prop("rows", SourceRef("users.items")), + )).Action("users.refresh", CallTool("users.refresh").Then(Invalidate("users"))) + + app := New(PluginID("users"), PluginTitle("Users"), PluginVersion("1.0.0")) + if err := app.AddView(view); err != nil { + t.Fatal(err) + } + manifest := app.Manifest() + if len(manifest.Views) != 1 || manifest.Views[0].ID != "users.list" { + t.Fatalf("manifest = %#v", manifest) + } + payload, err := json.Marshal(view.Schema()) + if err != nil { + t.Fatal(err) + } + var decoded struct { + Regions map[string]json.RawMessage `json:"regions"` + } + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + for _, region := range []string{"top-header", "left-panel", "content", "right-panel", "bottom"} { + if string(decoded.Regions[region]) == "null" { + t.Fatalf("region %q serialized as null", region) + } + } +} diff --git a/examples/dashboard/dashboard.go b/examples/dashboard/dashboard.go new file mode 100644 index 0000000..09566aa --- /dev/null +++ b/examples/dashboard/dashboard.go @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Package dashboard demonstrates shadcn components, grid placement, and data sources. +package dashboard + +import "go.osspkg.com/ui" + +// NewApp builds a dashboard whose metrics source is loaded when the view opens. +func NewApp() (*ui.App, error) { + app := ui.New( + ui.PluginID("metrics"), + ui.PluginTitle("Metrics"), + ui.PluginVersion("1.0.0"), + ) + view := ui.View( + "metrics.dashboard", + ui.Title("Metrics dashboard"), + ui.Source("metrics", ui.Tool("metrics.summary").OnMount().TTL(30)), + ui.TopHeader( + ui.HTML(ui.HTMLH1, "title").Row(1).Cols(9).Prop("text", "Metrics dashboard"), + ui.Shadcn(ui.ShadcnButton, "refresh").Row(1).Cols(3).Offset(9).Prop("text", "Refresh").On("click", ui.ActionRef("metrics.refresh")), + ), + ui.Content( + ui.Shadcn(ui.ShadcnCard, "requests-card").Row(1).Cols(6).Children( + ui.HTML(ui.HTMLH2, "requests-heading").Prop("text", "Requests"), + ui.Shadcn(ui.ShadcnTypography, "requests-value").Prop("text", ui.SourceRef("metrics.requests")), + ), + ui.Shadcn(ui.ShadcnCard, "latency-card").Row(1).Cols(6).Offset(6).Children( + ui.HTML(ui.HTMLH2, "latency-heading").Prop("text", "Latency"), + ui.Shadcn(ui.ShadcnTypography, "latency-value").Prop("text", ui.SourceRef("metrics.latency")), + ), + ui.DataTable("recent-events").Row(2).Cols(12).Prop("rows", ui.SourceRef("metrics.events")).Prop("loading", ui.SourceRef("metrics.$loading")).Prop("error", ui.SourceRef("metrics.$error")), + ), + ).Action("metrics.refresh", ui.CallTool("metrics.refresh").Then(ui.RefreshSource("metrics"), ui.ToastSuccess("Metrics refreshed"))) + if err := app.AddView(view); err != nil { + return nil, err + } + return app, nil +} diff --git a/examples/dashboard/dashboard_test.go b/examples/dashboard/dashboard_test.go new file mode 100644 index 0000000..00e8fb5 --- /dev/null +++ b/examples/dashboard/dashboard_test.go @@ -0,0 +1,20 @@ +package dashboard + +import "testing" + +func TestUnit_NewApp(t *testing.T) { + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + if err := app.Manifest().Validate(); err != nil { + t.Fatal(err) + } + view, ok := app.View("metrics.dashboard") + if !ok { + t.Fatal("dashboard view is missing") + } + if len(view.Regions.Content) != 3 { + t.Fatalf("content nodes = %d, want 3", len(view.Regions.Content)) + } +} diff --git a/examples/htmlform/htmlform.go b/examples/htmlform/htmlform.go new file mode 100644 index 0000000..251c054 --- /dev/null +++ b/examples/htmlform/htmlform.go @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Package htmlform demonstrates a declarative form built from safe HTML tags. +package htmlform + +import "go.osspkg.com/ui" + +// NewApp builds a profile form with native HTML elements and a local action. +func NewApp() (*ui.App, error) { + app := ui.New( + ui.PluginID("profile"), + ui.PluginTitle("Profile"), + ui.PluginVersion("1.0.0"), + ) + view := ui.View( + "profile.edit", + ui.Title("Profile"), + ui.State(map[string]any{"profile": map[string]any{"name": ""}, "submitted": false}), + ui.Content( + ui.HTML(ui.HTMLArticle, "profile-card").Row(1).Cols(8).Offset(2).Children( + ui.HTML(ui.HTMLH1, "title").Prop("text", "Edit profile"), + ui.HTML(ui.HTMLP, "hint").Prop("text", "Changes are saved when the form is submitted."), + ui.HTML(ui.HTMLForm, "profile-form").On("submit", ui.ActionRef("profile.submit")).Children( + ui.HTML(ui.HTMLLabel, "name-label").Prop("text", "Name"), + ui.HTML(ui.HTMLInput, "name").Prop("name", "name").Prop("value", ui.StateRef("profile.name")).Prop("placeholder", "Ada Lovelace"), + ui.HTML(ui.HTMLButton, "save").Prop("type", "submit").Prop("text", "Save profile"), + ), + ), + ), + ).Action("profile.submit", ui.SetState("submitted", true).Then(ui.ToastSuccess("Profile saved"))) + if err := app.AddView(view); err != nil { + return nil, err + } + return app, nil +} diff --git a/examples/htmlform/htmlform_test.go b/examples/htmlform/htmlform_test.go new file mode 100644 index 0000000..d8aa3c2 --- /dev/null +++ b/examples/htmlform/htmlform_test.go @@ -0,0 +1,16 @@ +package htmlform + +import "testing" + +func TestUnit_NewApp(t *testing.T) { + app, err := NewApp() + if err != nil { + t.Fatal(err) + } + if err := app.Manifest().Validate(); err != nil { + t.Fatal(err) + } + if _, ok := app.View("profile.edit"); !ok { + t.Fatal("profile view is missing") + } +} diff --git a/fixtures/invalid-layout.json b/fixtures/invalid-layout.json new file mode 100644 index 0000000..00cf088 --- /dev/null +++ b/fixtures/invalid-layout.json @@ -0,0 +1,13 @@ +{ + "protocolVersion": "1.0", + "id": "invalid", + "regions": { + "top-header": [], + "left-panel": [], + "content": [ + {"id": "broken", "component": "card", "layout": {"row": 1, "cols": 8, "offset": 5}} + ], + "right-panel": [], + "bottom": [] + } +} diff --git a/fixtures/valid-view.json b/fixtures/valid-view.json new file mode 100644 index 0000000..7eb17b8 --- /dev/null +++ b/fixtures/valid-view.json @@ -0,0 +1,32 @@ +{ + "protocolVersion": "1.0", + "id": "users.list", + "revision": "rev-1", + "title": "Users", + "state": {"filters": {"search": ""}}, + "sources": { + "users": { + "type": "tool", + "tool": "users.list", + "input": {"search": {"$state": "filters.search"}}, + "policy": "on-mount" + } + }, + "actions": { + "users.refresh": {"type": "refresh-source", "source": "users"} + }, + "regions": { + "top-header": [], + "left-panel": [], + "content": [ + { + "id": "users-table", + "component": "table", + "layout": {"row": 1, "cols": 12}, + "props": {"rows": {"$source": "users.items"}} + } + ], + "right-panel": [], + "bottom": [] + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a1837b6 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module go.osspkg.com/ui + +go 1.26.8 + +require go.osspkg.com/mcp v0.1.2 + +require ( + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6c78a69 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M= +github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +go.osspkg.com/mcp v0.1.2 h1:QOo55G9VaNH0KLcSF33eCx7/2r0gIJJySNwzkzOldOo= +go.osspkg.com/mcp v0.1.2/go.mod h1:/gWcxJAjnVPgcHrcw+lYjpD9uC4xe/YDRTVXAoFfbdo= diff --git a/html.go b/html.go new file mode 100644 index 0000000..08758db --- /dev/null +++ b/html.go @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package ui + +// HTMLTag is a safe, declarative HTML body-element name. Document, executable, +// and embedded-content elements are intentionally excluded. +type HTMLTag string + +const ( + HTMLA HTMLTag = "a" + HTMLAbbr HTMLTag = "abbr" + HTMLAddress HTMLTag = "address" + HTMLArea HTMLTag = "area" + HTMLArticle HTMLTag = "article" + HTMLAside HTMLTag = "aside" + HTMLAudio HTMLTag = "audio" + HTMLB HTMLTag = "b" + HTMLBDI HTMLTag = "bdi" + HTMLBDO HTMLTag = "bdo" + HTMLBlockquote HTMLTag = "blockquote" + HTMLBr HTMLTag = "br" + HTMLButton HTMLTag = "button" + HTMLCanvas HTMLTag = "canvas" + HTMLCaption HTMLTag = "caption" + HTMLCite HTMLTag = "cite" + HTMLCode HTMLTag = "code" + HTMLCol HTMLTag = "col" + HTMLColgroup HTMLTag = "colgroup" + HTMLData HTMLTag = "data" + HTMLDatalist HTMLTag = "datalist" + HTMLDd HTMLTag = "dd" + HTMLDel HTMLTag = "del" + HTMLDetails HTMLTag = "details" + HTMLDfn HTMLTag = "dfn" + HTMLDialog HTMLTag = "dialog" + HTMLDiv HTMLTag = "div" + HTMLDl HTMLTag = "dl" + HTMLDt HTMLTag = "dt" + HTMLEm HTMLTag = "em" + HTMLFieldset HTMLTag = "fieldset" + HTMLFigcaption HTMLTag = "figcaption" + HTMLFigure HTMLTag = "figure" + HTMLFooter HTMLTag = "footer" + HTMLForm HTMLTag = "form" + HTMLH1 HTMLTag = "h1" + HTMLH2 HTMLTag = "h2" + HTMLH3 HTMLTag = "h3" + HTMLH4 HTMLTag = "h4" + HTMLH5 HTMLTag = "h5" + HTMLH6 HTMLTag = "h6" + HTMLHeader HTMLTag = "header" + HTMLHgroup HTMLTag = "hgroup" + HTMLHr HTMLTag = "hr" + HTMLI HTMLTag = "i" + HTMLImg HTMLTag = "img" + HTMLInput HTMLTag = "input" + HTMLIns HTMLTag = "ins" + HTMLKbd HTMLTag = "kbd" + HTMLLabel HTMLTag = "label" + HTMLLegend HTMLTag = "legend" + HTMLLi HTMLTag = "li" + HTMLMain HTMLTag = "main" + HTMLMap HTMLTag = "map" + HTMLMark HTMLTag = "mark" + HTMLMenu HTMLTag = "menu" + HTMLMeter HTMLTag = "meter" + HTMLNav HTMLTag = "nav" + HTMLOl HTMLTag = "ol" + HTMLOptgroup HTMLTag = "optgroup" + HTMLOption HTMLTag = "option" + HTMLOutput HTMLTag = "output" + HTMLP HTMLTag = "p" + HTMLPicture HTMLTag = "picture" + HTMLPre HTMLTag = "pre" + HTMLProgress HTMLTag = "progress" + HTMLQ HTMLTag = "q" + HTMLRp HTMLTag = "rp" + HTMLRt HTMLTag = "rt" + HTMLRuby HTMLTag = "ruby" + HTMLS HTMLTag = "s" + HTMLSamp HTMLTag = "samp" + HTMLSearch HTMLTag = "search" + HTMLSection HTMLTag = "section" + HTMLSelect HTMLTag = "select" + HTMLSmall HTMLTag = "small" + HTMLSource HTMLTag = "source" + HTMLSpan HTMLTag = "span" + HTMLStrong HTMLTag = "strong" + HTMLSub HTMLTag = "sub" + HTMLSummary HTMLTag = "summary" + HTMLSup HTMLTag = "sup" + HTMLTable HTMLTag = "table" + HTMLTbody HTMLTag = "tbody" + HTMLTd HTMLTag = "td" + HTMLTextarea HTMLTag = "textarea" + HTMLTfoot HTMLTag = "tfoot" + HTMLTh HTMLTag = "th" + HTMLThead HTMLTag = "thead" + HTMLTime HTMLTag = "time" + HTMLTr HTMLTag = "tr" + HTMLTrack HTMLTag = "track" + HTMLU HTMLTag = "u" + HTMLUl HTMLTag = "ul" + HTMLVar HTMLTag = "var" + HTMLVideo HTMLTag = "video" + HTMLWbr HTMLTag = "wbr" +) + +// HTML creates a node for a safe HTML body element. The host must register the +// matching React component and explicitly allow its props and events. +func HTML(tag HTMLTag, id string) *NodeBuilder { return node(string(tag), id) } diff --git a/html_test.go b/html_test.go new file mode 100644 index 0000000..812f4db --- /dev/null +++ b/html_test.go @@ -0,0 +1,13 @@ +package ui + +import "testing" + +func TestUnit_HTMLBuilderProducesTagName(t *testing.T) { + for _, tag := range []HTMLTag{HTMLArticle, HTMLDiv, HTMLH1, HTMLInput, HTMLTable, HTMLVideo} { + t.Run(string(tag), func(t *testing.T) { + if got := HTML(tag, "node").Build().Component; got != string(tag) { + t.Fatalf("component = %q, want %q", got, tag) + } + }) + } +} diff --git a/mcp/register.go b/mcp/register.go new file mode 100644 index 0000000..0ca7c1c --- /dev/null +++ b/mcp/register.go @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Package mcp registers declarative UI resources on a go-mcp server. +package mcp + +import ( + "encoding/json" + "errors" + + ossmcp "go.osspkg.com/mcp" + + "go.osspkg.com/ui" +) + +func Capabilities() ossmcp.Option { + return ossmcp.WithCapabilities( + map[string]any{ + "experimental": map[string]any{ + "osspkg.ui": map[string]any{ + "version": ui.ProtocolVersion, + "manifest": "ui://manifest", + }, + }, + }, + ) +} + +func Register(server *ossmcp.Server, app *ui.App) error { + if server == nil || app == nil { + return errors.New("ui/mcp: server and app are required") + } + manifest, err := app.ManifestJSON() + if err != nil { + return err + } + if err := app.Manifest().Validate(); err != nil { + return err + } + if err := server.RegisterResource( + ossmcp.Resource{ + URI: "ui://manifest", + Name: "UI Manifest", + Description: "UI entry points exposed by the application", + MIMEType: ui.UIMIMEType, + Text: string(manifest), + }, + ); err != nil { + return err + } + for _, descriptor := range app.Manifest().Views { + view, ok := app.View(descriptor.ID) + if !ok { + return errors.New("ui/mcp: manifest view is missing") + } + payload, err := json.Marshal(view) + if err != nil { + return err + } + if err := server.RegisterResource( + ossmcp.Resource{ + URI: descriptor.Schema, + Name: descriptor.Title, + MIMEType: ui.UIMIMEType, + Text: string(payload), + }, + ); err != nil { + return err + } + } + return nil +} diff --git a/mcp/register_test.go b/mcp/register_test.go new file mode 100644 index 0000000..0a8ced5 --- /dev/null +++ b/mcp/register_test.go @@ -0,0 +1,23 @@ +package mcp + +import ( + "testing" + + goMCP "go.osspkg.com/mcp" + + "go.osspkg.com/ui" +) + +func TestUnit_Register(t *testing.T) { + server, err := goMCP.New(goMCP.ServerInfo{Name: "users", Version: "1.0.0"}, Capabilities()) + if err != nil { + t.Fatal(err) + } + app := ui.New(ui.PluginID("users"), ui.PluginTitle("Users"), ui.PluginVersion("1.0.0")) + if err := app.AddView(ui.View("users.list", ui.Title("Users"))); err != nil { + t.Fatal(err) + } + if err := Register(server, app); err != nil { + t.Fatal(err) + } +} diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..b6aee60 --- /dev/null +++ b/schema.go @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +// Package ui defines the transport-independent declarative UI protocol. +package ui + +import ( + "errors" + "fmt" + "strings" +) + +const ( + ProtocolVersion = "1.0" + UIMIMEType = "application/vnd.osspkg.ui+json" +) + +var ( + ErrInvalidSchema = errors.New("invalid ui schema") + ErrInvalidLayout = errors.New("invalid ui layout") + ErrInvalidValue = errors.New("invalid ui value") +) + +type Manifest struct { + ProtocolVersion string `json:"protocolVersion"` + Plugin PluginManifest `json:"plugin"` + Views []UIViewDescriptor `json:"views"` + RequiredComponents []string `json:"requiredComponents,omitempty"` +} + +type PluginManifest struct { + ID string `json:"id"` + Title string `json:"title"` + Version string `json:"version"` + Description string `json:"description,omitempty"` +} + +type UIViewDescriptor struct { + ID string `json:"id"` + Title string `json:"title"` + Route string `json:"route,omitempty"` + Schema string `json:"schema"` +} + +type ViewSchema struct { + ProtocolVersion string `json:"protocolVersion"` + ID string `json:"id"` + Revision string `json:"revision,omitempty"` + Title string `json:"title,omitempty"` + State map[string]any `json:"state,omitempty"` + Sources map[string]DataSource `json:"sources,omitempty"` + Actions map[string]Action `json:"actions,omitempty"` + Regions Regions `json:"regions"` +} + +type Regions struct { + TopHeader []Node `json:"top-header"` + LeftPanel []Node `json:"left-panel"` + Content []Node `json:"content"` + RightPanel []Node `json:"right-panel"` + Bottom []Node `json:"bottom"` +} + +type Layout struct { + Row int `json:"row"` + Cols int `json:"cols,omitempty"` + Offset int `json:"offset,omitempty"` +} + +type Node struct { + ID string `json:"id"` + Component string `json:"component"` + Layout *Layout `json:"layout,omitempty"` + Props map[string]Value `json:"props,omitempty"` + Events map[string]EventHandler `json:"events,omitempty"` + Children []Node `json:"children,omitempty"` + Slots map[string][]Node `json:"slots,omitempty"` + When *Value `json:"when,omitempty"` +} + +type EventHandler struct { + Action string `json:"action,omitempty"` + Steps []ActionStep `json:"steps,omitempty"` +} + +type DataSource struct { + Type string `json:"type"` + Tool string `json:"tool"` + Input map[string]Value `json:"input,omitempty"` + Policy SourcePolicy `json:"policy,omitempty"` + Cache *CachePolicy `json:"cache,omitempty"` + RefreshOn []string `json:"refreshOn,omitempty"` +} + +type SourcePolicy string + +const ( + SourceManual SourcePolicy = "manual" + SourceOnMount SourcePolicy = "on-mount" +) + +type CachePolicy struct { + TTL int64 `json:"ttl,omitempty"` +} + +type Action struct { + Type string `json:"type"` + Tool string `json:"tool,omitempty"` + Input map[string]Value `json:"input,omitempty"` + Path string `json:"path,omitempty"` + Value *Value `json:"value,omitempty"` + Source string `json:"source,omitempty"` + To string `json:"to,omitempty"` + Variant string `json:"variant,omitempty"` + Message string `json:"message,omitempty"` + Effects []Effect `json:"effects,omitempty"` +} + +type ActionStep struct { + Type string `json:"type"` + Action string `json:"action,omitempty"` + Input map[string]Value `json:"input,omitempty"` +} + +type Effect struct { + Type string `json:"type"` + Path string `json:"path,omitempty"` + Source string `json:"source,omitempty"` + Value *Value `json:"value,omitempty"` + BaseRevision string `json:"baseRevision,omitempty"` + Revision string `json:"revision,omitempty"` + To string `json:"to,omitempty"` + Variant string `json:"variant,omitempty"` + Message string `json:"message,omitempty"` +} + +func (m Manifest) Validate() error { + if m.ProtocolVersion != ProtocolVersion { + return fmt.Errorf("%w: unsupported manifest protocol version %q", ErrInvalidSchema, m.ProtocolVersion) + } + if m.Plugin.ID == "" { + return fmt.Errorf("%w: plugin id is required", ErrInvalidSchema) + } + seen := make(map[string]struct{}, len(m.Views)) + for _, view := range m.Views { + if view.ID == "" || view.Schema == "" { + return fmt.Errorf("%w: view id and schema are required", ErrInvalidSchema) + } + if _, ok := seen[view.ID]; ok { + return fmt.Errorf("%w: duplicate view %q", ErrInvalidSchema, view.ID) + } + seen[view.ID] = struct{}{} + } + return nil +} + +func validName(name string) bool { + return name != "" && !strings.ContainsAny(name, "\r\n") +} diff --git a/schema_test.go b/schema_test.go new file mode 100644 index 0000000..96b852b --- /dev/null +++ b/schema_test.go @@ -0,0 +1,175 @@ +package ui + +import ( + "encoding/json" + "errors" + "os" + "reflect" + "testing" +) + +func TestUnit_ValueRoundTrip(t *testing.T) { + value := Expr("$eq", StateRef("status"), Literal("active")) + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var decoded Value + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + if decoded.Kind != ValueExpr || decoded.Path != "$eq" { + t.Fatalf("decoded value = %#v", decoded) + } +} + +func TestUnit_LayoutValidation(t *testing.T) { + tests := []struct { + name string + layout Layout + region string + want error + }{ + {name: "valid", layout: Layout{Row: 1, Cols: 8, Offset: 2}, region: "content"}, + {name: "overflow", layout: Layout{Row: 1, Cols: 8, Offset: 5}, region: "content", want: ErrInvalidLayout}, + {name: "side panel", layout: Layout{Row: 1, Cols: 1}, region: "left-panel", want: ErrInvalidLayout}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := test.layout.Validate(test.region) + if !errors.Is(err, test.want) { + t.Fatalf("Validate() error = %v, want %v", err, test.want) + } + }) + } +} + +func TestUnit_SchemaLimits(t *testing.T) { + view := ViewSchema{ + ProtocolVersion: ProtocolVersion, + ID: "users.list", + Regions: func() Regions { + regions := emptyRegions() + regions.Content = []Node{{ID: "title", Component: "text", Props: map[string]Value{"text": Literal("Users")}}} + return regions + }(), + } + tests := []struct { + name string + limits Limits + }{ + {name: "bytes", limits: Limits{MaxBytes: 64}}, + {name: "strings", limits: Limits{MaxStrings: 1}}, + {name: "string length", limits: Limits{MaxStringLength: 3}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := view.ValidateWithLimits(test.limits); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("ValidateWithLimits() error = %v, want ErrInvalidSchema", err) + } + }) + } +} + +func TestUnit_SemanticValidation(t *testing.T) { + tests := []struct { + name string + value func() error + want error + }{ + {name: "unsupported operator", value: func() error { return Expr("$unknown", Literal(true)).Validate() }, want: ErrInvalidValue}, + {name: "operator arity", value: func() error { return Expr("$eq", Literal(true)).Validate() }, want: ErrInvalidValue}, + {name: "unknown source reference", value: func() error { + regions := emptyRegions() + regions.Content = []Node{{ID: "n", Component: "text", Props: map[string]Value{"value": SourceRef("missing.items")}}} + return (ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Regions: regions}).Validate() + }, want: ErrInvalidSchema}, + {name: "unknown effect source", value: func() error { + return (ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Actions: map[string]Action{"save": {Type: "tool", Tool: "users.save", Effects: []Effect{{Type: "invalidate", Source: "missing"}}}}, Regions: emptyRegions()}).Validate() + }, want: ErrInvalidSchema}, + {name: "unknown action value source", value: func() error { + return (ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Actions: map[string]Action{"save": {Type: "set-state", Path: "form", Value: ptrValue(SourceRef("missing.items"))}}, Regions: emptyRegions()}).Validate() + }, want: ErrInvalidSchema}, + {name: "module component name", value: func() error { + regions := emptyRegions() + regions.Content = []Node{{ID: "n", Component: "@scope/button"}} + return (ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Regions: regions}).Validate() + }, want: ErrInvalidSchema}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.value(); !errors.Is(err, test.want) { + t.Fatalf("validation error = %v, want %v", err, test.want) + } + }) + } +} + +func TestUnit_SchemaRequiresAllRegionArrays(t *testing.T) { + view := ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Regions: Regions{Content: []Node{}}} + if err := view.Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema", err) + } +} + +func TestUnit_SchemaExpressionDepthLimit(t *testing.T) { + value := Literal(true) + for range 3 { + value = Expr("$not", value) + } + regions := emptyRegions() + regions.Content = []Node{{ID: "condition", Component: "text", When: &value}} + view := ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Regions: regions} + if err := view.ValidateWithLimits(Limits{MaxExpressionDepth: 2}); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("ValidateWithLimits() error = %v, want ErrInvalidSchema", err) + } +} + +func TestUnit_JSONFixtures(t *testing.T) { + valid, err := os.ReadFile("fixtures/valid-view.json") + if err != nil { + t.Fatal(err) + } + var view ViewSchema + if err := json.Unmarshal(valid, &view); err != nil { + t.Fatal(err) + } + if err := view.Validate(); err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(view) + if err != nil { + t.Fatal(err) + } + var want, got any + if err := json.Unmarshal(valid, &want); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round-trip JSON differs:\n got: %s\nwant: %s", encoded, valid) + } + + invalid, err := os.ReadFile("fixtures/invalid-layout.json") + if err != nil { + t.Fatal(err) + } + var broken ViewSchema + if err := json.Unmarshal(invalid, &broken); err != nil { + t.Fatal(err) + } + if err := broken.Validate(); !errors.Is(err, ErrInvalidLayout) { + t.Fatalf("invalid fixture error = %v, want ErrInvalidLayout", err) + } +} + +func FuzzValueUnmarshal(f *testing.F) { + f.Add([]byte(`{"$state":"form.email"}`)) + f.Add([]byte(`{"$eq":[1,2]}`)) + f.Fuzz(func(t *testing.T, data []byte) { + var value Value + _ = json.Unmarshal(data, &value) + }) +} diff --git a/security_test.go b/security_test.go new file mode 100644 index 0000000..73e813d --- /dev/null +++ b/security_test.go @@ -0,0 +1,29 @@ +package ui + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +func TestUnit_SecuritySchemaBoundaries(t *testing.T) { + t.Run("unsafe value path", func(t *testing.T) { + var value Value + if err := json.Unmarshal([]byte(`{"$state":"constructor.name"}`), &value); !errors.Is(err, ErrInvalidValue) { + t.Fatalf("Unmarshal() error = %v, want ErrInvalidValue", err) + } + }) + t.Run("oversized serialized schema", func(t *testing.T) { + view := ViewSchema{ProtocolVersion: ProtocolVersion, ID: "safe", Title: strings.Repeat("x", 128), Regions: Regions{}} + if err := view.ValidateWithLimits(Limits{MaxBytes: 64}); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("ValidateWithLimits() error = %v, want ErrInvalidSchema", err) + } + }) + t.Run("unsafe action path", func(t *testing.T) { + view := ViewSchema{ProtocolVersion: ProtocolVersion, ID: "safe", Actions: map[string]Action{"save": {Type: "set-state", Path: "__proto__.polluted", Value: ptrValue("x")}}, Regions: Regions{}} + if err := view.Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema", err) + } + }) +} diff --git a/shadcn.go b/shadcn.go new file mode 100644 index 0000000..13d339c --- /dev/null +++ b/shadcn.go @@ -0,0 +1,142 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package ui + +// ShadcnComponent is a host-owned component name from the official shadcn/ui +// catalog. Register a matching React implementation before rendering it. +type ShadcnComponent string + +const ( + ShadcnAccordion ShadcnComponent = "accordion" + ShadcnAlert ShadcnComponent = "alert" + ShadcnAlertDialog ShadcnComponent = "alert-dialog" + ShadcnAspectRatio ShadcnComponent = "aspect-ratio" + ShadcnAttachment ShadcnComponent = "attachment" + ShadcnAvatar ShadcnComponent = "avatar" + ShadcnBadge ShadcnComponent = "badge" + ShadcnBreadcrumb ShadcnComponent = "breadcrumb" + ShadcnBubble ShadcnComponent = "bubble" + ShadcnButton ShadcnComponent = "button" + ShadcnButtonGroup ShadcnComponent = "button-group" + ShadcnCalendar ShadcnComponent = "calendar" + ShadcnCard ShadcnComponent = "card" + ShadcnCarousel ShadcnComponent = "carousel" + ShadcnChart ShadcnComponent = "chart" + ShadcnCheckbox ShadcnComponent = "checkbox" + ShadcnCollapsible ShadcnComponent = "collapsible" + ShadcnCombobox ShadcnComponent = "combobox" + ShadcnCommand ShadcnComponent = "command" + ShadcnContextMenu ShadcnComponent = "context-menu" + ShadcnDataTable ShadcnComponent = "data-table" + ShadcnDatePicker ShadcnComponent = "date-picker" + ShadcnDialog ShadcnComponent = "dialog" + ShadcnDirection ShadcnComponent = "direction" + ShadcnDrawer ShadcnComponent = "drawer" + ShadcnDropdownMenu ShadcnComponent = "dropdown-menu" + ShadcnEmpty ShadcnComponent = "empty" + ShadcnField ShadcnComponent = "field" + ShadcnHoverCard ShadcnComponent = "hover-card" + ShadcnInput ShadcnComponent = "input" + ShadcnInputGroup ShadcnComponent = "input-group" + ShadcnInputOTP ShadcnComponent = "input-otp" + ShadcnItem ShadcnComponent = "item" + ShadcnKbd ShadcnComponent = "kbd" + ShadcnLabel ShadcnComponent = "label" + ShadcnMarker ShadcnComponent = "marker" + ShadcnMenubar ShadcnComponent = "menubar" + ShadcnMessage ShadcnComponent = "message" + ShadcnMessageScroller ShadcnComponent = "message-scroller" + ShadcnNativeSelect ShadcnComponent = "native-select" + ShadcnNavigationMenu ShadcnComponent = "navigation-menu" + ShadcnPagination ShadcnComponent = "pagination" + ShadcnPopover ShadcnComponent = "popover" + ShadcnProgress ShadcnComponent = "progress" + ShadcnQuestionnaire ShadcnComponent = "questionnaire" + ShadcnRadioGroup ShadcnComponent = "radio-group" + ShadcnResizable ShadcnComponent = "resizable" + ShadcnScrollArea ShadcnComponent = "scroll-area" + ShadcnSelect ShadcnComponent = "select" + ShadcnSeparator ShadcnComponent = "separator" + ShadcnSheet ShadcnComponent = "sheet" + ShadcnSidebar ShadcnComponent = "sidebar" + ShadcnSkeleton ShadcnComponent = "skeleton" + ShadcnSlider ShadcnComponent = "slider" + ShadcnSpinner ShadcnComponent = "spinner" + ShadcnSwitch ShadcnComponent = "switch" + ShadcnTable ShadcnComponent = "table" + ShadcnTabs ShadcnComponent = "tabs" + ShadcnTextarea ShadcnComponent = "textarea" + ShadcnToast ShadcnComponent = "toast" + ShadcnToggle ShadcnComponent = "toggle" + ShadcnToggleGroup ShadcnComponent = "toggle-group" + ShadcnTooltip ShadcnComponent = "tooltip" + ShadcnTypography ShadcnComponent = "typography" +) + +// Shadcn creates a node for a component from the shadcn/ui catalog. +func Shadcn(component ShadcnComponent, id string) *NodeBuilder { return node(string(component), id) } + +func Accordion(id string) *NodeBuilder { return Shadcn(ShadcnAccordion, id) } +func Alert(id string) *NodeBuilder { return Shadcn(ShadcnAlert, id) } +func AlertDialog(id string) *NodeBuilder { return Shadcn(ShadcnAlertDialog, id) } +func AspectRatio(id string) *NodeBuilder { return Shadcn(ShadcnAspectRatio, id) } +func Attachment(id string) *NodeBuilder { return Shadcn(ShadcnAttachment, id) } +func Avatar(id string) *NodeBuilder { return Shadcn(ShadcnAvatar, id) } +func Badge(id string) *NodeBuilder { return Shadcn(ShadcnBadge, id) } +func Breadcrumb(id string) *NodeBuilder { return Shadcn(ShadcnBreadcrumb, id) } +func Bubble(id string) *NodeBuilder { return Shadcn(ShadcnBubble, id) } +func ButtonGroup(id string) *NodeBuilder { return Shadcn(ShadcnButtonGroup, id) } +func Calendar(id string) *NodeBuilder { return Shadcn(ShadcnCalendar, id) } +func Carousel(id string) *NodeBuilder { return Shadcn(ShadcnCarousel, id) } +func Chart(id string) *NodeBuilder { return Shadcn(ShadcnChart, id) } +func Checkbox(id string) *NodeBuilder { return Shadcn(ShadcnCheckbox, id) } +func Collapsible(id string) *NodeBuilder { return Shadcn(ShadcnCollapsible, id) } +func Combobox(id string) *NodeBuilder { return Shadcn(ShadcnCombobox, id) } +func Command(id string) *NodeBuilder { return Shadcn(ShadcnCommand, id) } +func ContextMenu(id string) *NodeBuilder { return Shadcn(ShadcnContextMenu, id) } +func DataTable(id string) *NodeBuilder { return Shadcn(ShadcnDataTable, id) } +func DatePicker(id string) *NodeBuilder { return Shadcn(ShadcnDatePicker, id) } +func Dialog(id string) *NodeBuilder { return Shadcn(ShadcnDialog, id) } +func Direction(id string) *NodeBuilder { return Shadcn(ShadcnDirection, id) } +func Drawer(id string) *NodeBuilder { return Shadcn(ShadcnDrawer, id) } +func DropdownMenu(id string) *NodeBuilder { return Shadcn(ShadcnDropdownMenu, id) } +func Empty(id string) *NodeBuilder { return Shadcn(ShadcnEmpty, id) } +func Field(id string) *NodeBuilder { return Shadcn(ShadcnField, id) } +func HoverCard(id string) *NodeBuilder { return Shadcn(ShadcnHoverCard, id) } +func Input(id string) *NodeBuilder { return Shadcn(ShadcnInput, id) } +func InputGroup(id string) *NodeBuilder { return Shadcn(ShadcnInputGroup, id) } +func InputOTP(id string) *NodeBuilder { return Shadcn(ShadcnInputOTP, id) } +func Item(id string) *NodeBuilder { return Shadcn(ShadcnItem, id) } +func Kbd(id string) *NodeBuilder { return Shadcn(ShadcnKbd, id) } +func Label(id string) *NodeBuilder { return Shadcn(ShadcnLabel, id) } +func Marker(id string) *NodeBuilder { return Shadcn(ShadcnMarker, id) } +func Menubar(id string) *NodeBuilder { return Shadcn(ShadcnMenubar, id) } +func Message(id string) *NodeBuilder { return Shadcn(ShadcnMessage, id) } +func MessageScroller(id string) *NodeBuilder { return Shadcn(ShadcnMessageScroller, id) } +func NativeSelect(id string) *NodeBuilder { return Shadcn(ShadcnNativeSelect, id) } +func NavigationMenu(id string) *NodeBuilder { return Shadcn(ShadcnNavigationMenu, id) } +func Pagination(id string) *NodeBuilder { return Shadcn(ShadcnPagination, id) } +func Popover(id string) *NodeBuilder { return Shadcn(ShadcnPopover, id) } +func Progress(id string) *NodeBuilder { return Shadcn(ShadcnProgress, id) } +func Questionnaire(id string) *NodeBuilder { return Shadcn(ShadcnQuestionnaire, id) } +func RadioGroup(id string) *NodeBuilder { return Shadcn(ShadcnRadioGroup, id) } +func Resizable(id string) *NodeBuilder { return Shadcn(ShadcnResizable, id) } +func ScrollArea(id string) *NodeBuilder { return Shadcn(ShadcnScrollArea, id) } +func Select(id string) *NodeBuilder { return Shadcn(ShadcnSelect, id) } +func Separator(id string) *NodeBuilder { return Shadcn(ShadcnSeparator, id) } +func Sheet(id string) *NodeBuilder { return Shadcn(ShadcnSheet, id) } +func Sidebar(id string) *NodeBuilder { return Shadcn(ShadcnSidebar, id) } +func Skeleton(id string) *NodeBuilder { return Shadcn(ShadcnSkeleton, id) } +func Slider(id string) *NodeBuilder { return Shadcn(ShadcnSlider, id) } +func Spinner(id string) *NodeBuilder { return Shadcn(ShadcnSpinner, id) } +func Switch(id string) *NodeBuilder { return Shadcn(ShadcnSwitch, id) } +func Tabs(id string) *NodeBuilder { return Shadcn(ShadcnTabs, id) } +func Textarea(id string) *NodeBuilder { return Shadcn(ShadcnTextarea, id) } +func Toast(id string) *NodeBuilder { return Shadcn(ShadcnToast, id) } +func Toggle(id string) *NodeBuilder { return Shadcn(ShadcnToggle, id) } +func ToggleGroup(id string) *NodeBuilder { return Shadcn(ShadcnToggleGroup, id) } +func Tooltip(id string) *NodeBuilder { return Shadcn(ShadcnTooltip, id) } +func Typography(id string) *NodeBuilder { return Shadcn(ShadcnTypography, id) } diff --git a/shadcn_test.go b/shadcn_test.go new file mode 100644 index 0000000..763150b --- /dev/null +++ b/shadcn_test.go @@ -0,0 +1,25 @@ +package ui + +import "testing" + +func TestUnit_ShadcnBuildersProduceCatalogNames(t *testing.T) { + tests := []struct { + name string + component ShadcnComponent + }{ + {"accordion", ShadcnAccordion}, {"alert", ShadcnAlert}, {"alert dialog", ShadcnAlertDialog}, {"aspect ratio", ShadcnAspectRatio}, {"attachment", ShadcnAttachment}, {"avatar", ShadcnAvatar}, {"badge", ShadcnBadge}, {"breadcrumb", ShadcnBreadcrumb}, {"bubble", ShadcnBubble}, {"button", ShadcnButton}, {"button group", ShadcnButtonGroup}, {"calendar", ShadcnCalendar}, {"card", ShadcnCard}, {"carousel", ShadcnCarousel}, {"chart", ShadcnChart}, {"checkbox", ShadcnCheckbox}, {"collapsible", ShadcnCollapsible}, {"combobox", ShadcnCombobox}, {"command", ShadcnCommand}, {"context menu", ShadcnContextMenu}, {"data table", ShadcnDataTable}, {"date picker", ShadcnDatePicker}, {"dialog", ShadcnDialog}, {"direction", ShadcnDirection}, {"drawer", ShadcnDrawer}, {"dropdown menu", ShadcnDropdownMenu}, {"empty", ShadcnEmpty}, {"field", ShadcnField}, {"hover card", ShadcnHoverCard}, {"input", ShadcnInput}, {"input group", ShadcnInputGroup}, {"input otp", ShadcnInputOTP}, {"item", ShadcnItem}, {"kbd", ShadcnKbd}, {"label", ShadcnLabel}, {"marker", ShadcnMarker}, {"menubar", ShadcnMenubar}, {"message", ShadcnMessage}, {"message scroller", ShadcnMessageScroller}, {"native select", ShadcnNativeSelect}, {"navigation menu", ShadcnNavigationMenu}, {"pagination", ShadcnPagination}, {"popover", ShadcnPopover}, {"progress", ShadcnProgress}, {"questionnaire", ShadcnQuestionnaire}, {"radio group", ShadcnRadioGroup}, {"resizable", ShadcnResizable}, {"scroll area", ShadcnScrollArea}, {"select", ShadcnSelect}, {"separator", ShadcnSeparator}, {"sheet", ShadcnSheet}, {"sidebar", ShadcnSidebar}, {"skeleton", ShadcnSkeleton}, {"slider", ShadcnSlider}, {"spinner", ShadcnSpinner}, {"switch", ShadcnSwitch}, {"table", ShadcnTable}, {"tabs", ShadcnTabs}, {"textarea", ShadcnTextarea}, {"toast", ShadcnToast}, {"toggle", ShadcnToggle}, {"toggle group", ShadcnToggleGroup}, {"tooltip", ShadcnTooltip}, {"typography", ShadcnTypography}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := Shadcn(test.component, "node").Build().Component; got != string(test.component) { + t.Fatalf("component = %q, want %q", got, test.component) + } + }) + } +} + +func TestUnit_ShadcnConvenienceBuilder(t *testing.T) { + if got := AlertDialog("confirm").Build().Component; got != string(ShadcnAlertDialog) { + t.Fatalf("AlertDialog() component = %q, want %q", got, ShadcnAlertDialog) + } +} diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..81568b6 --- /dev/null +++ b/validation.go @@ -0,0 +1,418 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package ui + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" +) + +type Limits struct { + MaxBytes int + MaxNodes int + MaxDepth int + MaxPropsPerNode int + MaxActions int + MaxSources int + MaxStrings int + MaxStringLength int + MaxExpressionDepth int +} + +func DefaultLimits() Limits { + return Limits{ + MaxBytes: 1 << 20, + MaxNodes: 1000, MaxDepth: 32, MaxPropsPerNode: 64, + MaxActions: 128, MaxSources: 64, MaxStrings: 10000, MaxStringLength: 16 << 10, + MaxExpressionDepth: 32, + } +} + +func (v ViewSchema) Validate() error { return v.ValidateWithLimits(DefaultLimits()) } + +func (v ViewSchema) ValidateWithLimits(limits Limits) error { + limits = withDefaultLimits(limits) + if v.ProtocolVersion != ProtocolVersion { + return fmt.Errorf("%w: unsupported view protocol version %q", ErrInvalidSchema, v.ProtocolVersion) + } + if !validName(v.ID) { + return fmt.Errorf("%w: view id is required", ErrInvalidSchema) + } + if len(v.Actions) > limits.MaxActions || len(v.Sources) > limits.MaxSources { + return fmt.Errorf("%w: source or action limit exceeded", ErrInvalidSchema) + } + if v.Regions.TopHeader == nil || v.Regions.LeftPanel == nil || v.Regions.Content == nil || v.Regions.RightPanel == nil || v.Regions.Bottom == nil { + return fmt.Errorf("%w: all regions must be arrays", ErrInvalidSchema) + } + if err := validateSerializedLimits(v, limits); err != nil { + return err + } + for name, source := range v.Sources { + if !validName(name) || source.Type != "tool" || !validName(source.Tool) { + return fmt.Errorf("%w: invalid source %q", ErrInvalidSchema, name) + } + if source.Policy != "" && source.Policy != SourceManual && source.Policy != SourceOnMount { + return fmt.Errorf("%w: source %q has unsupported policy", ErrInvalidSchema, name) + } + if source.Cache != nil && source.Cache.TTL < 0 { + return fmt.Errorf("%w: source %q has negative cache ttl", ErrInvalidSchema, name) + } + if err := validateValues(source.Input, limits, v); err != nil { + return fmt.Errorf("%w: source %q input: %w", ErrInvalidSchema, name, err) + } + for _, action := range source.RefreshOn { + if _, ok := v.Actions[action]; !ok { + return fmt.Errorf("%w: source %q refresh action %q is not defined", ErrInvalidSchema, name, action) + } + } + } + for name, action := range v.Actions { + if !validName(name) || !validActionType(action.Type) { + return fmt.Errorf("%w: invalid action %q", ErrInvalidSchema, name) + } + if err := validateAction(action, limits, v); err != nil { + return fmt.Errorf("%w: action %q: %w", ErrInvalidSchema, name, err) + } + } + seen := make(map[string]struct{}) + nodes := 0 + for region, list := range map[string][]Node{ + "top-header": v.Regions.TopHeader, "left-panel": v.Regions.LeftPanel, + "content": v.Regions.Content, "right-panel": v.Regions.RightPanel, + "bottom": v.Regions.Bottom, + } { + for _, node := range list { + if err := validateNode(node, region, 1, &nodes, seen, limits, v); err != nil { + return err + } + } + } + return nil +} + +func withDefaultLimits(limits Limits) Limits { + defaults := DefaultLimits() + if limits.MaxBytes <= 0 { + limits.MaxBytes = defaults.MaxBytes + } + if limits.MaxNodes <= 0 { + limits.MaxNodes = defaults.MaxNodes + } + if limits.MaxDepth <= 0 { + limits.MaxDepth = defaults.MaxDepth + } + if limits.MaxPropsPerNode <= 0 { + limits.MaxPropsPerNode = defaults.MaxPropsPerNode + } + if limits.MaxActions <= 0 { + limits.MaxActions = defaults.MaxActions + } + if limits.MaxSources <= 0 { + limits.MaxSources = defaults.MaxSources + } + if limits.MaxStrings <= 0 { + limits.MaxStrings = defaults.MaxStrings + } + if limits.MaxStringLength <= 0 { + limits.MaxStringLength = defaults.MaxStringLength + } + if limits.MaxExpressionDepth <= 0 { + limits.MaxExpressionDepth = defaults.MaxExpressionDepth + } + return limits +} + +func validateSerializedLimits(schema ViewSchema, limits Limits) error { + encoded, err := json.Marshal(schema) + if err != nil { + return fmt.Errorf("%w: serialize schema for limits: %w", ErrInvalidSchema, err) + } + if len(encoded) > limits.MaxBytes { + return fmt.Errorf("%w: schema byte limit exceeded", ErrInvalidSchema) + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var document any + if err := decoder.Decode(&document); err != nil { + return fmt.Errorf("%w: inspect schema limits: %w", ErrInvalidSchema, err) + } + strings := 0 + if err := walkLimitValue(document, 0, limits, &strings); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSchema, err) + } + return nil +} + +func walkLimitValue(value any, depth int, limits Limits, strings *int) error { + if depth > limits.MaxDepth { + return fmt.Errorf("schema depth limit exceeded") + } + switch typed := value.(type) { + case string: + if len(typed) > limits.MaxStringLength { + return fmt.Errorf("string length limit exceeded") + } + (*strings)++ + if *strings > limits.MaxStrings { + return fmt.Errorf("string count limit exceeded") + } + case []any: + for _, item := range typed { + if err := walkLimitValue(item, depth+1, limits, strings); err != nil { + return err + } + } + case map[string]any: + for key, item := range typed { + if err := walkLimitValue(key, depth+1, limits, strings); err != nil { + return err + } + if err := walkLimitValue(item, depth+1, limits, strings); err != nil { + return err + } + } + } + return nil +} + +func validateNode(node Node, region string, depth int, nodes *int, seen map[string]struct{}, limits Limits, view ViewSchema) error { + *nodes++ + if *nodes > limits.MaxNodes || depth > limits.MaxDepth { + return fmt.Errorf("%w: node limit exceeded", ErrInvalidSchema) + } + if !validName(node.ID) || !validComponentName(node.Component) { + return fmt.Errorf("%w: node id and component are required", ErrInvalidSchema) + } + if _, exists := seen[node.ID]; exists { + return fmt.Errorf("%w: duplicate node id %q", ErrInvalidSchema, node.ID) + } + seen[node.ID] = struct{}{} + if node.Layout != nil { + if err := node.Layout.Validate(region); err != nil { + return err + } + } + if len(node.Props) > limits.MaxPropsPerNode { + return fmt.Errorf("%w: too many props on node %q", ErrInvalidSchema, node.ID) + } + if err := validateValues(node.Props, limits, view); err != nil { + return fmt.Errorf("%w: node %q props: %w", ErrInvalidSchema, node.ID, err) + } + for event, handler := range node.Events { + if !validName(event) || handler.Action == "" && len(handler.Steps) == 0 { + return fmt.Errorf("%w: invalid event on node %q", ErrInvalidSchema, node.ID) + } + if handler.Action != "" { + if _, ok := view.Actions[handler.Action]; !ok { + return fmt.Errorf("%w: action %q is not defined", ErrInvalidSchema, handler.Action) + } + } + for _, step := range handler.Steps { + if step.Type != "action" || step.Action == "" { + return fmt.Errorf("%w: invalid event step on node %q", ErrInvalidSchema, node.ID) + } + if _, ok := view.Actions[step.Action]; !ok { + return fmt.Errorf("%w: action %q is not defined", ErrInvalidSchema, step.Action) + } + if err := validateValues(step.Input, limits, view); err != nil { + return fmt.Errorf("%w: event step input: %w", ErrInvalidSchema, err) + } + } + } + if node.When != nil { + if err := validateValue(*node.When, limits, view, 0); err != nil { + return fmt.Errorf("%w: node %q condition: %w", ErrInvalidSchema, node.ID, err) + } + } + for _, child := range node.Children { + if err := validateNode(child, region, depth+1, nodes, seen, limits, view); err != nil { + return err + } + } + for _, children := range node.Slots { + for _, child := range children { + if err := validateNode(child, region, depth+1, nodes, seen, limits, view); err != nil { + return err + } + } + } + return nil +} + +func (l Layout) Validate(region string) error { + if l.Row < 1 { + return fmt.Errorf("%w: row must be positive", ErrInvalidLayout) + } + if region == "left-panel" || region == "right-panel" { + if l.Cols != 0 || l.Offset != 0 { + return fmt.Errorf("%w: side panels do not accept cols or offset", ErrInvalidLayout) + } + return nil + } + if l.Cols < 1 || l.Cols > 12 || l.Offset < 0 || l.Offset > 11 || l.Cols+l.Offset > 12 { + return fmt.Errorf("%w: cols and offset must fit 12 columns", ErrInvalidLayout) + } + return nil +} + +func validateValues(values map[string]Value, limits Limits, view ViewSchema) error { + for key, value := range values { + if len(key) > limits.MaxStringLength { + return fmt.Errorf("property name is too long") + } + if err := validateValue(value, limits, view, 0); err != nil { + return err + } + } + return nil +} + +func validateAction(action Action, limits Limits, view ViewSchema) error { + if action.Type == "tool" && !validName(action.Tool) { + return fmt.Errorf("tool action requires a tool") + } + if action.Type == "set-state" || action.Type == "merge-state" { + if !safePath(action.Path) { + return fmt.Errorf("state action requires a safe path") + } + } + if action.Type == "refresh-source" || action.Type == "invalidate" { + if _, ok := view.Sources[action.Source]; !ok { + return fmt.Errorf("source %q is not defined", action.Source) + } + } + if action.Path != "" && !safePath(action.Path) { + return fmt.Errorf("unsafe action path") + } + if err := validateValues(action.Input, limits, view); err != nil { + return err + } + if action.Value != nil { + if err := validateValue(*action.Value, limits, view, 0); err != nil { + return err + } + } + for _, effect := range action.Effects { + if err := validateEffect(effect, limits, view); err != nil { + return err + } + } + return nil +} + +func validateEffect(effect Effect, limits Limits, view ViewSchema) error { + if effect.Type == "" { + return fmt.Errorf("effect type is required") + } + if !validEffectType(effect.Type) { + return fmt.Errorf("unsupported effect type %q", effect.Type) + } + if effect.Path != "" && !safePath(effect.Path) { + return fmt.Errorf("unsafe effect path") + } + if effect.Type == "set-state" || effect.Type == "merge-state" { + if !safePath(effect.Path) { + return fmt.Errorf("state effect requires a safe path") + } + } + if effect.Type == "invalidate" || effect.Type == "refresh-source" { + if _, ok := view.Sources[effect.Source]; !ok { + return fmt.Errorf("source %q is not defined", effect.Source) + } + } + if effect.Type == "toast" && effect.Message == "" { + return fmt.Errorf("toast effect requires a message") + } + if effect.Type == "navigate" && effect.To == "" { + return fmt.Errorf("navigate effect requires a target") + } + if effect.Value != nil { + if err := validateValue(*effect.Value, limits, view, 0); err != nil { + return err + } + } + return nil +} + +func validateValue(value Value, limits Limits, view ViewSchema, depth int) error { + if depth > limits.MaxExpressionDepth { + return fmt.Errorf("expression depth limit exceeded") + } + switch value.Kind { + case ValueLiteral: + return nil + case ValueState, ValueContext, ValueEvent, ValueResult: + if !safePath(value.Path) { + return fmt.Errorf("%w: unsafe reference path %q", ErrInvalidValue, value.Path) + } + return nil + case ValueSource: + if !safePath(value.Path) { + return fmt.Errorf("%w: unsafe reference path %q", ErrInvalidValue, value.Path) + } + name := value.Path + if index := strings.IndexByte(name, '.'); index >= 0 { + name = name[:index] + } + if _, ok := view.Sources[name]; !ok { + return fmt.Errorf("source %q is not defined", name) + } + return nil + case ValueExpr: + args, ok := value.Data.([]Value) + if !ok { + return fmt.Errorf("expression arguments have invalid type") + } + arity, ok := expressionArity[value.Path] + if !ok { + return fmt.Errorf("%w: unsupported expression operator %q", ErrInvalidValue, value.Path) + } + if len(args) < arity[0] || (arity[1] >= 0 && len(args) > arity[1]) { + return fmt.Errorf("%w: operator %s expects %d..%d arguments", ErrInvalidValue, value.Path, arity[0], arity[1]) + } + for _, arg := range args { + if err := validateValue(arg, limits, view, depth+1); err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("%w: unknown value kind %q", ErrInvalidValue, value.Kind) + } +} + +func validActionType(value string) bool { + switch value { + case "tool", "set-state", "merge-state", "refresh-source", "invalidate": + return true + default: + return false + } +} + +func validEffectType(value string) bool { + switch value { + case "set-state", "merge-state", "invalidate", "refresh-source", "refresh-view", "patch-view", "navigate", "toast", "dialog", "close-dialog": + return true + default: + return false + } +} + +func validComponentName(value string) bool { + if value == "" { + return false + } + for index, char := range value { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9' && index > 0) || char == '-' { + continue + } + return false + } + return true +} diff --git a/value.go b/value.go new file mode 100644 index 0000000..541af36 --- /dev/null +++ b/value.go @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. + */ + +package ui + +import ( + "bytes" + "encoding/json" + "fmt" +) + +type ValueKind string + +const ( + ValueLiteral ValueKind = "literal" + ValueState ValueKind = "state" + ValueContext ValueKind = "context" + ValueSource ValueKind = "source" + ValueEvent ValueKind = "event" + ValueResult ValueKind = "result" + ValueExpr ValueKind = "expression" +) + +type Value struct { + Kind ValueKind + Path string + Data any +} + +var expressionArity = map[string][2]int{ + "$eq": {2, 2}, "$ne": {2, 2}, "$gt": {2, 2}, "$gte": {2, 2}, + "$lt": {2, 2}, "$lte": {2, 2}, "$and": {1, -1}, "$or": {1, -1}, + "$not": {1, 1}, "$in": {2, 2}, "$exists": {1, 1}, "$concat": {0, -1}, + "$coalesce": {1, -1}, "$if": {3, 3}, +} + +func Literal(data any) Value { return Value{Kind: ValueLiteral, Data: data} } +func StateRef(path string) Value { return Value{Kind: ValueState, Path: path} } +func ContextRef(path string) Value { return Value{Kind: ValueContext, Path: path} } +func SourceRef(path string) Value { return Value{Kind: ValueSource, Path: path} } +func EventRef(path string) Value { return Value{Kind: ValueEvent, Path: path} } +func ResultRef(path string) Value { return Value{Kind: ValueResult, Path: path} } + +func Expr(operator string, args ...Value) Value { + return Value{Kind: ValueExpr, Path: operator, Data: append([]Value(nil), args...)} +} + +func (v Value) Validate() error { + switch v.Kind { + case ValueLiteral: + return nil + case ValueState, ValueContext, ValueSource, ValueEvent, ValueResult: + if !safePath(v.Path) { + return fmt.Errorf("%w: unsafe reference path %q", ErrInvalidValue, v.Path) + } + return nil + case ValueExpr: + arity, ok := expressionArity[v.Path] + if !ok { + return fmt.Errorf("%w: unsupported expression operator %q", ErrInvalidValue, v.Path) + } + if v.Path == "" { + return fmt.Errorf("%w: expression operator is required", ErrInvalidValue) + } + args, ok := v.Data.([]Value) + if !ok { + return fmt.Errorf("%w: expression arguments have invalid type", ErrInvalidValue) + } + if len(args) < arity[0] || (arity[1] >= 0 && len(args) > arity[1]) { + return fmt.Errorf("%w: operator %s expects %d..%d arguments", ErrInvalidValue, v.Path, arity[0], arity[1]) + } + for _, arg := range args { + if err := arg.Validate(); err != nil { + return err + } + } + return nil + default: + return fmt.Errorf("%w: unknown value kind %q", ErrInvalidValue, v.Kind) + } +} + +func (v Value) MarshalJSON() ([]byte, error) { + if err := v.Validate(); err != nil { + return nil, err + } + if v.Kind == ValueLiteral { + return json.Marshal(v.Data) + } + if v.Kind == ValueExpr { + args, _ := v.Data.([]Value) + return json.Marshal(map[string]any{v.Path: args}) + } + return json.Marshal(map[string]string{"$" + string(v.Kind): v.Path}) +} + +func (v *Value) UnmarshalJSON(data []byte) error { + if v == nil { + return fmt.Errorf("%w: nil destination", ErrInvalidValue) + } + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err == nil && object != nil { + if len(object) == 1 { + for key, raw := range object { + if key == "$state" || key == "$context" || key == "$source" || key == "$event" || key == "$result" { + var path string + if err := json.Unmarshal(raw, &path); err != nil { + return fmt.Errorf("%w: reference path: %w", ErrInvalidValue, err) + } + v.Kind = ValueKind(key[1:]) + v.Path = path + v.Data = nil + return v.Validate() + } + var args []Value + if err := json.Unmarshal(raw, &args); err == nil { + v.Kind = ValueExpr + v.Path = key + v.Data = args + return v.Validate() + } + } + } + } + var literal any + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&literal); err != nil { + return fmt.Errorf("%w: literal: %w", ErrInvalidValue, err) + } + v.Kind = ValueLiteral + v.Path = "" + v.Data = literal + return nil +} + +func safePath(path string) bool { + if path == "" { + return false + } + for _, part := range splitPath(path) { + if part == "__proto__" || part == "prototype" || part == "constructor" || part == "" { + return false + } + } + return true +} + +func splitPath(path string) []string { + parts := make([]string, 0, 4) + start := 0 + for i := 0; i <= len(path); i++ { + if i == len(path) || path[i] == '.' { + parts = append(parts, path[start:i]) + start = i + 1 + } + } + return parts +} From 921b9a64d3403f06f5646cdebf6e15caa87065b9 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Tue, 22 Sep 2026 06:19:05 +0300 Subject: [PATCH 02/26] change --- builder.go | 293 ++++- builder_test.go | 62 +- examples/dashboard/dashboard.go | 50 +- examples/htmlform/htmlform.go | 46 +- html.go | 4 +- mcp/register.go | 19 +- mcp/register_test.go | 35 +- schema.go | 20 + schema_easyjson.go | 2189 +++++++++++++++++++++++++++++++ schema_test.go | 51 + security_test.go | 61 +- shadcn_test.go | 77 +- validation.go | 183 ++- value.go | 77 +- 14 files changed, 3035 insertions(+), 132 deletions(-) create mode 100644 schema_easyjson.go diff --git a/builder.go b/builder.go index 16a7695..a0b5f46 100644 --- a/builder.go +++ b/builder.go @@ -5,7 +5,10 @@ package ui -import "encoding/json" +import ( + "encoding/json" + "fmt" +) type App struct { manifest Manifest @@ -14,17 +17,37 @@ type App struct { type AppOption func(*App) -func PluginID(value string) AppOption { return func(app *App) { app.manifest.Plugin.ID = value } } -func PluginTitle(value string) AppOption { return func(app *App) { app.manifest.Plugin.Title = value } } -func PluginVersion(value string) AppOption { - return func(app *App) { app.manifest.Plugin.Version = value } +func AppID(value string) AppOption { + return func(app *App) { + app.manifest.Plugin.ID = value + } +} + +func AppTitle(value string) AppOption { + return func(app *App) { + app.manifest.Plugin.Title = value + } +} + +func AppVersion(value string) AppOption { + return func(app *App) { + app.manifest.Plugin.Version = value + } } func New(options ...AppOption) *App { - app := &App{manifest: Manifest{ProtocolVersion: ProtocolVersion, Views: []UIViewDescriptor{}}, views: make(map[string]ViewSchema)} + app := &App{ + manifest: Manifest{ + ProtocolVersion: ProtocolVersion, + Views: []UIViewDescriptor{}, + }, + views: make(map[string]ViewSchema), + } + for _, option := range options { option(app) } + return app } @@ -33,61 +56,169 @@ func (app *App) AddView(view *ViewBuilder) error { if err := schema.Validate(); err != nil { return err } + if _, exists := app.views[schema.ID]; exists { return ErrInvalidSchema } - app.views[schema.ID] = schema - app.manifest.Views = append(app.manifest.Views, UIViewDescriptor{ID: schema.ID, Title: schema.Title, Schema: "ui://views/" + schema.ID}) + + snapshot, err := cloneViewSchema(schema) + if err != nil { + return fmt.Errorf("%w: copy view schema: %w", ErrInvalidSchema, err) + } + + app.views[schema.ID] = snapshot + app.manifest.Views = append( + app.manifest.Views, + UIViewDescriptor{ + ID: schema.ID, + Title: schema.Title, + Schema: "ui://views/" + schema.ID, + }, + ) + return nil } -func (app *App) Manifest() Manifest { return app.manifest } +func (app *App) Manifest() Manifest { + return cloneManifest(app.manifest) +} + +func (app *App) ManifestJSON() ([]byte, error) { + return json.Marshal(app.Manifest()) +} + +func (app *App) View(id string) (ViewSchema, bool) { + view, ok := app.views[id] + if !ok { + return ViewSchema{}, false + } + + snapshot, err := cloneViewSchema(view) + if err != nil { + return ViewSchema{}, false + } + + return snapshot, true +} + +func cloneManifest(manifest Manifest) Manifest { + if manifest.Views != nil { + manifest.Views = append(make([]UIViewDescriptor, 0, len(manifest.Views)), manifest.Views...) + } -func (app *App) ManifestJSON() ([]byte, error) { return json.Marshal(app.manifest) } + if manifest.RequiredComponents != nil { + manifest.RequiredComponents = append( + make([]string, 0, len(manifest.RequiredComponents)), + manifest.RequiredComponents..., + ) + } -func (app *App) View(id string) (ViewSchema, bool) { view, ok := app.views[id]; return view, ok } + return manifest +} + +func cloneViewSchema(schema ViewSchema) (ViewSchema, error) { + payload, err := json.Marshal(schema) + if err != nil { + return ViewSchema{}, err + } + + var clone ViewSchema + if err = json.Unmarshal(payload, &clone); err != nil { + return ViewSchema{}, err + } + + return clone, nil +} + +type ViewBuilder struct { + schema ViewSchema +} -type ViewBuilder struct{ schema ViewSchema } type ViewOption func(*ViewSchema) func View(id string, options ...ViewOption) *ViewBuilder { - builder := &ViewBuilder{schema: ViewSchema{ProtocolVersion: ProtocolVersion, ID: id, Regions: emptyRegions(), Sources: map[string]DataSource{}, Actions: map[string]Action{}}} + builder := &ViewBuilder{ + schema: ViewSchema{ + ProtocolVersion: ProtocolVersion, + ID: id, + Regions: emptyRegions(), + Sources: map[string]DataSource{}, + Actions: map[string]Action{}, + }, + } + for _, option := range options { option(&builder.schema) } + return builder } func emptyRegions() Regions { - return Regions{TopHeader: []Node{}, LeftPanel: []Node{}, Content: []Node{}, RightPanel: []Node{}, Bottom: []Node{}} + return Regions{ + TopHeader: []Node{}, + LeftPanel: []Node{}, + Content: []Node{}, + RightPanel: []Node{}, + Bottom: []Node{}, + } +} + +func Title(value string) ViewOption { + return func(schema *ViewSchema) { + schema.Title = value + } +} + +func State(value map[string]any) ViewOption { + return func(schema *ViewSchema) { + schema.State = value + } } -func Title(value string) ViewOption { return func(schema *ViewSchema) { schema.Title = value } } -func State(value map[string]any) ViewOption { return func(schema *ViewSchema) { schema.State = value } } func TopHeader(nodes ...*NodeBuilder) ViewOption { - return func(schema *ViewSchema) { schema.Regions.TopHeader = buildNodes(nodes) } + return func(schema *ViewSchema) { + schema.Regions.TopHeader = buildNodes(nodes) + } } + func LeftPanel(nodes ...*NodeBuilder) ViewOption { - return func(schema *ViewSchema) { schema.Regions.LeftPanel = buildNodes(nodes) } + return func(schema *ViewSchema) { + schema.Regions.LeftPanel = buildNodes(nodes) + } } func Content(nodes ...*NodeBuilder) ViewOption { - return func(schema *ViewSchema) { schema.Regions.Content = buildNodes(nodes) } + return func(schema *ViewSchema) { + schema.Regions.Content = buildNodes(nodes) + } } + func RightPanel(nodes ...*NodeBuilder) ViewOption { - return func(schema *ViewSchema) { schema.Regions.RightPanel = buildNodes(nodes) } + return func(schema *ViewSchema) { + schema.Regions.RightPanel = buildNodes(nodes) + } } + func Bottom(nodes ...*NodeBuilder) ViewOption { - return func(schema *ViewSchema) { schema.Regions.Bottom = buildNodes(nodes) } + return func(schema *ViewSchema) { + schema.Regions.Bottom = buildNodes(nodes) + } } + func (view *ViewBuilder) Action(name string, action *ActionBuilder) *ViewBuilder { view.schema.Actions[name] = action.action return view } -func (view *ViewBuilder) Schema() ViewSchema { return view.schema } + +func (view *ViewBuilder) Schema() ViewSchema { + return view.schema +} + func (view *ViewBuilder) Revision(value string) *ViewBuilder { view.schema.Revision = value return view } + func (view *ViewBuilder) Source(name string, source *SourceBuilder) *ViewBuilder { view.schema.Sources[name] = source.source return view @@ -96,44 +227,68 @@ func (view *ViewBuilder) Source(name string, source *SourceBuilder) *ViewBuilder type NodeBuilder struct{ node Node } func node(component, id string) *NodeBuilder { - return &NodeBuilder{node: Node{Component: component, ID: id, Props: map[string]Value{}, Events: map[string]EventHandler{}}} + return &NodeBuilder{ + node: Node{ + Component: component, + ID: id, + Props: map[string]Value{}, + Events: map[string]EventHandler{}, + }, + } } + func (node *NodeBuilder) Row(row int) *NodeBuilder { node.ensureLayout() node.node.Layout.Row = row return node } + func (node *NodeBuilder) Cols(cols int) *NodeBuilder { node.ensureLayout() node.node.Layout.Cols = cols return node } + func (node *NodeBuilder) Offset(offset int) *NodeBuilder { node.ensureLayout() node.node.Layout.Offset = offset return node } + func (node *NodeBuilder) Prop(name string, value any) *NodeBuilder { node.node.Props[name] = toValue(value) return node } + func (node *NodeBuilder) On(name string, handler EventHandler) *NodeBuilder { node.node.Events[name] = handler return node } + func (node *NodeBuilder) Children(children ...*NodeBuilder) *NodeBuilder { node.node.Children = buildNodes(children) return node } + func (node *NodeBuilder) Slots(name string, children ...*NodeBuilder) *NodeBuilder { if node.node.Slots == nil { node.node.Slots = map[string][]Node{} } + node.node.Slots[name] = buildNodes(children) + return node } -func (node *NodeBuilder) When(value Value) *NodeBuilder { node.node.When = &value; return node } -func (node *NodeBuilder) Build() Node { return node.node } + +func (node *NodeBuilder) When(value Value) *NodeBuilder { + node.node.When = &value + return node +} + +func (node *NodeBuilder) Build() Node { + return node.node +} + func (node *NodeBuilder) ensureLayout() { if node.node.Layout == nil { node.node.Layout = &Layout{} @@ -142,9 +297,10 @@ func (node *NodeBuilder) ensureLayout() { func buildNodes(nodes []*NodeBuilder) []Node { result := make([]Node, 0, len(nodes)) - for _, node := range nodes { - result = append(result, node.Build()) + for _, n := range nodes { + result = append(result, n.Build()) } + return result } @@ -155,20 +311,32 @@ func Source(name string, source *SourceBuilder) ViewOption { if schema.Sources == nil { schema.Sources = map[string]DataSource{} } + schema.Sources[name] = source.source } } + func Tool(name string) *SourceBuilder { - return &SourceBuilder{source: DataSource{Type: "tool", Tool: name, Policy: SourceManual, Input: map[string]Value{}}} + return &SourceBuilder{ + source: DataSource{ + Type: "tool", + Tool: name, + Policy: SourceManual, + Input: map[string]Value{}, + }, + } } + func (source *SourceBuilder) Input(name string, value any) *SourceBuilder { source.source.Input[name] = toValue(value) return source } + func (source *SourceBuilder) OnMount() *SourceBuilder { source.source.Policy = SourceOnMount return source } + func (source *SourceBuilder) TTL(value int64) *SourceBuilder { source.source.Cache = &CachePolicy{TTL: value} return source @@ -177,37 +345,88 @@ func (source *SourceBuilder) TTL(value int64) *SourceBuilder { type ActionBuilder struct{ action Action } func CallTool(name string) *ActionBuilder { - return &ActionBuilder{action: Action{Type: "tool", Tool: name, Input: map[string]Value{}}} + return &ActionBuilder{ + action: Action{ + Type: "tool", + Tool: name, + Input: map[string]Value{}, + }, + } } + func SetState(path string, value any) *ActionBuilder { - return &ActionBuilder{action: Action{Type: "set-state", Path: path, Value: ptrValue(value)}} + return &ActionBuilder{ + action: Action{ + Type: "set-state", + Path: path, + Value: ptrValue(value), + }, + } } + func MergeState(path string, value any) *ActionBuilder { - return &ActionBuilder{action: Action{Type: "merge-state", Path: path, Value: ptrValue(value)}} + return &ActionBuilder{ + action: Action{ + Type: "merge-state", + Path: path, + Value: ptrValue(value), + }, + } } + func (action *ActionBuilder) Input(name string, value any) *ActionBuilder { action.action.Input[name] = toValue(value) return action } + func (action *ActionBuilder) Then(effects ...Effect) *ActionBuilder { action.action.Effects = append(action.action.Effects, effects...) return action } -func ActionRef(name string) EventHandler { return EventHandler{Action: name} } -func Invalidate(source string) Effect { return Effect{Type: "invalidate", Source: source} } -func RefreshSource(source string) Effect { return Effect{Type: "refresh-source", Source: source} } +func ActionRef(name string) EventHandler { + return EventHandler{Action: name} +} + +func Invalidate(source string) Effect { + return Effect{ + Type: "invalidate", + Source: source, + } +} + +func RefreshSource(source string) Effect { + return Effect{ + Type: "refresh-source", + Source: source, + } +} + func ToastSuccess(message string) Effect { - return Effect{Type: "toast", Variant: "success", Message: message} + return Effect{ + Type: "toast", + Variant: "success", + Message: message, + } } + func SetStateEffect(path string, value any) Effect { - return Effect{Type: "set-state", Path: path, Value: ptrValue(value)} + return Effect{ + Type: "set-state", + Path: path, + Value: ptrValue(value), + } } func toValue(value any) Value { if typed, ok := value.(Value); ok { return typed } + return Literal(value) } -func ptrValue(value any) *Value { result := toValue(value); return &result } + +func ptrValue(value any) *Value { + result := toValue(value) + return &result +} diff --git a/builder_test.go b/builder_test.go index 6c042b4..a3e7bdd 100644 --- a/builder_test.go +++ b/builder_test.go @@ -6,11 +6,22 @@ import ( ) func TestUnit_BuilderCreatesView(t *testing.T) { - view := View("users.list", Title("Users"), Source("users", Tool("users.list").OnMount()), Content( - DataTable("users-table").Row(1).Cols(12).Prop("rows", SourceRef("users.items")), - )).Action("users.refresh", CallTool("users.refresh").Then(Invalidate("users"))) + view := View( + "users.list", + Title("Users"), + Source("users", Tool("users.list").OnMount()), + Content( + DataTable("users-table").Row(1).Cols(12).Prop( + "rows", + SourceRef("users.items"), + ), + ), + ).Action( + "users.refresh", + CallTool("users.refresh").Then(Invalidate("users")), + ) - app := New(PluginID("users"), PluginTitle("Users"), PluginVersion("1.0.0")) + app := New(AppID("users"), AppTitle("Users"), AppVersion("1.0.0")) if err := app.AddView(view); err != nil { t.Fatal(err) } @@ -28,9 +39,50 @@ func TestUnit_BuilderCreatesView(t *testing.T) { if err := json.Unmarshal(payload, &decoded); err != nil { t.Fatal(err) } - for _, region := range []string{"top-header", "left-panel", "content", "right-panel", "bottom"} { + for _, region := range []string{ + "top-header", + "left-panel", + "content", + "right-panel", + "bottom", + } { if string(decoded.Regions[region]) == "null" { t.Fatalf("region %q serialized as null", region) } } } + +func TestUnit_AppSnapshotsViews(t *testing.T) { + view := View("users.list", Title("Users")).Action( + "initial", + CallTool("initial"), + ) + app := New(AppID("users")) + if err := app.AddView(view); err != nil { + t.Fatal(err) + } + + manifest := app.Manifest() + manifest.Views[0].ID = "changed" + if got := app.Manifest().Views[0].ID; got != "users.list" { + t.Fatalf("stored manifest view id = %q, want users.list", got) + } + + view.Action("late", CallTool("late")) + returned, ok := app.View("users.list") + if !ok { + t.Fatal("view is missing") + } + returned.Actions["returned"] = Action{Type: "tool", Tool: "returned"} + + stored, ok := app.View("users.list") + if !ok { + t.Fatal("view is missing after mutation") + } + if len(stored.Actions) != 1 { + t.Fatalf("stored actions = %d, want 1", len(stored.Actions)) + } + if _, ok := stored.Actions["initial"]; !ok { + t.Fatal("initial action is missing") + } +} diff --git a/examples/dashboard/dashboard.go b/examples/dashboard/dashboard.go index 09566aa..351a2c1 100644 --- a/examples/dashboard/dashboard.go +++ b/examples/dashboard/dashboard.go @@ -11,30 +11,60 @@ import "go.osspkg.com/ui" // NewApp builds a dashboard whose metrics source is loaded when the view opens. func NewApp() (*ui.App, error) { app := ui.New( - ui.PluginID("metrics"), - ui.PluginTitle("Metrics"), - ui.PluginVersion("1.0.0"), + ui.AppID("metrics"), + ui.AppTitle("Metrics"), + ui.AppVersion("1.0.0"), ) view := ui.View( "metrics.dashboard", ui.Title("Metrics dashboard"), ui.Source("metrics", ui.Tool("metrics.summary").OnMount().TTL(30)), ui.TopHeader( - ui.HTML(ui.HTMLH1, "title").Row(1).Cols(9).Prop("text", "Metrics dashboard"), - ui.Shadcn(ui.ShadcnButton, "refresh").Row(1).Cols(3).Offset(9).Prop("text", "Refresh").On("click", ui.ActionRef("metrics.refresh")), + ui.HTML(ui.HTMLH1, "title").Row(1).Cols(9).Prop( + "text", + "Metrics dashboard", + ), + ui.Shadcn( + ui.ShadcnButton, + "refresh", + ).Row(1).Cols(3).Offset(9).Prop("text", "Refresh").On( + "click", + ui.ActionRef("metrics.refresh"), + ), ), ui.Content( ui.Shadcn(ui.ShadcnCard, "requests-card").Row(1).Cols(6).Children( ui.HTML(ui.HTMLH2, "requests-heading").Prop("text", "Requests"), - ui.Shadcn(ui.ShadcnTypography, "requests-value").Prop("text", ui.SourceRef("metrics.requests")), + ui.Shadcn(ui.ShadcnTypography, "requests-value").Prop( + "text", + ui.SourceRef("metrics.requests"), + ), ), - ui.Shadcn(ui.ShadcnCard, "latency-card").Row(1).Cols(6).Offset(6).Children( + ui.Shadcn( + ui.ShadcnCard, + "latency-card", + ).Row(1).Cols(6).Offset(6).Children( ui.HTML(ui.HTMLH2, "latency-heading").Prop("text", "Latency"), - ui.Shadcn(ui.ShadcnTypography, "latency-value").Prop("text", ui.SourceRef("metrics.latency")), + ui.Shadcn(ui.ShadcnTypography, "latency-value").Prop( + "text", + ui.SourceRef("metrics.latency"), + ), + ), + ui.DataTable("recent-events").Row(2).Cols(12).Prop( + "rows", + ui.SourceRef("metrics.events"), + ).Prop("loading", ui.SourceRef("metrics.$loading")).Prop( + "error", + ui.SourceRef("metrics.$error"), ), - ui.DataTable("recent-events").Row(2).Cols(12).Prop("rows", ui.SourceRef("metrics.events")).Prop("loading", ui.SourceRef("metrics.$loading")).Prop("error", ui.SourceRef("metrics.$error")), ), - ).Action("metrics.refresh", ui.CallTool("metrics.refresh").Then(ui.RefreshSource("metrics"), ui.ToastSuccess("Metrics refreshed"))) + ).Action( + "metrics.refresh", + ui.CallTool("metrics.refresh").Then( + ui.RefreshSource("metrics"), + ui.ToastSuccess("Metrics refreshed"), + ), + ) if err := app.AddView(view); err != nil { return nil, err } diff --git a/examples/htmlform/htmlform.go b/examples/htmlform/htmlform.go index 251c054..cb579a7 100644 --- a/examples/htmlform/htmlform.go +++ b/examples/htmlform/htmlform.go @@ -11,26 +11,52 @@ import "go.osspkg.com/ui" // NewApp builds a profile form with native HTML elements and a local action. func NewApp() (*ui.App, error) { app := ui.New( - ui.PluginID("profile"), - ui.PluginTitle("Profile"), - ui.PluginVersion("1.0.0"), + ui.AppID("profile"), + ui.AppTitle("Profile"), + ui.AppVersion("1.0.0"), ) view := ui.View( "profile.edit", ui.Title("Profile"), - ui.State(map[string]any{"profile": map[string]any{"name": ""}, "submitted": false}), + ui.State( + map[string]any{ + "profile": map[string]any{"name": ""}, + "submitted": false, + }, + ), ui.Content( - ui.HTML(ui.HTMLArticle, "profile-card").Row(1).Cols(8).Offset(2).Children( + ui.HTML( + ui.HTMLArticle, + "profile-card", + ).Row(1).Cols(8).Offset(2).Children( ui.HTML(ui.HTMLH1, "title").Prop("text", "Edit profile"), - ui.HTML(ui.HTMLP, "hint").Prop("text", "Changes are saved when the form is submitted."), - ui.HTML(ui.HTMLForm, "profile-form").On("submit", ui.ActionRef("profile.submit")).Children( + ui.HTML(ui.HTMLP, "hint").Prop( + "text", + "Changes are saved when the form is submitted.", + ), + ui.HTML(ui.HTMLForm, "profile-form").On( + "submit", + ui.ActionRef("profile.submit"), + ).Children( ui.HTML(ui.HTMLLabel, "name-label").Prop("text", "Name"), - ui.HTML(ui.HTMLInput, "name").Prop("name", "name").Prop("value", ui.StateRef("profile.name")).Prop("placeholder", "Ada Lovelace"), - ui.HTML(ui.HTMLButton, "save").Prop("type", "submit").Prop("text", "Save profile"), + ui.HTML(ui.HTMLInput, "name").Prop( + "name", + "name", + ).Prop( + "value", + ui.StateRef("profile.name"), + ).Prop("placeholder", "Ada Lovelace"), + ui.HTML(ui.HTMLButton, "save").Prop( + "type", + "submit", + ).Prop("text", "Save profile"), ), ), ), - ).Action("profile.submit", ui.SetState("submitted", true).Then(ui.ToastSuccess("Profile saved"))) + ).Action( + "profile.submit", + ui.SetState("submitted", true).Then(ui.ToastSuccess("Profile saved")), + ) if err := app.AddView(view); err != nil { return nil, err } diff --git a/html.go b/html.go index 08758db..8652e3c 100644 --- a/html.go +++ b/html.go @@ -111,4 +111,6 @@ const ( // HTML creates a node for a safe HTML body element. The host must register the // matching React component and explicitly allow its props and events. -func HTML(tag HTMLTag, id string) *NodeBuilder { return node(string(tag), id) } +func HTML(tag HTMLTag, id string) *NodeBuilder { + return node(string(tag), id) +} diff --git a/mcp/register.go b/mcp/register.go index 0ca7c1c..fdfae66 100644 --- a/mcp/register.go +++ b/mcp/register.go @@ -9,6 +9,7 @@ package mcp import ( "encoding/json" "errors" + "strings" ossmcp "go.osspkg.com/mcp" @@ -32,14 +33,17 @@ func Register(server *ossmcp.Server, app *ui.App) error { if server == nil || app == nil { return errors.New("ui/mcp: server and app are required") } + manifest, err := app.ManifestJSON() if err != nil { return err } - if err := app.Manifest().Validate(); err != nil { + + if err = app.Manifest().Validate(); err != nil { return err } - if err := server.RegisterResource( + + if err = server.RegisterResource( ossmcp.Resource{ URI: "ui://manifest", Name: "UI Manifest", @@ -50,19 +54,27 @@ func Register(server *ossmcp.Server, app *ui.App) error { ); err != nil { return err } + for _, descriptor := range app.Manifest().Views { view, ok := app.View(descriptor.ID) if !ok { return errors.New("ui/mcp: manifest view is missing") } + payload, err := json.Marshal(view) if err != nil { return err } + + name := descriptor.Title + if strings.TrimSpace(name) == "" { + name = descriptor.ID + } + if err := server.RegisterResource( ossmcp.Resource{ URI: descriptor.Schema, - Name: descriptor.Title, + Name: name, MIMEType: ui.UIMIMEType, Text: string(payload), }, @@ -70,5 +82,6 @@ func Register(server *ossmcp.Server, app *ui.App) error { return err } } + return nil } diff --git a/mcp/register_test.go b/mcp/register_test.go index 0a8ced5..3977d7f 100644 --- a/mcp/register_test.go +++ b/mcp/register_test.go @@ -9,12 +9,41 @@ import ( ) func TestUnit_Register(t *testing.T) { - server, err := goMCP.New(goMCP.ServerInfo{Name: "users", Version: "1.0.0"}, Capabilities()) + server, err := goMCP.New( + goMCP.ServerInfo{Name: "users", Version: "1.0.0"}, + Capabilities(), + ) if err != nil { t.Fatal(err) } - app := ui.New(ui.PluginID("users"), ui.PluginTitle("Users"), ui.PluginVersion("1.0.0")) - if err := app.AddView(ui.View("users.list", ui.Title("Users"))); err != nil { + app := ui.New( + ui.AppID("users"), + ui.AppTitle("Users"), + ui.AppVersion("1.0.0"), + ) + if err := app.AddView( + ui.View( + "users.list", + ui.Title("Users"), + ), + ); err != nil { + t.Fatal(err) + } + if err := Register(server, app); err != nil { + t.Fatal(err) + } +} + +func TestUnit_RegisterViewWithoutTitle(t *testing.T) { + server, err := goMCP.New( + goMCP.ServerInfo{Name: "users", Version: "1.0.0"}, + Capabilities(), + ) + if err != nil { + t.Fatal(err) + } + app := ui.New(ui.AppID("users")) + if err := app.AddView(ui.View("users.list")); err != nil { t.Fatal(err) } if err := Register(server, app); err != nil { diff --git a/schema.go b/schema.go index b6aee60..48a6d12 100644 --- a/schema.go +++ b/schema.go @@ -6,6 +6,8 @@ // Package ui defines the transport-independent declarative UI protocol. package ui +//go:generate easyjson + import ( "errors" "fmt" @@ -23,6 +25,7 @@ var ( ErrInvalidValue = errors.New("invalid ui value") ) +//easyjson:json type Manifest struct { ProtocolVersion string `json:"protocolVersion"` Plugin PluginManifest `json:"plugin"` @@ -30,6 +33,7 @@ type Manifest struct { RequiredComponents []string `json:"requiredComponents,omitempty"` } +//easyjson:json type PluginManifest struct { ID string `json:"id"` Title string `json:"title"` @@ -37,6 +41,7 @@ type PluginManifest struct { Description string `json:"description,omitempty"` } +//easyjson:json type UIViewDescriptor struct { ID string `json:"id"` Title string `json:"title"` @@ -44,6 +49,7 @@ type UIViewDescriptor struct { Schema string `json:"schema"` } +//easyjson:json type ViewSchema struct { ProtocolVersion string `json:"protocolVersion"` ID string `json:"id"` @@ -55,6 +61,7 @@ type ViewSchema struct { Regions Regions `json:"regions"` } +//easyjson:json type Regions struct { TopHeader []Node `json:"top-header"` LeftPanel []Node `json:"left-panel"` @@ -63,12 +70,14 @@ type Regions struct { Bottom []Node `json:"bottom"` } +//easyjson:json type Layout struct { Row int `json:"row"` Cols int `json:"cols,omitempty"` Offset int `json:"offset,omitempty"` } +//easyjson:json type Node struct { ID string `json:"id"` Component string `json:"component"` @@ -80,11 +89,13 @@ type Node struct { When *Value `json:"when,omitempty"` } +//easyjson:json type EventHandler struct { Action string `json:"action,omitempty"` Steps []ActionStep `json:"steps,omitempty"` } +//easyjson:json type DataSource struct { Type string `json:"type"` Tool string `json:"tool"` @@ -101,10 +112,12 @@ const ( SourceOnMount SourcePolicy = "on-mount" ) +//easyjson:json type CachePolicy struct { TTL int64 `json:"ttl,omitempty"` } +//easyjson:json type Action struct { Type string `json:"type"` Tool string `json:"tool,omitempty"` @@ -118,12 +131,14 @@ type Action struct { Effects []Effect `json:"effects,omitempty"` } +//easyjson:json type ActionStep struct { Type string `json:"type"` Action string `json:"action,omitempty"` Input map[string]Value `json:"input,omitempty"` } +//easyjson:json type Effect struct { Type string `json:"type"` Path string `json:"path,omitempty"` @@ -140,19 +155,24 @@ func (m Manifest) Validate() error { if m.ProtocolVersion != ProtocolVersion { return fmt.Errorf("%w: unsupported manifest protocol version %q", ErrInvalidSchema, m.ProtocolVersion) } + if m.Plugin.ID == "" { return fmt.Errorf("%w: plugin id is required", ErrInvalidSchema) } + seen := make(map[string]struct{}, len(m.Views)) for _, view := range m.Views { if view.ID == "" || view.Schema == "" { return fmt.Errorf("%w: view id and schema are required", ErrInvalidSchema) } + if _, ok := seen[view.ID]; ok { return fmt.Errorf("%w: duplicate view %q", ErrInvalidSchema, view.ID) } + seen[view.ID] = struct{}{} } + return nil } diff --git a/schema_easyjson.go b/schema_easyjson.go new file mode 100644 index 0000000..960ff45 --- /dev/null +++ b/schema_easyjson.go @@ -0,0 +1,2189 @@ +// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. + +package ui + +import ( + json "encoding/json" + easyjson "github.com/mailru/easyjson" + jlexer "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" +) + +// suppress unused package warning +var ( + _ *json.RawMessage + _ *jlexer.Lexer + _ *jwriter.Writer + _ easyjson.Marshaler +) + +func easyjsonCef4e921DecodeGoOsspkgComUi(in *jlexer.Lexer, out *ViewSchema) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "protocolVersion": + if in.IsNull() { + in.Skip() + } else { + out.ProtocolVersion = string(in.String()) + } + case "id": + if in.IsNull() { + in.Skip() + } else { + out.ID = string(in.String()) + } + case "revision": + if in.IsNull() { + in.Skip() + } else { + out.Revision = string(in.String()) + } + case "title": + if in.IsNull() { + in.Skip() + } else { + out.Title = string(in.String()) + } + case "state": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.State = make(map[string]interface{}) + } else { + out.State = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v1 interface{} + if m, ok := v1.(easyjson.Unmarshaler); ok { + m.UnmarshalEasyJSON(in) + } else if m, ok := v1.(json.Unmarshaler); ok { + _ = m.UnmarshalJSON(in.Raw()) + } else { + v1 = in.Interface() + } + (out.State)[key] = v1 + in.WantComma() + } + in.Delim('}') + } + case "sources": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Sources = make(map[string]DataSource) + } else { + out.Sources = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v2 DataSource + if in.IsNull() { + in.Skip() + } else { + (v2).UnmarshalEasyJSON(in) + } + (out.Sources)[key] = v2 + in.WantComma() + } + in.Delim('}') + } + case "actions": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Actions = make(map[string]Action) + } else { + out.Actions = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v3 Action + if in.IsNull() { + in.Skip() + } else { + (v3).UnmarshalEasyJSON(in) + } + (out.Actions)[key] = v3 + in.WantComma() + } + in.Delim('}') + } + case "regions": + if in.IsNull() { + in.Skip() + } else { + (out.Regions).UnmarshalEasyJSON(in) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi(out *jwriter.Writer, in ViewSchema) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"protocolVersion\":" + out.RawString(prefix[1:]) + out.String(string(in.ProtocolVersion)) + } + { + const prefix string = ",\"id\":" + out.RawString(prefix) + out.String(string(in.ID)) + } + if in.Revision != "" { + const prefix string = ",\"revision\":" + out.RawString(prefix) + out.String(string(in.Revision)) + } + if in.Title != "" { + const prefix string = ",\"title\":" + out.RawString(prefix) + out.String(string(in.Title)) + } + if len(in.State) != 0 { + const prefix string = ",\"state\":" + out.RawString(prefix) + { + out.RawByte('{') + v4First := true + for v4Name, v4Value := range in.State { + if v4First { + v4First = false + } else { + out.RawByte(',') + } + out.String(string(v4Name)) + out.RawByte(':') + if m, ok := v4Value.(easyjson.Marshaler); ok { + m.MarshalEasyJSON(out) + } else if m, ok := v4Value.(json.Marshaler); ok { + out.Raw(m.MarshalJSON()) + } else { + out.Raw(json.Marshal(v4Value)) + } + } + out.RawByte('}') + } + } + if len(in.Sources) != 0 { + const prefix string = ",\"sources\":" + out.RawString(prefix) + { + out.RawByte('{') + v5First := true + for v5Name, v5Value := range in.Sources { + if v5First { + v5First = false + } else { + out.RawByte(',') + } + out.String(string(v5Name)) + out.RawByte(':') + (v5Value).MarshalEasyJSON(out) + } + out.RawByte('}') + } + } + if len(in.Actions) != 0 { + const prefix string = ",\"actions\":" + out.RawString(prefix) + { + out.RawByte('{') + v6First := true + for v6Name, v6Value := range in.Actions { + if v6First { + v6First = false + } else { + out.RawByte(',') + } + out.String(string(v6Name)) + out.RawByte(':') + (v6Value).MarshalEasyJSON(out) + } + out.RawByte('}') + } + } + { + const prefix string = ",\"regions\":" + out.RawString(prefix) + (in.Regions).MarshalEasyJSON(out) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v ViewSchema) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v ViewSchema) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *ViewSchema) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *ViewSchema) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi1(in *jlexer.Lexer, out *UIViewDescriptor) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "id": + if in.IsNull() { + in.Skip() + } else { + out.ID = string(in.String()) + } + case "title": + if in.IsNull() { + in.Skip() + } else { + out.Title = string(in.String()) + } + case "route": + if in.IsNull() { + in.Skip() + } else { + out.Route = string(in.String()) + } + case "schema": + if in.IsNull() { + in.Skip() + } else { + out.Schema = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi1(out *jwriter.Writer, in UIViewDescriptor) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"id\":" + out.RawString(prefix[1:]) + out.String(string(in.ID)) + } + { + const prefix string = ",\"title\":" + out.RawString(prefix) + out.String(string(in.Title)) + } + if in.Route != "" { + const prefix string = ",\"route\":" + out.RawString(prefix) + out.String(string(in.Route)) + } + { + const prefix string = ",\"schema\":" + out.RawString(prefix) + out.String(string(in.Schema)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v UIViewDescriptor) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi1(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v UIViewDescriptor) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi1(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *UIViewDescriptor) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi1(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *UIViewDescriptor) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi1(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi2(in *jlexer.Lexer, out *Regions) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "top-header": + if in.IsNull() { + in.Skip() + out.TopHeader = nil + } else { + in.Delim('[') + if out.TopHeader == nil { + if !in.IsDelim(']') { + out.TopHeader = make([]Node, 0, 0) + } else { + out.TopHeader = []Node{} + } + } else { + out.TopHeader = (out.TopHeader)[:0] + } + for !in.IsDelim(']') { + var v7 Node + if in.IsNull() { + in.Skip() + } else { + (v7).UnmarshalEasyJSON(in) + } + out.TopHeader = append(out.TopHeader, v7) + in.WantComma() + } + in.Delim(']') + } + case "left-panel": + if in.IsNull() { + in.Skip() + out.LeftPanel = nil + } else { + in.Delim('[') + if out.LeftPanel == nil { + if !in.IsDelim(']') { + out.LeftPanel = make([]Node, 0, 0) + } else { + out.LeftPanel = []Node{} + } + } else { + out.LeftPanel = (out.LeftPanel)[:0] + } + for !in.IsDelim(']') { + var v8 Node + if in.IsNull() { + in.Skip() + } else { + (v8).UnmarshalEasyJSON(in) + } + out.LeftPanel = append(out.LeftPanel, v8) + in.WantComma() + } + in.Delim(']') + } + case "content": + if in.IsNull() { + in.Skip() + out.Content = nil + } else { + in.Delim('[') + if out.Content == nil { + if !in.IsDelim(']') { + out.Content = make([]Node, 0, 0) + } else { + out.Content = []Node{} + } + } else { + out.Content = (out.Content)[:0] + } + for !in.IsDelim(']') { + var v9 Node + if in.IsNull() { + in.Skip() + } else { + (v9).UnmarshalEasyJSON(in) + } + out.Content = append(out.Content, v9) + in.WantComma() + } + in.Delim(']') + } + case "right-panel": + if in.IsNull() { + in.Skip() + out.RightPanel = nil + } else { + in.Delim('[') + if out.RightPanel == nil { + if !in.IsDelim(']') { + out.RightPanel = make([]Node, 0, 0) + } else { + out.RightPanel = []Node{} + } + } else { + out.RightPanel = (out.RightPanel)[:0] + } + for !in.IsDelim(']') { + var v10 Node + if in.IsNull() { + in.Skip() + } else { + (v10).UnmarshalEasyJSON(in) + } + out.RightPanel = append(out.RightPanel, v10) + in.WantComma() + } + in.Delim(']') + } + case "bottom": + if in.IsNull() { + in.Skip() + out.Bottom = nil + } else { + in.Delim('[') + if out.Bottom == nil { + if !in.IsDelim(']') { + out.Bottom = make([]Node, 0, 0) + } else { + out.Bottom = []Node{} + } + } else { + out.Bottom = (out.Bottom)[:0] + } + for !in.IsDelim(']') { + var v11 Node + if in.IsNull() { + in.Skip() + } else { + (v11).UnmarshalEasyJSON(in) + } + out.Bottom = append(out.Bottom, v11) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi2(out *jwriter.Writer, in Regions) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"top-header\":" + out.RawString(prefix[1:]) + if in.TopHeader == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v12, v13 := range in.TopHeader { + if v12 > 0 { + out.RawByte(',') + } + (v13).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + { + const prefix string = ",\"left-panel\":" + out.RawString(prefix) + if in.LeftPanel == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v14, v15 := range in.LeftPanel { + if v14 > 0 { + out.RawByte(',') + } + (v15).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + { + const prefix string = ",\"content\":" + out.RawString(prefix) + if in.Content == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v16, v17 := range in.Content { + if v16 > 0 { + out.RawByte(',') + } + (v17).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + { + const prefix string = ",\"right-panel\":" + out.RawString(prefix) + if in.RightPanel == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v18, v19 := range in.RightPanel { + if v18 > 0 { + out.RawByte(',') + } + (v19).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + { + const prefix string = ",\"bottom\":" + out.RawString(prefix) + if in.Bottom == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v20, v21 := range in.Bottom { + if v20 > 0 { + out.RawByte(',') + } + (v21).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v Regions) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi2(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v Regions) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi2(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *Regions) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi2(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *Regions) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi2(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi3(in *jlexer.Lexer, out *PluginManifest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "id": + if in.IsNull() { + in.Skip() + } else { + out.ID = string(in.String()) + } + case "title": + if in.IsNull() { + in.Skip() + } else { + out.Title = string(in.String()) + } + case "version": + if in.IsNull() { + in.Skip() + } else { + out.Version = string(in.String()) + } + case "description": + if in.IsNull() { + in.Skip() + } else { + out.Description = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi3(out *jwriter.Writer, in PluginManifest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"id\":" + out.RawString(prefix[1:]) + out.String(string(in.ID)) + } + { + const prefix string = ",\"title\":" + out.RawString(prefix) + out.String(string(in.Title)) + } + { + const prefix string = ",\"version\":" + out.RawString(prefix) + out.String(string(in.Version)) + } + if in.Description != "" { + const prefix string = ",\"description\":" + out.RawString(prefix) + out.String(string(in.Description)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v PluginManifest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi3(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v PluginManifest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi3(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *PluginManifest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi3(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *PluginManifest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi3(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi4(in *jlexer.Lexer, out *Node) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "id": + if in.IsNull() { + in.Skip() + } else { + out.ID = string(in.String()) + } + case "component": + if in.IsNull() { + in.Skip() + } else { + out.Component = string(in.String()) + } + case "layout": + if in.IsNull() { + in.Skip() + out.Layout = nil + } else { + if out.Layout == nil { + out.Layout = new(Layout) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Layout).UnmarshalEasyJSON(in) + } + } + case "props": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Props = make(map[string]Value) + } else { + out.Props = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v22 Value + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((v22).UnmarshalJSON(data)) + } + } + (out.Props)[key] = v22 + in.WantComma() + } + in.Delim('}') + } + case "events": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Events = make(map[string]EventHandler) + } else { + out.Events = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v23 EventHandler + if in.IsNull() { + in.Skip() + } else { + (v23).UnmarshalEasyJSON(in) + } + (out.Events)[key] = v23 + in.WantComma() + } + in.Delim('}') + } + case "children": + if in.IsNull() { + in.Skip() + out.Children = nil + } else { + in.Delim('[') + if out.Children == nil { + if !in.IsDelim(']') { + out.Children = make([]Node, 0, 0) + } else { + out.Children = []Node{} + } + } else { + out.Children = (out.Children)[:0] + } + for !in.IsDelim(']') { + var v24 Node + if in.IsNull() { + in.Skip() + } else { + (v24).UnmarshalEasyJSON(in) + } + out.Children = append(out.Children, v24) + in.WantComma() + } + in.Delim(']') + } + case "slots": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Slots = make(map[string][]Node) + } else { + out.Slots = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v25 []Node + if in.IsNull() { + in.Skip() + v25 = nil + } else { + in.Delim('[') + if v25 == nil { + if !in.IsDelim(']') { + v25 = make([]Node, 0, 0) + } else { + v25 = []Node{} + } + } else { + v25 = (v25)[:0] + } + for !in.IsDelim(']') { + var v26 Node + if in.IsNull() { + in.Skip() + } else { + (v26).UnmarshalEasyJSON(in) + } + v25 = append(v25, v26) + in.WantComma() + } + in.Delim(']') + } + (out.Slots)[key] = v25 + in.WantComma() + } + in.Delim('}') + } + case "when": + if in.IsNull() { + in.Skip() + out.When = nil + } else { + if out.When == nil { + out.When = new(Value) + } + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.When).UnmarshalJSON(data)) + } + } + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi4(out *jwriter.Writer, in Node) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"id\":" + out.RawString(prefix[1:]) + out.String(string(in.ID)) + } + { + const prefix string = ",\"component\":" + out.RawString(prefix) + out.String(string(in.Component)) + } + if in.Layout != nil { + const prefix string = ",\"layout\":" + out.RawString(prefix) + (*in.Layout).MarshalEasyJSON(out) + } + if len(in.Props) != 0 { + const prefix string = ",\"props\":" + out.RawString(prefix) + { + out.RawByte('{') + v27First := true + for v27Name, v27Value := range in.Props { + if v27First { + v27First = false + } else { + out.RawByte(',') + } + out.String(string(v27Name)) + out.RawByte(':') + out.Raw((v27Value).MarshalJSON()) + } + out.RawByte('}') + } + } + if len(in.Events) != 0 { + const prefix string = ",\"events\":" + out.RawString(prefix) + { + out.RawByte('{') + v28First := true + for v28Name, v28Value := range in.Events { + if v28First { + v28First = false + } else { + out.RawByte(',') + } + out.String(string(v28Name)) + out.RawByte(':') + (v28Value).MarshalEasyJSON(out) + } + out.RawByte('}') + } + } + if len(in.Children) != 0 { + const prefix string = ",\"children\":" + out.RawString(prefix) + { + out.RawByte('[') + for v29, v30 := range in.Children { + if v29 > 0 { + out.RawByte(',') + } + (v30).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + if len(in.Slots) != 0 { + const prefix string = ",\"slots\":" + out.RawString(prefix) + { + out.RawByte('{') + v31First := true + for v31Name, v31Value := range in.Slots { + if v31First { + v31First = false + } else { + out.RawByte(',') + } + out.String(string(v31Name)) + out.RawByte(':') + if v31Value == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v32, v33 := range v31Value { + if v32 > 0 { + out.RawByte(',') + } + (v33).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + out.RawByte('}') + } + } + if in.When != nil { + const prefix string = ",\"when\":" + out.RawString(prefix) + out.Raw((*in.When).MarshalJSON()) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v Node) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi4(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v Node) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi4(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *Node) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi4(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *Node) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi4(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi5(in *jlexer.Lexer, out *Manifest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "protocolVersion": + if in.IsNull() { + in.Skip() + } else { + out.ProtocolVersion = string(in.String()) + } + case "plugin": + if in.IsNull() { + in.Skip() + } else { + (out.Plugin).UnmarshalEasyJSON(in) + } + case "views": + if in.IsNull() { + in.Skip() + out.Views = nil + } else { + in.Delim('[') + if out.Views == nil { + if !in.IsDelim(']') { + out.Views = make([]UIViewDescriptor, 0, 1) + } else { + out.Views = []UIViewDescriptor{} + } + } else { + out.Views = (out.Views)[:0] + } + for !in.IsDelim(']') { + var v34 UIViewDescriptor + if in.IsNull() { + in.Skip() + } else { + (v34).UnmarshalEasyJSON(in) + } + out.Views = append(out.Views, v34) + in.WantComma() + } + in.Delim(']') + } + case "requiredComponents": + if in.IsNull() { + in.Skip() + out.RequiredComponents = nil + } else { + in.Delim('[') + if out.RequiredComponents == nil { + if !in.IsDelim(']') { + out.RequiredComponents = make([]string, 0, 4) + } else { + out.RequiredComponents = []string{} + } + } else { + out.RequiredComponents = (out.RequiredComponents)[:0] + } + for !in.IsDelim(']') { + var v35 string + if in.IsNull() { + in.Skip() + } else { + v35 = string(in.String()) + } + out.RequiredComponents = append(out.RequiredComponents, v35) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi5(out *jwriter.Writer, in Manifest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"protocolVersion\":" + out.RawString(prefix[1:]) + out.String(string(in.ProtocolVersion)) + } + { + const prefix string = ",\"plugin\":" + out.RawString(prefix) + (in.Plugin).MarshalEasyJSON(out) + } + { + const prefix string = ",\"views\":" + out.RawString(prefix) + if in.Views == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { + out.RawString("null") + } else { + out.RawByte('[') + for v36, v37 := range in.Views { + if v36 > 0 { + out.RawByte(',') + } + (v37).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + if len(in.RequiredComponents) != 0 { + const prefix string = ",\"requiredComponents\":" + out.RawString(prefix) + { + out.RawByte('[') + for v38, v39 := range in.RequiredComponents { + if v38 > 0 { + out.RawByte(',') + } + out.String(string(v39)) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v Manifest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi5(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v Manifest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi5(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *Manifest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi5(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *Manifest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi5(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi6(in *jlexer.Lexer, out *Layout) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "row": + if in.IsNull() { + in.Skip() + } else { + out.Row = int(in.Int()) + } + case "cols": + if in.IsNull() { + in.Skip() + } else { + out.Cols = int(in.Int()) + } + case "offset": + if in.IsNull() { + in.Skip() + } else { + out.Offset = int(in.Int()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi6(out *jwriter.Writer, in Layout) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"row\":" + out.RawString(prefix[1:]) + out.Int(int(in.Row)) + } + if in.Cols != 0 { + const prefix string = ",\"cols\":" + out.RawString(prefix) + out.Int(int(in.Cols)) + } + if in.Offset != 0 { + const prefix string = ",\"offset\":" + out.RawString(prefix) + out.Int(int(in.Offset)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v Layout) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi6(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v Layout) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi6(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *Layout) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi6(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *Layout) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi6(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi7(in *jlexer.Lexer, out *EventHandler) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "action": + if in.IsNull() { + in.Skip() + } else { + out.Action = string(in.String()) + } + case "steps": + if in.IsNull() { + in.Skip() + out.Steps = nil + } else { + in.Delim('[') + if out.Steps == nil { + if !in.IsDelim(']') { + out.Steps = make([]ActionStep, 0, 1) + } else { + out.Steps = []ActionStep{} + } + } else { + out.Steps = (out.Steps)[:0] + } + for !in.IsDelim(']') { + var v40 ActionStep + if in.IsNull() { + in.Skip() + } else { + (v40).UnmarshalEasyJSON(in) + } + out.Steps = append(out.Steps, v40) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi7(out *jwriter.Writer, in EventHandler) { + out.RawByte('{') + first := true + _ = first + if in.Action != "" { + const prefix string = ",\"action\":" + first = false + out.RawString(prefix[1:]) + out.String(string(in.Action)) + } + if len(in.Steps) != 0 { + const prefix string = ",\"steps\":" + if first { + first = false + out.RawString(prefix[1:]) + } else { + out.RawString(prefix) + } + { + out.RawByte('[') + for v41, v42 := range in.Steps { + if v41 > 0 { + out.RawByte(',') + } + (v42).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v EventHandler) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi7(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v EventHandler) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi7(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *EventHandler) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi7(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *EventHandler) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi7(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi8(in *jlexer.Lexer, out *Effect) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "path": + if in.IsNull() { + in.Skip() + } else { + out.Path = string(in.String()) + } + case "source": + if in.IsNull() { + in.Skip() + } else { + out.Source = string(in.String()) + } + case "value": + if in.IsNull() { + in.Skip() + out.Value = nil + } else { + if out.Value == nil { + out.Value = new(Value) + } + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.Value).UnmarshalJSON(data)) + } + } + } + case "baseRevision": + if in.IsNull() { + in.Skip() + } else { + out.BaseRevision = string(in.String()) + } + case "revision": + if in.IsNull() { + in.Skip() + } else { + out.Revision = string(in.String()) + } + case "to": + if in.IsNull() { + in.Skip() + } else { + out.To = string(in.String()) + } + case "variant": + if in.IsNull() { + in.Skip() + } else { + out.Variant = string(in.String()) + } + case "message": + if in.IsNull() { + in.Skip() + } else { + out.Message = string(in.String()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi8(out *jwriter.Writer, in Effect) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + out.RawString(prefix[1:]) + out.String(string(in.Type)) + } + if in.Path != "" { + const prefix string = ",\"path\":" + out.RawString(prefix) + out.String(string(in.Path)) + } + if in.Source != "" { + const prefix string = ",\"source\":" + out.RawString(prefix) + out.String(string(in.Source)) + } + if in.Value != nil { + const prefix string = ",\"value\":" + out.RawString(prefix) + out.Raw((*in.Value).MarshalJSON()) + } + if in.BaseRevision != "" { + const prefix string = ",\"baseRevision\":" + out.RawString(prefix) + out.String(string(in.BaseRevision)) + } + if in.Revision != "" { + const prefix string = ",\"revision\":" + out.RawString(prefix) + out.String(string(in.Revision)) + } + if in.To != "" { + const prefix string = ",\"to\":" + out.RawString(prefix) + out.String(string(in.To)) + } + if in.Variant != "" { + const prefix string = ",\"variant\":" + out.RawString(prefix) + out.String(string(in.Variant)) + } + if in.Message != "" { + const prefix string = ",\"message\":" + out.RawString(prefix) + out.String(string(in.Message)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v Effect) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi8(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v Effect) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi8(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *Effect) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi8(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *Effect) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi8(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi9(in *jlexer.Lexer, out *DataSource) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "tool": + if in.IsNull() { + in.Skip() + } else { + out.Tool = string(in.String()) + } + case "input": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Input = make(map[string]Value) + } else { + out.Input = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v43 Value + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((v43).UnmarshalJSON(data)) + } + } + (out.Input)[key] = v43 + in.WantComma() + } + in.Delim('}') + } + case "policy": + if in.IsNull() { + in.Skip() + } else { + out.Policy = SourcePolicy(in.String()) + } + case "cache": + if in.IsNull() { + in.Skip() + out.Cache = nil + } else { + if out.Cache == nil { + out.Cache = new(CachePolicy) + } + if in.IsNull() { + in.Skip() + } else { + (*out.Cache).UnmarshalEasyJSON(in) + } + } + case "refreshOn": + if in.IsNull() { + in.Skip() + out.RefreshOn = nil + } else { + in.Delim('[') + if out.RefreshOn == nil { + if !in.IsDelim(']') { + out.RefreshOn = make([]string, 0, 4) + } else { + out.RefreshOn = []string{} + } + } else { + out.RefreshOn = (out.RefreshOn)[:0] + } + for !in.IsDelim(']') { + var v44 string + if in.IsNull() { + in.Skip() + } else { + v44 = string(in.String()) + } + out.RefreshOn = append(out.RefreshOn, v44) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi9(out *jwriter.Writer, in DataSource) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + out.RawString(prefix[1:]) + out.String(string(in.Type)) + } + { + const prefix string = ",\"tool\":" + out.RawString(prefix) + out.String(string(in.Tool)) + } + if len(in.Input) != 0 { + const prefix string = ",\"input\":" + out.RawString(prefix) + { + out.RawByte('{') + v45First := true + for v45Name, v45Value := range in.Input { + if v45First { + v45First = false + } else { + out.RawByte(',') + } + out.String(string(v45Name)) + out.RawByte(':') + out.Raw((v45Value).MarshalJSON()) + } + out.RawByte('}') + } + } + if in.Policy != "" { + const prefix string = ",\"policy\":" + out.RawString(prefix) + out.String(string(in.Policy)) + } + if in.Cache != nil { + const prefix string = ",\"cache\":" + out.RawString(prefix) + (*in.Cache).MarshalEasyJSON(out) + } + if len(in.RefreshOn) != 0 { + const prefix string = ",\"refreshOn\":" + out.RawString(prefix) + { + out.RawByte('[') + for v46, v47 := range in.RefreshOn { + if v46 > 0 { + out.RawByte(',') + } + out.String(string(v47)) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v DataSource) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi9(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v DataSource) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi9(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *DataSource) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi9(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *DataSource) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi9(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi10(in *jlexer.Lexer, out *CachePolicy) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "ttl": + if in.IsNull() { + in.Skip() + } else { + out.TTL = int64(in.Int64()) + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi10(out *jwriter.Writer, in CachePolicy) { + out.RawByte('{') + first := true + _ = first + if in.TTL != 0 { + const prefix string = ",\"ttl\":" + first = false + out.RawString(prefix[1:]) + out.Int64(int64(in.TTL)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v CachePolicy) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi10(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v CachePolicy) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi10(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *CachePolicy) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi10(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *CachePolicy) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi10(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi11(in *jlexer.Lexer, out *ActionStep) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "action": + if in.IsNull() { + in.Skip() + } else { + out.Action = string(in.String()) + } + case "input": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Input = make(map[string]Value) + } else { + out.Input = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v48 Value + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((v48).UnmarshalJSON(data)) + } + } + (out.Input)[key] = v48 + in.WantComma() + } + in.Delim('}') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi11(out *jwriter.Writer, in ActionStep) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + out.RawString(prefix[1:]) + out.String(string(in.Type)) + } + if in.Action != "" { + const prefix string = ",\"action\":" + out.RawString(prefix) + out.String(string(in.Action)) + } + if len(in.Input) != 0 { + const prefix string = ",\"input\":" + out.RawString(prefix) + { + out.RawByte('{') + v49First := true + for v49Name, v49Value := range in.Input { + if v49First { + v49First = false + } else { + out.RawByte(',') + } + out.String(string(v49Name)) + out.RawByte(':') + out.Raw((v49Value).MarshalJSON()) + } + out.RawByte('}') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v ActionStep) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi11(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v ActionStep) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi11(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *ActionStep) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi11(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *ActionStep) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi11(l, v) +} +func easyjsonCef4e921DecodeGoOsspkgComUi12(in *jlexer.Lexer, out *Action) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + switch key { + case "type": + if in.IsNull() { + in.Skip() + } else { + out.Type = string(in.String()) + } + case "tool": + if in.IsNull() { + in.Skip() + } else { + out.Tool = string(in.String()) + } + case "input": + if in.IsNull() { + in.Skip() + } else { + in.Delim('{') + if !in.IsDelim('}') { + out.Input = make(map[string]Value) + } else { + out.Input = nil + } + for !in.IsDelim('}') { + key := string(in.String()) + in.WantColon() + var v50 Value + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((v50).UnmarshalJSON(data)) + } + } + (out.Input)[key] = v50 + in.WantComma() + } + in.Delim('}') + } + case "path": + if in.IsNull() { + in.Skip() + } else { + out.Path = string(in.String()) + } + case "value": + if in.IsNull() { + in.Skip() + out.Value = nil + } else { + if out.Value == nil { + out.Value = new(Value) + } + if in.IsNull() { + in.Skip() + } else { + if data := in.Raw(); in.Ok() { + in.AddError((*out.Value).UnmarshalJSON(data)) + } + } + } + case "source": + if in.IsNull() { + in.Skip() + } else { + out.Source = string(in.String()) + } + case "to": + if in.IsNull() { + in.Skip() + } else { + out.To = string(in.String()) + } + case "variant": + if in.IsNull() { + in.Skip() + } else { + out.Variant = string(in.String()) + } + case "message": + if in.IsNull() { + in.Skip() + } else { + out.Message = string(in.String()) + } + case "effects": + if in.IsNull() { + in.Skip() + out.Effects = nil + } else { + in.Delim('[') + if out.Effects == nil { + if !in.IsDelim(']') { + out.Effects = make([]Effect, 0, 0) + } else { + out.Effects = []Effect{} + } + } else { + out.Effects = (out.Effects)[:0] + } + for !in.IsDelim(']') { + var v51 Effect + if in.IsNull() { + in.Skip() + } else { + (v51).UnmarshalEasyJSON(in) + } + out.Effects = append(out.Effects, v51) + in.WantComma() + } + in.Delim(']') + } + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonCef4e921EncodeGoOsspkgComUi12(out *jwriter.Writer, in Action) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"type\":" + out.RawString(prefix[1:]) + out.String(string(in.Type)) + } + if in.Tool != "" { + const prefix string = ",\"tool\":" + out.RawString(prefix) + out.String(string(in.Tool)) + } + if len(in.Input) != 0 { + const prefix string = ",\"input\":" + out.RawString(prefix) + { + out.RawByte('{') + v52First := true + for v52Name, v52Value := range in.Input { + if v52First { + v52First = false + } else { + out.RawByte(',') + } + out.String(string(v52Name)) + out.RawByte(':') + out.Raw((v52Value).MarshalJSON()) + } + out.RawByte('}') + } + } + if in.Path != "" { + const prefix string = ",\"path\":" + out.RawString(prefix) + out.String(string(in.Path)) + } + if in.Value != nil { + const prefix string = ",\"value\":" + out.RawString(prefix) + out.Raw((*in.Value).MarshalJSON()) + } + if in.Source != "" { + const prefix string = ",\"source\":" + out.RawString(prefix) + out.String(string(in.Source)) + } + if in.To != "" { + const prefix string = ",\"to\":" + out.RawString(prefix) + out.String(string(in.To)) + } + if in.Variant != "" { + const prefix string = ",\"variant\":" + out.RawString(prefix) + out.String(string(in.Variant)) + } + if in.Message != "" { + const prefix string = ",\"message\":" + out.RawString(prefix) + out.String(string(in.Message)) + } + if len(in.Effects) != 0 { + const prefix string = ",\"effects\":" + out.RawString(prefix) + { + out.RawByte('[') + for v53, v54 := range in.Effects { + if v53 > 0 { + out.RawByte(',') + } + (v54).MarshalEasyJSON(out) + } + out.RawByte(']') + } + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v Action) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonCef4e921EncodeGoOsspkgComUi12(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v Action) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonCef4e921EncodeGoOsspkgComUi12(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *Action) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonCef4e921DecodeGoOsspkgComUi12(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *Action) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonCef4e921DecodeGoOsspkgComUi12(l, v) +} diff --git a/schema_test.go b/schema_test.go index 96b852b..ca6529b 100644 --- a/schema_test.go +++ b/schema_test.go @@ -23,6 +23,37 @@ func TestUnit_ValueRoundTrip(t *testing.T) { } } +func TestUnit_ValueLiteralObjects(t *testing.T) { + for _, data := range []string{ + `{"rows":[{"id":1}]}`, + `{"disabled":null}`, + } { + t.Run(data, func(t *testing.T) { + var value Value + if err := json.Unmarshal([]byte(data), &value); err != nil { + t.Fatal(err) + } + if value.Kind != ValueLiteral { + t.Fatalf("value kind = %q, want %q", value.Kind, ValueLiteral) + } + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + var want, got any + if err := json.Unmarshal([]byte(data), &want); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("round-trip JSON differs: got %s, want %s", encoded, data) + } + }) + } +} + func TestUnit_LayoutValidation(t *testing.T) { tests := []struct { name string @@ -105,6 +136,26 @@ func TestUnit_SemanticValidation(t *testing.T) { } } +func TestUnit_DottedSourceReference(t *testing.T) { + regions := emptyRegions() + regions.Content = []Node{{ + ID: "orders-table", + Component: "table", + Props: map[string]Value{"rows": SourceRef("orders.list.items")}, + }} + view := ViewSchema{ + ProtocolVersion: ProtocolVersion, + ID: "orders", + Sources: map[string]DataSource{ + "orders.list": {Type: "tool", Tool: "orders.list"}, + }, + Regions: regions, + } + if err := view.Validate(); err != nil { + t.Fatalf("Validate() error = %v", err) + } +} + func TestUnit_SchemaRequiresAllRegionArrays(t *testing.T) { view := ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Regions: Regions{Content: []Node{}}} if err := view.Validate(); !errors.Is(err, ErrInvalidSchema) { diff --git a/security_test.go b/security_test.go index 73e813d..fd4e3c9 100644 --- a/security_test.go +++ b/security_test.go @@ -8,22 +8,47 @@ import ( ) func TestUnit_SecuritySchemaBoundaries(t *testing.T) { - t.Run("unsafe value path", func(t *testing.T) { - var value Value - if err := json.Unmarshal([]byte(`{"$state":"constructor.name"}`), &value); !errors.Is(err, ErrInvalidValue) { - t.Fatalf("Unmarshal() error = %v, want ErrInvalidValue", err) - } - }) - t.Run("oversized serialized schema", func(t *testing.T) { - view := ViewSchema{ProtocolVersion: ProtocolVersion, ID: "safe", Title: strings.Repeat("x", 128), Regions: Regions{}} - if err := view.ValidateWithLimits(Limits{MaxBytes: 64}); !errors.Is(err, ErrInvalidSchema) { - t.Fatalf("ValidateWithLimits() error = %v, want ErrInvalidSchema", err) - } - }) - t.Run("unsafe action path", func(t *testing.T) { - view := ViewSchema{ProtocolVersion: ProtocolVersion, ID: "safe", Actions: map[string]Action{"save": {Type: "set-state", Path: "__proto__.polluted", Value: ptrValue("x")}}, Regions: Regions{}} - if err := view.Validate(); !errors.Is(err, ErrInvalidSchema) { - t.Fatalf("Validate() error = %v, want ErrInvalidSchema", err) - } - }) + t.Run( + "unsafe value path", func(t *testing.T) { + var value Value + if err := json.Unmarshal([]byte(`{"$state":"constructor.name"}`), &value); !errors.Is( + err, + ErrInvalidValue, + ) { + t.Fatalf("Unmarshal() error = %v, want ErrInvalidValue", err) + } + }, + ) + t.Run( + "oversized serialized schema", func(t *testing.T) { + view := ViewSchema{ + ProtocolVersion: ProtocolVersion, + ID: "safe", + Title: strings.Repeat("x", 128), + Regions: Regions{}, + } + if err := view.ValidateWithLimits(Limits{MaxBytes: 64}); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("ValidateWithLimits() error = %v, want ErrInvalidSchema", err) + } + }, + ) + t.Run( + "unsafe action path", func(t *testing.T) { + view := ViewSchema{ + ProtocolVersion: ProtocolVersion, + ID: "safe", + Actions: map[string]Action{ + "save": { + Type: "set-state", + Path: "__proto__.polluted", + Value: ptrValue("x"), + }, + }, + Regions: Regions{}, + } + if err := view.Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema", err) + } + }, + ) } diff --git a/shadcn_test.go b/shadcn_test.go index 763150b..1633db2 100644 --- a/shadcn_test.go +++ b/shadcn_test.go @@ -7,14 +7,79 @@ func TestUnit_ShadcnBuildersProduceCatalogNames(t *testing.T) { name string component ShadcnComponent }{ - {"accordion", ShadcnAccordion}, {"alert", ShadcnAlert}, {"alert dialog", ShadcnAlertDialog}, {"aspect ratio", ShadcnAspectRatio}, {"attachment", ShadcnAttachment}, {"avatar", ShadcnAvatar}, {"badge", ShadcnBadge}, {"breadcrumb", ShadcnBreadcrumb}, {"bubble", ShadcnBubble}, {"button", ShadcnButton}, {"button group", ShadcnButtonGroup}, {"calendar", ShadcnCalendar}, {"card", ShadcnCard}, {"carousel", ShadcnCarousel}, {"chart", ShadcnChart}, {"checkbox", ShadcnCheckbox}, {"collapsible", ShadcnCollapsible}, {"combobox", ShadcnCombobox}, {"command", ShadcnCommand}, {"context menu", ShadcnContextMenu}, {"data table", ShadcnDataTable}, {"date picker", ShadcnDatePicker}, {"dialog", ShadcnDialog}, {"direction", ShadcnDirection}, {"drawer", ShadcnDrawer}, {"dropdown menu", ShadcnDropdownMenu}, {"empty", ShadcnEmpty}, {"field", ShadcnField}, {"hover card", ShadcnHoverCard}, {"input", ShadcnInput}, {"input group", ShadcnInputGroup}, {"input otp", ShadcnInputOTP}, {"item", ShadcnItem}, {"kbd", ShadcnKbd}, {"label", ShadcnLabel}, {"marker", ShadcnMarker}, {"menubar", ShadcnMenubar}, {"message", ShadcnMessage}, {"message scroller", ShadcnMessageScroller}, {"native select", ShadcnNativeSelect}, {"navigation menu", ShadcnNavigationMenu}, {"pagination", ShadcnPagination}, {"popover", ShadcnPopover}, {"progress", ShadcnProgress}, {"questionnaire", ShadcnQuestionnaire}, {"radio group", ShadcnRadioGroup}, {"resizable", ShadcnResizable}, {"scroll area", ShadcnScrollArea}, {"select", ShadcnSelect}, {"separator", ShadcnSeparator}, {"sheet", ShadcnSheet}, {"sidebar", ShadcnSidebar}, {"skeleton", ShadcnSkeleton}, {"slider", ShadcnSlider}, {"spinner", ShadcnSpinner}, {"switch", ShadcnSwitch}, {"table", ShadcnTable}, {"tabs", ShadcnTabs}, {"textarea", ShadcnTextarea}, {"toast", ShadcnToast}, {"toggle", ShadcnToggle}, {"toggle group", ShadcnToggleGroup}, {"tooltip", ShadcnTooltip}, {"typography", ShadcnTypography}, + {"accordion", ShadcnAccordion}, + {"alert", ShadcnAlert}, + {"alert dialog", ShadcnAlertDialog}, + {"aspect ratio", ShadcnAspectRatio}, + {"attachment", ShadcnAttachment}, + {"avatar", ShadcnAvatar}, + {"badge", ShadcnBadge}, + {"breadcrumb", ShadcnBreadcrumb}, + {"bubble", ShadcnBubble}, + {"button", ShadcnButton}, + {"button group", ShadcnButtonGroup}, + {"calendar", ShadcnCalendar}, + {"card", ShadcnCard}, + {"carousel", ShadcnCarousel}, + {"chart", ShadcnChart}, + {"checkbox", ShadcnCheckbox}, + {"collapsible", ShadcnCollapsible}, + {"combobox", ShadcnCombobox}, + {"command", ShadcnCommand}, + {"context menu", ShadcnContextMenu}, + {"data table", ShadcnDataTable}, + {"date picker", ShadcnDatePicker}, + {"dialog", ShadcnDialog}, + {"direction", ShadcnDirection}, + {"drawer", ShadcnDrawer}, + {"dropdown menu", ShadcnDropdownMenu}, + {"empty", ShadcnEmpty}, + {"field", ShadcnField}, + {"hover card", ShadcnHoverCard}, + {"input", ShadcnInput}, + {"input group", ShadcnInputGroup}, + {"input otp", ShadcnInputOTP}, + {"item", ShadcnItem}, + {"kbd", ShadcnKbd}, + {"label", ShadcnLabel}, + {"marker", ShadcnMarker}, + {"menubar", ShadcnMenubar}, + {"message", ShadcnMessage}, + {"message scroller", ShadcnMessageScroller}, + {"native select", ShadcnNativeSelect}, + {"navigation menu", ShadcnNavigationMenu}, + {"pagination", ShadcnPagination}, + {"popover", ShadcnPopover}, + {"progress", ShadcnProgress}, + {"questionnaire", ShadcnQuestionnaire}, + {"radio group", ShadcnRadioGroup}, + {"resizable", ShadcnResizable}, + {"scroll area", ShadcnScrollArea}, + {"select", ShadcnSelect}, + {"separator", ShadcnSeparator}, + {"sheet", ShadcnSheet}, + {"sidebar", ShadcnSidebar}, + {"skeleton", ShadcnSkeleton}, + {"slider", ShadcnSlider}, + {"spinner", ShadcnSpinner}, + {"switch", ShadcnSwitch}, + {"table", ShadcnTable}, + {"tabs", ShadcnTabs}, + {"textarea", ShadcnTextarea}, + {"toast", ShadcnToast}, + {"toggle", ShadcnToggle}, + {"toggle group", ShadcnToggleGroup}, + {"tooltip", ShadcnTooltip}, + {"typography", ShadcnTypography}, } for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := Shadcn(test.component, "node").Build().Component; got != string(test.component) { - t.Fatalf("component = %q, want %q", got, test.component) - } - }) + t.Run( + test.name, func(t *testing.T) { + if got := Shadcn(test.component, "node").Build().Component; got != string(test.component) { + t.Fatalf("component = %q, want %q", got, test.component) + } + }, + ) } } diff --git a/validation.go b/validation.go index 81568b6..e645728 100644 --- a/validation.go +++ b/validation.go @@ -26,104 +26,142 @@ type Limits struct { func DefaultLimits() Limits { return Limits{ - MaxBytes: 1 << 20, - MaxNodes: 1000, MaxDepth: 32, MaxPropsPerNode: 64, - MaxActions: 128, MaxSources: 64, MaxStrings: 10000, MaxStringLength: 16 << 10, + MaxBytes: 1 << 20, + MaxNodes: 1000, + MaxDepth: 32, + MaxPropsPerNode: 64, + MaxActions: 128, + MaxSources: 64, + MaxStrings: 10000, + MaxStringLength: 16 << 10, MaxExpressionDepth: 32, } } -func (v ViewSchema) Validate() error { return v.ValidateWithLimits(DefaultLimits()) } +func (v ViewSchema) Validate() error { + return v.ValidateWithLimits(DefaultLimits()) +} func (v ViewSchema) ValidateWithLimits(limits Limits) error { limits = withDefaultLimits(limits) + if v.ProtocolVersion != ProtocolVersion { return fmt.Errorf("%w: unsupported view protocol version %q", ErrInvalidSchema, v.ProtocolVersion) } + if !validName(v.ID) { return fmt.Errorf("%w: view id is required", ErrInvalidSchema) } + if len(v.Actions) > limits.MaxActions || len(v.Sources) > limits.MaxSources { return fmt.Errorf("%w: source or action limit exceeded", ErrInvalidSchema) } - if v.Regions.TopHeader == nil || v.Regions.LeftPanel == nil || v.Regions.Content == nil || v.Regions.RightPanel == nil || v.Regions.Bottom == nil { + + if v.Regions.TopHeader == nil || + v.Regions.LeftPanel == nil || + v.Regions.Content == nil || + v.Regions.RightPanel == nil || + v.Regions.Bottom == nil { return fmt.Errorf("%w: all regions must be arrays", ErrInvalidSchema) } + if err := validateSerializedLimits(v, limits); err != nil { return err } + for name, source := range v.Sources { if !validName(name) || source.Type != "tool" || !validName(source.Tool) { return fmt.Errorf("%w: invalid source %q", ErrInvalidSchema, name) } + if source.Policy != "" && source.Policy != SourceManual && source.Policy != SourceOnMount { return fmt.Errorf("%w: source %q has unsupported policy", ErrInvalidSchema, name) } + if source.Cache != nil && source.Cache.TTL < 0 { return fmt.Errorf("%w: source %q has negative cache ttl", ErrInvalidSchema, name) } + if err := validateValues(source.Input, limits, v); err != nil { return fmt.Errorf("%w: source %q input: %w", ErrInvalidSchema, name, err) } + for _, action := range source.RefreshOn { if _, ok := v.Actions[action]; !ok { return fmt.Errorf("%w: source %q refresh action %q is not defined", ErrInvalidSchema, name, action) } } } + for name, action := range v.Actions { if !validName(name) || !validActionType(action.Type) { return fmt.Errorf("%w: invalid action %q", ErrInvalidSchema, name) } + if err := validateAction(action, limits, v); err != nil { return fmt.Errorf("%w: action %q: %w", ErrInvalidSchema, name, err) } } + seen := make(map[string]struct{}) nodes := 0 + for region, list := range map[string][]Node{ - "top-header": v.Regions.TopHeader, "left-panel": v.Regions.LeftPanel, - "content": v.Regions.Content, "right-panel": v.Regions.RightPanel, - "bottom": v.Regions.Bottom, + "top-header": v.Regions.TopHeader, + "left-panel": v.Regions.LeftPanel, + "content": v.Regions.Content, + "right-panel": v.Regions.RightPanel, + "bottom": v.Regions.Bottom, } { - for _, node := range list { - if err := validateNode(node, region, 1, &nodes, seen, limits, v); err != nil { + for _, n := range list { + if err := validateNode(n, region, 1, &nodes, seen, limits, v); err != nil { return err } } } + return nil } func withDefaultLimits(limits Limits) Limits { defaults := DefaultLimits() + if limits.MaxBytes <= 0 { limits.MaxBytes = defaults.MaxBytes } + if limits.MaxNodes <= 0 { limits.MaxNodes = defaults.MaxNodes } + if limits.MaxDepth <= 0 { limits.MaxDepth = defaults.MaxDepth } + if limits.MaxPropsPerNode <= 0 { limits.MaxPropsPerNode = defaults.MaxPropsPerNode } + if limits.MaxActions <= 0 { limits.MaxActions = defaults.MaxActions } + if limits.MaxSources <= 0 { limits.MaxSources = defaults.MaxSources } + if limits.MaxStrings <= 0 { limits.MaxStrings = defaults.MaxStrings } + if limits.MaxStringLength <= 0 { limits.MaxStringLength = defaults.MaxStringLength } + if limits.MaxExpressionDepth <= 0 { limits.MaxExpressionDepth = defaults.MaxExpressionDepth } + return limits } @@ -132,108 +170,142 @@ func validateSerializedLimits(schema ViewSchema, limits Limits) error { if err != nil { return fmt.Errorf("%w: serialize schema for limits: %w", ErrInvalidSchema, err) } + if len(encoded) > limits.MaxBytes { return fmt.Errorf("%w: schema byte limit exceeded", ErrInvalidSchema) } + decoder := json.NewDecoder(bytes.NewReader(encoded)) decoder.UseNumber() var document any + if err := decoder.Decode(&document); err != nil { return fmt.Errorf("%w: inspect schema limits: %w", ErrInvalidSchema, err) } - strings := 0 - if err := walkLimitValue(document, 0, limits, &strings); err != nil { + + count := 0 + if err := walkLimitValue(document, 0, limits, &count); err != nil { return fmt.Errorf("%w: %w", ErrInvalidSchema, err) } + return nil } -func walkLimitValue(value any, depth int, limits Limits, strings *int) error { +func walkLimitValue(value any, depth int, limits Limits, count *int) error { if depth > limits.MaxDepth { return fmt.Errorf("schema depth limit exceeded") } + switch typed := value.(type) { case string: if len(typed) > limits.MaxStringLength { return fmt.Errorf("string length limit exceeded") } - (*strings)++ - if *strings > limits.MaxStrings { + + *count++ + if *count > limits.MaxStrings { return fmt.Errorf("string count limit exceeded") } + case []any: for _, item := range typed { - if err := walkLimitValue(item, depth+1, limits, strings); err != nil { + if err := walkLimitValue(item, depth+1, limits, count); err != nil { return err } } + case map[string]any: for key, item := range typed { - if err := walkLimitValue(key, depth+1, limits, strings); err != nil { + if err := walkLimitValue(key, depth+1, limits, count); err != nil { return err } - if err := walkLimitValue(item, depth+1, limits, strings); err != nil { + + if err := walkLimitValue(item, depth+1, limits, count); err != nil { return err } } + + default: } + return nil } -func validateNode(node Node, region string, depth int, nodes *int, seen map[string]struct{}, limits Limits, view ViewSchema) error { +func validateNode( + node Node, + region string, + depth int, + nodes *int, + seen map[string]struct{}, + limits Limits, + view ViewSchema, +) error { *nodes++ if *nodes > limits.MaxNodes || depth > limits.MaxDepth { return fmt.Errorf("%w: node limit exceeded", ErrInvalidSchema) } + if !validName(node.ID) || !validComponentName(node.Component) { return fmt.Errorf("%w: node id and component are required", ErrInvalidSchema) } + if _, exists := seen[node.ID]; exists { return fmt.Errorf("%w: duplicate node id %q", ErrInvalidSchema, node.ID) } + seen[node.ID] = struct{}{} if node.Layout != nil { if err := node.Layout.Validate(region); err != nil { return err } } + if len(node.Props) > limits.MaxPropsPerNode { return fmt.Errorf("%w: too many props on node %q", ErrInvalidSchema, node.ID) } + if err := validateValues(node.Props, limits, view); err != nil { return fmt.Errorf("%w: node %q props: %w", ErrInvalidSchema, node.ID, err) } + for event, handler := range node.Events { if !validName(event) || handler.Action == "" && len(handler.Steps) == 0 { return fmt.Errorf("%w: invalid event on node %q", ErrInvalidSchema, node.ID) } + if handler.Action != "" { if _, ok := view.Actions[handler.Action]; !ok { return fmt.Errorf("%w: action %q is not defined", ErrInvalidSchema, handler.Action) } } + for _, step := range handler.Steps { if step.Type != "action" || step.Action == "" { return fmt.Errorf("%w: invalid event step on node %q", ErrInvalidSchema, node.ID) } + if _, ok := view.Actions[step.Action]; !ok { return fmt.Errorf("%w: action %q is not defined", ErrInvalidSchema, step.Action) } + if err := validateValues(step.Input, limits, view); err != nil { return fmt.Errorf("%w: event step input: %w", ErrInvalidSchema, err) } } } + if node.When != nil { if err := validateValue(*node.When, limits, view, 0); err != nil { return fmt.Errorf("%w: node %q condition: %w", ErrInvalidSchema, node.ID, err) } } + for _, child := range node.Children { if err := validateNode(child, region, depth+1, nodes, seen, limits, view); err != nil { return err } } + for _, children := range node.Slots { for _, child := range children { if err := validateNode(child, region, depth+1, nodes, seen, limits, view); err != nil { @@ -241,6 +313,7 @@ func validateNode(node Node, region string, depth int, nodes *int, seen map[stri } } } + return nil } @@ -248,15 +321,18 @@ func (l Layout) Validate(region string) error { if l.Row < 1 { return fmt.Errorf("%w: row must be positive", ErrInvalidLayout) } + if region == "left-panel" || region == "right-panel" { if l.Cols != 0 || l.Offset != 0 { return fmt.Errorf("%w: side panels do not accept cols or offset", ErrInvalidLayout) } return nil } + if l.Cols < 1 || l.Cols > 12 || l.Offset < 0 || l.Offset > 11 || l.Cols+l.Offset > 12 { return fmt.Errorf("%w: cols and offset must fit 12 columns", ErrInvalidLayout) } + return nil } @@ -265,10 +341,12 @@ func validateValues(values map[string]Value, limits Limits, view ViewSchema) err if len(key) > limits.MaxStringLength { return fmt.Errorf("property name is too long") } + if err := validateValue(value, limits, view, 0); err != nil { return err } } + return nil } @@ -276,32 +354,39 @@ func validateAction(action Action, limits Limits, view ViewSchema) error { if action.Type == "tool" && !validName(action.Tool) { return fmt.Errorf("tool action requires a tool") } + if action.Type == "set-state" || action.Type == "merge-state" { if !safePath(action.Path) { return fmt.Errorf("state action requires a safe path") } } + if action.Type == "refresh-source" || action.Type == "invalidate" { if _, ok := view.Sources[action.Source]; !ok { return fmt.Errorf("source %q is not defined", action.Source) } } + if action.Path != "" && !safePath(action.Path) { return fmt.Errorf("unsafe action path") } + if err := validateValues(action.Input, limits, view); err != nil { return err } + if action.Value != nil { if err := validateValue(*action.Value, limits, view, 0); err != nil { return err } } + for _, effect := range action.Effects { if err := validateEffect(effect, limits, view); err != nil { return err } } + return nil } @@ -309,33 +394,41 @@ func validateEffect(effect Effect, limits Limits, view ViewSchema) error { if effect.Type == "" { return fmt.Errorf("effect type is required") } + if !validEffectType(effect.Type) { return fmt.Errorf("unsupported effect type %q", effect.Type) } + if effect.Path != "" && !safePath(effect.Path) { return fmt.Errorf("unsafe effect path") } + if effect.Type == "set-state" || effect.Type == "merge-state" { if !safePath(effect.Path) { return fmt.Errorf("state effect requires a safe path") } } + if effect.Type == "invalidate" || effect.Type == "refresh-source" { if _, ok := view.Sources[effect.Source]; !ok { return fmt.Errorf("source %q is not defined", effect.Source) } } + if effect.Type == "toast" && effect.Message == "" { return fmt.Errorf("toast effect requires a message") } + if effect.Type == "navigate" && effect.To == "" { return fmt.Errorf("navigate effect requires a target") } + if effect.Value != nil { if err := validateValue(*effect.Value, limits, view, 0); err != nil { return err } } + return nil } @@ -343,49 +436,80 @@ func validateValue(value Value, limits Limits, view ViewSchema, depth int) error if depth > limits.MaxExpressionDepth { return fmt.Errorf("expression depth limit exceeded") } + switch value.Kind { case ValueLiteral: return nil + case ValueState, ValueContext, ValueEvent, ValueResult: if !safePath(value.Path) { return fmt.Errorf("%w: unsafe reference path %q", ErrInvalidValue, value.Path) } + return nil + case ValueSource: if !safePath(value.Path) { return fmt.Errorf("%w: unsafe reference path %q", ErrInvalidValue, value.Path) } - name := value.Path - if index := strings.IndexByte(name, '.'); index >= 0 { - name = name[:index] - } - if _, ok := view.Sources[name]; !ok { - return fmt.Errorf("source %q is not defined", name) + + if _, ok := sourceForPath(value.Path, view.Sources); !ok { + return fmt.Errorf("source %q is not defined", value.Path) } + return nil + case ValueExpr: args, ok := value.Data.([]Value) if !ok { return fmt.Errorf("expression arguments have invalid type") } + arity, ok := expressionArity[value.Path] if !ok { return fmt.Errorf("%w: unsupported expression operator %q", ErrInvalidValue, value.Path) } + if len(args) < arity[0] || (arity[1] >= 0 && len(args) > arity[1]) { - return fmt.Errorf("%w: operator %s expects %d..%d arguments", ErrInvalidValue, value.Path, arity[0], arity[1]) + return fmt.Errorf( + "%w: operator %s expects %d..%d arguments", + ErrInvalidValue, + value.Path, + arity[0], + arity[1], + ) } + for _, arg := range args { if err := validateValue(arg, limits, view, depth+1); err != nil { return err } } + return nil + default: return fmt.Errorf("%w: unknown value kind %q", ErrInvalidValue, value.Kind) } } +func sourceForPath(path string, sources map[string]DataSource) (string, bool) { + for name := path; name != ""; { + if _, ok := sources[name]; ok { + return name, true + } + + index := strings.LastIndexByte(name, '.') + if index < 0 { + break + } + + name = name[:index] + } + + return "", false +} + func validActionType(value string) bool { switch value { case "tool", "set-state", "merge-state", "refresh-source", "invalidate": @@ -397,7 +521,9 @@ func validActionType(value string) bool { func validEffectType(value string) bool { switch value { - case "set-state", "merge-state", "invalidate", "refresh-source", "refresh-view", "patch-view", "navigate", "toast", "dialog", "close-dialog": + case "set-state", "merge-state", "invalidate", "refresh-source", + "refresh-view", "patch-view", "navigate", "toast", + "dialog", "close-dialog": return true default: return false @@ -408,11 +534,14 @@ func validComponentName(value string) bool { if value == "" { return false } + for index, char := range value { if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9' && index > 0) || char == '-' { continue } + return false } + return true } diff --git a/value.go b/value.go index 541af36..a54e7c7 100644 --- a/value.go +++ b/value.go @@ -30,18 +30,45 @@ type Value struct { } var expressionArity = map[string][2]int{ - "$eq": {2, 2}, "$ne": {2, 2}, "$gt": {2, 2}, "$gte": {2, 2}, - "$lt": {2, 2}, "$lte": {2, 2}, "$and": {1, -1}, "$or": {1, -1}, - "$not": {1, 1}, "$in": {2, 2}, "$exists": {1, 1}, "$concat": {0, -1}, - "$coalesce": {1, -1}, "$if": {3, 3}, + "$eq": {2, 2}, + "$ne": {2, 2}, + "$gt": {2, 2}, + "$gte": {2, 2}, + "$lt": {2, 2}, + "$lte": {2, 2}, + "$and": {1, -1}, + "$or": {1, -1}, + "$not": {1, 1}, + "$in": {2, 2}, + "$exists": {1, 1}, + "$concat": {0, -1}, + "$coalesce": {1, -1}, + "$if": {3, 3}, } -func Literal(data any) Value { return Value{Kind: ValueLiteral, Data: data} } -func StateRef(path string) Value { return Value{Kind: ValueState, Path: path} } -func ContextRef(path string) Value { return Value{Kind: ValueContext, Path: path} } -func SourceRef(path string) Value { return Value{Kind: ValueSource, Path: path} } -func EventRef(path string) Value { return Value{Kind: ValueEvent, Path: path} } -func ResultRef(path string) Value { return Value{Kind: ValueResult, Path: path} } +func Literal(data any) Value { + return Value{Kind: ValueLiteral, Data: data} +} + +func StateRef(path string) Value { + return Value{Kind: ValueState, Path: path} +} + +func ContextRef(path string) Value { + return Value{Kind: ValueContext, Path: path} +} + +func SourceRef(path string) Value { + return Value{Kind: ValueSource, Path: path} +} + +func EventRef(path string) Value { + return Value{Kind: ValueEvent, Path: path} +} + +func ResultRef(path string) Value { + return Value{Kind: ValueResult, Path: path} +} func Expr(operator string, args ...Value) Value { return Value{Kind: ValueExpr, Path: operator, Data: append([]Value(nil), args...)} @@ -51,32 +78,41 @@ func (v Value) Validate() error { switch v.Kind { case ValueLiteral: return nil + case ValueState, ValueContext, ValueSource, ValueEvent, ValueResult: if !safePath(v.Path) { return fmt.Errorf("%w: unsafe reference path %q", ErrInvalidValue, v.Path) } + return nil + case ValueExpr: arity, ok := expressionArity[v.Path] if !ok { return fmt.Errorf("%w: unsupported expression operator %q", ErrInvalidValue, v.Path) } + if v.Path == "" { return fmt.Errorf("%w: expression operator is required", ErrInvalidValue) } + args, ok := v.Data.([]Value) if !ok { return fmt.Errorf("%w: expression arguments have invalid type", ErrInvalidValue) } + if len(args) < arity[0] || (arity[1] >= 0 && len(args) > arity[1]) { return fmt.Errorf("%w: operator %s expects %d..%d arguments", ErrInvalidValue, v.Path, arity[0], arity[1]) } + for _, arg := range args { if err := arg.Validate(); err != nil { return err } } + return nil + default: return fmt.Errorf("%w: unknown value kind %q", ErrInvalidValue, v.Kind) } @@ -86,13 +122,20 @@ func (v Value) MarshalJSON() ([]byte, error) { if err := v.Validate(); err != nil { return nil, err } + if v.Kind == ValueLiteral { return json.Marshal(v.Data) } + if v.Kind == ValueExpr { - args, _ := v.Data.([]Value) + args, ok := v.Data.([]Value) + if !ok { + return nil, fmt.Errorf("%w: expression arguments have invalid type", ErrInvalidValue) + } + return json.Marshal(map[string]any{v.Path: args}) } + return json.Marshal(map[string]string{"$" + string(v.Kind): v.Path}) } @@ -100,6 +143,7 @@ func (v *Value) UnmarshalJSON(data []byte) error { if v == nil { return fmt.Errorf("%w: nil destination", ErrInvalidValue) } + var object map[string]json.RawMessage if err := json.Unmarshal(data, &object); err == nil && object != nil { if len(object) == 1 { @@ -109,13 +153,15 @@ func (v *Value) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(raw, &path); err != nil { return fmt.Errorf("%w: reference path: %w", ErrInvalidValue, err) } + v.Kind = ValueKind(key[1:]) v.Path = path v.Data = nil return v.Validate() } + var args []Value - if err := json.Unmarshal(raw, &args); err == nil { + if len(key) > 0 && key[0] == '$' && json.Unmarshal(raw, &args) == nil { v.Kind = ValueExpr v.Path = key v.Data = args @@ -124,15 +170,18 @@ func (v *Value) UnmarshalJSON(data []byte) error { } } } + var literal any decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() if err := decoder.Decode(&literal); err != nil { return fmt.Errorf("%w: literal: %w", ErrInvalidValue, err) } + v.Kind = ValueLiteral v.Path = "" v.Data = literal + return nil } @@ -140,22 +189,26 @@ func safePath(path string) bool { if path == "" { return false } + for _, part := range splitPath(path) { if part == "__proto__" || part == "prototype" || part == "constructor" || part == "" { return false } } + return true } func splitPath(path string) []string { parts := make([]string, 0, 4) start := 0 + for i := 0; i <= len(path); i++ { if i == len(path) || path[i] == '.' { parts = append(parts, path[start:i]) start = i + 1 } } + return parts } From df59a923c352f808f7347dfe7b1d5c20d3a7a99a Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Tue, 22 Sep 2026 06:35:38 +0300 Subject: [PATCH 03/26] change --- .golangci.yml | 199 ++++++++++++++++++++++++++++++++++++++++++++- builder.go | 1 + go.mod | 8 +- schema_easyjson.go | 1 + validation.go | 31 +++---- value.go | 17 +--- 6 files changed, 221 insertions(+), 36 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index f2b16a7..1bbdbef 100755 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,7 +3,7 @@ version: "2" run: go: "1.26" timeout: 5m - tests: false + tests: true issues-exit-code: 1 modules-download-mode: readonly allow-parallel-runners: true @@ -12,7 +12,7 @@ issues: max-issues-per-linter: 0 max-same-issues: 0 new: false - fix: false + fix: true output: formats: @@ -21,12 +21,23 @@ output: print-issued-lines: true formatters: + settings: + gofmt: + simplify: true + rewrite-rules: + - pattern: 'interface{}' + replacement: 'any' + - pattern: 'a[b:len(a)]' + replacement: 'a[b:]' + exclusions: paths: - vendors/ + enable: - gofmt - goimports + - gofumpt linters: settings: @@ -46,6 +57,155 @@ linters: - G306 - G501 - G505 + tagliatelle: + case: + rules: + mcp: snake + json: snake # JSON: snake_case (user_id) + yaml: snake # YAML: snake_case + xml: camel # XML: camelCase + mapstructure: snake # mapstructure: snake_case + env: upperSnake # ENV: UPPER_SNAKE_CASE + varnamelen: + min-name-length: 2 # Минимальная длина имени + max-distance: 5 # i, j, k допустимы в scope <= 5 строк + ignore-names: + - err # err — идиоматично + - ok # ok — идиоматично + - id # id — часто используется + - db # db — часто используется + - tx # tx — транзакция + - wg # wg — WaitGroup + - mu # mu — mutex + - rw # rw — RWMutex + - ch # ch — channel + - fn # fn — function + - sb # sb — strings.Builder + - ctx # ctx — context + - q # q — querier + - r # r — repo / reader + - s # s — strategy / service + - f # f — filter + - a # a — left operand (сравнение) + - b # b — right operand (сравнение) + ignore-type-assert-ok: true # Игнорировать v, ok := x.(T) + ignore-map-index-ok: true # Игнорировать v, ok := m[k] + ignore-chan-recv-ok: true # Игнорировать v, ok := <-ch + ignore-decls: + - i int # for i := ... + - j int # вложенные циклы + - n int # количество + - t testing.T # тесты + - b testing.B # бенчмарки + - r *http.Request # HTTP handler + - w http.ResponseWriter # HTTP handler + - c *gin.Context # Gin context + - c echo.Context # Echo context + - s *Server # конструктор Server + - m *metrics # конструктор metrics + revive: + severity: warning + rules: + # ------------------------------------------------------------------------- + # Предотвращение багов + # ------------------------------------------------------------------------- + - name: atomic # Проверяет правильное использование sync/atomic + - name: range-val-in-closure # Захват переменной цикла в замыкании + - name: range-val-address # Взятие адреса переменной цикла + - name: unreachable-code # Недостижимый код после return/panic + - name: unchecked-type-assertion # Type assertion без проверки ok + - name: datarace # Потенциальные data races + - name: identical-branches # Одинаковые ветки if/else + - name: defer # Проблемы с defer (в циклах, результат) + - name: call-to-gc # Явные вызовы runtime.GC() + - name: waitgroup-by-value # WaitGroup передан по значению + + # ------------------------------------------------------------------------- + # Обработка ошибок — Go proverb: "Don't just check errors, handle them gracefully" + # ------------------------------------------------------------------------- + - name: error-strings # Ошибки не должны начинаться с большой буквы + - name: error-return # error должен быть последним возвращаемым значением + - name: errorf # Использовать fmt.Errorf вместо errors.New + fmt.Sprintf + - name: unhandled-error # Необработанные ошибки + arguments: + - "fmt.Print" + - "fmt.Printf" + - "fmt.Println" + + # ------------------------------------------------------------------------- + # Сложность — Go proverb: "Clear is better than clever" + # ------------------------------------------------------------------------- + - name: cognitive-complexity + arguments: [50] # Cognitive complexity <= 15 + - name: cyclomatic + arguments: [50] # Cyclomatic complexity <= 10 + - name: function-result-limit + arguments: [3] # Максимум 3 возвращаемых значения + - name: argument-limit + arguments: [5] # Максимум 5 аргументов функции + + # ------------------------------------------------------------------------- + # Чистота кода — Go proverb: "A little copying is better than a little dependency" + # ------------------------------------------------------------------------- + - name: indent-error-flow # if err != nil { return } вместо else + - name: early-return # Ранний возврат вместо вложенности + - name: superfluous-else # Лишний else после return + - name: if-return # Упрощение if/return + - name: empty-block # Пустые блоки кода + - name: unnecessary-stmt # Ненужные операторы + - name: redundant-import-alias # import pkg "pkg" — лишний алиас + - name: confusing-results # Запутанные возвращаемые значения + - name: bool-literal-in-expr # if x == true → if x + - name: constant-logical-expr # Константные логические выражения + - name: modifies-parameter # Модификация параметров функции + - name: modifies-value-receiver # Модификация value receiver (бесполезно) + - name: redefines-builtin-id # Переопределение встроенных идентификаторов + - name: string-of-int # string(int) — частая ошибка + - name: time-equal # time.Time сравнение через == + - name: unconditional-recursion # Безусловная рекурсия (бесконечный цикл) + - name: useless-break # break в конце case (Go делает это автоматически) + + # ------------------------------------------------------------------------- + # Хорошие практики — Go proverbs + # ------------------------------------------------------------------------- + - name: context-as-argument # context.Context первым аргументом + - name: context-keys-type # Ключи контекста должны быть типизированы + - name: var-declaration # var x = 1 → x := 1 + - name: blank-imports # Запрет blank imports кроме main/test + - name: dot-imports # Запрет dot imports + - name: unexported-return # Публичная функция возвращает приватный тип + - name: exported # Экспортируемые идентификаторы должны быть задокументированы + arguments: + - "checkPrivateReceivers" + - "disableStutteringCheck" + + # ------------------------------------------------------------------------- + # Именование — Go proverb: "Good naming is like good coding: concise" + # ------------------------------------------------------------------------- + - name: var-naming + arguments: + - [ + "ID", + "URL", + "API", + "HTTP", + "JSON", + "XML", + "DB", + "SQL", + "UUID", + "UID", + "GUID", + "TTL", + "TCP", + "UDP", + "IP", + "RPC", + "QPS", + "EOF", + ] + - name: package-comments # Пакеты должны иметь комментарии + - name: receiver-naming # Имена receiver (r, s, c, не this/self) exclusions: paths: - vendors/ @@ -66,3 +226,38 @@ linters: - errorlint - bodyclose - gosec + - nilerr + - nilnesserr + - nilnil + - bidichk + - contextcheck + - fatcontext + - makezero + - forcetypeassert + - unconvert + - copyloopvar + - prealloc + - perfsprint + - gocritic + - goconst + - mnd + - revive + - predeclared + - reassign + - recvcheck + - asciicheck + - importas + - durationcheck + - tparallel + - thelper + - usetesting + - musttag + - errchkjson + - tagalign + - usestdlibvars + - nestif + - mirror + - whitespace + - decorder + - nonamedreturns + - inamedparam \ No newline at end of file diff --git a/builder.go b/builder.go index a0b5f46..34ed851 100644 --- a/builder.go +++ b/builder.go @@ -187,6 +187,7 @@ func LeftPanel(nodes ...*NodeBuilder) ViewOption { schema.Regions.LeftPanel = buildNodes(nodes) } } + func Content(nodes ...*NodeBuilder) ViewOption { return func(schema *ViewSchema) { schema.Regions.Content = buildNodes(nodes) diff --git a/go.mod b/go.mod index a1837b6..f8e5c62 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,9 @@ module go.osspkg.com/ui go 1.26.8 -require go.osspkg.com/mcp v0.1.2 - require ( - github.com/josharian/intern v1.0.0 // indirect - github.com/mailru/easyjson v0.9.2 // indirect + github.com/mailru/easyjson v0.9.2 + go.osspkg.com/mcp v0.1.2 ) + +require github.com/josharian/intern v1.0.0 // indirect diff --git a/schema_easyjson.go b/schema_easyjson.go index 960ff45..239d0f0 100644 --- a/schema_easyjson.go +++ b/schema_easyjson.go @@ -4,6 +4,7 @@ package ui import ( json "encoding/json" + easyjson "github.com/mailru/easyjson" jlexer "github.com/mailru/easyjson/jlexer" jwriter "github.com/mailru/easyjson/jwriter" diff --git a/validation.go b/validation.go index e645728..f384596 100644 --- a/validation.go +++ b/validation.go @@ -8,6 +8,7 @@ package ui import ( "bytes" "encoding/json" + "errors" "fmt" "strings" ) @@ -193,18 +194,18 @@ func validateSerializedLimits(schema ViewSchema, limits Limits) error { func walkLimitValue(value any, depth int, limits Limits, count *int) error { if depth > limits.MaxDepth { - return fmt.Errorf("schema depth limit exceeded") + return errors.New("schema depth limit exceeded") } switch typed := value.(type) { case string: if len(typed) > limits.MaxStringLength { - return fmt.Errorf("string length limit exceeded") + return errors.New("string length limit exceeded") } *count++ if *count > limits.MaxStrings { - return fmt.Errorf("string count limit exceeded") + return errors.New("string count limit exceeded") } case []any: @@ -239,7 +240,7 @@ func validateNode( seen map[string]struct{}, limits Limits, view ViewSchema, -) error { +) error { //nolint:revive *nodes++ if *nodes > limits.MaxNodes || depth > limits.MaxDepth { return fmt.Errorf("%w: node limit exceeded", ErrInvalidSchema) @@ -339,7 +340,7 @@ func (l Layout) Validate(region string) error { func validateValues(values map[string]Value, limits Limits, view ViewSchema) error { for key, value := range values { if len(key) > limits.MaxStringLength { - return fmt.Errorf("property name is too long") + return errors.New("property name is too long") } if err := validateValue(value, limits, view, 0); err != nil { @@ -352,12 +353,12 @@ func validateValues(values map[string]Value, limits Limits, view ViewSchema) err func validateAction(action Action, limits Limits, view ViewSchema) error { if action.Type == "tool" && !validName(action.Tool) { - return fmt.Errorf("tool action requires a tool") + return errors.New("tool action requires a tool") } if action.Type == "set-state" || action.Type == "merge-state" { if !safePath(action.Path) { - return fmt.Errorf("state action requires a safe path") + return errors.New("state action requires a safe path") } } @@ -368,7 +369,7 @@ func validateAction(action Action, limits Limits, view ViewSchema) error { } if action.Path != "" && !safePath(action.Path) { - return fmt.Errorf("unsafe action path") + return errors.New("unsafe action path") } if err := validateValues(action.Input, limits, view); err != nil { @@ -392,7 +393,7 @@ func validateAction(action Action, limits Limits, view ViewSchema) error { func validateEffect(effect Effect, limits Limits, view ViewSchema) error { if effect.Type == "" { - return fmt.Errorf("effect type is required") + return errors.New("effect type is required") } if !validEffectType(effect.Type) { @@ -400,12 +401,12 @@ func validateEffect(effect Effect, limits Limits, view ViewSchema) error { } if effect.Path != "" && !safePath(effect.Path) { - return fmt.Errorf("unsafe effect path") + return errors.New("unsafe effect path") } if effect.Type == "set-state" || effect.Type == "merge-state" { if !safePath(effect.Path) { - return fmt.Errorf("state effect requires a safe path") + return errors.New("state effect requires a safe path") } } @@ -416,11 +417,11 @@ func validateEffect(effect Effect, limits Limits, view ViewSchema) error { } if effect.Type == "toast" && effect.Message == "" { - return fmt.Errorf("toast effect requires a message") + return errors.New("toast effect requires a message") } if effect.Type == "navigate" && effect.To == "" { - return fmt.Errorf("navigate effect requires a target") + return errors.New("navigate effect requires a target") } if effect.Value != nil { @@ -434,7 +435,7 @@ func validateEffect(effect Effect, limits Limits, view ViewSchema) error { func validateValue(value Value, limits Limits, view ViewSchema, depth int) error { if depth > limits.MaxExpressionDepth { - return fmt.Errorf("expression depth limit exceeded") + return errors.New("expression depth limit exceeded") } switch value.Kind { @@ -462,7 +463,7 @@ func validateValue(value Value, limits Limits, view ViewSchema, depth int) error case ValueExpr: args, ok := value.Data.([]Value) if !ok { - return fmt.Errorf("expression arguments have invalid type") + return errors.New("expression arguments have invalid type") } arity, ok := expressionArity[value.Path] diff --git a/value.go b/value.go index a54e7c7..7725c8d 100644 --- a/value.go +++ b/value.go @@ -9,6 +9,7 @@ import ( "bytes" "encoding/json" "fmt" + "strings" ) type ValueKind string @@ -190,7 +191,7 @@ func safePath(path string) bool { return false } - for _, part := range splitPath(path) { + for _, part := range strings.Split(path, ".") { if part == "__proto__" || part == "prototype" || part == "constructor" || part == "" { return false } @@ -198,17 +199,3 @@ func safePath(path string) bool { return true } - -func splitPath(path string) []string { - parts := make([]string, 0, 4) - start := 0 - - for i := 0; i <= len(path); i++ { - if i == len(path) || path[i] == '.' { - parts = append(parts, path[start:i]) - start = i + 1 - } - } - - return parts -} From 16c9efd2b90312e20a656976fe8f80d18c120585 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Tue, 22 Sep 2026 06:37:43 +0300 Subject: [PATCH 04/26] change --- .golangci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.golangci.yml b/.golangci.yml index 1bbdbef..ae15bad 100755 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,6 +19,7 @@ output: text: print-linter-name: true print-issued-lines: true + colors: true formatters: settings: From 3f1f80a92051c3656a87bcee3b9095d5723c05e3 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Wed, 23 Sep 2026 23:33:43 +0300 Subject: [PATCH 05/26] change --- builder.go | 61 +++++++- examples/dashboard/dashboard.go | 23 ++- examples/htmlform/htmlform.go | 7 +- html.go | 11 +- mcp/register.go | 2 + schema.go | 54 ++++++- shadcn.go | 252 ++++++++++++++++++++++++-------- validation.go | 110 ++++++++------ value.go | 106 ++++++++++---- 9 files changed, 472 insertions(+), 154 deletions(-) diff --git a/builder.go b/builder.go index 34ed851..adc6f6e 100644 --- a/builder.go +++ b/builder.go @@ -10,31 +10,37 @@ import ( "fmt" ) +// App builds and stores declarative UI views for a plugin. type App struct { manifest Manifest views map[string]ViewSchema } +// AppOption configures an App during construction. type AppOption func(*App) +// AppID sets the plugin identifier in the application manifest. func AppID(value string) AppOption { return func(app *App) { app.manifest.Plugin.ID = value } } +// AppTitle sets the plugin title in the application manifest. func AppTitle(value string) AppOption { return func(app *App) { app.manifest.Plugin.Title = value } } +// AppVersion sets the plugin version in the application manifest. func AppVersion(value string) AppOption { return func(app *App) { app.manifest.Plugin.Version = value } } +// New creates an application with the supplied manifest options. func New(options ...AppOption) *App { app := &App{ manifest: Manifest{ @@ -51,6 +57,7 @@ func New(options ...AppOption) *App { return app } +// AddView validates and registers a view in the application. func (app *App) AddView(view *ViewBuilder) error { schema := view.Schema() if err := schema.Validate(); err != nil { @@ -79,14 +86,17 @@ func (app *App) AddView(view *ViewBuilder) error { return nil } +// Manifest returns a copy of the application manifest. func (app *App) Manifest() Manifest { return cloneManifest(app.manifest) } +// ManifestJSON serializes a copy of the application manifest. func (app *App) ManifestJSON() ([]byte, error) { return json.Marshal(app.Manifest()) } +// View returns a copy of a registered view by ID. func (app *App) View(id string) (ViewSchema, bool) { view, ok := app.views[id] if !ok { @@ -130,12 +140,15 @@ func cloneViewSchema(schema ViewSchema) (ViewSchema, error) { return clone, nil } +// ViewBuilder incrementally constructs a ViewSchema. type ViewBuilder struct { schema ViewSchema } +// ViewOption configures a view during construction. type ViewOption func(*ViewSchema) +// View creates a view builder with the supplied ID and options. func View(id string, options ...ViewOption) *ViewBuilder { builder := &ViewBuilder{ schema: ViewSchema{ @@ -164,67 +177,79 @@ func emptyRegions() Regions { } } +// Title sets the view title. func Title(value string) ViewOption { return func(schema *ViewSchema) { schema.Title = value } } +// State sets the initial view state. func State(value map[string]any) ViewOption { return func(schema *ViewSchema) { schema.State = value } } +// TopHeader places nodes in the top header region. func TopHeader(nodes ...*NodeBuilder) ViewOption { return func(schema *ViewSchema) { schema.Regions.TopHeader = buildNodes(nodes) } } +// LeftPanel places nodes in the left panel region. func LeftPanel(nodes ...*NodeBuilder) ViewOption { return func(schema *ViewSchema) { schema.Regions.LeftPanel = buildNodes(nodes) } } +// Content places nodes in the content region. func Content(nodes ...*NodeBuilder) ViewOption { return func(schema *ViewSchema) { schema.Regions.Content = buildNodes(nodes) } } +// RightPanel places nodes in the right panel region. func RightPanel(nodes ...*NodeBuilder) ViewOption { return func(schema *ViewSchema) { schema.Regions.RightPanel = buildNodes(nodes) } } +// Bottom places nodes in the bottom region. func Bottom(nodes ...*NodeBuilder) ViewOption { return func(schema *ViewSchema) { schema.Regions.Bottom = buildNodes(nodes) } } +// Action adds a named action to the view. func (view *ViewBuilder) Action(name string, action *ActionBuilder) *ViewBuilder { view.schema.Actions[name] = action.action return view } +// Schema returns the current view schema. func (view *ViewBuilder) Schema() ViewSchema { return view.schema } +// Revision sets the view revision. func (view *ViewBuilder) Revision(value string) *ViewBuilder { view.schema.Revision = value return view } +// Source adds a named data source to the view. func (view *ViewBuilder) Source(name string, source *SourceBuilder) *ViewBuilder { view.schema.Sources[name] = source.source return view } +// NodeBuilder incrementally constructs a UI node. type NodeBuilder struct{ node Node } func node(component, id string) *NodeBuilder { @@ -238,39 +263,46 @@ func node(component, id string) *NodeBuilder { } } +// Row sets the node's grid row. func (node *NodeBuilder) Row(row int) *NodeBuilder { node.ensureLayout() node.node.Layout.Row = row return node } +// Cols sets the node's grid column span. func (node *NodeBuilder) Cols(cols int) *NodeBuilder { node.ensureLayout() node.node.Layout.Cols = cols return node } +// Offset sets the node's grid column offset. func (node *NodeBuilder) Offset(offset int) *NodeBuilder { node.ensureLayout() node.node.Layout.Offset = offset return node } +// Prop sets a named node property. func (node *NodeBuilder) Prop(name string, value any) *NodeBuilder { node.node.Props[name] = toValue(value) return node } +// On attaches an event handler to the node. func (node *NodeBuilder) On(name string, handler EventHandler) *NodeBuilder { node.node.Events[name] = handler return node } +// Children sets the node's child nodes. func (node *NodeBuilder) Children(children ...*NodeBuilder) *NodeBuilder { node.node.Children = buildNodes(children) return node } +// Slots sets the nodes in a named slot. func (node *NodeBuilder) Slots(name string, children ...*NodeBuilder) *NodeBuilder { if node.node.Slots == nil { node.node.Slots = map[string][]Node{} @@ -281,11 +313,13 @@ func (node *NodeBuilder) Slots(name string, children ...*NodeBuilder) *NodeBuild return node } +// When sets the conditional expression for the node. func (node *NodeBuilder) When(value Value) *NodeBuilder { node.node.When = &value return node } +// Build returns the current node value. func (node *NodeBuilder) Build() Node { return node.node } @@ -305,8 +339,10 @@ func buildNodes(nodes []*NodeBuilder) []Node { return result } +// SourceBuilder incrementally constructs a data source. type SourceBuilder struct{ source DataSource } +// Source adds a named data source to a view. func Source(name string, source *SourceBuilder) ViewOption { return func(schema *ViewSchema) { if schema.Sources == nil { @@ -317,6 +353,7 @@ func Source(name string, source *SourceBuilder) ViewOption { } } +// Tool creates a tool-backed data source builder. func Tool(name string) *SourceBuilder { return &SourceBuilder{ source: DataSource{ @@ -328,81 +365,94 @@ func Tool(name string) *SourceBuilder { } } +// Input adds an input value to the data source. func (source *SourceBuilder) Input(name string, value any) *SourceBuilder { source.source.Input[name] = toValue(value) return source } +// OnMount configures the source to load when the view mounts. func (source *SourceBuilder) OnMount() *SourceBuilder { source.source.Policy = SourceOnMount return source } +// TTL sets the source cache lifetime in seconds. func (source *SourceBuilder) TTL(value int64) *SourceBuilder { source.source.Cache = &CachePolicy{TTL: value} return source } +// ActionBuilder incrementally constructs an action. type ActionBuilder struct{ action Action } +// CallTool creates a tool-calling action builder. func CallTool(name string) *ActionBuilder { return &ActionBuilder{ action: Action{ - Type: "tool", + Type: actionTypeTool, Tool: name, Input: map[string]Value{}, }, } } +// SetState creates an action that replaces a state path. func SetState(path string, value any) *ActionBuilder { return &ActionBuilder{ action: Action{ - Type: "set-state", + Type: actionTypeSetState, Path: path, Value: ptrValue(value), }, } } +// MergeState creates an action that merges a value into a state path. func MergeState(path string, value any) *ActionBuilder { return &ActionBuilder{ action: Action{ - Type: "merge-state", + Type: actionTypeMergeState, Path: path, Value: ptrValue(value), }, } } +// Input adds an input value to the action. func (action *ActionBuilder) Input(name string, value any) *ActionBuilder { action.action.Input[name] = toValue(value) return action } +// Then appends effects to the action. func (action *ActionBuilder) Then(effects ...Effect) *ActionBuilder { action.action.Effects = append(action.action.Effects, effects...) return action } +// ActionRef creates an event handler that invokes a named action. func ActionRef(name string) EventHandler { return EventHandler{Action: name} } +// Invalidate creates an effect that invalidates a data source. func Invalidate(source string) Effect { return Effect{ - Type: "invalidate", + Type: actionTypeInvalidate, Source: source, } } +// RefreshSource creates an effect that refreshes a data source. func RefreshSource(source string) Effect { return Effect{ - Type: "refresh-source", + Type: actionTypeRefreshSource, Source: source, } } +// ToastSuccess creates a successful toast effect. func ToastSuccess(message string) Effect { return Effect{ Type: "toast", @@ -411,6 +461,7 @@ func ToastSuccess(message string) Effect { } } +// SetStateEffect creates an effect that replaces a state path. func SetStateEffect(path string, value any) Effect { return Effect{ Type: "set-state", diff --git a/examples/dashboard/dashboard.go b/examples/dashboard/dashboard.go index 351a2c1..fc65a0d 100644 --- a/examples/dashboard/dashboard.go +++ b/examples/dashboard/dashboard.go @@ -8,6 +8,17 @@ package dashboard import "go.osspkg.com/ui" +const ( + metricsCacheTTL = 30 + headerTitleCols = 9 + refreshButtonCols = 3 + refreshButtonPos = 9 + metricCardCols = 6 + latencyCardOffset = 6 + eventTableCols = 12 + eventTableRow = 2 +) + // NewApp builds a dashboard whose metrics source is loaded when the view opens. func NewApp() (*ui.App, error) { app := ui.New( @@ -18,22 +29,22 @@ func NewApp() (*ui.App, error) { view := ui.View( "metrics.dashboard", ui.Title("Metrics dashboard"), - ui.Source("metrics", ui.Tool("metrics.summary").OnMount().TTL(30)), + ui.Source("metrics", ui.Tool("metrics.summary").OnMount().TTL(metricsCacheTTL)), ui.TopHeader( - ui.HTML(ui.HTMLH1, "title").Row(1).Cols(9).Prop( + ui.HTML(ui.HTMLH1, "title").Row(1).Cols(headerTitleCols).Prop( "text", "Metrics dashboard", ), ui.Shadcn( ui.ShadcnButton, "refresh", - ).Row(1).Cols(3).Offset(9).Prop("text", "Refresh").On( + ).Row(1).Cols(refreshButtonCols).Offset(refreshButtonPos).Prop("text", "Refresh").On( "click", ui.ActionRef("metrics.refresh"), ), ), ui.Content( - ui.Shadcn(ui.ShadcnCard, "requests-card").Row(1).Cols(6).Children( + ui.Shadcn(ui.ShadcnCard, "requests-card").Row(1).Cols(metricCardCols).Children( ui.HTML(ui.HTMLH2, "requests-heading").Prop("text", "Requests"), ui.Shadcn(ui.ShadcnTypography, "requests-value").Prop( "text", @@ -43,14 +54,14 @@ func NewApp() (*ui.App, error) { ui.Shadcn( ui.ShadcnCard, "latency-card", - ).Row(1).Cols(6).Offset(6).Children( + ).Row(1).Cols(metricCardCols).Offset(latencyCardOffset).Children( ui.HTML(ui.HTMLH2, "latency-heading").Prop("text", "Latency"), ui.Shadcn(ui.ShadcnTypography, "latency-value").Prop( "text", ui.SourceRef("metrics.latency"), ), ), - ui.DataTable("recent-events").Row(2).Cols(12).Prop( + ui.DataTable("recent-events").Row(eventTableRow).Cols(eventTableCols).Prop( "rows", ui.SourceRef("metrics.events"), ).Prop("loading", ui.SourceRef("metrics.$loading")).Prop( diff --git a/examples/htmlform/htmlform.go b/examples/htmlform/htmlform.go index cb579a7..6ef6c26 100644 --- a/examples/htmlform/htmlform.go +++ b/examples/htmlform/htmlform.go @@ -8,6 +8,11 @@ package htmlform import "go.osspkg.com/ui" +const ( + profileCardCols = 8 + profileCardOffset = 2 +) + // NewApp builds a profile form with native HTML elements and a local action. func NewApp() (*ui.App, error) { app := ui.New( @@ -28,7 +33,7 @@ func NewApp() (*ui.App, error) { ui.HTML( ui.HTMLArticle, "profile-card", - ).Row(1).Cols(8).Offset(2).Children( + ).Row(1).Cols(profileCardCols).Offset(profileCardOffset).Children( ui.HTML(ui.HTMLH1, "title").Prop("text", "Edit profile"), ui.HTML(ui.HTMLP, "hint").Prop( "text", diff --git a/html.go b/html.go index 8652e3c..6366e59 100644 --- a/html.go +++ b/html.go @@ -9,10 +9,15 @@ package ui // and embedded-content elements are intentionally excluded. type HTMLTag string +// HTML constants identify safe HTML body elements supported by the protocol. const ( - HTMLA HTMLTag = "a" - HTMLAbbr HTMLTag = "abbr" - HTMLAddress HTMLTag = "address" + // HTMLA through HTMLWbr enumerate supported safe HTML body elements. + HTMLA HTMLTag = "a" + // HTMLAbbr is the safe HTML abbreviation element. + HTMLAbbr HTMLTag = "abbr" + // HTMLAddress is the safe HTML contact-information element. + HTMLAddress HTMLTag = "address" + // HTMLArea is the safe HTML area element. HTMLArea HTMLTag = "area" HTMLArticle HTMLTag = "article" HTMLAside HTMLTag = "aside" diff --git a/mcp/register.go b/mcp/register.go index fdfae66..e4894e0 100644 --- a/mcp/register.go +++ b/mcp/register.go @@ -16,6 +16,7 @@ import ( "go.osspkg.com/ui" ) +// Capabilities returns the MCP option advertising the UI capability. func Capabilities() ossmcp.Option { return ossmcp.WithCapabilities( map[string]any{ @@ -29,6 +30,7 @@ func Capabilities() ossmcp.Option { ) } +// Register publishes an app manifest and its view resources on server. func Register(server *ossmcp.Server, app *ui.App) error { if server == nil || app == nil { return errors.New("ui/mcp: server and app are required") diff --git a/schema.go b/schema.go index 48a6d12..b872cc9 100644 --- a/schema.go +++ b/schema.go @@ -15,17 +15,25 @@ import ( ) const ( + // ProtocolVersion is the supported declarative UI protocol version. ProtocolVersion = "1.0" - UIMIMEType = "application/vnd.osspkg.ui+json" + // UIMIMEType is the MIME type used for declarative UI resources. + UIMIMEType = "application/vnd.osspkg.ui+json" ) var ( + // ErrInvalidSchema reports an invalid manifest or view schema. ErrInvalidSchema = errors.New("invalid ui schema") + // ErrInvalidLayout reports an invalid node layout. ErrInvalidLayout = errors.New("invalid ui layout") - ErrInvalidValue = errors.New("invalid ui value") + // ErrInvalidValue reports an invalid declarative value. + ErrInvalidValue = errors.New("invalid ui value") ) +// Manifest describes the application entry points exposed to a host. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type Manifest struct { ProtocolVersion string `json:"protocolVersion"` Plugin PluginManifest `json:"plugin"` @@ -33,7 +41,10 @@ type Manifest struct { RequiredComponents []string `json:"requiredComponents,omitempty"` } +// PluginManifest identifies the plugin that owns the UI. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type PluginManifest struct { ID string `json:"id"` Title string `json:"title"` @@ -41,7 +52,10 @@ type PluginManifest struct { Description string `json:"description,omitempty"` } +// UIViewDescriptor identifies a view schema resource in a manifest. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type UIViewDescriptor struct { ID string `json:"id"` Title string `json:"title"` @@ -49,7 +63,10 @@ type UIViewDescriptor struct { Schema string `json:"schema"` } +// ViewSchema is the declarative layout, state, source, and action model for a view. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type ViewSchema struct { ProtocolVersion string `json:"protocolVersion"` ID string `json:"id"` @@ -61,7 +78,10 @@ type ViewSchema struct { Regions Regions `json:"regions"` } +// Regions groups view nodes into the host-supported layout regions. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type Regions struct { TopHeader []Node `json:"top-header"` LeftPanel []Node `json:"left-panel"` @@ -70,14 +90,20 @@ type Regions struct { Bottom []Node `json:"bottom"` } +// Layout places a node in a twelve-column row layout. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type Layout struct { Row int `json:"row"` Cols int `json:"cols,omitempty"` Offset int `json:"offset,omitempty"` } +// Node describes one declarative UI component and its descendants. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type Node struct { ID string `json:"id"` Component string `json:"component"` @@ -89,13 +115,19 @@ type Node struct { When *Value `json:"when,omitempty"` } +// EventHandler maps a UI event to an action or sequence of action steps. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type EventHandler struct { Action string `json:"action,omitempty"` Steps []ActionStep `json:"steps,omitempty"` } +// DataSource describes a host tool used to populate view data. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type DataSource struct { Type string `json:"type"` Tool string `json:"tool"` @@ -105,19 +137,28 @@ type DataSource struct { RefreshOn []string `json:"refreshOn,omitempty"` } +// SourcePolicy controls when a data source is fetched. type SourcePolicy string const ( - SourceManual SourcePolicy = "manual" + // SourceManual fetches the source only when explicitly requested. + SourceManual SourcePolicy = "manual" + // SourceOnMount fetches the source when the view is mounted. SourceOnMount SourcePolicy = "on-mount" ) +// CachePolicy controls how long fetched source data may be reused. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type CachePolicy struct { TTL int64 `json:"ttl,omitempty"` } +// Action describes a named operation available to the view. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type Action struct { Type string `json:"type"` Tool string `json:"tool,omitempty"` @@ -131,14 +172,20 @@ type Action struct { Effects []Effect `json:"effects,omitempty"` } +// ActionStep is one step in an event action sequence. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type ActionStep struct { Type string `json:"type"` Action string `json:"action,omitempty"` Input map[string]Value `json:"input,omitempty"` } +// Effect describes a client-side consequence of an action. +// //easyjson:json +//nolint:recvcheck // easyjson generates value marshal and pointer unmarshal methods. type Effect struct { Type string `json:"type"` Path string `json:"path,omitempty"` @@ -151,6 +198,7 @@ type Effect struct { Message string `json:"message,omitempty"` } +// Validate checks whether the manifest contains valid plugin and view entries. func (m Manifest) Validate() error { if m.ProtocolVersion != ProtocolVersion { return fmt.Errorf("%w: unsupported manifest protocol version %q", ErrInvalidSchema, m.ProtocolVersion) diff --git a/shadcn.go b/shadcn.go index 13d339c..14f6f43 100644 --- a/shadcn.go +++ b/shadcn.go @@ -9,10 +9,15 @@ package ui // catalog. Register a matching React implementation before rendering it. type ShadcnComponent string +// Shadcn constants identify host-owned shadcn/ui components. const ( - ShadcnAccordion ShadcnComponent = "accordion" - ShadcnAlert ShadcnComponent = "alert" - ShadcnAlertDialog ShadcnComponent = "alert-dialog" + // ShadcnAccordion through ShadcnTypography enumerate supported shadcn/ui components. + ShadcnAccordion ShadcnComponent = "accordion" + // ShadcnAlert identifies the alert component. + ShadcnAlert ShadcnComponent = "alert" + // ShadcnAlertDialog identifies the alert dialog component. + ShadcnAlertDialog ShadcnComponent = "alert-dialog" + // ShadcnAspectRatio identifies the aspect ratio component. ShadcnAspectRatio ShadcnComponent = "aspect-ratio" ShadcnAttachment ShadcnComponent = "attachment" ShadcnAvatar ShadcnComponent = "avatar" @@ -79,64 +84,185 @@ const ( // Shadcn creates a node for a component from the shadcn/ui catalog. func Shadcn(component ShadcnComponent, id string) *NodeBuilder { return node(string(component), id) } -func Accordion(id string) *NodeBuilder { return Shadcn(ShadcnAccordion, id) } -func Alert(id string) *NodeBuilder { return Shadcn(ShadcnAlert, id) } -func AlertDialog(id string) *NodeBuilder { return Shadcn(ShadcnAlertDialog, id) } -func AspectRatio(id string) *NodeBuilder { return Shadcn(ShadcnAspectRatio, id) } -func Attachment(id string) *NodeBuilder { return Shadcn(ShadcnAttachment, id) } -func Avatar(id string) *NodeBuilder { return Shadcn(ShadcnAvatar, id) } -func Badge(id string) *NodeBuilder { return Shadcn(ShadcnBadge, id) } -func Breadcrumb(id string) *NodeBuilder { return Shadcn(ShadcnBreadcrumb, id) } -func Bubble(id string) *NodeBuilder { return Shadcn(ShadcnBubble, id) } -func ButtonGroup(id string) *NodeBuilder { return Shadcn(ShadcnButtonGroup, id) } -func Calendar(id string) *NodeBuilder { return Shadcn(ShadcnCalendar, id) } -func Carousel(id string) *NodeBuilder { return Shadcn(ShadcnCarousel, id) } -func Chart(id string) *NodeBuilder { return Shadcn(ShadcnChart, id) } -func Checkbox(id string) *NodeBuilder { return Shadcn(ShadcnCheckbox, id) } -func Collapsible(id string) *NodeBuilder { return Shadcn(ShadcnCollapsible, id) } -func Combobox(id string) *NodeBuilder { return Shadcn(ShadcnCombobox, id) } -func Command(id string) *NodeBuilder { return Shadcn(ShadcnCommand, id) } -func ContextMenu(id string) *NodeBuilder { return Shadcn(ShadcnContextMenu, id) } -func DataTable(id string) *NodeBuilder { return Shadcn(ShadcnDataTable, id) } -func DatePicker(id string) *NodeBuilder { return Shadcn(ShadcnDatePicker, id) } -func Dialog(id string) *NodeBuilder { return Shadcn(ShadcnDialog, id) } -func Direction(id string) *NodeBuilder { return Shadcn(ShadcnDirection, id) } -func Drawer(id string) *NodeBuilder { return Shadcn(ShadcnDrawer, id) } -func DropdownMenu(id string) *NodeBuilder { return Shadcn(ShadcnDropdownMenu, id) } -func Empty(id string) *NodeBuilder { return Shadcn(ShadcnEmpty, id) } -func Field(id string) *NodeBuilder { return Shadcn(ShadcnField, id) } -func HoverCard(id string) *NodeBuilder { return Shadcn(ShadcnHoverCard, id) } -func Input(id string) *NodeBuilder { return Shadcn(ShadcnInput, id) } -func InputGroup(id string) *NodeBuilder { return Shadcn(ShadcnInputGroup, id) } -func InputOTP(id string) *NodeBuilder { return Shadcn(ShadcnInputOTP, id) } -func Item(id string) *NodeBuilder { return Shadcn(ShadcnItem, id) } -func Kbd(id string) *NodeBuilder { return Shadcn(ShadcnKbd, id) } -func Label(id string) *NodeBuilder { return Shadcn(ShadcnLabel, id) } -func Marker(id string) *NodeBuilder { return Shadcn(ShadcnMarker, id) } -func Menubar(id string) *NodeBuilder { return Shadcn(ShadcnMenubar, id) } -func Message(id string) *NodeBuilder { return Shadcn(ShadcnMessage, id) } +// Accordion creates an accordion node. +func Accordion(id string) *NodeBuilder { return Shadcn(ShadcnAccordion, id) } + +// Alert creates an alert node. +func Alert(id string) *NodeBuilder { return Shadcn(ShadcnAlert, id) } + +// AlertDialog creates an alert dialog node. +func AlertDialog(id string) *NodeBuilder { return Shadcn(ShadcnAlertDialog, id) } + +// AspectRatio creates an aspect ratio node. +func AspectRatio(id string) *NodeBuilder { return Shadcn(ShadcnAspectRatio, id) } + +// Attachment creates an attachment node. +func Attachment(id string) *NodeBuilder { return Shadcn(ShadcnAttachment, id) } + +// Avatar creates an avatar node. +func Avatar(id string) *NodeBuilder { return Shadcn(ShadcnAvatar, id) } + +// Badge creates a badge node. +func Badge(id string) *NodeBuilder { return Shadcn(ShadcnBadge, id) } + +// Breadcrumb creates a breadcrumb node. +func Breadcrumb(id string) *NodeBuilder { return Shadcn(ShadcnBreadcrumb, id) } + +// Bubble creates a bubble node. +func Bubble(id string) *NodeBuilder { return Shadcn(ShadcnBubble, id) } + +// ButtonGroup creates a button group node. +func ButtonGroup(id string) *NodeBuilder { return Shadcn(ShadcnButtonGroup, id) } + +// Calendar creates a calendar node. +func Calendar(id string) *NodeBuilder { return Shadcn(ShadcnCalendar, id) } + +// Carousel creates a carousel node. +func Carousel(id string) *NodeBuilder { return Shadcn(ShadcnCarousel, id) } + +// Chart creates a chart node. +func Chart(id string) *NodeBuilder { return Shadcn(ShadcnChart, id) } + +// Checkbox creates a checkbox node. +func Checkbox(id string) *NodeBuilder { return Shadcn(ShadcnCheckbox, id) } + +// Collapsible creates a collapsible node. +func Collapsible(id string) *NodeBuilder { return Shadcn(ShadcnCollapsible, id) } + +// Combobox creates a combobox node. +func Combobox(id string) *NodeBuilder { return Shadcn(ShadcnCombobox, id) } + +// Command creates a command node. +func Command(id string) *NodeBuilder { return Shadcn(ShadcnCommand, id) } + +// ContextMenu creates a context menu node. +func ContextMenu(id string) *NodeBuilder { return Shadcn(ShadcnContextMenu, id) } + +// DataTable creates a data table node. +func DataTable(id string) *NodeBuilder { return Shadcn(ShadcnDataTable, id) } + +// DatePicker creates a date picker node. +func DatePicker(id string) *NodeBuilder { return Shadcn(ShadcnDatePicker, id) } + +// Dialog creates a dialog node. +func Dialog(id string) *NodeBuilder { return Shadcn(ShadcnDialog, id) } + +// Direction creates a direction node. +func Direction(id string) *NodeBuilder { return Shadcn(ShadcnDirection, id) } + +// Drawer creates a drawer node. +func Drawer(id string) *NodeBuilder { return Shadcn(ShadcnDrawer, id) } + +// DropdownMenu creates a dropdown menu node. +func DropdownMenu(id string) *NodeBuilder { return Shadcn(ShadcnDropdownMenu, id) } + +// Empty creates an empty-state node. +func Empty(id string) *NodeBuilder { return Shadcn(ShadcnEmpty, id) } + +// Field creates a field node. +func Field(id string) *NodeBuilder { return Shadcn(ShadcnField, id) } + +// HoverCard creates a hover card node. +func HoverCard(id string) *NodeBuilder { return Shadcn(ShadcnHoverCard, id) } + +// Input creates an input node. +func Input(id string) *NodeBuilder { return Shadcn(ShadcnInput, id) } + +// InputGroup creates an input group node. +func InputGroup(id string) *NodeBuilder { return Shadcn(ShadcnInputGroup, id) } + +// InputOTP creates an input OTP node. +func InputOTP(id string) *NodeBuilder { return Shadcn(ShadcnInputOTP, id) } + +// Item creates an item node. +func Item(id string) *NodeBuilder { return Shadcn(ShadcnItem, id) } + +// Kbd creates a keyboard shortcut node. +func Kbd(id string) *NodeBuilder { return Shadcn(ShadcnKbd, id) } + +// Label creates a label node. +func Label(id string) *NodeBuilder { return Shadcn(ShadcnLabel, id) } + +// Marker creates a marker node. +func Marker(id string) *NodeBuilder { return Shadcn(ShadcnMarker, id) } + +// Menubar creates a menubar node. +func Menubar(id string) *NodeBuilder { return Shadcn(ShadcnMenubar, id) } + +// Message creates a message node. +func Message(id string) *NodeBuilder { return Shadcn(ShadcnMessage, id) } + +// MessageScroller creates a message scroller node. func MessageScroller(id string) *NodeBuilder { return Shadcn(ShadcnMessageScroller, id) } -func NativeSelect(id string) *NodeBuilder { return Shadcn(ShadcnNativeSelect, id) } -func NavigationMenu(id string) *NodeBuilder { return Shadcn(ShadcnNavigationMenu, id) } -func Pagination(id string) *NodeBuilder { return Shadcn(ShadcnPagination, id) } -func Popover(id string) *NodeBuilder { return Shadcn(ShadcnPopover, id) } -func Progress(id string) *NodeBuilder { return Shadcn(ShadcnProgress, id) } -func Questionnaire(id string) *NodeBuilder { return Shadcn(ShadcnQuestionnaire, id) } -func RadioGroup(id string) *NodeBuilder { return Shadcn(ShadcnRadioGroup, id) } -func Resizable(id string) *NodeBuilder { return Shadcn(ShadcnResizable, id) } -func ScrollArea(id string) *NodeBuilder { return Shadcn(ShadcnScrollArea, id) } -func Select(id string) *NodeBuilder { return Shadcn(ShadcnSelect, id) } -func Separator(id string) *NodeBuilder { return Shadcn(ShadcnSeparator, id) } -func Sheet(id string) *NodeBuilder { return Shadcn(ShadcnSheet, id) } -func Sidebar(id string) *NodeBuilder { return Shadcn(ShadcnSidebar, id) } -func Skeleton(id string) *NodeBuilder { return Shadcn(ShadcnSkeleton, id) } -func Slider(id string) *NodeBuilder { return Shadcn(ShadcnSlider, id) } -func Spinner(id string) *NodeBuilder { return Shadcn(ShadcnSpinner, id) } -func Switch(id string) *NodeBuilder { return Shadcn(ShadcnSwitch, id) } -func Tabs(id string) *NodeBuilder { return Shadcn(ShadcnTabs, id) } -func Textarea(id string) *NodeBuilder { return Shadcn(ShadcnTextarea, id) } -func Toast(id string) *NodeBuilder { return Shadcn(ShadcnToast, id) } -func Toggle(id string) *NodeBuilder { return Shadcn(ShadcnToggle, id) } -func ToggleGroup(id string) *NodeBuilder { return Shadcn(ShadcnToggleGroup, id) } -func Tooltip(id string) *NodeBuilder { return Shadcn(ShadcnTooltip, id) } -func Typography(id string) *NodeBuilder { return Shadcn(ShadcnTypography, id) } + +// NativeSelect creates a native select node. +func NativeSelect(id string) *NodeBuilder { return Shadcn(ShadcnNativeSelect, id) } + +// NavigationMenu creates a navigation menu node. +func NavigationMenu(id string) *NodeBuilder { return Shadcn(ShadcnNavigationMenu, id) } + +// Pagination creates a pagination node. +func Pagination(id string) *NodeBuilder { return Shadcn(ShadcnPagination, id) } + +// Popover creates a popover node. +func Popover(id string) *NodeBuilder { return Shadcn(ShadcnPopover, id) } + +// Progress creates a progress node. +func Progress(id string) *NodeBuilder { return Shadcn(ShadcnProgress, id) } + +// Questionnaire creates a questionnaire node. +func Questionnaire(id string) *NodeBuilder { return Shadcn(ShadcnQuestionnaire, id) } + +// RadioGroup creates a radio group node. +func RadioGroup(id string) *NodeBuilder { return Shadcn(ShadcnRadioGroup, id) } + +// Resizable creates a resizable node. +func Resizable(id string) *NodeBuilder { return Shadcn(ShadcnResizable, id) } + +// ScrollArea creates a scroll area node. +func ScrollArea(id string) *NodeBuilder { return Shadcn(ShadcnScrollArea, id) } + +// Select creates a select node. +func Select(id string) *NodeBuilder { return Shadcn(ShadcnSelect, id) } + +// Separator creates a separator node. +func Separator(id string) *NodeBuilder { return Shadcn(ShadcnSeparator, id) } + +// Sheet creates a sheet node. +func Sheet(id string) *NodeBuilder { return Shadcn(ShadcnSheet, id) } + +// Sidebar creates a sidebar node. +func Sidebar(id string) *NodeBuilder { return Shadcn(ShadcnSidebar, id) } + +// Skeleton creates a skeleton node. +func Skeleton(id string) *NodeBuilder { return Shadcn(ShadcnSkeleton, id) } + +// Slider creates a slider node. +func Slider(id string) *NodeBuilder { return Shadcn(ShadcnSlider, id) } + +// Spinner creates a spinner node. +func Spinner(id string) *NodeBuilder { return Shadcn(ShadcnSpinner, id) } + +// Switch creates a switch node. +func Switch(id string) *NodeBuilder { return Shadcn(ShadcnSwitch, id) } + +// Tabs creates a tabs node. +func Tabs(id string) *NodeBuilder { return Shadcn(ShadcnTabs, id) } + +// Textarea creates a textarea node. +func Textarea(id string) *NodeBuilder { return Shadcn(ShadcnTextarea, id) } + +// Toast creates a toast node. +func Toast(id string) *NodeBuilder { return Shadcn(ShadcnToast, id) } + +// Toggle creates a toggle node. +func Toggle(id string) *NodeBuilder { return Shadcn(ShadcnToggle, id) } + +// ToggleGroup creates a toggle group node. +func ToggleGroup(id string) *NodeBuilder { return Shadcn(ShadcnToggleGroup, id) } + +// Tooltip creates a tooltip node. +func Tooltip(id string) *NodeBuilder { return Shadcn(ShadcnTooltip, id) } + +// Typography creates a typography node. +func Typography(id string) *NodeBuilder { return Shadcn(ShadcnTypography, id) } diff --git a/validation.go b/validation.go index f384596..b5aaf73 100644 --- a/validation.go +++ b/validation.go @@ -13,6 +13,26 @@ import ( "strings" ) +const ( + defaultMaxBytes = 1 << 20 + defaultMaxNodes = 1000 + defaultMaxDepth = 32 + defaultMaxPropsPerNode = 64 + defaultMaxActions = 128 + defaultMaxSources = 64 + defaultMaxStrings = 10000 + defaultMaxStringLength = 16 << 10 + defaultMaxExpressionDepth = 32 + + actionTypeTool = "tool" + actionTypeSetState = "set-state" + actionTypeMergeState = "merge-state" + actionTypeRefreshSource = "refresh-source" + actionTypeInvalidate = "invalidate" + actionStepTypeAction = "action" +) + +// Limits bounds the size and complexity of a view schema. type Limits struct { MaxBytes int MaxNodes int @@ -25,24 +45,27 @@ type Limits struct { MaxExpressionDepth int } +// DefaultLimits returns the default schema validation limits. func DefaultLimits() Limits { return Limits{ - MaxBytes: 1 << 20, - MaxNodes: 1000, - MaxDepth: 32, - MaxPropsPerNode: 64, - MaxActions: 128, - MaxSources: 64, - MaxStrings: 10000, - MaxStringLength: 16 << 10, - MaxExpressionDepth: 32, + MaxBytes: defaultMaxBytes, + MaxNodes: defaultMaxNodes, + MaxDepth: defaultMaxDepth, + MaxPropsPerNode: defaultMaxPropsPerNode, + MaxActions: defaultMaxActions, + MaxSources: defaultMaxSources, + MaxStrings: defaultMaxStrings, + MaxStringLength: defaultMaxStringLength, + MaxExpressionDepth: defaultMaxExpressionDepth, } } +// Validate checks the schema against the default validation limits. func (v ViewSchema) Validate() error { return v.ValidateWithLimits(DefaultLimits()) } +// ValidateWithLimits checks the schema against caller-provided limits. func (v ViewSchema) ValidateWithLimits(limits Limits) error { limits = withDefaultLimits(limits) @@ -71,7 +94,7 @@ func (v ViewSchema) ValidateWithLimits(limits Limits) error { } for name, source := range v.Sources { - if !validName(name) || source.Type != "tool" || !validName(source.Tool) { + if !validName(name) || source.Type != actionTypeTool || !validName(source.Tool) { return fmt.Errorf("%w: invalid source %q", ErrInvalidSchema, name) } @@ -104,8 +127,11 @@ func (v ViewSchema) ValidateWithLimits(limits Limits) error { } } - seen := make(map[string]struct{}) - nodes := 0 + context := validationContext{ + limits: limits, + view: v, + seen: make(map[string]struct{}), + } for region, list := range map[string][]Node{ "top-header": v.Regions.TopHeader, @@ -115,7 +141,7 @@ func (v ViewSchema) ValidateWithLimits(limits Limits) error { "bottom": v.Regions.Bottom, } { for _, n := range list { - if err := validateNode(n, region, 1, &nodes, seen, limits, v); err != nil { + if err := context.validateNode(n, region, 1); err != nil { return err } } @@ -232,17 +258,16 @@ func walkLimitValue(value any, depth int, limits Limits, count *int) error { return nil } -func validateNode( - node Node, - region string, - depth int, - nodes *int, - seen map[string]struct{}, - limits Limits, - view ViewSchema, -) error { //nolint:revive - *nodes++ - if *nodes > limits.MaxNodes || depth > limits.MaxDepth { +type validationContext struct { + limits Limits + view ViewSchema + seen map[string]struct{} + nodes int +} + +func (context *validationContext) validateNode(node Node, region string, depth int) error { + context.nodes++ + if context.nodes > context.limits.MaxNodes || depth > context.limits.MaxDepth { return fmt.Errorf("%w: node limit exceeded", ErrInvalidSchema) } @@ -250,22 +275,22 @@ func validateNode( return fmt.Errorf("%w: node id and component are required", ErrInvalidSchema) } - if _, exists := seen[node.ID]; exists { + if _, exists := context.seen[node.ID]; exists { return fmt.Errorf("%w: duplicate node id %q", ErrInvalidSchema, node.ID) } - seen[node.ID] = struct{}{} + context.seen[node.ID] = struct{}{} if node.Layout != nil { if err := node.Layout.Validate(region); err != nil { return err } } - if len(node.Props) > limits.MaxPropsPerNode { + if len(node.Props) > context.limits.MaxPropsPerNode { return fmt.Errorf("%w: too many props on node %q", ErrInvalidSchema, node.ID) } - if err := validateValues(node.Props, limits, view); err != nil { + if err := validateValues(node.Props, context.limits, context.view); err != nil { return fmt.Errorf("%w: node %q props: %w", ErrInvalidSchema, node.ID, err) } @@ -275,41 +300,41 @@ func validateNode( } if handler.Action != "" { - if _, ok := view.Actions[handler.Action]; !ok { + if _, ok := context.view.Actions[handler.Action]; !ok { return fmt.Errorf("%w: action %q is not defined", ErrInvalidSchema, handler.Action) } } for _, step := range handler.Steps { - if step.Type != "action" || step.Action == "" { + if step.Type != actionStepTypeAction || step.Action == "" { return fmt.Errorf("%w: invalid event step on node %q", ErrInvalidSchema, node.ID) } - if _, ok := view.Actions[step.Action]; !ok { + if _, ok := context.view.Actions[step.Action]; !ok { return fmt.Errorf("%w: action %q is not defined", ErrInvalidSchema, step.Action) } - if err := validateValues(step.Input, limits, view); err != nil { + if err := validateValues(step.Input, context.limits, context.view); err != nil { return fmt.Errorf("%w: event step input: %w", ErrInvalidSchema, err) } } } if node.When != nil { - if err := validateValue(*node.When, limits, view, 0); err != nil { + if err := validateValue(*node.When, context.limits, context.view, 0); err != nil { return fmt.Errorf("%w: node %q condition: %w", ErrInvalidSchema, node.ID, err) } } for _, child := range node.Children { - if err := validateNode(child, region, depth+1, nodes, seen, limits, view); err != nil { + if err := context.validateNode(child, region, depth+1); err != nil { return err } } for _, children := range node.Slots { for _, child := range children { - if err := validateNode(child, region, depth+1, nodes, seen, limits, view); err != nil { + if err := context.validateNode(child, region, depth+1); err != nil { return err } } @@ -318,6 +343,7 @@ func validateNode( return nil } +// Validate checks whether the layout fits the specified region. func (l Layout) Validate(region string) error { if l.Row < 1 { return fmt.Errorf("%w: row must be positive", ErrInvalidLayout) @@ -352,17 +378,17 @@ func validateValues(values map[string]Value, limits Limits, view ViewSchema) err } func validateAction(action Action, limits Limits, view ViewSchema) error { - if action.Type == "tool" && !validName(action.Tool) { + if action.Type == actionTypeTool && !validName(action.Tool) { return errors.New("tool action requires a tool") } - if action.Type == "set-state" || action.Type == "merge-state" { + if action.Type == actionTypeSetState || action.Type == actionTypeMergeState { if !safePath(action.Path) { return errors.New("state action requires a safe path") } } - if action.Type == "refresh-source" || action.Type == "invalidate" { + if action.Type == actionTypeRefreshSource || action.Type == actionTypeInvalidate { if _, ok := view.Sources[action.Source]; !ok { return fmt.Errorf("source %q is not defined", action.Source) } @@ -404,13 +430,13 @@ func validateEffect(effect Effect, limits Limits, view ViewSchema) error { return errors.New("unsafe effect path") } - if effect.Type == "set-state" || effect.Type == "merge-state" { + if effect.Type == actionTypeSetState || effect.Type == actionTypeMergeState { if !safePath(effect.Path) { return errors.New("state effect requires a safe path") } } - if effect.Type == "invalidate" || effect.Type == "refresh-source" { + if effect.Type == actionTypeInvalidate || effect.Type == actionTypeRefreshSource { if _, ok := view.Sources[effect.Source]; !ok { return fmt.Errorf("source %q is not defined", effect.Source) } @@ -513,7 +539,7 @@ func sourceForPath(path string, sources map[string]DataSource) (string, bool) { func validActionType(value string) bool { switch value { - case "tool", "set-state", "merge-state", "refresh-source", "invalidate": + case actionTypeTool, actionTypeSetState, actionTypeMergeState, actionTypeRefreshSource, actionTypeInvalidate: return true default: return false @@ -522,7 +548,7 @@ func validActionType(value string) bool { func validEffectType(value string) bool { switch value { - case "set-state", "merge-state", "invalidate", "refresh-source", + case actionTypeSetState, actionTypeMergeState, actionTypeInvalidate, actionTypeRefreshSource, "refresh-view", "patch-view", "navigate", "toast", "dialog", "close-dialog": return true diff --git a/value.go b/value.go index 7725c8d..fe6c45e 100644 --- a/value.go +++ b/value.go @@ -12,18 +12,27 @@ import ( "strings" ) +// ValueKind identifies how a Value obtains its data. type ValueKind string +// Value kind constants identify the supported value representations. const ( + // ValueLiteral stores data directly in the value. ValueLiteral ValueKind = "literal" - ValueState ValueKind = "state" + // ValueState references view state. + ValueState ValueKind = "state" + // ValueContext references host context. ValueContext ValueKind = "context" - ValueSource ValueKind = "source" - ValueEvent ValueKind = "event" - ValueResult ValueKind = "result" - ValueExpr ValueKind = "expression" + // ValueSource references source data. + ValueSource ValueKind = "source" + ValueEvent ValueKind = "event" + ValueResult ValueKind = "result" + ValueExpr ValueKind = "expression" ) +// Value is a literal, reference, or expression used by the declarative UI model. +// +//nolint:recvcheck // JSON values intentionally marshal by value and unmarshal by pointer. type Value struct { Kind ValueKind Path string @@ -47,34 +56,42 @@ var expressionArity = map[string][2]int{ "$if": {3, 3}, } +// Literal creates a value containing data directly. func Literal(data any) Value { return Value{Kind: ValueLiteral, Data: data} } +// StateRef creates a value referencing view state at path. func StateRef(path string) Value { return Value{Kind: ValueState, Path: path} } +// ContextRef creates a value referencing host context at path. func ContextRef(path string) Value { return Value{Kind: ValueContext, Path: path} } +// SourceRef creates a value referencing source data at path. func SourceRef(path string) Value { return Value{Kind: ValueSource, Path: path} } +// EventRef creates a value referencing the current event at path. func EventRef(path string) Value { return Value{Kind: ValueEvent, Path: path} } +// ResultRef creates a value referencing the previous action result at path. func ResultRef(path string) Value { return Value{Kind: ValueResult, Path: path} } +// Expr creates a value representing an expression operator and its arguments. func Expr(operator string, args ...Value) Value { return Value{Kind: ValueExpr, Path: operator, Data: append([]Value(nil), args...)} } +// Validate checks that the value kind, path, and expression arguments are valid. func (v Value) Validate() error { switch v.Kind { case ValueLiteral: @@ -119,6 +136,7 @@ func (v Value) Validate() error { } } +// MarshalJSON encodes the value in the declarative UI wire format. func (v Value) MarshalJSON() ([]byte, error) { if err := v.Validate(); err != nil { return nil, err @@ -140,42 +158,68 @@ func (v Value) MarshalJSON() ([]byte, error) { return json.Marshal(map[string]string{"$" + string(v.Kind): v.Path}) } +func (v *Value) unmarshalSpecial(data []byte) (bool, error) { + var object map[string]json.RawMessage + if err := json.Unmarshal(data, &object); err != nil { + return false, fmt.Errorf("%w: %s", ErrInvalidValue, err.Error()) + } + + if len(object) != 1 { + return false, fmt.Errorf("%w: expected 1 object, got %d", ErrInvalidValue, len(object)) + } + + for key, raw := range object { + return v.unmarshalSpecialEntry(key, raw) + } + + return false, nil +} + +func (v *Value) unmarshalSpecialEntry(key string, raw json.RawMessage) (bool, error) { + switch key { + case "$state", "$context", "$source", "$event", "$result": + var path string + if err := json.Unmarshal(raw, &path); err != nil { + return true, fmt.Errorf("%w: reference path: %w", ErrInvalidValue, err) + } + + v.Kind = ValueKind(key[1:]) + v.Path = path + v.Data = nil + return true, v.Validate() + } + + if len(key) == 0 || key[0] != '$' { + return false, fmt.Errorf("%w: reference path %q", ErrInvalidValue, key) + } + + var args []Value + if err := json.Unmarshal(raw, &args); err != nil { + return false, fmt.Errorf("%w: reference arguments have invalid type", ErrInvalidValue) + } + + v.Kind = ValueExpr + v.Path = key + v.Data = args + + return true, v.Validate() +} + +// UnmarshalJSON decodes a literal, reference, or expression value. func (v *Value) UnmarshalJSON(data []byte) error { if v == nil { return fmt.Errorf("%w: nil destination", ErrInvalidValue) } - var object map[string]json.RawMessage - if err := json.Unmarshal(data, &object); err == nil && object != nil { - if len(object) == 1 { - for key, raw := range object { - if key == "$state" || key == "$context" || key == "$source" || key == "$event" || key == "$result" { - var path string - if err := json.Unmarshal(raw, &path); err != nil { - return fmt.Errorf("%w: reference path: %w", ErrInvalidValue, err) - } - - v.Kind = ValueKind(key[1:]) - v.Path = path - v.Data = nil - return v.Validate() - } - - var args []Value - if len(key) > 0 && key[0] == '$' && json.Unmarshal(raw, &args) == nil { - v.Kind = ValueExpr - v.Path = key - v.Data = args - return v.Validate() - } - } - } + handled, err := v.unmarshalSpecial(data) + if handled { + return err } var literal any decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() - if err := decoder.Decode(&literal); err != nil { + if err = decoder.Decode(&literal); err != nil { return fmt.Errorf("%w: literal: %w", ErrInvalidValue, err) } From e6e5be7d91b1e6eb9be9e1d127e6a33ec8fe3a7e Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Wed, 23 Sep 2026 23:34:22 +0300 Subject: [PATCH 06/26] change --- .gitignore | 3 +-- Makefile | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) create mode 100755 Makefile diff --git a/.gitignore b/.gitignore index 62936fa..ba0552b 100755 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,4 @@ coverage.out *.mmdb *.test *.out -.env -Makefile \ No newline at end of file +.env \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..c04cf9f --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ + +SHELL=/bin/bash + + +.PHONY: install +install: + go install go.osspkg.com/goppy/v3/cmd/goppy@latest + goppy setup-lib + +.PHONY: lint +lint: + goppy lint + +.PHONY: license +license: + goppy license + +.PHONY: build +build: + goppy build --arch=amd64 + +.PHONY: tests +tests: + goppy test + +.PHONY: pre-commit +pre-commit: install license lint tests build + +.PHONY: ci +ci: pre-commit + From 4de5e7b7919ff5694cc3fa16c5cbbec69dec5466 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Thu, 24 Sep 2026 01:06:15 +0300 Subject: [PATCH 07/26] change --- .github/dependabot.yml | 7 + .github/workflows/ci.yml | 27 + .gitignore | 15 +- .../go}/dashboard/dashboard.go | 0 .../go}/dashboard/dashboard_test.go | 0 {examples => example/go}/htmlform/htmlform.go | 0 .../go}/htmlform/htmlform_test.go | 0 example/react/README.md | 37 + example/react/basic.tsx | 34 + example/react/dashboard.tsx | 34 + example/react/html-form.tsx | 52 + example/react/index.html | 12 + example/react/main.tsx | 47 + example/react/package.json | 23 + example/react/preview.css | 16 + example/react/preview.css.d.ts | 3 + example/react/tsconfig.json | 16 + example/react/vite.config.ts | 15 + {mcp => go-sdk/mcp}/register.go | 0 {mcp => go-sdk/mcp}/register_test.go | 0 builder.go => go-sdk/ui/builder.go | 0 builder_test.go => go-sdk/ui/builder_test.go | 0 html.go => go-sdk/ui/html.go | 0 html_test.go => go-sdk/ui/html_test.go | 0 schema.go => go-sdk/ui/schema.go | 0 .../ui/schema_easyjson.go | 0 schema_test.go => go-sdk/ui/schema_test.go | 0 .../ui/security_test.go | 0 shadcn.go => go-sdk/ui/shadcn.go | 0 shadcn_test.go => go-sdk/ui/shadcn_test.go | 0 validation.go => go-sdk/ui/validation.go | 0 value.go => go-sdk/ui/value.go | 0 package.json | 15 + pnpm-lock.yaml | 1722 +++++++++++++++++ pnpm-workspace.yaml | 7 + protocol/error-codes.json | 16 + react-sdk/ui-core/package.json | 14 + react-sdk/ui-core/src/core.test.ts | 156 ++ react-sdk/ui-core/src/expression.ts | 85 + react-sdk/ui-core/src/index.ts | 6 + react-sdk/ui-core/src/path.ts | 45 + react-sdk/ui-core/src/request.ts | 16 + react-sdk/ui-core/src/runtime.ts | 29 + react-sdk/ui-core/src/schema.ts | 125 ++ react-sdk/ui-core/src/validate.ts | 274 +++ react-sdk/ui-core/tsconfig.json | 5 + react-sdk/ui-react/package.json | 41 + .../ui-react/scripts/copy-styles-types.mjs | 6 + react-sdk/ui-react/src/defaults.test.tsx | 68 + react-sdk/ui-react/src/defaults.tsx | 154 ++ react-sdk/ui-react/src/demo.test.tsx | 12 + react-sdk/ui-react/src/demo.tsx | 32 + react-sdk/ui-react/src/html.test.tsx | 20 + react-sdk/ui-react/src/html.ts | 28 + react-sdk/ui-react/src/index.ts | 6 + react-sdk/ui-react/src/registry.test.tsx | 18 + react-sdk/ui-react/src/registry.ts | 26 + react-sdk/ui-react/src/renderer.test.tsx | 119 ++ react-sdk/ui-react/src/renderer.tsx | 111 ++ react-sdk/ui-react/src/runtime.test.tsx | 163 ++ react-sdk/ui-react/src/runtime.tsx | 226 +++ react-sdk/ui-react/src/shadcn.test.tsx | 18 + react-sdk/ui-react/src/shadcn.ts | 30 + react-sdk/ui-react/src/styles.css | 17 + react-sdk/ui-react/src/styles.css.d.ts | 3 + react-sdk/ui-react/tsconfig.json | 5 + react-sdk/ui-transport/package.json | 15 + react-sdk/ui-transport/src/http.ts | 47 + react-sdk/ui-transport/src/index.ts | 3 + react-sdk/ui-transport/src/rpc.ts | 60 + react-sdk/ui-transport/src/transport.test.ts | 51 + react-sdk/ui-transport/src/websocket.test.ts | 89 + react-sdk/ui-transport/src/websocket.ts | 171 ++ react-sdk/ui-transport/tsconfig.json | 5 + tsconfig.base.json | 15 + vitest.config.ts | 10 + 76 files changed, 4420 insertions(+), 2 deletions(-) create mode 100755 .github/dependabot.yml create mode 100755 .github/workflows/ci.yml rename {examples => example/go}/dashboard/dashboard.go (100%) rename {examples => example/go}/dashboard/dashboard_test.go (100%) rename {examples => example/go}/htmlform/htmlform.go (100%) rename {examples => example/go}/htmlform/htmlform_test.go (100%) create mode 100644 example/react/README.md create mode 100644 example/react/basic.tsx create mode 100644 example/react/dashboard.tsx create mode 100644 example/react/html-form.tsx create mode 100644 example/react/index.html create mode 100644 example/react/main.tsx create mode 100644 example/react/package.json create mode 100644 example/react/preview.css create mode 100644 example/react/preview.css.d.ts create mode 100644 example/react/tsconfig.json create mode 100644 example/react/vite.config.ts rename {mcp => go-sdk/mcp}/register.go (100%) rename {mcp => go-sdk/mcp}/register_test.go (100%) rename builder.go => go-sdk/ui/builder.go (100%) rename builder_test.go => go-sdk/ui/builder_test.go (100%) rename html.go => go-sdk/ui/html.go (100%) rename html_test.go => go-sdk/ui/html_test.go (100%) rename schema.go => go-sdk/ui/schema.go (100%) rename schema_easyjson.go => go-sdk/ui/schema_easyjson.go (100%) rename schema_test.go => go-sdk/ui/schema_test.go (100%) rename security_test.go => go-sdk/ui/security_test.go (100%) rename shadcn.go => go-sdk/ui/shadcn.go (100%) rename shadcn_test.go => go-sdk/ui/shadcn_test.go (100%) rename validation.go => go-sdk/ui/validation.go (100%) rename value.go => go-sdk/ui/value.go (100%) create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 protocol/error-codes.json create mode 100644 react-sdk/ui-core/package.json create mode 100644 react-sdk/ui-core/src/core.test.ts create mode 100644 react-sdk/ui-core/src/expression.ts create mode 100644 react-sdk/ui-core/src/index.ts create mode 100644 react-sdk/ui-core/src/path.ts create mode 100644 react-sdk/ui-core/src/request.ts create mode 100644 react-sdk/ui-core/src/runtime.ts create mode 100644 react-sdk/ui-core/src/schema.ts create mode 100644 react-sdk/ui-core/src/validate.ts create mode 100644 react-sdk/ui-core/tsconfig.json create mode 100644 react-sdk/ui-react/package.json create mode 100644 react-sdk/ui-react/scripts/copy-styles-types.mjs create mode 100644 react-sdk/ui-react/src/defaults.test.tsx create mode 100644 react-sdk/ui-react/src/defaults.tsx create mode 100644 react-sdk/ui-react/src/demo.test.tsx create mode 100644 react-sdk/ui-react/src/demo.tsx create mode 100644 react-sdk/ui-react/src/html.test.tsx create mode 100644 react-sdk/ui-react/src/html.ts create mode 100644 react-sdk/ui-react/src/index.ts create mode 100644 react-sdk/ui-react/src/registry.test.tsx create mode 100644 react-sdk/ui-react/src/registry.ts create mode 100644 react-sdk/ui-react/src/renderer.test.tsx create mode 100644 react-sdk/ui-react/src/renderer.tsx create mode 100644 react-sdk/ui-react/src/runtime.test.tsx create mode 100644 react-sdk/ui-react/src/runtime.tsx create mode 100644 react-sdk/ui-react/src/shadcn.test.tsx create mode 100644 react-sdk/ui-react/src/shadcn.ts create mode 100644 react-sdk/ui-react/src/styles.css create mode 100644 react-sdk/ui-react/src/styles.css.d.ts create mode 100644 react-sdk/ui-react/tsconfig.json create mode 100644 react-sdk/ui-transport/package.json create mode 100644 react-sdk/ui-transport/src/http.ts create mode 100644 react-sdk/ui-transport/src/index.ts create mode 100644 react-sdk/ui-transport/src/rpc.ts create mode 100644 react-sdk/ui-transport/src/transport.test.ts create mode 100644 react-sdk/ui-transport/src/websocket.test.ts create mode 100644 react-sdk/ui-transport/src/websocket.ts create mode 100644 react-sdk/ui-transport/tsconfig.json create mode 100644 tsconfig.base.json create mode 100644 vitest.config.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100755 index 0000000..da0407c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ + +version: 2 +updates: + - package-ecosystem: "gomod" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100755 index 0000000..7270150 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ + +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + go: [ '1.26' ] + steps: + - uses: actions/checkout@v3 + + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version: ${{ matrix.go }} + + - name: Run CI + env: + COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} + run: make ci diff --git a/.gitignore b/.gitignore index ba0552b..c2539b1 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.github/ .tools/ bin/ vendor/ @@ -17,4 +16,16 @@ coverage.out *.mmdb *.test *.out -.env \ No newline at end of file +.env + +example/react/dist/ +example/react/node_modules/ + +react-sdk/ui-transport/dist/ +react-sdk/ui-transport/node_modules/ + +react-sdk/ui-core/dist/ +react-sdk/ui-core/node_modules/ + +react-sdk/ui-react/node_modules/ +react-sdk/ui-react/dist/ \ No newline at end of file diff --git a/examples/dashboard/dashboard.go b/example/go/dashboard/dashboard.go similarity index 100% rename from examples/dashboard/dashboard.go rename to example/go/dashboard/dashboard.go diff --git a/examples/dashboard/dashboard_test.go b/example/go/dashboard/dashboard_test.go similarity index 100% rename from examples/dashboard/dashboard_test.go rename to example/go/dashboard/dashboard_test.go diff --git a/examples/htmlform/htmlform.go b/example/go/htmlform/htmlform.go similarity index 100% rename from examples/htmlform/htmlform.go rename to example/go/htmlform/htmlform.go diff --git a/examples/htmlform/htmlform_test.go b/example/go/htmlform/htmlform_test.go similarity index 100% rename from examples/htmlform/htmlform_test.go rename to example/go/htmlform/htmlform_test.go diff --git a/example/react/README.md b/example/react/README.md new file mode 100644 index 0000000..ef41f8d --- /dev/null +++ b/example/react/README.md @@ -0,0 +1,37 @@ +# React examples + +These TypeScript entrypoints are intended for a React application that has +`@osspkg/ui-react` and `@osspkg/ui-core` installed. Each imports the packaged +Tailwind v4 stylesheet and uses only the public API. + +The directory is a private workspace package, so React and its JSX runtime are +installed for the examples themselves. Check it with: + +```bash +cd react +pnpm --filter @osspkg/ui-examples typecheck +``` + +To browse the examples locally, run this from the repository root: + +```bash +make examples-dev +``` + +Vite prints the local address (normally http://localhost:5173). Use the +buttons at the top of the page to switch between the examples. + +- [`basic.tsx`](basic.tsx) renders a static schema. +- [`html-form.tsx`](html-form.tsx) demonstrates native HTML, local state, and a + submit action. +- [`dashboard.tsx`](dashboard.tsx) demonstrates shadcn defaults and the + 12-column `row`/`cols`/`offset` layout. + +Render one exported component from an application entrypoint, for example: + +```tsx +import { createRoot } from "react-dom/client"; +import { DashboardExample } from "./dashboard"; + +createRoot(document.getElementById("root")!).render(); +``` diff --git a/example/react/basic.tsx b/example/react/basic.tsx new file mode 100644 index 0000000..d8115d5 --- /dev/null +++ b/example/react/basic.tsx @@ -0,0 +1,34 @@ +import { createComponentRegistry, registerDefaultHTMLComponents, registerDefaultShadcnComponents, UIProvider, ViewRenderer } from "@osspkg/ui-react"; +import type { ViewSchema } from "@osspkg/ui-core"; +import "@osspkg/ui-react/styles.css"; + +const components = createComponentRegistry(); +registerDefaultHTMLComponents(components); +registerDefaultShadcnComponents(components); + +const schema: ViewSchema = { + protocolVersion: "1.0", + id: "welcome", + title: "Welcome", + regions: { + "top-header": [], + "left-panel": [], + "right-panel": [], + bottom: [], + content: [ + { + id: "welcome-card", + component: "card", + layout: { row: 1, cols: 8, offset: 2 }, + children: [ + { id: "title", component: "h1", props: { text: "Welcome" } }, + { id: "body", component: "p", props: { text: "This view is rendered from a declarative schema." } }, + ], + }, + ], + }, +}; + +export function BasicExample() { + return ; +} diff --git a/example/react/dashboard.tsx b/example/react/dashboard.tsx new file mode 100644 index 0000000..8967917 --- /dev/null +++ b/example/react/dashboard.tsx @@ -0,0 +1,34 @@ +import { createComponentRegistry, registerDefaultHTMLComponents, registerDefaultShadcnComponents, UIProvider, ViewRenderer } from "@osspkg/ui-react"; +import type { ViewSchema } from "@osspkg/ui-core"; +import "@osspkg/ui-react/styles.css"; + +const components = createComponentRegistry(); +registerDefaultHTMLComponents(components); +registerDefaultShadcnComponents(components); + +const schema: ViewSchema = { + protocolVersion: "1.0", + id: "metrics.dashboard", + regions: { + "top-header": [ + { id: "title", component: "h1", layout: { row: 1, cols: 12 }, props: { text: "Metrics dashboard" } }, + ], + "left-panel": [], + "right-panel": [], + bottom: [], + content: [ + { id: "requests", component: "card", layout: { row: 1, cols: 6 }, props: { text: "Requests: 12,480" } }, + { id: "latency", component: "card", layout: { row: 1, cols: 6, offset: 6 }, props: { text: "p95 latency: 184 ms" } }, + { + id: "events", + component: "data-table", + layout: { row: 2, cols: 12 }, + props: { rows: [{ event: "user.created", status: "success" }, { event: "user.deleted", status: "success" }] }, + }, + ], + }, +}; + +export function DashboardExample() { + return ; +} diff --git a/example/react/html-form.tsx b/example/react/html-form.tsx new file mode 100644 index 0000000..9d67254 --- /dev/null +++ b/example/react/html-form.tsx @@ -0,0 +1,52 @@ +import { createComponentRegistry, registerDefaultHTMLComponents, registerDefaultShadcnComponents, UIProvider, ViewRenderer } from "@osspkg/ui-react"; +import type { ViewSchema } from "@osspkg/ui-core"; +import "@osspkg/ui-react/styles.css"; + +const components = createComponentRegistry(); +registerDefaultHTMLComponents(components); +registerDefaultShadcnComponents(components); + +const schema: ViewSchema = { + protocolVersion: "1.0", + id: "profile.edit", + state: { submitted: false }, + actions: { + save: { type: "set-state", path: "submitted", value: true }, + }, + regions: { + "top-header": [], + "left-panel": [], + "right-panel": [], + bottom: [], + content: [ + { + id: "form-card", + component: "article", + layout: { row: 1, cols: 8, offset: 2 }, + children: [ + { id: "heading", component: "h1", props: { text: "Edit profile" } }, + { + id: "profile-form", + component: "form", + events: { submit: { action: "save" } }, + children: [ + { id: "name-label", component: "label", props: { text: "Name" } }, + { id: "name", component: "input", props: { name: "name", placeholder: "Ada Lovelace" } }, + { id: "save", component: "button", props: { type: "submit", text: "Save profile" } }, + ], + }, + { + id: "confirmation", + component: "p", + when: { $eq: [{ $state: "submitted" }, true] }, + props: { text: "Profile saved." }, + }, + ], + }, + ], + }, +}; + +export function HTMLFormExample() { + return ; +} diff --git a/example/react/index.html b/example/react/index.html new file mode 100644 index 0000000..5d65e7a --- /dev/null +++ b/example/react/index.html @@ -0,0 +1,12 @@ + + + + + + UI React examples + + +
+ + + diff --git a/example/react/main.tsx b/example/react/main.tsx new file mode 100644 index 0000000..66a721e --- /dev/null +++ b/example/react/main.tsx @@ -0,0 +1,47 @@ +import { createRoot } from "react-dom/client"; +import { useState, type ComponentType } from "react"; +import { BasicExample } from "./basic.js"; +import { DashboardExample } from "./dashboard.js"; +import { HTMLFormExample } from "./html-form.js"; +import "./preview.css"; + +const examples: Record = { + Basic: BasicExample, + "HTML form": HTMLFormExample, + Dashboard: DashboardExample, +}; + +function ExamplesApp() { + const [name, setName] = useState("Basic"); + const Example = examples[name] ?? BasicExample; + + return ( +
+
+
+

@osspkg/ui-react

+

Interactive examples

+

Preview declarative schemas rendered with the default HTML and shadcn component registries.

+
+ +
+ +
+
+
+ ); +} + +createRoot(document.getElementById("root")!).render(); diff --git a/example/react/package.json b/example/react/package.json new file mode 100644 index 0000000..31fa120 --- /dev/null +++ b/example/react/package.json @@ -0,0 +1,23 @@ +{ + "name": "@osspkg/ui-examples", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@osspkg/ui-core": "workspace:*", + "@osspkg/ui-react": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwindcss": "^4.3.3", + "vite": "^7.3.6" + } +} diff --git a/example/react/preview.css b/example/react/preview.css new file mode 100644 index 0000000..c843085 --- /dev/null +++ b/example/react/preview.css @@ -0,0 +1,16 @@ +@import "tailwindcss"; + +@source "./"; + +@theme { + --color-ui-background: oklch(1 0 0); + --color-ui-foreground: oklch(0.145 0 0); + --color-ui-card: oklch(1 0 0); + --color-ui-card-foreground: oklch(0.145 0 0); + --color-ui-primary: oklch(0.205 0 0); + --color-ui-primary-foreground: oklch(0.985 0 0); + --color-ui-muted: oklch(0.97 0 0); + --color-ui-muted-foreground: oklch(0.556 0 0); + --color-ui-border: oklch(0.922 0 0); + --color-ui-ring: oklch(0.708 0 0); +} diff --git a/example/react/preview.css.d.ts b/example/react/preview.css.d.ts new file mode 100644 index 0000000..a9f6f08 --- /dev/null +++ b/example/react/preview.css.d.ts @@ -0,0 +1,3 @@ +declare const stylesheet: string; + +export default stylesheet; diff --git a/example/react/tsconfig.json b/example/react/tsconfig.json new file mode 100644 index 0000000..9b4a3d4 --- /dev/null +++ b/example/react/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "noEmit": true, + "noUncheckedSideEffectImports": true, + "baseUrl": "..", + "paths": { + "@osspkg/ui-core": ["packages/ui-core/src/index.ts"], + "@osspkg/ui-react": ["packages/ui-react/src/index.ts"], + "@osspkg/ui-transport": ["packages/ui-transport/src/index.ts"], + "@osspkg/ui-react/styles.css": ["packages/ui-react/src/styles.css.d.ts"] + } + }, + "include": ["*.tsx"] +} diff --git a/example/react/vite.config.ts b/example/react/vite.config.ts new file mode 100644 index 0000000..cc03c4f --- /dev/null +++ b/example/react/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath, URL } from "node:url"; +import { defineConfig } from "vite"; + +const source = (path: string) => fileURLToPath(new URL(path, import.meta.url)); + +export default defineConfig({ + resolve: { + alias: [ + { find: "@osspkg/ui-react/styles.css", replacement: source("../packages/ui-react/src/styles.css") }, + { find: "@osspkg/ui-core", replacement: source("../packages/ui-core/src/index.ts") }, + { find: "@osspkg/ui-react", replacement: source("../packages/ui-react/src/index.ts") }, + { find: "@osspkg/ui-transport", replacement: source("../packages/ui-transport/src/index.ts") }, + ], + }, +}); diff --git a/mcp/register.go b/go-sdk/mcp/register.go similarity index 100% rename from mcp/register.go rename to go-sdk/mcp/register.go diff --git a/mcp/register_test.go b/go-sdk/mcp/register_test.go similarity index 100% rename from mcp/register_test.go rename to go-sdk/mcp/register_test.go diff --git a/builder.go b/go-sdk/ui/builder.go similarity index 100% rename from builder.go rename to go-sdk/ui/builder.go diff --git a/builder_test.go b/go-sdk/ui/builder_test.go similarity index 100% rename from builder_test.go rename to go-sdk/ui/builder_test.go diff --git a/html.go b/go-sdk/ui/html.go similarity index 100% rename from html.go rename to go-sdk/ui/html.go diff --git a/html_test.go b/go-sdk/ui/html_test.go similarity index 100% rename from html_test.go rename to go-sdk/ui/html_test.go diff --git a/schema.go b/go-sdk/ui/schema.go similarity index 100% rename from schema.go rename to go-sdk/ui/schema.go diff --git a/schema_easyjson.go b/go-sdk/ui/schema_easyjson.go similarity index 100% rename from schema_easyjson.go rename to go-sdk/ui/schema_easyjson.go diff --git a/schema_test.go b/go-sdk/ui/schema_test.go similarity index 100% rename from schema_test.go rename to go-sdk/ui/schema_test.go diff --git a/security_test.go b/go-sdk/ui/security_test.go similarity index 100% rename from security_test.go rename to go-sdk/ui/security_test.go diff --git a/shadcn.go b/go-sdk/ui/shadcn.go similarity index 100% rename from shadcn.go rename to go-sdk/ui/shadcn.go diff --git a/shadcn_test.go b/go-sdk/ui/shadcn_test.go similarity index 100% rename from shadcn_test.go rename to go-sdk/ui/shadcn_test.go diff --git a/validation.go b/go-sdk/ui/validation.go similarity index 100% rename from validation.go rename to go-sdk/ui/validation.go diff --git a/value.go b/go-sdk/ui/value.go similarity index 100% rename from value.go rename to go-sdk/ui/value.go diff --git a/package.json b/package.json new file mode 100644 index 0000000..7729d8f --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "@osspkg/ui-workspace", + "private": true, + "packageManager": "pnpm@11.23.0", + "scripts": { + "build": "pnpm --recursive --if-present build", + "test": "pnpm --recursive --if-present test", + "typecheck": "pnpm --recursive --if-present typecheck" + }, + "devDependencies": { + "@types/node": "^24.0.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..eb313ab --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1722 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.13.6 + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + + examples: + dependencies: + '@osspkg/ui-core': + specifier: workspace:* + version: link:../packages/ui-core + '@osspkg/ui-react': + specifier: workspace:* + version: link:../packages/ui-react + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.3.0(@types/react@19.3.0) + react: + specifier: ^19.0.0 + version: 19.3.0 + react-dom: + specifier: ^19.0.0 + version: 19.3.0(react@19.3.0) + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + vite: + specifier: ^7.3.6 + version: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + + packages/ui-core: {} + + packages/ui-react: + dependencies: + '@osspkg/ui-core': + specifier: workspace:* + version: link:../ui-core + '@osspkg/ui-transport': + specifier: workspace:* + version: link:../ui-transport + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + devDependencies: + '@tailwindcss/cli': + specifier: ^4.3.3 + version: 4.3.3 + '@types/react': + specifier: ^19.0.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.3.0(@types/react@19.3.0) + react: + specifier: ^19.0.0 + version: 19.3.0 + react-dom: + specifier: ^19.0.0 + version: 19.3.0(react@19.3.0) + + packages/ui-transport: + dependencies: + '@osspkg/ui-core': + specifier: workspace:* + version: link:../ui-core + +packages: + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rollup/rollup-android-arm-eabi@4.63.4': + resolution: {integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.4': + resolution: {integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: {integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.4': + resolution: {integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: {integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: {integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + resolution: {integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + resolution: {integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + resolution: {integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.4': + resolution: {integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + resolution: {integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.4': + resolution: {integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + resolution: {integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + resolution: {integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + resolution: {integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + resolution: {integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + resolution: {integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.4': + resolution: {integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.4': + resolution: {integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: {integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: {integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: {integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: {integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.4': + resolution: {integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: {integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==} + cpu: [x64] + os: [win32] + + '@tailwindcss/cli@4.3.3': + resolution: {integrity: sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==} + hasBin: true + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.6': + resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + enhanced-resolve@5.25.1: + resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} + engines: {node: '>=10.13.0'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + rollup@4.63.4: + resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + + '@rollup/rollup-android-arm-eabi@4.63.4': + optional: true + + '@rollup/rollup-android-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-x64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.4': + optional: true + + '@tailwindcss/cli@4.3.3': + dependencies: + '@parcel/watcher': 2.5.1 + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + enhanced-resolve: 5.25.1 + mri: 1.2.0 + picocolors: 1.1.1 + tailwindcss: 4.3.3 + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.25.1 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.6': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + detect-libc@1.0.3: {} + + detect-libc@2.1.2: {} + + enhanced-resolve@5.25.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-module-lexer@1.7.0: {} + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + jiti@2.7.0: {} + + js-tokens@9.0.1: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mri@1.2.0: {} + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + node-addon-api@7.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react@19.3.0: {} + + rollup@4.63.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.4 + '@rollup/rollup-android-arm64': 4.63.4 + '@rollup/rollup-darwin-arm64': 4.63.4 + '@rollup/rollup-darwin-x64': 4.63.4 + '@rollup/rollup-freebsd-arm64': 4.63.4 + '@rollup/rollup-freebsd-x64': 4.63.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 + '@rollup/rollup-linux-arm-musleabihf': 4.63.4 + '@rollup/rollup-linux-arm64-gnu': 4.63.4 + '@rollup/rollup-linux-arm64-musl': 4.63.4 + '@rollup/rollup-linux-loong64-gnu': 4.63.4 + '@rollup/rollup-linux-loong64-musl': 4.63.4 + '@rollup/rollup-linux-ppc64-gnu': 4.63.4 + '@rollup/rollup-linux-ppc64-musl': 4.63.4 + '@rollup/rollup-linux-riscv64-gnu': 4.63.4 + '@rollup/rollup-linux-riscv64-musl': 4.63.4 + '@rollup/rollup-linux-s390x-gnu': 4.63.4 + '@rollup/rollup-linux-x64-gnu': 4.63.4 + '@rollup/rollup-linux-x64-musl': 4.63.4 + '@rollup/rollup-openbsd-x64': 4.63.4 + '@rollup/rollup-openharmony-arm64': 4.63.4 + '@rollup/rollup-win32-arm64-msvc': 4.63.4 + '@rollup/rollup-win32-ia32-msvc': 4.63.4 + '@rollup/rollup-win32-x64-gnu': 4.63.4 + '@rollup/rollup-win32-x64-msvc': 4.63.4 + fsevents: 2.3.3 + + scheduler@0.28.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + typescript@5.9.3: {} + + undici-types@7.18.2: {} + + vite-node@3.2.4(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.6 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + + vitest@3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + vite-node: 3.2.4(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.6 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..df33dce --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - packages/* + - examples + +allowBuilds: + '@parcel/watcher': false + esbuild: true diff --git a/protocol/error-codes.json b/protocol/error-codes.json new file mode 100644 index 0000000..c0601d7 --- /dev/null +++ b/protocol/error-codes.json @@ -0,0 +1,16 @@ +{ + "PLUGIN_NOT_FOUND": "plugin was not found", + "PLUGIN_UNAVAILABLE": "plugin is unavailable", + "PLUGIN_PROTOCOL_ERROR": "plugin protocol error", + "VIEW_NOT_FOUND": "view was not found", + "INVALID_UI_SCHEMA": "ui schema is invalid", + "UNSUPPORTED_UI_VERSION": "ui protocol version is unsupported", + "COMPONENT_NOT_SUPPORTED": "component is not supported", + "ACTION_NOT_ALLOWED": "action is not allowed", + "SOURCE_FAILED": "source failed", + "ACTION_FAILED": "action failed", + "TIMEOUT": "operation timed out", + "CANCELLED": "operation was cancelled", + "FORBIDDEN": "operation is forbidden", + "UNAUTHORIZED": "operation is unauthorized" +} diff --git a/react-sdk/ui-core/package.json b/react-sdk/ui-core/package.json new file mode 100644 index 0000000..a227583 --- /dev/null +++ b/react-sdk/ui-core/package.json @@ -0,0 +1,14 @@ +{ + "name": "@osspkg/ui-core", + "version": "0.1.0", + "type": "module", + "files": ["dist"], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": {".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"}}, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/react-sdk/ui-core/src/core.test.ts b/react-sdk/ui-core/src/core.test.ts new file mode 100644 index 0000000..c8248b5 --- /dev/null +++ b/react-sdk/ui-core/src/core.test.ts @@ -0,0 +1,156 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import type { UIValue } from "./schema.js"; +import { resolvePath } from "./path.js"; +import { resolveValue, type ExpressionLimits } from "./expression.js"; +import { createStateRuntime } from "./runtime.js"; +import { createRequestTracker } from "./request.js"; +import { parseManifest, parseView, validateView, SchemaValidationError } from "./validate.js"; + +function fixture(name: string): unknown { + return JSON.parse(readFileSync(new URL(`../../../../fixtures/${name}`, import.meta.url), "utf8")) as unknown; +} + +const scope = { + state: { status: "active", name: "Ada" }, + context: { workspace: { id: "w-1" } }, + sources: { users: { status: "success" as const, data: { items: [{ id: 1 }] } } }, + event: { value: "draft" }, + result: { id: 42 }, +}; + +describe("ui core", () => { + it("rejects prototype traversal", () => { + expect(() => resolvePath({}, "constructor.name")).toThrow("unsafe path"); + }); + + it("resolves references and expressions", () => { + expect(resolveValue({ $state: "name" }, scope)).toBe("Ada"); + expect(resolveValue({ $source: "users.items" }, scope)).toEqual([{ id: 1 }]); + expect(resolveValue({ $eq: [{ $state: "status" }, "active"] }, scope)).toBe(true); + }); + + it.each([ + ["idle", false], + ["loading", true], + ["success", false], + ["error", false], + ] as const)("maps source loading marker for %s", (status, expected) => { + const sourceScope = { + ...scope, + sources: { + users: { + status, + data: { items: [{ id: 1 }] }, + ...(status === "error" ? { error: { code: "FAILED", message: "failed" } } : {}), + }, + }, + }; + expect(resolveValue({ $source: "users.$loading" }, sourceScope)).toBe(expected); + }); + + it("maps source errors and data paths", () => { + const error = { code: "FAILED", message: "failed" }; + const sourceScope = { ...scope, sources: { users: { status: "error" as const, error, data: { items: [] } } } }; + expect(resolveValue({ $source: "users.$error" }, sourceScope)).toEqual(error); + expect(resolveValue({ $source: "users.items" }, sourceScope)).toEqual([]); + }); + + it("accepts only the latest request for a source", () => { + const tracker = createRequestTracker(); + const first = tracker.next("users"); + const second = tracker.next("users"); + + expect(tracker.isCurrent("users", first)).toBe(false); + expect(tracker.isCurrent("users", second)).toBe(true); + expect(tracker.isCurrent("orders", second)).toBe(false); + }); + + it("rejects invalid layouts", () => { + expect(() => validateView({ + protocolVersion: "1.0", + id: "broken", + regions: { "top-header": [], "left-panel": [], content: [{ id: "n", component: "card", layout: { row: 1, cols: 8, offset: 5 } }], "right-panel": [], bottom: [] }, + })).toThrow(SchemaValidationError); + }); + + it("parses manifests and views only after validation", () => { + expect(parseManifest({ protocolVersion: "1.0", plugin: { id: "users", title: "Users", version: "1.0" }, views: [] }).plugin.id).toBe("users"); + expect(parseView({ + protocolVersion: "1.0", + id: "users.list", + regions: { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] }, + }).id).toBe("users.list"); + expect(() => parseView({ protocolVersion: "1.0", id: "broken", regions: { content: [] } })).toThrow(SchemaValidationError); + }); + + it("rejects references to unknown sources", () => { + expect(() => validateView({ + protocolVersion: "1.0", + id: "users.list", + regions: { "top-header": [], "left-panel": [], content: [{ id: "table", component: "table", props: { rows: { $source: "missing.items" } } }], "right-panel": [], bottom: [] }, + })).toThrow(SchemaValidationError); + }); + + it("accepts and rejects the shared Go fixtures identically", () => { + expect(parseView(fixture("valid-view.json")).id).toBe("users.list"); + expect(() => parseView(fixture("invalid-layout.json"))).toThrow(SchemaValidationError); + }); + + it("enforces expression collection, object, and string limits", () => { + const limits: ExpressionLimits = { maxDepth: 4, maxArrayItems: 2, maxObjectKeys: 1, maxStringLength: 4 }; + expect(() => resolveValue([1, 2, 3], scope, 0, limits)).toThrow("expression array limit exceeded"); + expect(() => resolveValue({ first: 1, second: 2 }, scope, 0, limits)).toThrow("expression object limit exceeded"); + expect(() => resolveValue("12345", scope, 0, limits)).toThrow("expression string limit exceeded"); + expect(() => resolveValue({ $concat: ["123", "45"] }, scope, 0, limits)).toThrow("expression string limit exceeded"); + }); + + it.each([ + ["$eq", { $eq: [1, 1] }, true], + ["$ne", { $ne: [1, 2] }, true], + ["$gt", { $gt: [2, 1] }, true], + ["$gte", { $gte: [2, 2] }, true], + ["$lt", { $lt: [1, 2] }, true], + ["$lte", { $lte: [2, 2] }, true], + ["$and", { $and: [true, true] }, true], + ["$or", { $or: [false, true] }, true], + ["$not", { $not: [false] }, true], + ["$in", { $in: ["active", ["draft", "active"]] }, true], + ["$exists", { $exists: [{ $state: "name" }] }, true], + ["$concat", { $concat: ["Ada", " Lovelace"] }, "Ada Lovelace"], + ["$coalesce", { $coalesce: [{ $state: "missing" }, "fallback"] }, "fallback"], + ["$if", { $if: [true, "yes", "no"] }, "yes"], + ] as const)("evaluates %s", (_operator, expression, expected) => { + expect(resolveValue(expression as UIValue, scope)).toBe(expected); + }); + + it("keeps schema, path, and expression decoders bounded for hostile inputs", () => { + let seed = 0x12345678; + const next = (): number => { + seed = (seed * 1664525 + 1013904223) >>> 0; + return seed; + }; + for (let index = 0; index < 256; index += 1) { + const token = next().toString(16); + expect(() => resolvePath({ value: token }, index % 2 === 0 ? `value.${token}` : "__proto__.x")).not.toThrow(TypeError); + expect(() => resolveValue({ $concat: [token, { $state: "missing" }] }, scope)).not.toThrow(TypeError); + expect(() => parseView(index % 2 === 0 ? { protocolVersion: "bad", id: token } : { [token]: next() })).not.toThrow(TypeError); + } + }); + + it.each([ + ["set", (runtime: ReturnType) => runtime.set("form.name", "Grace")], + ["merge", (runtime: ReturnType) => runtime.merge("form", { valid: true })], + ])("supports local state action: %s", (_name, apply) => { + const initial = { form: { name: "Ada", valid: false } }; + const runtime = createStateRuntime(initial); + + apply(runtime); + + expect(runtime.get()).not.toBe(initial); + expect(initial).toEqual({ form: { name: "Ada", valid: false } }); + expect(runtime.get()).toEqual(_name === "set" + ? { form: { name: "Grace", valid: false } } + : { form: { name: "Ada", valid: true } }); + }); +}); diff --git a/react-sdk/ui-core/src/expression.ts b/react-sdk/ui-core/src/expression.ts new file mode 100644 index 0000000..a5995f4 --- /dev/null +++ b/react-sdk/ui-core/src/expression.ts @@ -0,0 +1,85 @@ +import { resolvePath } from "./path.js"; +import type { ResolveScope, UIValue } from "./schema.js"; + +const operators = new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$and", "$or", "$not", "$in", "$exists", "$concat", "$coalesce", "$if"]); + +export interface ExpressionLimits { + maxDepth: number; + maxArrayItems: number; + maxObjectKeys: number; + maxStringLength: number; +} + +export const defaultExpressionLimits: ExpressionLimits = { + maxDepth: 32, + maxArrayItems: 1000, + maxObjectKeys: 256, + maxStringLength: 16 * 1024, +}; + +export function resolveValue(value: UIValue, scope: ResolveScope, depth = 0, limits: ExpressionLimits = defaultExpressionLimits): unknown { + if (depth > limits.maxDepth) throw new Error("expression depth exceeded"); + if (typeof value === "string" && value.length > limits.maxStringLength) throw new Error("expression string limit exceeded"); + if (Array.isArray(value)) { + if (value.length > limits.maxArrayItems) throw new Error("expression array limit exceeded"); + return value.map((item) => resolveValue(item, scope, depth + 1, limits)); + } + if (!value || typeof value !== "object") return value; + const entries = Object.entries(value); + if (entries.length > limits.maxObjectKeys) throw new Error("expression object limit exceeded"); + if (entries.length === 1) { + const [key, raw] = entries[0]!; + if (key === "$state" || key === "$context" || key === "$event" || key === "$result") { + const root = key === "$state" ? scope.state : key === "$context" ? scope.context : key === "$event" ? scope.event : scope.result; + return resolvePath(root, String(raw)); + } + if (key === "$source") return resolveSource(String(raw), scope); + if (operators.has(key)) return evaluateExpression(key, raw, scope, depth + 1, limits); + } + return Object.fromEntries(entries.map(([key, nested]) => [key, resolveValue(nested, scope, depth + 1, limits)])); +} + +function resolveSource(path: string, scope: ResolveScope): unknown { + const separator = path.indexOf("."); + const name = separator < 0 ? path : path.slice(0, separator); + const rest = separator < 0 ? "" : path.slice(separator + 1); + const source = scope.sources[name]; + if (!source) return undefined; + if (rest === "$loading") return source.status === "loading"; + if (rest === "$error") return source.error; + if (rest === "$data" || rest === "") return source.data; + if (rest === "$updatedAt") return source.updatedAt; + return resolvePath(source.data, rest); +} + +function evaluateExpression(operator: string, raw: unknown, scope: ResolveScope, depth: number, limits: ExpressionLimits): unknown { + if (!Array.isArray(raw)) throw new Error(`operator ${operator} requires an array`); + if (raw.length > limits.maxArrayItems) throw new Error("expression array limit exceeded"); + const args = raw.map((item) => resolveValue(item, scope, depth, limits)); + switch (operator) { + case "$eq": return args[0] === args[1]; + case "$ne": return args[0] !== args[1]; + case "$gt": return comparable(args[0]) > comparable(args[1]); + case "$gte": return comparable(args[0]) >= comparable(args[1]); + case "$lt": return comparable(args[0]) < comparable(args[1]); + case "$lte": return comparable(args[0]) <= comparable(args[1]); + case "$and": return args.every(Boolean); + case "$or": return args.some(Boolean); + case "$not": return !args[0]; + case "$in": return Array.isArray(args[1]) && args[1].includes(args[0]); + case "$exists": return args[0] !== undefined && args[0] !== null; + case "$concat": { + const result = args.map((item) => String(item ?? "")).join(""); + if (result.length > limits.maxStringLength) throw new Error("expression string limit exceeded"); + return result; + } + case "$coalesce": return args.find((item) => item !== undefined && item !== null); + case "$if": return args[0] ? args[1] : args[2]; + default: throw new Error(`unsupported expression operator ${operator}`); + } +} + +function comparable(value: unknown): string | number { + if (typeof value === "string" || typeof value === "number") return value; + throw new Error("comparison requires a string or number"); +} diff --git a/react-sdk/ui-core/src/index.ts b/react-sdk/ui-core/src/index.ts new file mode 100644 index 0000000..b6594bc --- /dev/null +++ b/react-sdk/ui-core/src/index.ts @@ -0,0 +1,6 @@ +export * from "./expression.js"; +export * from "./path.js"; +export * from "./request.js"; +export * from "./runtime.js"; +export * from "./schema.js"; +export * from "./validate.js"; diff --git a/react-sdk/ui-core/src/path.ts b/react-sdk/ui-core/src/path.ts new file mode 100644 index 0000000..cfeaa6a --- /dev/null +++ b/react-sdk/ui-core/src/path.ts @@ -0,0 +1,45 @@ +const forbiddenSegments = new Set(["__proto__", "prototype", "constructor"]); + +export function resolvePath(value: unknown, path: string): unknown { + const segments = validatePath(path); + let current = value; + for (const segment of segments) { + if (current === null || current === undefined) return undefined; + if (typeof current !== "object" && typeof current !== "function") return undefined; + if (!Object.prototype.hasOwnProperty.call(current, segment)) return undefined; + current = (current as Record)[segment]; + } + return current; +} + +export function setPath(value: T, path: string, next: unknown): T { + const segments = validatePath(path); + if (segments.length === 0) throw new Error("path is required"); + const root = cloneValue(value) as Record; + let current = root; + for (let index = 0; index < segments.length - 1; index += 1) { + const segment = segments[index]!; + const existing = current[segment]; + current[segment] = existing && typeof existing === "object" ? cloneValue(existing) : {}; + current = current[segment] as Record; + } + current[segments.at(-1)!] = next; + return root as T; +} + +export function validatePath(path: string): string[] { + if (path.length === 0 || path.length > 1024) throw new Error("invalid path"); + const segments = path.split("."); + if (segments.some((segment) => segment.length === 0 || forbiddenSegments.has(segment))) { + throw new Error("unsafe path"); + } + return segments; +} + +function cloneValue(value: T): T { + if (Array.isArray(value)) return value.map(cloneValue) as T; + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)])) as T; + } + return value; +} diff --git a/react-sdk/ui-core/src/request.ts b/react-sdk/ui-core/src/request.ts new file mode 100644 index 0000000..4b4dfda --- /dev/null +++ b/react-sdk/ui-core/src/request.ts @@ -0,0 +1,16 @@ +export interface RequestTracker { + next(key: string): number; + isCurrent(key: string, requestId: number): boolean; +} + +export function createRequestTracker(): RequestTracker { + const current = new Map(); + return { + next: (key) => { + const requestId = (current.get(key) ?? 0) + 1; + current.set(key, requestId); + return requestId; + }, + isCurrent: (key, requestId) => current.get(key) === requestId, + }; +} diff --git a/react-sdk/ui-core/src/runtime.ts b/react-sdk/ui-core/src/runtime.ts new file mode 100644 index 0000000..094e60f --- /dev/null +++ b/react-sdk/ui-core/src/runtime.ts @@ -0,0 +1,29 @@ +import { resolvePath, setPath } from "./path.js"; +import type { RuntimeError, SourceRuntimeState, UIValue } from "./schema.js"; + +export interface StateRuntime { + get(): T; + set(path: string, value: unknown): void; + merge(path: string, value: Record): void; +} + +export function createStateRuntime(initial: T): StateRuntime { + let state = initial; + return { + get: () => state, + set: (path, value) => { state = setPath(state, path, value); }, + merge: (path, value) => { + const existing = resolvePath(state, path); + const base = existing && typeof existing === "object" ? existing : {}; + state = setPath(state, path, {...base, ...value}); + }, + }; +} + +export function runtimeError(code: string, message: string, details?: unknown, retryable?: boolean): RuntimeError { + return { code, message, ...(details === undefined ? {} : { details }), ...(retryable === undefined ? {} : { retryable }) }; +} + +export function emptySource(): SourceRuntimeState { + return { status: "idle" }; +} diff --git a/react-sdk/ui-core/src/schema.ts b/react-sdk/ui-core/src/schema.ts new file mode 100644 index 0000000..6bab04b --- /dev/null +++ b/react-sdk/ui-core/src/schema.ts @@ -0,0 +1,125 @@ +export const protocolVersion = "1.0" as const; + +export type RegionName = "top-header" | "left-panel" | "content" | "right-panel" | "bottom"; +export type SourcePolicy = "manual" | "on-mount"; +export type ValueReference = "$state" | "$context" | "$source" | "$event" | "$result"; +export type ExpressionOperator = "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$and" | "$or" | "$not" | "$in" | "$exists" | "$concat" | "$coalesce" | "$if"; +export type ActionType = "tool" | "set-state" | "merge-state" | "refresh-source" | "invalidate"; +export type EffectType = "set-state" | "merge-state" | "invalidate" | "refresh-source" | "refresh-view" | "patch-view" | "navigate" | "toast" | "dialog" | "close-dialog"; + +export interface Manifest { + protocolVersion: string; + plugin: { id: string; title: string; version: string; description?: string }; + views: ViewDescriptor[]; + requiredComponents?: string[]; +} + +export interface ViewDescriptor { + id: string; + title: string; + route?: string; + schema: string; +} + +export interface ViewSchema { + protocolVersion: string; + id: string; + revision?: string; + title?: string; + state?: Record; + sources?: Record; + actions?: Record; + regions: Record; +} + +export interface GridLayout { + row: number; + cols?: number; + offset?: number; +} + +export interface UINode { + id: string; + component: string; + layout?: GridLayout; + props?: Record; + events?: Record; + children?: UINode[]; + slots?: Record; + when?: UIValue; +} + +export interface DataSource { + type: "tool"; + tool: string; + input?: Record; + policy?: SourcePolicy; + cache?: { ttl?: number }; + refreshOn?: string[]; +} + +export interface EventHandler { + action?: string; + steps?: ActionStep[]; +} + +export interface ActionStep { + type: "action"; + action?: string; + input?: Record; +} + +export interface ActionDefinition { + type: ActionType; + tool?: string; + input?: Record; + path?: string; + value?: UIValue; + source?: string; + to?: string; + variant?: string; + message?: string; + effects?: Effect[]; +} + +export interface Effect { + type: EffectType; + path?: string; + source?: string; + value?: UIValue; + baseRevision?: string; + revision?: string; + to?: string; + variant?: string; + message?: string; +} + +export type JSONValue = string | number | boolean | null | readonly JSONValue[] | { [key: string]: JSONValue }; +export type UIReference = { $state: string } | { $context: string } | { $source: string } | { $event: string } | { $result: string }; +export type UIExpression = { [operator in ExpressionOperator]?: readonly UIValue[] }; +export type UIValue = JSONValue | UIReference | UIExpression; + +export interface ResolveScope { + state: unknown; + context: unknown; + sources: Record; + event?: unknown; + result?: unknown; +} + +export interface SourceRuntimeState { + status: "idle" | "loading" | "success" | "error"; + data?: T; + error?: RuntimeError; + updatedAt?: number; + requestId?: string; +} + +export interface RuntimeError { + code: string; + message: string; + details?: unknown; + retryable?: boolean; +} + +export const regionNames: readonly RegionName[] = ["top-header", "left-panel", "content", "right-panel", "bottom"]; diff --git a/react-sdk/ui-core/src/validate.ts b/react-sdk/ui-core/src/validate.ts new file mode 100644 index 0000000..374f6f7 --- /dev/null +++ b/react-sdk/ui-core/src/validate.ts @@ -0,0 +1,274 @@ +import { regionNames, type Manifest, type RuntimeError, type ViewSchema } from "./schema.js"; + +export interface ValidationLimits { + maxBytes: number; + maxNodes: number; + maxDepth: number; + maxPropsPerNode: number; + maxActions: number; + maxSources: number; + maxStrings: number; + maxStringLength: number; + maxExpressionDepth: number; + maxArrayItems: number; +} + +export const defaultLimits: ValidationLimits = { + maxBytes: 1 << 20, + maxNodes: 1000, + maxDepth: 32, + maxPropsPerNode: 64, + maxActions: 128, + maxSources: 64, + maxStrings: 10000, + maxStringLength: 16 * 1024, + maxExpressionDepth: 32, + maxArrayItems: 1000, +}; + +export class SchemaValidationError extends Error { + readonly runtimeError: RuntimeError; + + constructor(message: string, details?: unknown) { + super(message); + this.name = "SchemaValidationError"; + this.runtimeError = { code: "INVALID_UI_SCHEMA", message, ...(details === undefined ? {} : { details }) }; + } +} + +export function parseManifest(input: unknown, limits: Partial = {}): Manifest { + if (!isRecord(input)) throw new SchemaValidationError("manifest must be an object"); + const manifest = input as Manifest; + validateManifest(manifest, limits); + return manifest; +} + +export function parseView(input: unknown, limits: Partial = {}): ViewSchema { + if (!isRecord(input)) throw new SchemaValidationError("view must be an object"); + const view = input as ViewSchema; + validateView(view, limits); + return view; +} + +export function validateManifest(manifest: Manifest, customLimits: Partial = {}): void { + const limits = withDefaults(customLimits); + if (!isRecord(manifest) || manifest.protocolVersion !== "1.0") throw new SchemaValidationError("unsupported manifest protocol version"); + if (!isRecord(manifest.plugin) || !isSafeName(manifest.plugin.id) || !isText(manifest.plugin.title) || !isText(manifest.plugin.version)) { + throw new SchemaValidationError("manifest plugin metadata is required"); + } + if (!Array.isArray(manifest.views)) throw new SchemaValidationError("manifest views must be an array"); + const ids = new Set(); + for (const view of manifest.views) { + if (!isRecord(view) || !isSafeName(view.id) || !isText(view.title) || !isText(view.schema) || ids.has(view.id)) { + throw new SchemaValidationError("manifest view is invalid"); + } + ids.add(view.id); + } + if (manifest.requiredComponents !== undefined && (!Array.isArray(manifest.requiredComponents) || manifest.requiredComponents.some((name) => !isComponentName(name)))) { + throw new SchemaValidationError("manifest required components are invalid"); + } + validateSerializedLimits(manifest, limits); +} + +export function validateView(view: ViewSchema, customLimits: Partial = {}): void { + const limits = withDefaults(customLimits); + if (!isRecord(view) || view.protocolVersion !== "1.0") throw new SchemaValidationError("unsupported view protocol version"); + if (!isSafeName(view.id)) throw new SchemaValidationError("view id is required"); + if (view.title !== undefined && !isText(view.title)) throw new SchemaValidationError("view title is invalid"); + if (view.sources !== undefined && !isRecord(view.sources)) throw new SchemaValidationError("sources must be an object"); + if (view.actions !== undefined && !isRecord(view.actions)) throw new SchemaValidationError("actions must be an object"); + if (!isRecord(view.regions)) throw new SchemaValidationError("regions are required"); + validateSerializedLimits(view, limits); + + const actions = view.actions ?? {}; + const sources = view.sources ?? {}; + if (Object.keys(actions).length > limits.maxActions || Object.keys(sources).length > limits.maxSources) { + throw new SchemaValidationError("source or action limit exceeded"); + } + for (const [name, source] of Object.entries(sources)) validateSource(name, source, view, limits); + for (const [name, action] of Object.entries(actions)) validateAction(name, action, view, limits); + + let nodes = 0; + const nodeIDs = new Set(); + for (const region of regionNames) { + const nodesInRegion = view.regions[region]; + if (!Array.isArray(nodesInRegion)) throw new SchemaValidationError(`region ${region} must be an array`); + for (const node of nodesInRegion) nodes = validateNode(node, region, 1, view, limits, nodeIDs, nodes); + } +} + +function validateSource(name: string, source: unknown, view: ViewSchema, limits: ValidationLimits): void { + if (!isSafeName(name) || !isRecord(source) || source.type !== "tool" || !isSafeName(source.tool)) throw new SchemaValidationError(`source ${name} is invalid`); + if (source.policy !== undefined && source.policy !== "manual" && source.policy !== "on-mount") throw new SchemaValidationError(`source ${name} policy is invalid`); + if (source.cache !== undefined && (!isRecord(source.cache) || (source.cache.ttl !== undefined && (!Number.isInteger(source.cache.ttl) || source.cache.ttl < 0)))) throw new SchemaValidationError(`source ${name} cache is invalid`); + validateValues(source.input, view, limits); + if (source.refreshOn !== undefined && (!Array.isArray(source.refreshOn) || source.refreshOn.some((action) => !view.actions?.[action]))) throw new SchemaValidationError(`source ${name} refresh action is invalid`); +} + +function validateAction(name: string, action: unknown, view: ViewSchema, limits: ValidationLimits): void { + if (!isSafeName(name) || !isRecord(action) || !["tool", "set-state", "merge-state", "refresh-source", "invalidate"].includes(action.type)) throw new SchemaValidationError(`action ${name} is invalid`); + if (action.type === "tool" && !isSafeName(action.tool)) throw new SchemaValidationError(`tool action ${name} requires a tool`); + if ((action.type === "set-state" || action.type === "merge-state") && !isSafePath(action.path)) throw new SchemaValidationError(`action ${name} requires a safe path`); + if ((action.type === "refresh-source" || action.type === "invalidate") && !view.sources?.[action.source as string]) throw new SchemaValidationError(`action ${name} source is invalid`); + validateValues(action.input, view, limits); + if (action.value !== undefined) validateValue(action.value, view, limits, 0); + if (action.effects !== undefined) { + if (!Array.isArray(action.effects)) throw new SchemaValidationError(`action ${name} effects are invalid`); + for (const effect of action.effects) validateEffect(effect, view, limits); + } +} + +function validateEffect(effect: unknown, view: ViewSchema, limits: ValidationLimits): void { + if (!isRecord(effect) || !["set-state", "merge-state", "invalidate", "refresh-source", "refresh-view", "patch-view", "navigate", "toast", "dialog", "close-dialog"].includes(effect.type)) throw new SchemaValidationError("effect type is invalid"); + if ((effect.type === "set-state" || effect.type === "merge-state") && !isSafePath(effect.path)) throw new SchemaValidationError("state effect path is invalid"); + if ((effect.type === "invalidate" || effect.type === "refresh-source") && !view.sources?.[effect.source as string]) throw new SchemaValidationError("effect source is invalid"); + if (effect.type === "toast" && !isText(effect.message)) throw new SchemaValidationError("toast effect message is required"); + if (effect.type === "navigate" && !isText(effect.to)) throw new SchemaValidationError("navigate effect target is required"); + if (effect.value !== undefined) validateValue(effect.value, view, limits, 0); +} + +function validateNode(node: unknown, region: string, depth: number, view: ViewSchema, limits: ValidationLimits, ids: Set, count: number): number { + if (!isRecord(node) || !isSafeName(node.id) || !isComponentName(node.component) || ids.has(node.id)) throw new SchemaValidationError("node id or component is invalid"); + count += 1; + if (count > limits.maxNodes || depth > limits.maxDepth) throw new SchemaValidationError("schema complexity limit exceeded"); + ids.add(node.id); + if (node.layout !== undefined) validateLayout(node.layout, region); + if (node.props !== undefined && (!isRecord(node.props) || Object.keys(node.props).length > limits.maxPropsPerNode)) throw new SchemaValidationError("node props are invalid"); + validateValues(node.props, view, limits); + if (node.events !== undefined) { + if (!isRecord(node.events)) throw new SchemaValidationError("node events are invalid"); + for (const [event, handler] of Object.entries(node.events)) { + if (!isSafeName(event) || !isRecord(handler) || (!handler.action && !Array.isArray(handler.steps))) throw new SchemaValidationError("event handler is invalid"); + if (handler.action && !view.actions?.[handler.action]) throw new SchemaValidationError("event action is not defined"); + if (handler.steps) for (const step of handler.steps) { + if (!isRecord(step) || step.type !== "action" || !isSafeName(step.action) || !view.actions?.[step.action]) throw new SchemaValidationError("event step is invalid"); + validateValues(step.input, view, limits); + } + } + } + if (node.when !== undefined) validateValue(node.when, view, limits, 0); + if (node.children !== undefined) { + if (!Array.isArray(node.children)) throw new SchemaValidationError("children must be an array"); + for (const child of node.children) count = validateNode(child, region, depth + 1, view, limits, ids, count); + } + if (node.slots !== undefined) { + if (!isRecord(node.slots)) throw new SchemaValidationError("slots must be an object"); + for (const children of Object.values(node.slots)) { + if (!Array.isArray(children)) throw new SchemaValidationError("slot children must be an array"); + for (const child of children) count = validateNode(child, region, depth + 1, view, limits, ids, count); + } + } + return count; +} + +function validateLayout(layout: unknown, region: string): void { + if (!isRecord(layout) || !Number.isInteger(layout.row) || layout.row < 1) throw new SchemaValidationError("layout row is invalid"); + if (region === "left-panel" || region === "right-panel") { + if (layout.cols !== undefined || layout.offset !== undefined) throw new SchemaValidationError("side panel layout is invalid"); + return; + } + const cols = layout.cols ?? 0; + const offset = layout.offset ?? 0; + if (!Number.isInteger(cols) || cols < 1 || cols > 12 || !Number.isInteger(offset) || offset < 0 || offset > 11 || cols + offset > 12) throw new SchemaValidationError("grid layout is invalid"); +} + +function validateValues(values: unknown, view: ViewSchema, limits: ValidationLimits): void { + if (values === undefined) return; + if (!isRecord(values)) throw new SchemaValidationError("values must be an object"); + for (const value of Object.values(values)) validateValue(value, view, limits, 0); +} + +function validateValue(value: unknown, view: ViewSchema, limits: ValidationLimits, depth: number): void { + if (depth > limits.maxExpressionDepth) throw new SchemaValidationError("expression depth exceeded"); + if (value === null || typeof value === "boolean" || typeof value === "number") return; + if (typeof value === "string") { + if (value.length > limits.maxStringLength) throw new SchemaValidationError("value is too long"); + return; + } + if (Array.isArray(value)) { + if (value.length > limits.maxArrayItems) throw new SchemaValidationError("array is too large"); + for (const item of value) validateValue(item, view, limits, depth + 1); + return; + } + if (!isRecord(value)) throw new SchemaValidationError("value is not JSON-compatible"); + for (const key of Object.keys(value)) if (["__proto__", "prototype", "constructor"].includes(key)) throw new SchemaValidationError("unsafe value key"); + const entries = Object.entries(value); + if (entries.length === 1) { + const [key, raw] = entries[0]!; + if (["$state", "$context", "$event", "$result"].includes(key)) { + if (!isSafePath(raw)) throw new SchemaValidationError("reference path is unsafe"); + return; + } + if (key === "$source") { + if (!isSafePath(raw)) throw new SchemaValidationError("source path is unsafe"); + const sourceName = raw.split(".", 1)[0]!; + if (!view.sources?.[sourceName]) throw new SchemaValidationError("source reference is not defined"); + return; + } + const arity = expressionArity[key]; + if (arity) { + if (!Array.isArray(raw) || raw.length < arity[0] || (arity[1] >= 0 && raw.length > arity[1])) throw new SchemaValidationError(`operator ${key} has invalid arity`); + for (const item of raw) validateValue(item, view, limits, depth + 1); + return; + } + if (key.startsWith("$")) throw new SchemaValidationError(`unsupported expression operator ${key}`); + } + for (const nested of Object.values(value)) validateValue(nested, view, limits, depth + 1); +} + +const expressionArity: Record = { + $eq: [2, 2], $ne: [2, 2], $gt: [2, 2], $gte: [2, 2], $lt: [2, 2], $lte: [2, 2], + $and: [1, -1], $or: [1, -1], $not: [1, 1], $in: [2, 2], $exists: [1, 1], + $concat: [0, -1], $coalesce: [1, -1], $if: [3, 3], +}; + +function validateSerializedLimits(value: unknown, limits: ValidationLimits): void { + let encoded: string; + try { + encoded = JSON.stringify(value); + } catch { + throw new SchemaValidationError("schema is not JSON serializable"); + } + if (new TextEncoder().encode(encoded).length > limits.maxBytes) throw new SchemaValidationError("schema byte limit exceeded"); + let strings = 0; + walkSerialized(value, 0, limits, () => { strings += 1; if (strings > limits.maxStrings) throw new SchemaValidationError("string count limit exceeded"); }); +} + +function walkSerialized(value: unknown, depth: number, limits: ValidationLimits, onString: () => void): void { + if (depth > limits.maxDepth) throw new SchemaValidationError("schema depth limit exceeded"); + if (typeof value === "string") { + if (value.length > limits.maxStringLength) throw new SchemaValidationError("string length limit exceeded"); + onString(); + } else if (Array.isArray(value)) { + for (const item of value) walkSerialized(item, depth + 1, limits, onString); + } else if (isRecord(value)) { + for (const [key, nested] of Object.entries(value)) { + walkSerialized(key, depth + 1, limits, onString); + walkSerialized(nested, depth + 1, limits, onString); + } + } +} + +function withDefaults(custom: Partial): ValidationLimits { + return { ...defaultLimits, ...custom }; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isText(value: unknown): value is string { + return typeof value === "string"; +} + +function isSafeName(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && !/[\r\n]/.test(value); +} + +function isSafePath(value: unknown): value is string { + return typeof value === "string" && value.length > 0 && value.length <= 1024 && value.split(".").every((segment) => segment.length > 0 && !["__proto__", "prototype", "constructor"].includes(segment)); +} + +function isComponentName(value: unknown): value is string { + return typeof value === "string" && /^[a-z][a-z0-9-]*$/.test(value); +} diff --git a/react-sdk/ui-core/tsconfig.json b/react-sdk/ui-core/tsconfig.json new file mode 100644 index 0000000..0b1110e --- /dev/null +++ b/react-sdk/ui-core/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {"rootDir": "src", "outDir": "dist"}, + "include": ["src"] +} diff --git a/react-sdk/ui-react/package.json b/react-sdk/ui-react/package.json new file mode 100644 index 0000000..10c2cbf --- /dev/null +++ b/react-sdk/ui-react/package.json @@ -0,0 +1,41 @@ +{ + "name": "@osspkg/ui-react", + "version": "0.1.0", + "type": "module", + "files": [ + "dist" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./styles.css": { + "types": "./dist/styles.css.d.ts", + "default": "./dist/styles.css" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json && tailwindcss -i src/styles.css -o dist/styles.css && node scripts/copy-styles-types.mjs", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@osspkg/ui-core": "workspace:*", + "@osspkg/ui-transport": "workspace:*", + "tailwindcss": "^4.3.3" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.3.3", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} diff --git a/react-sdk/ui-react/scripts/copy-styles-types.mjs b/react-sdk/ui-react/scripts/copy-styles-types.mjs new file mode 100644 index 0000000..84632fe --- /dev/null +++ b/react-sdk/ui-react/scripts/copy-styles-types.mjs @@ -0,0 +1,6 @@ +import { copyFile } from "node:fs/promises"; + +await copyFile( + new URL("../src/styles.css.d.ts", import.meta.url), + new URL("../dist/styles.css.d.ts", import.meta.url), +); diff --git a/react-sdk/ui-react/src/defaults.test.tsx b/react-sdk/ui-react/src/defaults.test.tsx new file mode 100644 index 0000000..3af6e11 --- /dev/null +++ b/react-sdk/ui-react/src/defaults.test.tsx @@ -0,0 +1,68 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { ViewSchema } from "@osspkg/ui-core"; +import { createComponentRegistry } from "./registry.js"; +import { htmlTagNames, registerDefaultHTMLComponents } from "./html.js"; +import { registerDefaultShadcnComponents, shadcnComponentNames } from "./shadcn.js"; +import { UIProvider } from "./runtime.js"; +import { ViewRenderer } from "./renderer.js"; + +describe("default component registrations", () => { + it("registers every HTML tag and shadcn component without overwriting a host component", () => { + const registry = createComponentRegistry(); + const hostButton = () => null; + registry.register("button", { component: hostButton }); + + registerDefaultHTMLComponents(registry); + registerDefaultShadcnComponents(registry); + + for (const name of htmlTagNames) expect(registry.has(name)).toBe(true); + for (const name of shadcnComponentNames) expect(registry.has(name)).toBe(true); + expect(registry.get("button")?.component).toBe(hostButton); + expect(registry.get("a")?.events).toContain("click"); + expect(registry.get("data-table")?.allowedProps).toContain("rows"); + }); + + it("renders safe native elements and the baseline data table", () => { + const registry = createComponentRegistry(); + registerDefaultHTMLComponents(registry); + registerDefaultShadcnComponents(registry); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "defaults", + regions: { + "top-header": [], "left-panel": [], "right-panel": [], bottom: [], + content: [ + { id: "link", component: "a", props: { href: "https://example.com", text: "Example" } }, + { id: "profile-form", component: "form", props: { method: "post", enctype: "multipart/form-data", novalidate: true, autocomplete: "off" } }, + { id: "email-label", component: "label", props: { for: "email", text: "Email" } }, + { id: "email", component: "input", props: { id: "email", type: "email", autocomplete: "email", inputmode: "email", enterkeyhint: "next", maxlength: 255, spellcheck: false } }, + { id: "notes", component: "textarea", props: { dirname: "notes.dir", wrap: "soft" } }, + { id: "submit", component: "button", props: { type: "submit", text: "Save" } }, + { id: "users", component: "data-table", props: { rows: [{ name: "Ada" }] } }, + ], + }, + }; + + const markup = renderToStaticMarkup(); + expect(markup).toContain('href="https://example.com"'); + expect(markup).toContain('method="post"'); + expect(markup).toContain('encType="multipart/form-data"'); + expect(markup).toContain('for="email"'); + expect(markup).toContain('autoComplete="email"'); + expect(markup).toContain('inputMode="email"'); + expect(markup).toContain('enterKeyHint="next"'); + expect(markup).toContain('maxLength="255"'); + expect(markup).toContain('dirname="notes.dir"'); + expect(markup).toContain('wrap="soft"'); + expect(markup).toContain('type="submit"'); + expect(markup).toContain("Ada"); + + schema.regions.content[0]!.props = { href: "javascript:alert(1)", text: "unsafe" }; + expect(() => renderToStaticMarkup()).toThrow("href must be a safe URL"); + + schema.regions.content[0]!.component = "form"; + schema.regions.content[0]!.props = { action: "https://attacker.example" }; + expect(() => renderToStaticMarkup()).toThrow("prop action is not allowed"); + }); +}); diff --git a/react-sdk/ui-react/src/defaults.tsx b/react-sdk/ui-react/src/defaults.tsx new file mode 100644 index 0000000..d1a992d --- /dev/null +++ b/react-sdk/ui-react/src/defaults.tsx @@ -0,0 +1,154 @@ +import { createElement, type ComponentType, type ReactNode } from "react"; +import type { ComponentDefinition } from "./registry.js"; + +type Props = Record; + +const globalProps = [ + "id", "title", "hidden", "dir", "lang", "role", "tabIndex", "contentEditable", "draggable", "translate", + "aria-label", "aria-describedby", "aria-labelledby", "aria-live", "aria-current", "aria-expanded", "aria-controls", "aria-pressed", "aria-description", "aria-details", "aria-errormessage", "aria-invalid", "aria-placeholder", "aria-required", +] as const; + +const commonProps = ["text", "type", "value", "defaultValue", "name", "placeholder", "disabled", "readOnly", "required", "autoComplete", "autoFocus", "min", "max", "minLength", "maxLength", "step", "pattern", "multiple", "checked", "selected", "rows", "cols", "width", "height", "loading", "error"] as const; +const linkProps = ["href", "target", "rel", "download", "src", "alt", "poster"] as const; +const formProps = ["accept", "acceptCharset", "capture", "dirname", "encType", "enterKeyHint", "form", "formEncType", "formMethod", "formNoValidate", "formTarget", "htmlFor", "inputMode", "list", "method", "noValidate", "size", "spellCheck", "wrap"] as const; +const htmlPropAliases = { + "accept-charset": "acceptCharset", + autocomplete: "autoComplete", + autofocus: "autoFocus", + contenteditable: "contentEditable", + dirname: "dirname", + enctype: "encType", + enterkeyhint: "enterKeyHint", + for: "htmlFor", + formenctype: "formEncType", + formmethod: "formMethod", + formnovalidate: "formNoValidate", + formtarget: "formTarget", + inputmode: "inputMode", + maxlength: "maxLength", + minlength: "minLength", + novalidate: "noValidate", + readonly: "readOnly", + spellcheck: "spellCheck", + tabindex: "tabIndex", +} as const; +const htmlAliases = Object.keys(htmlPropAliases); +const events = ["click", "change", "input", "submit", "focus", "blur"] as const; +const htmlProps = [...globalProps, ...commonProps, ...linkProps, ...formProps, ...htmlAliases] as const; +const shadcnProps = [...htmlProps, "variant", "size", "orientation", "side", "align", "open", "defaultOpen", "modal", "label", "description", "status"] as const; +const domProps = new Set([...globalProps, ...commonProps, ...linkProps, ...formProps]); +const eventProps = new Set(["onClick", "onChange", "onInput", "onSubmit", "onFocus", "onBlur"]); +const urlProps = new Set(["href", "src", "poster"]); + +function safeURL(value: string): boolean { + if (value.startsWith("/") || value.startsWith("#") || value.startsWith("?")) return true; + try { + return ["http:", "https:", "mailto:", "tel:"].includes(new URL(value).protocol); + } catch { + return false; + } +} + +function sanitizeProps(props: Props): Props { + const result: Props = {}; + for (const [name, value] of Object.entries(props)) { + if (value === undefined) continue; + const normalizedName: string = htmlPropAliases[name as keyof typeof htmlPropAliases] ?? name; + if (normalizedName in result) throw new Error(`duplicate prop ${normalizedName}`); + if (normalizedName === "rows") { + if (!Array.isArray(value)) throw new Error("rows must be an array"); + result[normalizedName] = value; + continue; + } + if (urlProps.has(normalizedName)) { + if (typeof value !== "string" || !safeURL(value)) throw new Error(`${normalizedName} must be a safe URL`); + result[normalizedName] = value; + continue; + } + if (["string", "number", "boolean"].includes(typeof value)) { + result[normalizedName] = value; + continue; + } + throw new Error(`${normalizedName} must be a string, number, or boolean`); + } + return result; +} + +function DOMElement({ tag, props, className, children }: { tag: string; props: Props; className: string; children?: ReactNode }): ReactNode { + const elementProps: Props = {}; + for (const [name, value] of Object.entries(props)) { + if (eventProps.has(name) || (domProps.has(name) && name !== "text" && name !== "rows" && name !== "loading" && name !== "error")) elementProps[name] = value; + } + elementProps.className = className; + const content = props.text ?? children; + if (tag === "form" && typeof props.onSubmit === "function") { + elementProps.onSubmit = (event: { preventDefault(): void }) => { + event.preventDefault(); + void (props.onSubmit as (event: unknown) => Promise)(event); + }; + } + return createElement(tag, elementProps, content as ReactNode); +} + +function htmlClass(tag: string): string { + if (tag === "a") return "text-ui-primary underline-offset-4 hover:underline"; + if (tag === "button") return "inline-flex items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; + if (["input", "textarea", "select"].includes(tag)) return "flex w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; + if (tag === "table") return "w-full caption-bottom text-sm"; + if (["th", "td"].includes(tag)) return "border-b border-ui-border px-4 py-3 text-left align-middle"; + if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) return "font-semibold tracking-tight text-ui-foreground"; + if (tag === "img") return "h-auto max-w-full rounded-md"; + return "text-ui-foreground"; +} + +export function createDefaultHTMLDefinition(tag: string): ComponentDefinition { + return { + component: ((props: Props) => {props.children as ReactNode}) as ComponentType, + allowedProps: htmlProps, + propsSchema: sanitizeProps, + events, + }; +} + +const shadcnElement: Record = { + button: "button", checkbox: "input", input: "input", "native-select": "select", select: "select", slider: "input", switch: "button", textarea: "textarea", + table: "table", "data-table": "table", progress: "progress", separator: "hr", label: "label", kbd: "kbd", typography: "p", spinner: "output", + accordion: "details", collapsible: "details", dialog: "dialog", "alert-dialog": "dialog", +}; + +function shadcnClass(name: string): string { + if (name === "button") return "inline-flex items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; + if (["input", "textarea", "select", "native-select"].includes(name)) return "flex w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; + if (name === "card") return "rounded-xl border border-ui-border bg-ui-card p-6 text-ui-card-foreground shadow-sm"; + if (["badge", "marker"].includes(name)) return "inline-flex items-center rounded-md bg-ui-muted px-2 py-1 text-xs font-medium text-ui-muted-foreground"; + if (["alert", "empty", "message", "toast"].includes(name)) return "rounded-lg border border-ui-border bg-ui-card p-4 text-ui-card-foreground"; + if (name === "separator") return "my-4 border-ui-border"; + if (name === "spinner") return "inline-flex size-4 animate-spin rounded-full border-2 border-ui-muted border-t-ui-primary"; + if (name === "skeleton") return "animate-pulse rounded-md bg-ui-muted"; + if (name === "table") return "w-full caption-bottom text-sm"; + return "rounded-md border border-ui-border bg-ui-card p-4 text-ui-card-foreground"; +} + +function DataTable({ rows, loading, error }: Props): ReactNode { + if (loading) return Loading…; + if (error) return {String(error)}; + const items = Array.isArray(rows) ? rows : []; + return {items.map((row, index) => {Object.values(row as Record).map((value, cell) => )})}
{String(value ?? "")}
; +} + +export function createDefaultShadcnDefinition(name: string): ComponentDefinition { + if (name === "data-table") return { + component: DataTable as ComponentType, + allowedProps: shadcnProps, + propsSchema: sanitizeProps, + events, + children: false, + }; + const tag = shadcnElement[name] ?? "div"; + return { + component: ((props: Props) => {props.children as ReactNode}) as ComponentType, + allowedProps: shadcnProps, + propsSchema: sanitizeProps, + events, + }; +} diff --git a/react-sdk/ui-react/src/demo.test.tsx b/react-sdk/ui-react/src/demo.test.tsx new file mode 100644 index 0000000..ce83850 --- /dev/null +++ b/react-sdk/ui-react/src/demo.test.tsx @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { createUsersDemoRegistry } from "./demo.js"; + +describe("users demo registry", () => { + it("contains host-owned CRUD primitives", () => { + const registry = createUsersDemoRegistry(); + expect(registry.has("table")).toBe(true); + expect(registry.has("form")).toBe(true); + expect(registry.has("text-field")).toBe(true); + expect(registry.get("table")?.allowedProps).toEqual(["rows", "loading", "error"]); + }); +}); diff --git a/react-sdk/ui-react/src/demo.tsx b/react-sdk/ui-react/src/demo.tsx new file mode 100644 index 0000000..a5f7493 --- /dev/null +++ b/react-sdk/ui-react/src/demo.tsx @@ -0,0 +1,32 @@ +import { useMemo, type ReactElement, type ReactNode } from "react"; +import type { RPCTransport } from "@osspkg/ui-transport"; +import { createComponentRegistry, type ComponentRegistry } from "./registry.js"; +import { PluginView } from "./renderer.js"; +import { UIProvider } from "./runtime.js"; + +export interface UsersDemoProps { + transport?: RPCTransport; +} + +export function createUsersDemoRegistry(): ComponentRegistry { + const registry = createComponentRegistry(); + registry.register("heading", { component: ({ text }: { text?: unknown }) =>

{String(text ?? "")}

, allowedProps: ["text"] }); + registry.register("text", { component: ({ text }: { text?: unknown }) =>

{String(text ?? "")}

, allowedProps: ["text"] }); + registry.register("button", { component: ({ text, children }: { text?: unknown; children?: ReactNode }) => , allowedProps: ["text"], events: ["click"] }); + registry.register("text-field", { component: ({ value }: { value?: unknown }) => , allowedProps: ["value"] }); + registry.register("form", { component: ({ children }: { children?: ReactNode }) =>
{children}
}); + registry.register("table", { component: UsersTable, allowedProps: ["rows", "loading", "error"], children: false }); + return registry; +} + +function UsersTable({ rows, loading, error }: { rows?: unknown; loading?: unknown; error?: unknown }): ReactElement { + if (loading) return
Loading users…
; + if (error) return
Unable to load users
; + const items = Array.isArray(rows) ? rows : []; + return {items.map((row, index) => )}
{String((row as Record).name ?? "")}
; +} + +export function UsersDemo({ transport }: UsersDemoProps): ReactElement { + const components = useMemo(() => createUsersDemoRegistry(), []); + return ; +} diff --git a/react-sdk/ui-react/src/html.test.tsx b/react-sdk/ui-react/src/html.test.tsx new file mode 100644 index 0000000..bd4a0c6 --- /dev/null +++ b/react-sdk/ui-react/src/html.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { createComponentRegistry } from "./registry.js"; +import { htmlTagNames, isHTMLTagName, registerHTMLTags } from "./html.js"; + +describe("HTML tag catalog", () => { + it("contains safe semantic, form, table, and media tags", () => { + expect(htmlTagNames).toContain("article"); + expect(htmlTagNames).toContain("input"); + expect(htmlTagNames).toContain("table"); + expect(htmlTagNames).toContain("video"); + expect(isHTMLTagName("script")).toBe(false); + expect(isHTMLTagName("iframe")).toBe(false); + }); + + it("registers host implementations only for catalog tags", () => { + const registry = createComponentRegistry(); + registerHTMLTags(registry, { article: { component: () => null } }); + expect(registry.has("article")).toBe(true); + }); +}); diff --git a/react-sdk/ui-react/src/html.ts b/react-sdk/ui-react/src/html.ts new file mode 100644 index 0000000..3da621b --- /dev/null +++ b/react-sdk/ui-react/src/html.ts @@ -0,0 +1,28 @@ +import type { ComponentDefinition, ComponentRegistry } from "./registry.js"; +import { createDefaultHTMLDefinition } from "./defaults.js"; + +export const htmlTagNames = [ + "a", "abbr", "address", "area", "article", "aside", "audio", "b", "bdi", "bdo", "blockquote", "br", "button", "canvas", "caption", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt", "em", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "i", "img", "input", "ins", "kbd", "label", "legend", "li", "main", "map", "mark", "menu", "meter", "nav", "ol", "optgroup", "option", "output", "p", "picture", "pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "search", "section", "select", "small", "source", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "textarea", "tfoot", "th", "thead", "time", "tr", "track", "u", "ul", "var", "video", "wbr", +] as const; + +export type HTMLTagName = typeof htmlTagNames[number]; + +const htmlTagSet = new Set(htmlTagNames); + +export function isHTMLTagName(name: string): name is HTMLTagName { + return htmlTagSet.has(name); +} + +// registerHTMLTags registers host-owned implementations for safe HTML tags. +export function registerHTMLTags(registry: ComponentRegistry, definitions: Partial>): void { + for (const [name, definition] of Object.entries(definitions)) { + if (!isHTMLTagName(name) || !definition) throw new Error(`unsupported html tag ${name}`); + registry.register(name, definition); + } +} + +// registerDefaultHTMLComponents registers safe native React implementations for every supported HTML tag. +// Existing host registrations take precedence. +export function registerDefaultHTMLComponents(registry: ComponentRegistry): void { + for (const name of htmlTagNames) if (!registry.has(name)) registry.register(name, createDefaultHTMLDefinition(name)); +} diff --git a/react-sdk/ui-react/src/index.ts b/react-sdk/ui-react/src/index.ts new file mode 100644 index 0000000..4d9d3e5 --- /dev/null +++ b/react-sdk/ui-react/src/index.ts @@ -0,0 +1,6 @@ +export * from "./registry.js"; +export * from "./renderer.js"; +export * from "./runtime.js"; +export * from "./demo.js"; +export * from "./shadcn.js"; +export * from "./html.js"; diff --git a/react-sdk/ui-react/src/registry.test.tsx b/react-sdk/ui-react/src/registry.test.tsx new file mode 100644 index 0000000..4cd587b --- /dev/null +++ b/react-sdk/ui-react/src/registry.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { createComponentRegistry } from "./registry.js"; + +describe("ComponentRegistry", () => { + it("registers and resolves host components", () => { + const registry = createComponentRegistry(); + const component = () => null; + registry.register("text", { component }); + expect(registry.has("text")).toBe(true); + expect(registry.get("text")?.component).toBe(component); + }); + + it("rejects duplicate logical component names", () => { + const registry = createComponentRegistry(); + registry.register("text", { component: () => null }); + expect(() => registry.register("text", { component: () => null })).toThrow("already registered"); + }); +}); diff --git a/react-sdk/ui-react/src/registry.ts b/react-sdk/ui-react/src/registry.ts new file mode 100644 index 0000000..499deef --- /dev/null +++ b/react-sdk/ui-react/src/registry.ts @@ -0,0 +1,26 @@ +import type { ComponentType } from "react"; + +export interface ComponentDefinition> { + component: ComponentType; + allowedProps?: readonly string[]; + propsSchema?: (props: Record) => Props; + events?: readonly string[]; + slots?: readonly string[]; + children?: boolean; +} + +export class ComponentRegistry { + private readonly definitions = new Map(); + + register(name: string, definition: ComponentDefinition): void { + if (!name || this.definitions.has(name)) throw new Error(`component ${name} is already registered`); + this.definitions.set(name, definition); + } + + get(name: string): ComponentDefinition | undefined { return this.definitions.get(name); } + has(name: string): boolean { return this.definitions.has(name); } +} + +export function createComponentRegistry(): ComponentRegistry { + return new ComponentRegistry(); +} diff --git a/react-sdk/ui-react/src/renderer.test.tsx b/react-sdk/ui-react/src/renderer.test.tsx new file mode 100644 index 0000000..81fc47c --- /dev/null +++ b/react-sdk/ui-react/src/renderer.test.tsx @@ -0,0 +1,119 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { ReactNode } from "react"; +import type { ViewSchema } from "@osspkg/ui-core"; +import { createComponentRegistry } from "./registry.js"; +import { UIProvider } from "./runtime.js"; +import { ViewRenderer } from "./renderer.js"; + +function emptyRegions() { + return { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] }; +} + +describe("static renderer", () => { + it("renders registered components, children, and grid metadata", () => { + const registry = createComponentRegistry(); + registry.register("card", { + component: (props: Record) =>
{String(props.title)}{props.children as ReactNode}
, + allowedProps: ["title"], + }); + registry.register("text", { + component: (props: Record) => {String(props.value)}, + allowedProps: ["value"], + children: false, + }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "renderer", + regions: { + ...emptyRegions(), + content: [{ + id: "card", + component: "card", + layout: { row: 1, cols: 8, offset: 2 }, + props: { title: "Users" }, + children: [{ id: "text", component: "text", props: { value: "Ada" } }], + }], + }, + }; + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Users"); + expect(markup).toContain("Ada"); + expect(markup).toContain('data-ui-cols="8"'); + expect(markup).toContain('data-ui-offset="2"'); + expect(markup).toContain("grid grid-cols-12 gap-4"); + expect(markup).toContain("col-span-[var(--ui-cols)]"); + expect(markup).toContain("--ui-row:1;--ui-cols:8;--ui-start:3"); + }); + + it("renders declared slots and rejects undeclared slots", () => { + const registry = createComponentRegistry(); + registry.register("card", { + component: (props: Record) =>
{(props.slots as Record).footer}
, + slots: ["footer"], + }); + registry.register("text", { component: (props: Record) => {String(props.value)}, allowedProps: ["value"] }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "slots", + regions: { ...emptyRegions(), content: [{ id: "card", component: "card", slots: { footer: [{ id: "footer", component: "text", props: { value: "Footer" } }] } }] }, + }; + + expect(renderToStaticMarkup()).toContain("Footer"); + + schema.regions.content[0]!.slots = { other: [] }; + expect(() => renderToStaticMarkup()).toThrow("slot other is not allowed"); + }); + + it("renders a safe fallback for unsupported components", () => { + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "unsupported", + regions: { ...emptyRegions(), content: [{ id: "missing", component: "missing-component" }] }, + }; + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("Unsupported component: missing-component"); + expect(markup).toContain('data-unsupported-component="missing-component"'); + }); + + it("rejects props without a host declaration", () => { + const registry = createComponentRegistry(); + registry.register("text", { component: () => text }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "props", + regions: { ...emptyRegions(), content: [{ id: "text", component: "text", props: { value: "unsafe" } }] }, + }; + + expect(() => renderToStaticMarkup()).toThrow("component props are not declared"); + }); + + it("rejects events without a host allowlist", () => { + const registry = createComponentRegistry(); + registry.register("button", { component: () => }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "events", + actions: { save: { type: "set-state", path: "saved", value: true } }, + regions: { ...emptyRegions(), content: [{ id: "button", component: "button", events: { click: { action: "save" } } }] }, + }; + + expect(() => renderToStaticMarkup()).toThrow("component events are not declared"); + }); + + it("rejects plugin styling and raw React props", () => { + const registry = createComponentRegistry(); + registry.register("card", { component: () =>
, allowedProps: ["style"] }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "unsafe-props", + regions: { ...emptyRegions(), content: [{ id: "card", component: "card", props: { style: { color: "red" } } }] }, + }; + + expect(() => renderToStaticMarkup()).toThrow("prop style is forbidden"); + }); +}); diff --git a/react-sdk/ui-react/src/renderer.tsx b/react-sdk/ui-react/src/renderer.tsx new file mode 100644 index 0000000..ec3af0f --- /dev/null +++ b/react-sdk/ui-react/src/renderer.tsx @@ -0,0 +1,111 @@ +import { useEffect, useState, type CSSProperties, type ReactElement, type ReactNode } from "react"; +import type { EventHandler, RegionName, UINode, ViewSchema } from "@osspkg/ui-core"; +import { validateView } from "@osspkg/ui-core"; +import { useUIRuntime, useUIViewRuntime, ViewRuntimeProvider } from "./runtime.js"; + +export interface PluginViewProps { + plugin?: string; + view: string; + context?: unknown; + schema?: ViewSchema; +} + +export function PluginView(props: PluginViewProps): ReactElement { + const { transport } = useUIRuntime(); + const [schema, setSchema] = useState(props.schema); + const [error, setError] = useState(); + useEffect(() => { + if (props.schema) { + setSchema(props.schema); + setError(undefined); + return; + } + setSchema(undefined); + setError(undefined); + if (!transport || !props.plugin) return; + let active = true; + void transport.call<{ schema: ViewSchema }>("ui.get", { plugin: props.plugin, view: props.view, context: props.context }).then((response) => { + if (active) setSchema(response.schema); + }).catch((reason: unknown) => { if (active) setError(reason instanceof Error ? reason : new Error("view loading failed")); }); + return () => { active = false; }; + }, [props.context, props.plugin, props.schema, props.view, transport]); + if (error) return
{error.message}
; + if (!schema) return
Loading…
; + return ; +} + +export function ViewRenderer({ schema, plugin, context = {} }: { schema: ViewSchema; plugin?: string; context?: unknown }): ReactElement { + validateView(schema); + const runtimeKey = `${plugin ?? ""}:${schema.id}:${schema.revision ?? ""}`; + return ; +} + +function ViewRegions(): ReactElement { + const runtime = useUIViewRuntime(); + const regions: RegionName[] = ["top-header", "left-panel", "content", "right-panel", "bottom"]; + return <> + {regions.map((region) =>
{runtime.schema.regions[region].map((node) => )}
)} + ; +} + +function NodeRenderer({ node }: { node: UINode }): ReactNode { + const runtime = useUIViewRuntime(); + if (node.when !== undefined && !runtime.resolve(node.when)) return null; + const definition = runtime.components.get(node.component); + if (!definition) return
Unsupported component: {node.component}
; + const rawProps = Object.fromEntries(Object.entries(node.props ?? {}).map(([key, value]) => [key, runtime.resolve(value)])); + const props = validateProps(rawProps, definition.allowedProps, definition.propsSchema); + const events = bindEvents(node.events, definition.events, runtime); + const children = definition.children === false ? undefined : node.children?.map((child) => ); + const slots = renderSlots(node.slots, definition.slots); + const Component = definition.component as (props: Record) => ReactElement; + const layout = node.layout; + const layoutStyle = layout ? { + "--ui-row": layout.row, + "--ui-cols": layout.cols ?? 12, + "--ui-start": (layout.offset ?? 0) + 1, + } as CSSProperties : undefined; + return
{children}
; +} + +function renderSlots(slots: Record | undefined, allowedSlots: readonly string[] | undefined): Record | undefined { + if (!slots) return undefined; + if (!allowedSlots) throw new Error("component slots are not declared"); + const rendered: Record = {}; + for (const [name, nodes] of Object.entries(slots)) { + if (!allowedSlots.includes(name)) throw new Error(`slot ${name} is not allowed`); + rendered[name] = nodes.map((child) => ); + } + return rendered; +} + +function validateProps(rawProps: Record, allowedProps: readonly string[] | undefined, schema: ((props: Record) => unknown) | undefined): Record { + for (const key of Object.keys(rawProps)) { + if (forbiddenPluginProps.has(key) || key.startsWith("on")) throw new Error(`prop ${key} is forbidden`); + } + if (!allowedProps && !schema && Object.keys(rawProps).length > 0) throw new Error("component props are not declared"); + if (allowedProps) for (const key of Object.keys(rawProps)) if (!allowedProps.includes(key)) throw new Error(`prop ${key} is not allowed`); + return schema ? schema(rawProps) as Record : rawProps; +} + +const forbiddenPluginProps = new Set(["dangerouslySetInnerHTML", "style", "className", "ref", "key"]); + +function bindEvents(handlers: Record | undefined, allowedEvents: readonly string[] | undefined, runtime: ReturnType): Record Promise> { + const entries = Object.entries(handlers ?? {}); + if (entries.length > 0 && !allowedEvents) throw new Error("component events are not declared"); + return Object.fromEntries(entries.map(([name, handler]) => { + if (!/^[a-z][a-z0-9-]*$/.test(name) || !allowedEvents?.includes(name)) throw new Error(`event ${name} is not allowed`); + return [eventProp(name), createEventHandler(handler, runtime)]; + })); +} + +function eventProp(name: string): string { + return `on${name.slice(0, 1).toUpperCase()}${name.slice(1)}`; +} + +function createEventHandler(handler: EventHandler, runtime: ReturnType): (event: unknown) => Promise { + return async (event) => { + if (handler.action) await runtime.runAction(handler.action, event); + for (const step of handler.steps ?? []) if (step.action) await runtime.runAction(step.action, event); + }; +} diff --git a/react-sdk/ui-react/src/runtime.test.tsx b/react-sdk/ui-react/src/runtime.test.tsx new file mode 100644 index 0000000..8f7202f --- /dev/null +++ b/react-sdk/ui-react/src/runtime.test.tsx @@ -0,0 +1,163 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { ViewSchema } from "@osspkg/ui-core"; +import { createComponentRegistry } from "./registry.js"; +import { ViewRenderer } from "./renderer.js"; +import { UIProvider, useUISource, type UISource } from "./runtime.js"; +import type { RPCTransport } from "@osspkg/ui-transport"; + +describe("view runtime bindings", () => { + it("resolves state and context values before rendering and binds events", () => { + let received: Record | undefined; + const registry = createComponentRegistry(); + registry.register("probe", { + component: (props: Record) => { + received = props; + return {String(props.name)}:{String(props.workspace)}; + }, + allowedProps: ["name", "workspace"], + events: ["click"], + }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "bindings", + state: { name: "Ada" }, + actions: { submit: { type: "set-state", path: "name", value: "Grace" } }, + regions: { + "top-header": [], + "left-panel": [], + content: [{ + id: "probe", + component: "probe", + props: { name: { $state: "name" }, workspace: { $context: "workspace.id" } }, + events: { click: { action: "submit" } }, + }], + "right-panel": [], + bottom: [], + }, + }; + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("Ada:w-1"); + expect(received?.name).toBe("Ada"); + expect(received?.workspace).toBe("w-1"); + expect(received?.onClick).toEqual(expect.any(Function)); + }); + + it("does not render nodes whose when condition is false", () => { + const registry = createComponentRegistry(); + registry.register("probe", { component: () => visible }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "conditions", + state: { enabled: false }, + regions: { + "top-header": [], + "left-panel": [], + content: [{ id: "conditional", component: "probe", when: { $state: "enabled" } }], + "right-panel": [], + bottom: [], + }, + }; + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).not.toContain("visible"); + }); + + it("exposes idle source state and manual refresh", () => { + let source: UISource | undefined; + const registry = createComponentRegistry(); + registry.register("source-probe", { + component: () => { + source = useUISource("users"); + return {source.status}; + }, + }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "source", + sources: { users: { type: "tool", tool: "users.list", policy: "manual" } }, + regions: { + "top-header": [], + "left-panel": [], + content: [{ id: "source-probe", component: "source-probe" }], + "right-panel": [], + bottom: [], + }, + }; + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain("idle"); + expect(source?.refresh).toEqual(expect.any(Function)); + }); + + it("executes server actions and host-controlled effects", async () => { + let received: Record | undefined; + const calls: Array<{ method: string; params: unknown }> = []; + const transport: RPCTransport = { + async call(method: string, params: unknown): Promise { + calls.push({ method, params }); + return { id: "u-1" } as T; + }, + subscribe: () => () => undefined, + close: () => undefined, + }; + const toasts: string[] = []; + const registry = createComponentRegistry(); + registry.register("button", { + component: (props: Record) => { + received = props; + return ; + }, + events: ["click"], + }); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "actions", + actions: { + save: { + type: "tool", + tool: "users.save", + input: { name: { $event: "value" } }, + effects: [{ type: "toast", variant: "success", message: "Saved" }], + }, + }, + sources: { users: { type: "tool", tool: "users.list", refreshOn: ["save"] } }, + regions: { + "top-header": [], + "left-panel": [], + content: [{ id: "save", component: "button", events: { click: { action: "save" } } }], + "right-panel": [], + bottom: [], + }, + }; + + renderToStaticMarkup( + { toasts.push(message); } }}> + + , + ); + await (received?.onClick as (event: unknown) => Promise)({ value: "Ada" }); + + expect(calls).toEqual([ + { method: "ui.action", params: { plugin: "users", view: "actions", action: "save", input: { name: "Ada" } } }, + { method: "data.call", params: { plugin: "users", view: "actions", source: "users", operation: "users.list", input: {} } }, + ]); + expect(toasts).toEqual(["Saved"]); + }); +}); diff --git a/react-sdk/ui-react/src/runtime.tsx b/react-sdk/ui-react/src/runtime.tsx new file mode 100644 index 0000000..33c973b --- /dev/null +++ b/react-sdk/ui-react/src/runtime.tsx @@ -0,0 +1,226 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type Dispatch, type PropsWithChildren, type ReactElement, type SetStateAction } from "react"; +import { createRequestTracker, resolveValue, resolvePath, setPath, type Effect, type ResolveScope, type SourceRuntimeState, type UIValue, type ViewSchema } from "@osspkg/ui-core"; +import type { RPCTransport } from "@osspkg/ui-transport"; +import { ComponentRegistry } from "./registry.js"; + +export interface UIEffectHandlers { + toast?(message: string, variant?: string): void | Promise; + navigate?(to: string): void | Promise; + dialog?(effect: Effect): void | Promise; + closeDialog?(effect: Effect): void | Promise; +} + +export interface UIRuntime { + components: ComponentRegistry; + transport?: RPCTransport; + effects?: UIEffectHandlers; +} + +export interface ViewRuntime extends UIRuntime { + schema: ViewSchema; + plugin?: string; + context: unknown; + state: unknown; + sources: Record; + setState(path: string, value: unknown): void; + mergeState(path: string, value: Record): void; + resolve(value: unknown, event?: unknown, result?: unknown): unknown; + runAction(name: string, event?: unknown): Promise; + refreshSource(name: string): Promise; +} + +export interface UISource extends SourceRuntimeState { + refresh(): Promise; +} + +const runtimeContext = createContext(undefined); +const viewRuntimeContext = createContext(undefined); + +export function UIProvider({ components, transport, effects, children }: PropsWithChildren): ReactElement { + const value = useMemo(() => ({ components, ...(transport ? { transport } : {}), ...(effects ? { effects } : {}) }), [components, effects, transport]); + return {children}; +} + +export function useUIRuntime(): UIRuntime { + const runtime = useContext(runtimeContext); + if (!runtime) throw new Error("UIProvider is required"); + return runtime; +} + +export function useUIViewRuntime(): ViewRuntime { + const runtime = useContext(viewRuntimeContext); + if (!runtime) throw new Error("ViewRuntime is required"); + return runtime; +} + +export function useUISource(name: string): UISource { + const runtime = useUIViewRuntime(); + const source = runtime.sources[name] as SourceRuntimeState | undefined; + const refresh = useCallback(() => runtime.refreshSource(name), [name, runtime]); + return useMemo(() => ({ ...(source ?? { status: "idle" as const }), refresh }), [refresh, source]); +} + +export function useUIAction(name: string): (event?: unknown) => Promise { + const runtime = useUIViewRuntime(); + return useCallback((event?: unknown) => runtime.runAction(name, event), [name, runtime]); +} + +export function ViewRuntimeProvider({ schema, plugin, context, children }: PropsWithChildren<{ schema: ViewSchema; plugin?: string; context: unknown }>): ReactElement { + const base = useUIRuntime(); + const [state, setState] = useState(schema.state ?? {}); + const [sources, setSources] = useState>(() => initialSourceStates(schema)); + const requestIDs = useRef(createRequestTracker()); + const controllers = useRef(new Map()); + + useEffect(() => { + requestIDs.current = createRequestTracker(); + setState(schema.state ?? {}); + setSources(initialSourceStates(schema)); + }, [schema]); + + const refreshSource = useCallback(async (name: string): Promise => { + const source = schema.sources?.[name]; + if (!source || !base.transport) return; + controllers.current.get(name)?.abort(); + const controller = new AbortController(); + controllers.current.set(name, controller); + const requestID = requestIDs.current.next(name); + setSources((current) => ({ ...current, [name]: { status: "loading", requestId: String(requestID) } })); + try { + const input = resolveValue(source.input ?? {}, { state, context, sources }); + const data = await base.transport.call("data.call", { plugin, view: schema.id, source: name, operation: source.tool, input }, { signal: controller.signal }); + if (!requestIDs.current.isCurrent(name, requestID)) return; + setSources((current) => ({ ...current, [name]: { status: "success", data, updatedAt: Date.now(), requestId: String(requestID) } })); + } catch (error) { + if (controller.signal.aborted || !requestIDs.current.isCurrent(name, requestID)) return; + setSources((current) => ({ ...current, [name]: { status: "error", error: { code: "SOURCE_FAILED", message: error instanceof Error ? error.message : "source failed", retryable: true }, requestId: String(requestID) } })); + } finally { + if (controllers.current.get(name) === controller) controllers.current.delete(name); + } + }, [base.transport, context, plugin, schema, sources, state]); + + useEffect(() => { + for (const [name, source] of Object.entries(schema.sources ?? {})) { + if (source.policy === "on-mount") void refreshSource(name); + } + return () => { for (const controller of controllers.current.values()) controller.abort(); }; + // on-mount execution belongs to one schema lifecycle; source updates must not retrigger it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [schema, base.transport, plugin]); + + useEffect(() => { + const onReconnect = base.transport?.onReconnect; + if (!onReconnect) return; + return onReconnect(() => { + for (const [name, source] of Object.entries(schema.sources ?? {})) { + if (source.policy === "on-mount") void refreshSource(name); + } + }); + }, [base.transport, refreshSource, schema]); + + useEffect(() => { + const transport = base.transport; + if (!transport) return; + return transport.subscribe((notification) => { + const params = isRecord(notification.params) ? notification.params : {}; + const notificationPlugin = params.plugin; + const notificationView = params.view; + if (typeof notificationPlugin === "string" && notificationPlugin !== plugin) return; + if (typeof notificationView === "string" && notificationView !== schema.id) return; + if (notification.method === "data.invalidate" && typeof params.source === "string") { + void refreshSource(params.source); + return; + } + if (notification.method === "ui.invalidate" || notification.method === "plugin.changed") { + for (const [name, source] of Object.entries(schema.sources ?? {})) { + if (source.policy === "on-mount") void refreshSource(name); + } + } + }); + }, [base.transport, plugin, refreshSource, schema]); + + const runtime = useMemo(() => ({ + ...base, + schema, + ...(plugin ? { plugin } : {}), + context, + state, + sources, + setState: (path, value) => setState((current: unknown) => setPath(current, path, value)), + mergeState: (path, value) => setState((current: unknown) => { + const currentValue = resolvePath(current, path); + const merged = currentValue && typeof currentValue === "object" ? { ...currentValue, ...value } : value; + return setPath(current, path, merged); + }), + resolve: (value, event, result) => resolveValue(value as UIValue, { state, context, sources, event, result }), + runAction: (name, event) => executeAction(name, schema, base.transport, base.effects, plugin, state, context, sources, setState, refreshSource, event), + refreshSource, + }), [base, context, plugin, refreshSource, schema, sources, state]); + return {children}; +} + +function initialSourceStates(schema: ViewSchema): Record { + return Object.fromEntries(Object.keys(schema.sources ?? {}).map((name) => [name, { status: "idle" as const }])); +} + +async function executeAction(name: string, schema: ViewSchema, transport: RPCTransport | undefined, handlers: UIEffectHandlers | undefined, plugin: string | undefined, state: unknown, context: unknown, sources: Record, setState: Dispatch>, refreshSource: (name: string) => Promise, event?: unknown): Promise { + const action = schema.actions?.[name]; + if (!action) throw new Error(`action ${name} is not defined`); + const scope: ResolveScope = { state, context, sources, event }; + if (action.type === "set-state") { + setState((current: unknown) => setPath(current, action.path ?? "", resolveValue(action.value ?? null, scope))); + for (const effect of action.effects ?? []) await executeEffect(effect, undefined, scope, handlers, setState, refreshSource); + await refreshTriggeredSources(name, schema, refreshSource); + return undefined; + } + if (action.type === "merge-state") { + const current = resolvePath(state, action.path ?? ""); + const next = resolveValue(action.value ?? null, scope); + setState((value: unknown) => setPath(value, action.path ?? "", { ...(current && typeof current === "object" ? current : {}), ...(next && typeof next === "object" ? next : {}) })); + for (const effect of action.effects ?? []) await executeEffect(effect, undefined, scope, handlers, setState, refreshSource); + await refreshTriggeredSources(name, schema, refreshSource); + return undefined; + } + if (action.type === "refresh-source" || action.type === "invalidate") { + if (action.source) await refreshSource(action.source); + await refreshTriggeredSources(name, schema, refreshSource); + return undefined; + } + if (action.type !== "tool" || !transport) throw new Error(`action ${name} requires a transport`); + const input = resolveValue(action.input ?? {}, scope); + const response = await transport.call("ui.action", { plugin, view: schema.id, action: name, input }); + for (const effect of action.effects ?? []) await executeEffect(effect, response, scope, handlers, setState, refreshSource); + await refreshTriggeredSources(name, schema, refreshSource); + return response; +} + +async function refreshTriggeredSources(name: string, schema: ViewSchema, refreshSource: (name: string) => Promise): Promise { + for (const [sourceName, source] of Object.entries(schema.sources ?? {})) { + if (source.refreshOn?.includes(name)) await refreshSource(sourceName); + } +} + +async function executeEffect(effect: Effect, result: unknown, scope: ResolveScope, handlers: UIEffectHandlers | undefined, setState: Dispatch>, refreshSource: (name: string) => Promise): Promise { + const effectScope = { ...scope, result }; + if (effect.type === "set-state" && effect.path) { + const value = effect.value === undefined ? result : resolveValue(effect.value, effectScope); + setState((current: unknown) => setPath(current, effect.path!, value)); + } + if (effect.type === "merge-state" && effect.path) { + const value = resolveValue(effect.value ?? {}, effectScope); + setState((current: unknown) => { + const existing = resolvePath(current, effect.path!); + const merged = existing && typeof existing === "object" ? { ...existing, ...(value && typeof value === "object" ? value : {}) } : value; + return setPath(current, effect.path!, merged); + }); + } + if ((effect.type === "invalidate" || effect.type === "refresh-source") && effect.source) await refreshSource(effect.source); + if (effect.type === "toast" && effect.message && handlers?.toast) await handlers.toast(effect.message, effect.variant); + if (effect.type === "navigate" && effect.to && handlers?.navigate) await handlers.navigate(effect.to); + if (effect.type === "dialog" && handlers?.dialog) await handlers.dialog(effect); + if (effect.type === "close-dialog" && handlers?.closeDialog) await handlers.closeDialog(effect); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} diff --git a/react-sdk/ui-react/src/shadcn.test.tsx b/react-sdk/ui-react/src/shadcn.test.tsx new file mode 100644 index 0000000..5ee5262 --- /dev/null +++ b/react-sdk/ui-react/src/shadcn.test.tsx @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { createComponentRegistry } from "./registry.js"; +import { isShadcnComponentName, registerShadcnComponents, shadcnComponentNames } from "./shadcn.js"; + +describe("shadcn component catalog", () => { + it("contains the official top-level component names", () => { + expect(shadcnComponentNames).toHaveLength(64); + expect(isShadcnComponentName("alert-dialog")).toBe(true); + expect(isShadcnComponentName("input-otp")).toBe(true); + expect(isShadcnComponentName("not-a-component")).toBe(false); + }); + + it("registers only catalog components with host-owned definitions", () => { + const registry = createComponentRegistry(); + registerShadcnComponents(registry, { button: { component: () => null, events: ["click"] } }); + expect(registry.has("button")).toBe(true); + }); +}); diff --git a/react-sdk/ui-react/src/shadcn.ts b/react-sdk/ui-react/src/shadcn.ts new file mode 100644 index 0000000..cd9c2af --- /dev/null +++ b/react-sdk/ui-react/src/shadcn.ts @@ -0,0 +1,30 @@ +import type { ComponentDefinition, ComponentRegistry } from "./registry.js"; +import { createDefaultShadcnDefinition } from "./defaults.js"; + +export const shadcnComponentNames = [ + "accordion", "alert", "alert-dialog", "aspect-ratio", "attachment", "avatar", "badge", "breadcrumb", "bubble", "button", "button-group", "calendar", "card", "carousel", "chart", "checkbox", "collapsible", "combobox", "command", "context-menu", "data-table", "date-picker", "dialog", "direction", "drawer", "dropdown-menu", "empty", "field", "hover-card", "input", "input-group", "input-otp", "item", "kbd", "label", "marker", "menubar", "message", "message-scroller", "native-select", "navigation-menu", "pagination", "popover", "progress", "questionnaire", "radio-group", "resizable", "scroll-area", "select", "separator", "sheet", "sidebar", "skeleton", "slider", "spinner", "switch", "table", "tabs", "textarea", "toast", "toggle", "toggle-group", "tooltip", "typography", +] as const; + +export type ShadcnComponentName = typeof shadcnComponentNames[number]; + +const shadcnComponentSet = new Set(shadcnComponentNames); + +export function isShadcnComponentName(name: string): name is ShadcnComponentName { + return shadcnComponentSet.has(name); +} + +// registerShadcnComponents registers host implementations for shadcn/ui names. +// The host deliberately supplies the implementations and their prop allowlists. +export function registerShadcnComponents(registry: ComponentRegistry, definitions: Partial>): void { + for (const [name, definition] of Object.entries(definitions)) { + if (!isShadcnComponentName(name) || !definition) throw new Error(`unsupported shadcn component ${name}`); + registry.register(name, definition); + } +} + +// registerDefaultShadcnComponents registers baseline, dependency-free renderers for every shadcn catalog name. +// Install and register host-owned shadcn/ui source components to replace these defaults where richer behavior is needed. +// Existing host registrations take precedence. +export function registerDefaultShadcnComponents(registry: ComponentRegistry): void { + for (const name of shadcnComponentNames) if (!registry.has(name)) registry.register(name, createDefaultShadcnDefinition(name)); +} diff --git a/react-sdk/ui-react/src/styles.css b/react-sdk/ui-react/src/styles.css new file mode 100644 index 0000000..c9b6ef3 --- /dev/null +++ b/react-sdk/ui-react/src/styles.css @@ -0,0 +1,17 @@ +@import "tailwindcss"; + +@source "./"; + +@theme { + --color-ui-background: oklch(1 0 0); + --color-ui-foreground: oklch(0.145 0 0); + --color-ui-card: oklch(1 0 0); + --color-ui-card-foreground: oklch(0.145 0 0); + --color-ui-primary: oklch(0.205 0 0); + --color-ui-primary-foreground: oklch(0.985 0 0); + --color-ui-muted: oklch(0.97 0 0); + --color-ui-muted-foreground: oklch(0.556 0 0); + --color-ui-border: oklch(0.922 0 0); + --color-ui-ring: oklch(0.708 0 0); + --color-ui-destructive: oklch(0.577 0.245 27.325); +} diff --git a/react-sdk/ui-react/src/styles.css.d.ts b/react-sdk/ui-react/src/styles.css.d.ts new file mode 100644 index 0000000..a9f6f08 --- /dev/null +++ b/react-sdk/ui-react/src/styles.css.d.ts @@ -0,0 +1,3 @@ +declare const stylesheet: string; + +export default stylesheet; diff --git a/react-sdk/ui-react/tsconfig.json b/react-sdk/ui-react/tsconfig.json new file mode 100644 index 0000000..1c2ad44 --- /dev/null +++ b/react-sdk/ui-react/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {"jsx": "react-jsx", "rootDir": "src", "outDir": "dist"}, + "include": ["src"] +} diff --git a/react-sdk/ui-transport/package.json b/react-sdk/ui-transport/package.json new file mode 100644 index 0000000..e76ee98 --- /dev/null +++ b/react-sdk/ui-transport/package.json @@ -0,0 +1,15 @@ +{ + "name": "@osspkg/ui-transport", + "version": "0.1.0", + "type": "module", + "files": ["dist"], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": {".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"}}, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": {"@osspkg/ui-core": "workspace:*"} +} diff --git a/react-sdk/ui-transport/src/http.ts b/react-sdk/ui-transport/src/http.ts new file mode 100644 index 0000000..826d56b --- /dev/null +++ b/react-sdk/ui-transport/src/http.ts @@ -0,0 +1,47 @@ +import { createRequest, readResponse, type CallOptions, type RPCNotification, type RPCTransport } from "./rpc.js"; + +export interface HTTPTransportOptions { + fetch?: typeof globalThis.fetch; + headers?: Record; + timeoutMs?: number; +} + +export class HTTPTransport implements RPCTransport { + private readonly fetcher: typeof globalThis.fetch; + private readonly headers: Record; + private readonly timeoutMs: number; + + constructor(private readonly endpoint: string, options: HTTPTransportOptions = {}) { + this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis); + this.headers = { "content-type": "application/json", ...(options.headers ?? {}) }; + this.timeoutMs = options.timeoutMs ?? 30_000; + } + + async call(method: string, params: unknown, options: CallOptions = {}): Promise { + const request = createRequest(method, params); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? this.timeoutMs); + const abort = () => controller.abort(); + options.signal?.addEventListener("abort", abort, { once: true }); + try { + const response = await this.fetcher(this.endpoint, { + method: "POST", + headers: this.headers, + body: JSON.stringify(request), + signal: controller.signal, + }); + const payload = await response.json() as unknown; + if (!response.ok) throw new Error(`http request failed with status ${response.status}`); + return readResponse(payload, request.id); + } finally { + clearTimeout(timeout); + options.signal?.removeEventListener("abort", abort); + } + } + + subscribe(_handler: (notification: RPCNotification) => void): () => void { + return () => undefined; + } + + close(): void {} +} diff --git a/react-sdk/ui-transport/src/index.ts b/react-sdk/ui-transport/src/index.ts new file mode 100644 index 0000000..69bb765 --- /dev/null +++ b/react-sdk/ui-transport/src/index.ts @@ -0,0 +1,3 @@ +export * from "./http.js"; +export * from "./rpc.js"; +export * from "./websocket.js"; diff --git a/react-sdk/ui-transport/src/rpc.ts b/react-sdk/ui-transport/src/rpc.ts new file mode 100644 index 0000000..95f95e6 --- /dev/null +++ b/react-sdk/ui-transport/src/rpc.ts @@ -0,0 +1,60 @@ +export interface RPCRequest { + jsonrpc: "2.0"; + id: number; + method: string; + params: unknown; + meta?: { correlationId: string }; +} + +export interface RPCResponse { + jsonrpc: "2.0"; + id: number; + result?: T; + error?: { code: number | string; message: string; data?: unknown }; +} + +export interface RPCNotification { + jsonrpc: "2.0"; + method: string; + params?: unknown; +} + +export interface CallOptions { + signal?: AbortSignal; + timeoutMs?: number; +} + +export interface RPCTransport { + call(method: string, params: unknown, options?: CallOptions): Promise; + subscribe(handler: (notification: RPCNotification) => void): () => void; + onReconnect?(handler: () => void): () => void; + close(): void; +} + +export class RPCError extends Error { + readonly code: number | string; + readonly details: unknown; + + constructor(code: number | string, message: string, details?: unknown) { + super(message); + this.name = "RPCError"; + this.code = code; + this.details = details; + } +} + +let nextRequestID = 1; +let nextCorrelationID = 1; + +export function createRequest(method: string, params: unknown): RPCRequest { + const id = nextRequestID++; + return { jsonrpc: "2.0", id, method, params, meta: { correlationId: `ui-${nextCorrelationID++}` } }; +} + +export function readResponse(value: unknown, expectedID: number): T { + if (!value || typeof value !== "object") throw new RPCError("INVALID_RESPONSE", "rpc response is invalid"); + const response = value as RPCResponse; + if (response.jsonrpc !== "2.0" || response.id !== expectedID) throw new RPCError("INVALID_RESPONSE", "rpc response id is invalid"); + if (response.error) throw new RPCError(response.error.code, response.error.message, response.error.data); + return response.result as T; +} diff --git a/react-sdk/ui-transport/src/transport.test.ts b/react-sdk/ui-transport/src/transport.test.ts new file mode 100644 index 0000000..fdfc91b --- /dev/null +++ b/react-sdk/ui-transport/src/transport.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest"; +import { HTTPTransport } from "./http.js"; + +describe("HTTPTransport", () => { + it("sends JSON-RPC requests and returns result", async () => { + let requestInit: RequestInit | undefined; + const fetcher = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + requestInit = init; + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } }), { status: 200 }); + }); + const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher }); + await expect(transport.call("ping", { value: 1 })).resolves.toEqual({ ok: true }); + expect(fetcher).toHaveBeenCalledOnce(); + expect(JSON.parse(String(requestInit?.body))).toMatchObject({ method: "ping", params: { value: 1 } }); + }); + + it("propagates caller cancellation to fetch", async () => { + const fetcher = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); + })); + const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher }); + const controller = new AbortController(); + const request = transport.call("slow", {}, { signal: controller.signal }); + + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: "AbortError" }); + }); + + it("aborts requests that exceed the timeout", async () => { + vi.useFakeTimers(); + const fetcher = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("timed out", "AbortError")), { once: true }); + })); + const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher, timeoutMs: 10 }); + const request = transport.call("slow", {}); + const assertion = expect(request).rejects.toMatchObject({ name: "AbortError" }); + + await vi.advanceTimersByTimeAsync(10); + + await assertion; + vi.useRealTimers(); + }); + + it("rejects malformed JSON-RPC responses", async () => { + const fetcher = vi.fn(async () => new Response(JSON.stringify({ jsonrpc: "2.0", id: 999, result: true }), { status: 200 })); + const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher }); + + await expect(transport.call("ping", {})).rejects.toMatchObject({ name: "RPCError", code: "INVALID_RESPONSE" }); + }); +}); diff --git a/react-sdk/ui-transport/src/websocket.test.ts b/react-sdk/ui-transport/src/websocket.test.ts new file mode 100644 index 0000000..4a17f9c --- /dev/null +++ b/react-sdk/ui-transport/src/websocket.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { WebSocketTransport } from "./websocket.js"; + +class FakeSocket { + static instances: FakeSocket[] = []; + readyState = 0; + onopen: ((event: unknown) => void) | null = null; + onclose: ((event: unknown) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + readonly sent: string[] = []; + + constructor(readonly url: string) { + FakeSocket.instances.push(this); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = 3; + this.onclose?.({}); + } + + open(): void { + this.readyState = 1; + this.onopen?.({}); + } + + receive(message: unknown): void { + this.onmessage?.({ data: JSON.stringify(message) }); + } +} + +afterEach(() => { + FakeSocket.instances = []; + vi.useRealTimers(); +}); + +describe("WebSocketTransport", () => { + it("sends calls and delivers notifications", async () => { + const transport = new WebSocketTransport("wss://example.test/rpc", { socket: FakeSocket }); + const notifications: unknown[] = []; + transport.subscribe((notification) => notifications.push(notification)); + const result = transport.call<{ ok: boolean }>("ping", { value: 1 }); + const socket = FakeSocket.instances[0]!; + socket.open(); + await Promise.resolve(); + const request = JSON.parse(socket.sent[0]!) as { id: number }; + socket.receive({ jsonrpc: "2.0", id: request.id, result: { ok: true } }); + await expect(result).resolves.toEqual({ ok: true }); + + socket.receive({ jsonrpc: "2.0", method: "ui.invalidate", params: { view: "users" } }); + expect(notifications).toEqual([{ jsonrpc: "2.0", method: "ui.invalidate", params: { view: "users" } }]); + transport.close(); + }); + + it("reconnects and notifies the runtime after a new socket opens", async () => { + vi.useFakeTimers(); + const transport = new WebSocketTransport("wss://example.test/rpc", { socket: FakeSocket, reconnect: true, reconnectDelayMs: 10 }); + const reconnected = vi.fn(); + transport.onReconnect(reconnected); + const result = transport.call("ping", {}); + const first = FakeSocket.instances[0]!; + first.open(); + await Promise.resolve(); + const request = JSON.parse(first.sent[0]!) as { id: number }; + first.receive({ jsonrpc: "2.0", id: request.id, result: true }); + await expect(result).resolves.toBe(true); + + first.close(); + await vi.advanceTimersByTimeAsync(10); + const second = FakeSocket.instances[1]!; + second.open(); + + expect(reconnected).toHaveBeenCalledOnce(); + transport.close(); + }); + + it("rejects a call when the socket closes before opening", async () => { + const transport = new WebSocketTransport("wss://example.test/rpc", { socket: FakeSocket, reconnect: true }); + const result = transport.call("ping", {}); + FakeSocket.instances[0]!.close(); + + await expect(result).rejects.toThrow("websocket closed before connection opened"); + transport.close(); + }); +}); diff --git a/react-sdk/ui-transport/src/websocket.ts b/react-sdk/ui-transport/src/websocket.ts new file mode 100644 index 0000000..7d46e22 --- /dev/null +++ b/react-sdk/ui-transport/src/websocket.ts @@ -0,0 +1,171 @@ +import { createRequest, readResponse, type CallOptions, type RPCNotification, type RPCTransport } from "./rpc.js"; + +interface SocketLike { + readyState: number; + onopen: ((event: unknown) => void) | null; + onclose: ((event: unknown) => void) | null; + onerror: ((event: unknown) => void) | null; + onmessage: ((event: { data: string }) => void) | null; + send(data: string): void; + close(): void; +} + +interface SocketConstructor { + new (url: string): SocketLike; +} + +export interface WebSocketTransportOptions { + socket?: SocketConstructor; + timeoutMs?: number; + reconnect?: boolean; + reconnectDelayMs?: number; +} + +interface PendingCall { + resolve(value: unknown): void; + reject(error: unknown): void; + timer: ReturnType; + abort?: () => void; + signal?: AbortSignal; +} + +export class WebSocketTransport implements RPCTransport { + private socket: SocketLike | undefined; + private closed = false; + private connecting: Promise | undefined; + private readonly pending = new Map(); + private readonly listeners = new Set<(notification: RPCNotification) => void>(); + private readonly reconnectListeners = new Set<() => void>(); + private readonly Socket: SocketConstructor; + private reconnectTimer: ReturnType | undefined; + private hasConnected = false; + + constructor(private readonly endpoint: string, private readonly options: WebSocketTransportOptions = {}) { + this.Socket = options.socket ?? (WebSocket as unknown as SocketConstructor); + } + + async call(method: string, params: unknown, callOptions: CallOptions = {}): Promise { + if (this.closed) throw new Error("transport is closed"); + await this.connect(); + const request = createRequest(method, params); + const timeoutMs = callOptions.timeoutMs ?? this.options.timeoutMs ?? 30_000; + return new Promise((resolve, reject) => { + const pending: PendingCall = { resolve, reject, timer: setTimeout(() => { + if (this.pending.get(request.id) !== pending) return; + this.pending.delete(request.id); + this.cleanupPending(pending); + reject(new Error("rpc request timed out")); + }, timeoutMs), ...(callOptions.signal ? { signal: callOptions.signal } : {}) }; + const abort = () => { + if (this.pending.get(request.id) !== pending) return; + this.pending.delete(request.id); + this.cleanupPending(pending); + reject(new DOMException("request was cancelled", "AbortError")); + }; + pending.abort = abort; + this.pending.set(request.id, pending); + callOptions.signal?.addEventListener("abort", abort, { once: true }); + if (callOptions.signal?.aborted) { + abort(); + return; + } + try { + this.socket!.send(JSON.stringify(request)); + } catch (error) { + this.pending.delete(request.id); + this.cleanupPending(pending); + reject(error); + } + }); + } + + subscribe(handler: (notification: RPCNotification) => void): () => void { + this.listeners.add(handler); + return () => this.listeners.delete(handler); + } + + onReconnect(handler: () => void): () => void { + this.reconnectListeners.add(handler); + return () => this.reconnectListeners.delete(handler); + } + + close(): void { + this.closed = true; + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + this.socket?.close(); + for (const pending of this.pending.values()) { + this.cleanupPending(pending); + pending.reject(new Error("transport is closed")); + } + this.pending.clear(); + } + + private connect(): Promise { + if (this.socket?.readyState === 1) return Promise.resolve(); + if (this.connecting) return this.connecting; + this.connecting = new Promise((resolve, reject) => { + const socket = new this.Socket(this.endpoint); + this.socket = socket; + socket.onopen = () => { + if (this.socket !== socket) return; + this.connecting = undefined; + const reconnect = this.hasConnected; + this.hasConnected = true; + resolve(); + if (reconnect) for (const listener of this.reconnectListeners) listener(); + }; + socket.onerror = () => { + if (this.socket !== socket) return; + this.socket = undefined; + this.connecting = undefined; + reject(new Error("websocket connection failed")); + if (!this.closed && this.options.reconnect) this.scheduleReconnect(); + }; + socket.onclose = () => { + if (this.socket !== socket) return; + this.socket = undefined; + this.connecting = undefined; + reject(new Error("websocket closed before connection opened")); + if (!this.closed && this.options.reconnect) this.scheduleReconnect(); + for (const [id, pending] of this.pending) { + this.pending.delete(id); + this.cleanupPending(pending); + pending.reject(new Error("websocket closed")); + } + }; + socket.onmessage = (event) => this.handleMessage(event.data); + }); + return this.connecting; + } + + private handleMessage(data: string): void { + let message: unknown; + try { message = JSON.parse(data); } catch { return; } + if (!message || typeof message !== "object") return; + const record = message as { id?: unknown; method?: unknown }; + if (typeof record.id === "number") { + const pending = this.pending.get(record.id); + if (!pending) return; + this.pending.delete(record.id); + this.cleanupPending(pending); + try { pending.resolve(readResponse(message, record.id)); } catch (error) { pending.reject(error); } + return; + } + if (typeof record.method === "string") for (const listener of this.listeners) listener(message as RPCNotification); + } + + private scheduleReconnect(): void { + if (this.reconnectTimer) return; + const delay = this.options.reconnectDelayMs ?? 1000; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + if (!this.closed) void this.connect().catch(() => undefined); + }, delay); + } + + private cleanupPending(pending: PendingCall): void { + clearTimeout(pending.timer); + if (pending.abort && pending.signal) pending.signal.removeEventListener("abort", pending.abort); + } +} diff --git a/react-sdk/ui-transport/tsconfig.json b/react-sdk/ui-transport/tsconfig.json new file mode 100644 index 0000000..0b1110e --- /dev/null +++ b/react-sdk/ui-transport/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": {"rootDir": "src", "outDir": "dist"}, + "include": ["src"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..2e66530 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "skipLibCheck": true + } +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..9690a32 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + test: { + include: ["packages/*/src/**/*.test.ts", "packages/*/src/**/*.test.tsx"], + environment: "node", + }, +}); From 5c153441550c6ed03a19968c1a334f40ddf0c653 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Thu, 24 Sep 2026 01:26:44 +0300 Subject: [PATCH 08/26] change --- .gitignore | 1 + Makefile | 4 +++ example/go/dashboard/dashboard.go | 2 +- example/go/htmlform/htmlform.go | 2 +- example/react/README.md | 3 +-- example/react/package.json | 1 + example/react/tsconfig.json | 12 ++++----- example/react/vite.config.ts | 8 +++--- go-sdk/mcp/register.go | 2 +- go-sdk/mcp/register_test.go | 2 +- go-sdk/ui/schema_test.go | 4 +-- pnpm-lock.yaml | 35 ++++++++++++++++++++----- pnpm-workspace.yaml | 4 +-- react-sdk/ui-core/package.json | 6 ++++- react-sdk/ui-core/src/core.test.ts | 2 +- react-sdk/ui-core/vitest.config.ts | 8 ++++++ react-sdk/ui-react/package.json | 6 +++-- react-sdk/ui-react/vitest.config.ts | 8 ++++++ react-sdk/ui-transport/package.json | 8 ++++-- react-sdk/ui-transport/vitest.config.ts | 8 ++++++ vitest.config.ts | 2 +- 21 files changed, 95 insertions(+), 33 deletions(-) create mode 100644 react-sdk/ui-core/vitest.config.ts create mode 100644 react-sdk/ui-react/vitest.config.ts create mode 100644 react-sdk/ui-transport/vitest.config.ts diff --git a/.gitignore b/.gitignore index c2539b1..11f7a88 100755 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .tools/ bin/ vendor/ +node_modules/ build/ .idea/ .vscode/ diff --git a/Makefile b/Makefile index c04cf9f..d61e100 100755 --- a/Makefile +++ b/Makefile @@ -29,3 +29,7 @@ pre-commit: install license lint tests build .PHONY: ci ci: pre-commit +.PHONY: examples-dev +examples-dev: + pnpm --filter @osspkg/ui-examples dev + diff --git a/example/go/dashboard/dashboard.go b/example/go/dashboard/dashboard.go index fc65a0d..1b57396 100644 --- a/example/go/dashboard/dashboard.go +++ b/example/go/dashboard/dashboard.go @@ -6,7 +6,7 @@ // Package dashboard demonstrates shadcn components, grid placement, and data sources. package dashboard -import "go.osspkg.com/ui" +import "go.osspkg.com/ui/go-sdk/ui" const ( metricsCacheTTL = 30 diff --git a/example/go/htmlform/htmlform.go b/example/go/htmlform/htmlform.go index 6ef6c26..5133ec3 100644 --- a/example/go/htmlform/htmlform.go +++ b/example/go/htmlform/htmlform.go @@ -6,7 +6,7 @@ // Package htmlform demonstrates a declarative form built from safe HTML tags. package htmlform -import "go.osspkg.com/ui" +import "go.osspkg.com/ui/go-sdk/ui" const ( profileCardCols = 8 diff --git a/example/react/README.md b/example/react/README.md index ef41f8d..959fb91 100644 --- a/example/react/README.md +++ b/example/react/README.md @@ -8,14 +8,13 @@ The directory is a private workspace package, so React and its JSX runtime are installed for the examples themselves. Check it with: ```bash -cd react pnpm --filter @osspkg/ui-examples typecheck ``` To browse the examples locally, run this from the repository root: ```bash -make examples-dev +pnpm --filter @osspkg/ui-examples dev ``` Vite prints the local address (normally http://localhost:5173). Use the diff --git a/example/react/package.json b/example/react/package.json index 31fa120..8f9bd27 100644 --- a/example/react/package.json +++ b/example/react/package.json @@ -18,6 +18,7 @@ "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.3.3", + "typescript": "^5.9.0", "vite": "^7.3.6" } } diff --git a/example/react/tsconfig.json b/example/react/tsconfig.json index 9b4a3d4..ade9ebf 100644 --- a/example/react/tsconfig.json +++ b/example/react/tsconfig.json @@ -1,15 +1,15 @@ { - "extends": "../tsconfig.base.json", + "extends": "../../tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", "noEmit": true, "noUncheckedSideEffectImports": true, - "baseUrl": "..", + "baseUrl": "../..", "paths": { - "@osspkg/ui-core": ["packages/ui-core/src/index.ts"], - "@osspkg/ui-react": ["packages/ui-react/src/index.ts"], - "@osspkg/ui-transport": ["packages/ui-transport/src/index.ts"], - "@osspkg/ui-react/styles.css": ["packages/ui-react/src/styles.css.d.ts"] + "@osspkg/ui-core": ["react-sdk/ui-core/src/index.ts"], + "@osspkg/ui-react": ["react-sdk/ui-react/src/index.ts"], + "@osspkg/ui-transport": ["react-sdk/ui-transport/src/index.ts"], + "@osspkg/ui-react/styles.css": ["react-sdk/ui-react/src/styles.css.d.ts"] } }, "include": ["*.tsx"] diff --git a/example/react/vite.config.ts b/example/react/vite.config.ts index cc03c4f..92648de 100644 --- a/example/react/vite.config.ts +++ b/example/react/vite.config.ts @@ -6,10 +6,10 @@ const source = (path: string) => fileURLToPath(new URL(path, import.meta.url)); export default defineConfig({ resolve: { alias: [ - { find: "@osspkg/ui-react/styles.css", replacement: source("../packages/ui-react/src/styles.css") }, - { find: "@osspkg/ui-core", replacement: source("../packages/ui-core/src/index.ts") }, - { find: "@osspkg/ui-react", replacement: source("../packages/ui-react/src/index.ts") }, - { find: "@osspkg/ui-transport", replacement: source("../packages/ui-transport/src/index.ts") }, + { find: "@osspkg/ui-react/styles.css", replacement: source("../../react-sdk/ui-react/src/styles.css") }, + { find: "@osspkg/ui-core", replacement: source("../../react-sdk/ui-core/src/index.ts") }, + { find: "@osspkg/ui-react", replacement: source("../../react-sdk/ui-react/src/index.ts") }, + { find: "@osspkg/ui-transport", replacement: source("../../react-sdk/ui-transport/src/index.ts") }, ], }, }); diff --git a/go-sdk/mcp/register.go b/go-sdk/mcp/register.go index e4894e0..f63f5b7 100644 --- a/go-sdk/mcp/register.go +++ b/go-sdk/mcp/register.go @@ -13,7 +13,7 @@ import ( ossmcp "go.osspkg.com/mcp" - "go.osspkg.com/ui" + "go.osspkg.com/ui/go-sdk/ui" ) // Capabilities returns the MCP option advertising the UI capability. diff --git a/go-sdk/mcp/register_test.go b/go-sdk/mcp/register_test.go index 3977d7f..5205964 100644 --- a/go-sdk/mcp/register_test.go +++ b/go-sdk/mcp/register_test.go @@ -5,7 +5,7 @@ import ( goMCP "go.osspkg.com/mcp" - "go.osspkg.com/ui" + "go.osspkg.com/ui/go-sdk/ui" ) func TestUnit_Register(t *testing.T) { diff --git a/go-sdk/ui/schema_test.go b/go-sdk/ui/schema_test.go index ca6529b..3f3f186 100644 --- a/go-sdk/ui/schema_test.go +++ b/go-sdk/ui/schema_test.go @@ -177,7 +177,7 @@ func TestUnit_SchemaExpressionDepthLimit(t *testing.T) { } func TestUnit_JSONFixtures(t *testing.T) { - valid, err := os.ReadFile("fixtures/valid-view.json") + valid, err := os.ReadFile("../../fixtures/valid-view.json") if err != nil { t.Fatal(err) } @@ -203,7 +203,7 @@ func TestUnit_JSONFixtures(t *testing.T) { t.Fatalf("round-trip JSON differs:\n got: %s\nwant: %s", encoded, valid) } - invalid, err := os.ReadFile("fixtures/invalid-layout.json") + invalid, err := os.ReadFile("../../fixtures/invalid-layout.json") if err != nil { t.Fatal(err) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb313ab..a189901 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,14 +18,14 @@ importers: specifier: ^3.2.0 version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) - examples: + example/react: dependencies: '@osspkg/ui-core': specifier: workspace:* - version: link:../packages/ui-core + version: link:../../react-sdk/ui-core '@osspkg/ui-react': specifier: workspace:* - version: link:../packages/ui-react + version: link:../../react-sdk/ui-react devDependencies: '@types/react': specifier: ^19.0.0 @@ -42,13 +42,23 @@ importers: tailwindcss: specifier: ^4.3.3 version: 4.3.3 + typescript: + specifier: ^5.9.0 + version: 5.9.3 vite: specifier: ^7.3.6 version: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) - packages/ui-core: {} + react-sdk/ui-core: + devDependencies: + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) - packages/ui-react: + react-sdk/ui-react: dependencies: '@osspkg/ui-core': specifier: workspace:* @@ -75,12 +85,25 @@ importers: react-dom: specifier: ^19.0.0 version: 19.3.0(react@19.3.0) + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) - packages/ui-transport: + react-sdk/ui-transport: dependencies: '@osspkg/ui-core': specifier: workspace:* version: link:../ui-core + devDependencies: + typescript: + specifier: ^5.9.0 + version: 5.9.3 + vitest: + specifier: ^3.2.0 + version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) packages: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df33dce..40f096a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,6 @@ packages: - - packages/* - - examples + - react-sdk/* + - example/react allowBuilds: '@parcel/watcher': false diff --git a/react-sdk/ui-core/package.json b/react-sdk/ui-core/package.json index a227583..a4c8e4c 100644 --- a/react-sdk/ui-core/package.json +++ b/react-sdk/ui-core/package.json @@ -8,7 +8,11 @@ "exports": {".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"}}, "scripts": { "build": "tsc -p tsconfig.json", - "test": "vitest run", + "test": "vitest run --config vitest.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.0", + "vitest": "^3.2.0" } } diff --git a/react-sdk/ui-core/src/core.test.ts b/react-sdk/ui-core/src/core.test.ts index c8248b5..7d7a146 100644 --- a/react-sdk/ui-core/src/core.test.ts +++ b/react-sdk/ui-core/src/core.test.ts @@ -8,7 +8,7 @@ import { createRequestTracker } from "./request.js"; import { parseManifest, parseView, validateView, SchemaValidationError } from "./validate.js"; function fixture(name: string): unknown { - return JSON.parse(readFileSync(new URL(`../../../../fixtures/${name}`, import.meta.url), "utf8")) as unknown; + return JSON.parse(readFileSync(new URL(`../../../fixtures/${name}`, import.meta.url), "utf8")) as unknown; } const scope = { diff --git a/react-sdk/ui-core/vitest.config.ts b/react-sdk/ui-core/vitest.config.ts new file mode 100644 index 0000000..c1433e6 --- /dev/null +++ b/react-sdk/ui-core/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/react-sdk/ui-react/package.json b/react-sdk/ui-react/package.json index 10c2cbf..bc98f1e 100644 --- a/react-sdk/ui-react/package.json +++ b/react-sdk/ui-react/package.json @@ -19,7 +19,7 @@ }, "scripts": { "build": "tsc -p tsconfig.json && tailwindcss -i src/styles.css -o dist/styles.css && node scripts/copy-styles-types.mjs", - "test": "vitest run", + "test": "vitest run --config vitest.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { @@ -36,6 +36,8 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "typescript": "^5.9.0", + "vitest": "^3.2.0" } } diff --git a/react-sdk/ui-react/vitest.config.ts b/react-sdk/ui-react/vitest.config.ts new file mode 100644 index 0000000..5465441 --- /dev/null +++ b/react-sdk/ui-react/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.tsx"], + }, +}); diff --git a/react-sdk/ui-transport/package.json b/react-sdk/ui-transport/package.json index e76ee98..2540280 100644 --- a/react-sdk/ui-transport/package.json +++ b/react-sdk/ui-transport/package.json @@ -8,8 +8,12 @@ "exports": {".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"}}, "scripts": { "build": "tsc -p tsconfig.json", - "test": "vitest run", + "test": "vitest run --config vitest.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, - "dependencies": {"@osspkg/ui-core": "workspace:*"} + "dependencies": {"@osspkg/ui-core": "workspace:*"}, + "devDependencies": { + "typescript": "^5.9.0", + "vitest": "^3.2.0" + } } diff --git a/react-sdk/ui-transport/vitest.config.ts b/react-sdk/ui-transport/vitest.config.ts new file mode 100644 index 0000000..c1433e6 --- /dev/null +++ b/react-sdk/ui-transport/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9690a32..afed279 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; export default defineConfig({ root: fileURLToPath(new URL(".", import.meta.url)), test: { - include: ["packages/*/src/**/*.test.ts", "packages/*/src/**/*.test.tsx"], + include: ["react-sdk/*/src/**/*.test.ts", "react-sdk/*/src/**/*.test.tsx"], environment: "node", }, }); From 518a08ad6522ecbc3b3520fe737e6cc353955259 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sat, 26 Sep 2026 03:04:15 +0300 Subject: [PATCH 09/26] change --- example/react/preview.css | 114 ++++++++++++++++++++++++ go-sdk/ui/literal_validation.go | 65 ++++++++++++++ go-sdk/ui/rpc.go | 43 +++++++++ go-sdk/ui/schema.go | 25 +++++- go-sdk/ui/schema_test.go | 58 ++++++++++++ go-sdk/ui/validation.go | 49 ++++++++-- go-sdk/ui/value.go | 8 +- react-sdk/ui-core/src/core.test.ts | 25 +++++- react-sdk/ui-core/src/expression.ts | 31 ++++--- react-sdk/ui-core/src/index.ts | 1 + react-sdk/ui-core/src/rpc.ts | 30 +++++++ react-sdk/ui-core/src/validate.ts | 38 ++++++-- react-sdk/ui-react/src/renderer.tsx | 5 +- react-sdk/ui-react/src/runtime.test.tsx | 8 +- react-sdk/ui-react/src/runtime.tsx | 10 ++- 15 files changed, 471 insertions(+), 39 deletions(-) create mode 100644 go-sdk/ui/literal_validation.go create mode 100644 go-sdk/ui/rpc.go create mode 100644 react-sdk/ui-core/src/rpc.ts diff --git a/example/react/preview.css b/example/react/preview.css index c843085..c1d284d 100644 --- a/example/react/preview.css +++ b/example/react/preview.css @@ -14,3 +14,117 @@ --color-ui-border: oklch(0.922 0 0); --color-ui-ring: oklch(0.708 0 0); } + +:root { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #171717; + background: #f5f6f8; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +#root { min-height: 100vh; } + +/* Keep the example page readable even when a consuming Vite app does not run + Tailwind's CSS transform over imported package stylesheets. */ +.min-h-screen { min-height: 100vh; } +.bg-ui-muted { background: #f5f6f8; } +.text-ui-foreground { color: #171717; } +.px-4 { padding-inline: 1rem; } +.py-8 { padding-block: 2rem; } +.sm\:px-8 { padding-inline: 2rem; } +.sm\:py-12 { padding-block: 3rem; } +.mx-auto { margin-inline: auto; } +.flex { display: flex; } +.w-full { width: 100%; } +.max-w-6xl { max-width: 72rem; } +.flex-col { flex-direction: column; } +.flex-wrap { flex-wrap: wrap; } +.gap-2 { gap: .5rem; } +.gap-8 { gap: 2rem; } +.text-sm { font-size: .875rem; line-height: 1.25rem; } +.font-medium { font-weight: 500; } +.font-semibold { font-weight: 600; } +.tracking-tight { letter-spacing: -.025em; } +.text-3xl { font-size: 1.875rem; line-height: 2.25rem; } +.sm\:text-4xl { font-size: 2.25rem; line-height: 2.5rem; } +.max-w-2xl { max-width: 42rem; } +.text-ui-muted-foreground { color: #737373; } +.rounded-xl { border-radius: .75rem; } +.rounded-md { border-radius: .375rem; } +.border { border-width: 1px; border-style: solid; } +.border-ui-border { border-color: #e5e7eb; } +.bg-ui-card { background: #fff; } +.p-2 { padding: .5rem; } +.p-4 { padding: 1rem; } +.sm\:p-6 { padding: 1.5rem; } +.shadow-sm { box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 1px 3px rgb(0 0 0 / .08); } +.px-3 { padding-inline: .75rem; } +.py-2 { padding-block: .5rem; } +.transition-colors { transition: color .15s, background-color .15s, border-color .15s; } + +button { + cursor: pointer; +} + +button:hover { background: #f3f4f6; } +button[aria-pressed="true"] { + border-color: #171717; + background: #171717; + color: #fff; +} +button:focus-visible, input:focus-visible { + outline: 2px solid #737373; + outline-offset: 2px; +} + +/* The renderer provides semantic data attributes, so layout stays reliable + without requiring application-specific utility compilation. */ +[data-ui-region] { + display: grid; + grid-template-columns: repeat(12, minmax(0, 1fr)); + gap: 1rem; +} +[data-ui-node] { + grid-column: var(--ui-start, span 12) / span var(--ui-cols, 12); + grid-row: var(--ui-row, auto); + min-width: 0; +} +[data-ui-node] h1 { margin: 0 0 1rem; font-size: 1.5rem; line-height: 1.3; font-weight: 650; letter-spacing: -.025em; } +[data-ui-node] p { margin: .75rem 0 0; line-height: 1.6; } +[data-ui-node] form { display: grid; gap: .75rem; } +[data-ui-node] input { + width: 100%; box-sizing: border-box; border: 1px solid #d1d5db; border-radius: .5rem; + background: #fff; padding: .65rem .75rem; color: #171717; +} +[data-ui-node] button:not([aria-pressed]) { + width: fit-content; border: 1px solid #171717; border-radius: .5rem; background: #171717; + padding: .65rem .9rem; color: #fff; font-weight: 550; +} +[data-ui-node] button:not([aria-pressed]):hover { background: #333; } + +@media (min-width: 640px) { + .sm\:px-8 { padding-inline: 2rem; } + .sm\:py-12 { padding-block: 3rem; } + .sm\:p-6 { padding: 1.5rem; } + .sm\:text-4xl { font-size: 2.25rem; line-height: 2.5rem; } +} + +@media (max-width: 640px) { + [data-ui-node] { grid-column: 1 / -1; } +} diff --git a/go-sdk/ui/literal_validation.go b/go-sdk/ui/literal_validation.go new file mode 100644 index 0000000..c1cec90 --- /dev/null +++ b/go-sdk/ui/literal_validation.go @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD-3-Clause license that can be found in the LICENSE file. + */ + +package ui + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" +) + +func validateLiteralData(data any, limits Limits, depth int) error { + encoded, err := json.Marshal(data) + if err != nil { + return fmt.Errorf("%w: literal is not JSON-compatible: %w", ErrInvalidValue, err) + } + + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var decoded any + if err := decoder.Decode(&decoded); err != nil { + return fmt.Errorf("%w: literal: %w", ErrInvalidValue, err) + } + + return validateLiteralJSON(decoded, limits, depth) +} + +func validateLiteralJSON(value any, limits Limits, depth int) error { + if depth > limits.MaxExpressionDepth { + return errors.New("expression depth limit exceeded") + } + + switch typed := value.(type) { + case string: + if len(typed) > limits.MaxStringLength { + return errors.New("expression string limit exceeded") + } + case []any: + if len(typed) > limits.MaxArrayItems { + return errors.New("expression array limit exceeded") + } + for _, item := range typed { + if err := validateLiteralJSON(item, limits, depth+1); err != nil { + return err + } + } + case map[string]any: + if len(typed) > limits.MaxObjectKeys { + return errors.New("expression object limit exceeded") + } + for key, item := range typed { + if key == "__proto__" || key == "prototype" || key == "constructor" { + return errors.New("unsafe object key") + } + if err := validateLiteralJSON(item, limits, depth+1); err != nil { + return err + } + } + } + + return nil +} diff --git a/go-sdk/ui/rpc.go b/go-sdk/ui/rpc.go new file mode 100644 index 0000000..7d5eb8a --- /dev/null +++ b/go-sdk/ui/rpc.go @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. + * Use of this source code is governed by a BSD-3-Clause license that can be found in the LICENSE file. + */ + +package ui + +// Host RPC method names used by the React runtime. The MCP adapter publishes +// UI resources; a host transport is responsible for exposing these methods. +const ( + RPCMethodGetView = "ui.get" + RPCMethodDataCall = "data.call" + RPCMethodAction = "ui.action" +) + +// GetViewRequest requests a view schema from a host UI backend. +type GetViewRequest struct { + Plugin string `json:"plugin,omitempty"` + View string `json:"view"` + Context any `json:"context,omitempty"` +} + +// GetViewResponse contains a view schema returned by a host UI backend. +type GetViewResponse struct { + Schema ViewSchema `json:"schema"` +} + +// DataCallRequest requests a data source operation from a host UI backend. +type DataCallRequest struct { + Plugin string `json:"plugin,omitempty"` + View string `json:"view"` + Source string `json:"source"` + Operation string `json:"operation"` + Input map[string]any `json:"input,omitempty"` +} + +// ActionRequest requests a declared view action from a host UI backend. +type ActionRequest struct { + Plugin string `json:"plugin,omitempty"` + View string `json:"view"` + Action string `json:"action"` + Input map[string]any `json:"input,omitempty"` +} diff --git a/go-sdk/ui/schema.go b/go-sdk/ui/schema.go index b872cc9..f5132c4 100644 --- a/go-sdk/ui/schema.go +++ b/go-sdk/ui/schema.go @@ -200,17 +200,28 @@ type Effect struct { // Validate checks whether the manifest contains valid plugin and view entries. func (m Manifest) Validate() error { + return m.ValidateWithLimits(DefaultLimits()) +} + +// ValidateWithLimits checks the manifest against caller-provided limits. +func (m Manifest) ValidateWithLimits(limits Limits) error { + limits = withDefaultLimits(limits) + if m.ProtocolVersion != ProtocolVersion { return fmt.Errorf("%w: unsupported manifest protocol version %q", ErrInvalidSchema, m.ProtocolVersion) } - if m.Plugin.ID == "" { + if !validName(m.Plugin.ID) { return fmt.Errorf("%w: plugin id is required", ErrInvalidSchema) } + if m.Views == nil { + return fmt.Errorf("%w: manifest views must be an array", ErrInvalidSchema) + } + seen := make(map[string]struct{}, len(m.Views)) for _, view := range m.Views { - if view.ID == "" || view.Schema == "" { + if !validName(view.ID) || view.Schema == "" { return fmt.Errorf("%w: view id and schema are required", ErrInvalidSchema) } @@ -221,6 +232,16 @@ func (m Manifest) Validate() error { seen[view.ID] = struct{}{} } + for _, component := range m.RequiredComponents { + if !validComponentName(component) { + return fmt.Errorf("%w: invalid required component %q", ErrInvalidSchema, component) + } + } + + if err := validateSerializedLimits(m, limits); err != nil { + return err + } + return nil } diff --git a/go-sdk/ui/schema_test.go b/go-sdk/ui/schema_test.go index 3f3f186..f255f43 100644 --- a/go-sdk/ui/schema_test.go +++ b/go-sdk/ui/schema_test.go @@ -3,8 +3,10 @@ package ui import ( "encoding/json" "errors" + "fmt" "os" "reflect" + "strings" "testing" ) @@ -216,6 +218,62 @@ func TestUnit_JSONFixtures(t *testing.T) { } } +func TestUnit_ManifestValidationParity(t *testing.T) { + base := Manifest{ + ProtocolVersion: ProtocolVersion, + Plugin: PluginManifest{ID: "users", Title: "Users", Version: "1.0"}, + Views: []UIViewDescriptor{}, + } + + if err := (Manifest{ProtocolVersion: ProtocolVersion, Plugin: base.Plugin}).Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema for nil views", err) + } + + base.RequiredComponents = []string{"/button"} + if err := base.Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema for invalid component", err) + } + + base.RequiredComponents = nil + if err := base.ValidateWithLimits(Limits{MaxBytes: 1}); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("ValidateWithLimits() error = %v, want ErrInvalidSchema for byte limit", err) + } +} + +func TestUnit_ValueValidationParity(t *testing.T) { + viewWith := func(value Value) ViewSchema { + regions := emptyRegions() + regions.Content = []Node{{ID: "value", Component: "text", Props: map[string]Value{"value": value}}} + return ViewSchema{ProtocolVersion: ProtocolVersion, ID: "values", Regions: regions} + } + + tooManyItems := make([]any, DefaultLimits().MaxArrayItems+1) + if err := viewWith(Literal(tooManyItems)).Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema for array limit", err) + } + + tooManyKeys := make(map[string]any, DefaultLimits().MaxObjectKeys+1) + for index := 0; index <= DefaultLimits().MaxObjectKeys; index++ { + tooManyKeys[fmt.Sprintf("key-%d", index)] = true + } + if err := viewWith(Literal(tooManyKeys)).Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema for object limit", err) + } + + if err := viewWith(Literal(map[string]any{"__proto__": true})).Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema for unsafe key", err) + } + + longPath := strings.Repeat("a", DefaultLimits().MaxPathLength+1) + if err := viewWith(StateRef(longPath)).Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema for path limit", err) + } + + if err := viewWith(Literal([]any{1, 2})).ValidateWithLimits(Limits{MaxArrayItems: 1}); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("ValidateWithLimits() error = %v, want ErrInvalidSchema for custom array limit", err) + } +} + func FuzzValueUnmarshal(f *testing.F) { f.Add([]byte(`{"$state":"form.email"}`)) f.Add([]byte(`{"$eq":[1,2]}`)) diff --git a/go-sdk/ui/validation.go b/go-sdk/ui/validation.go index b5aaf73..4870fd2 100644 --- a/go-sdk/ui/validation.go +++ b/go-sdk/ui/validation.go @@ -23,6 +23,9 @@ const ( defaultMaxStrings = 10000 defaultMaxStringLength = 16 << 10 defaultMaxExpressionDepth = 32 + defaultMaxArrayItems = 1000 + defaultMaxObjectKeys = 256 + defaultMaxPathLength = 1024 actionTypeTool = "tool" actionTypeSetState = "set-state" @@ -43,6 +46,9 @@ type Limits struct { MaxStrings int MaxStringLength int MaxExpressionDepth int + MaxArrayItems int + MaxObjectKeys int + MaxPathLength int } // DefaultLimits returns the default schema validation limits. @@ -57,6 +63,9 @@ func DefaultLimits() Limits { MaxStrings: defaultMaxStrings, MaxStringLength: defaultMaxStringLength, MaxExpressionDepth: defaultMaxExpressionDepth, + MaxArrayItems: defaultMaxArrayItems, + MaxObjectKeys: defaultMaxObjectKeys, + MaxPathLength: defaultMaxPathLength, } } @@ -189,10 +198,22 @@ func withDefaultLimits(limits Limits) Limits { limits.MaxExpressionDepth = defaults.MaxExpressionDepth } + if limits.MaxArrayItems <= 0 { + limits.MaxArrayItems = defaults.MaxArrayItems + } + + if limits.MaxObjectKeys <= 0 { + limits.MaxObjectKeys = defaults.MaxObjectKeys + } + + if limits.MaxPathLength <= 0 { + limits.MaxPathLength = defaults.MaxPathLength + } + return limits } -func validateSerializedLimits(schema ViewSchema, limits Limits) error { +func validateSerializedLimits(schema any, limits Limits) error { encoded, err := json.Marshal(schema) if err != nil { return fmt.Errorf("%w: serialize schema for limits: %w", ErrInvalidSchema, err) @@ -235,6 +256,10 @@ func walkLimitValue(value any, depth int, limits Limits, count *int) error { } case []any: + if len(typed) > limits.MaxArrayItems { + return errors.New("array item limit exceeded") + } + for _, item := range typed { if err := walkLimitValue(item, depth+1, limits, count); err != nil { return err @@ -242,7 +267,15 @@ func walkLimitValue(value any, depth int, limits Limits, count *int) error { } case map[string]any: + if len(typed) > limits.MaxObjectKeys { + return errors.New("object key limit exceeded") + } + for key, item := range typed { + if key == "__proto__" || key == "prototype" || key == "constructor" { + return errors.New("unsafe object key") + } + if err := walkLimitValue(key, depth+1, limits, count); err != nil { return err } @@ -383,7 +416,7 @@ func validateAction(action Action, limits Limits, view ViewSchema) error { } if action.Type == actionTypeSetState || action.Type == actionTypeMergeState { - if !safePath(action.Path) { + if !safePathWithLimit(action.Path, limits.MaxPathLength) { return errors.New("state action requires a safe path") } } @@ -394,7 +427,7 @@ func validateAction(action Action, limits Limits, view ViewSchema) error { } } - if action.Path != "" && !safePath(action.Path) { + if action.Path != "" && !safePathWithLimit(action.Path, limits.MaxPathLength) { return errors.New("unsafe action path") } @@ -426,12 +459,12 @@ func validateEffect(effect Effect, limits Limits, view ViewSchema) error { return fmt.Errorf("unsupported effect type %q", effect.Type) } - if effect.Path != "" && !safePath(effect.Path) { + if effect.Path != "" && !safePathWithLimit(effect.Path, limits.MaxPathLength) { return errors.New("unsafe effect path") } if effect.Type == actionTypeSetState || effect.Type == actionTypeMergeState { - if !safePath(effect.Path) { + if !safePathWithLimit(effect.Path, limits.MaxPathLength) { return errors.New("state effect requires a safe path") } } @@ -466,17 +499,17 @@ func validateValue(value Value, limits Limits, view ViewSchema, depth int) error switch value.Kind { case ValueLiteral: - return nil + return validateLiteralData(value.Data, limits, depth) case ValueState, ValueContext, ValueEvent, ValueResult: - if !safePath(value.Path) { + if !safePathWithLimit(value.Path, limits.MaxPathLength) { return fmt.Errorf("%w: unsafe reference path %q", ErrInvalidValue, value.Path) } return nil case ValueSource: - if !safePath(value.Path) { + if !safePathWithLimit(value.Path, limits.MaxPathLength) { return fmt.Errorf("%w: unsafe reference path %q", ErrInvalidValue, value.Path) } diff --git a/go-sdk/ui/value.go b/go-sdk/ui/value.go index fe6c45e..8e2743c 100644 --- a/go-sdk/ui/value.go +++ b/go-sdk/ui/value.go @@ -95,7 +95,7 @@ func Expr(operator string, args ...Value) Value { func (v Value) Validate() error { switch v.Kind { case ValueLiteral: - return nil + return validateLiteralData(v.Data, DefaultLimits(), 0) case ValueState, ValueContext, ValueSource, ValueEvent, ValueResult: if !safePath(v.Path) { @@ -231,7 +231,11 @@ func (v *Value) UnmarshalJSON(data []byte) error { } func safePath(path string) bool { - if path == "" { + return safePathWithLimit(path, defaultMaxPathLength) +} + +func safePathWithLimit(path string, maxLength int) bool { + if path == "" || len(path) > maxLength { return false } diff --git a/react-sdk/ui-core/src/core.test.ts b/react-sdk/ui-core/src/core.test.ts index 7d7a146..f1e2428 100644 --- a/react-sdk/ui-core/src/core.test.ts +++ b/react-sdk/ui-core/src/core.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import type { UIValue } from "./schema.js"; +import type { UIValue, ViewSchema } from "./schema.js"; import { resolvePath } from "./path.js"; import { resolveValue, type ExpressionLimits } from "./expression.js"; import { createStateRuntime } from "./runtime.js"; @@ -153,4 +153,27 @@ describe("ui core", () => { ? { form: { name: "Grace", valid: false } } : { form: { name: "Ada", valid: true } }); }); + it("resolves source names containing dots", () => { + const dottedScope = { + ...scope, + sources: { "orders.list": { status: "success" as const, data: { items: [{ id: 1 }] } } }, + }; + expect(resolveValue({ $source: "orders.list.items" }, dottedScope)).toEqual([{ id: 1 }]); + }); + + it("validates the shared value bounds", () => { + const regions = { "top-header": [], "left-panel": [], content: [{ id: "value", component: "text", props: { value: null } }], "right-panel": [], bottom: [] }; + const view = (value: unknown): ViewSchema => ({ protocolVersion: "1.0", id: "limits", regions: { ...regions, content: [{ id: "value", component: "text", props: { value: value as never } }] } }); + const tooManyItems = Array.from({ length: 1001 }, () => null); + expect(() => validateView(view(tooManyItems))).toThrow(SchemaValidationError); + const tooManyKeys = Object.fromEntries(Array.from({ length: 257 }, (_, index) => [`key-${index}`, index])); + expect(() => validateView(view(tooManyKeys))).toThrow(SchemaValidationError); + expect(() => validateView(view(JSON.parse("{\"__proto__\":true}")))).toThrow(SchemaValidationError); + }); + + it("rejects empty event step lists and overlong paths", () => { + const base = { protocolVersion: "1.0" as const, id: "invalid", regions: { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] } }; + expect(() => validateView({ ...base, regions: { ...base.regions, content: [{ id: "button", component: "button", events: { click: { steps: [] } } }] } })).toThrow(SchemaValidationError); + expect(() => validateView({ ...base, actions: { save: { type: "set-state", path: "a".repeat(1025), value: true } } })).toThrow(SchemaValidationError); + }); }); diff --git a/react-sdk/ui-core/src/expression.ts b/react-sdk/ui-core/src/expression.ts index a5995f4..f120d32 100644 --- a/react-sdk/ui-core/src/expression.ts +++ b/react-sdk/ui-core/src/expression.ts @@ -1,5 +1,5 @@ import { resolvePath } from "./path.js"; -import type { ResolveScope, UIValue } from "./schema.js"; +import type { ResolveScope, SourceRuntimeState, UIValue } from "./schema.js"; const operators = new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$and", "$or", "$not", "$in", "$exists", "$concat", "$coalesce", "$if"]); @@ -40,16 +40,27 @@ export function resolveValue(value: UIValue, scope: ResolveScope, depth = 0, lim } function resolveSource(path: string, scope: ResolveScope): unknown { - const separator = path.indexOf("."); - const name = separator < 0 ? path : path.slice(0, separator); - const rest = separator < 0 ? "" : path.slice(separator + 1); - const source = scope.sources[name]; + const match = sourceForPath(path, scope.sources); + if (!match) return undefined; + const source = scope.sources[match.name]; if (!source) return undefined; - if (rest === "$loading") return source.status === "loading"; - if (rest === "$error") return source.error; - if (rest === "$data" || rest === "") return source.data; - if (rest === "$updatedAt") return source.updatedAt; - return resolvePath(source.data, rest); + if (match.rest === "$loading") return source.status === "loading"; + if (match.rest === "$error") return source.error; + if (match.rest === "$data" || match.rest === "") return source.data; + if (match.rest === "$updatedAt") return source.updatedAt; + return resolvePath(source.data, match.rest); +} + +function sourceForPath(path: string, sources: Record): { name: string; rest: string } | undefined { + for (let name = path; name; ) { + if (Object.prototype.hasOwnProperty.call(sources, name)) { + return { name, rest: path === name ? "" : path.slice(name.length + 1) }; + } + const index = name.lastIndexOf("."); + if (index < 0) break; + name = name.slice(0, index); + } + return undefined; } function evaluateExpression(operator: string, raw: unknown, scope: ResolveScope, depth: number, limits: ExpressionLimits): unknown { diff --git a/react-sdk/ui-core/src/index.ts b/react-sdk/ui-core/src/index.ts index b6594bc..dca0fb6 100644 --- a/react-sdk/ui-core/src/index.ts +++ b/react-sdk/ui-core/src/index.ts @@ -1,6 +1,7 @@ export * from "./expression.js"; export * from "./path.js"; export * from "./request.js"; +export * from "./rpc.js"; export * from "./runtime.js"; export * from "./schema.js"; export * from "./validate.js"; diff --git a/react-sdk/ui-core/src/rpc.ts b/react-sdk/ui-core/src/rpc.ts new file mode 100644 index 0000000..9df9419 --- /dev/null +++ b/react-sdk/ui-core/src/rpc.ts @@ -0,0 +1,30 @@ +export const uiRPCMethods = { + getView: "ui.get", + dataCall: "data.call", + action: "ui.action", +} as const; + +export interface UIGetViewRequest { + plugin?: string; + view: string; + context?: unknown; +} + +export interface UIGetViewResponse { + schema: import("./schema.js").ViewSchema; +} + +export interface UIDataCallRequest { + plugin?: string; + view: string; + source: string; + operation: string; + input?: unknown; +} + +export interface UIActionRequest { + plugin?: string; + view: string; + action: string; + input?: unknown; +} diff --git a/react-sdk/ui-core/src/validate.ts b/react-sdk/ui-core/src/validate.ts index 374f6f7..6168fbb 100644 --- a/react-sdk/ui-core/src/validate.ts +++ b/react-sdk/ui-core/src/validate.ts @@ -11,6 +11,8 @@ export interface ValidationLimits { maxStringLength: number; maxExpressionDepth: number; maxArrayItems: number; + maxObjectKeys: number; + maxPathLength: number; } export const defaultLimits: ValidationLimits = { @@ -24,6 +26,8 @@ export const defaultLimits: ValidationLimits = { maxStringLength: 16 * 1024, maxExpressionDepth: 32, maxArrayItems: 1000, + maxObjectKeys: 256, + maxPathLength: 1024, }; export class SchemaValidationError extends Error { @@ -108,7 +112,8 @@ function validateSource(name: string, source: unknown, view: ViewSchema, limits: function validateAction(name: string, action: unknown, view: ViewSchema, limits: ValidationLimits): void { if (!isSafeName(name) || !isRecord(action) || !["tool", "set-state", "merge-state", "refresh-source", "invalidate"].includes(action.type)) throw new SchemaValidationError(`action ${name} is invalid`); if (action.type === "tool" && !isSafeName(action.tool)) throw new SchemaValidationError(`tool action ${name} requires a tool`); - if ((action.type === "set-state" || action.type === "merge-state") && !isSafePath(action.path)) throw new SchemaValidationError(`action ${name} requires a safe path`); + if ((action.type === "set-state" || action.type === "merge-state") && !isSafePath(action.path, limits.maxPathLength)) throw new SchemaValidationError(`action ${name} requires a safe path`); + if (action.path !== undefined && action.path !== "" && !isSafePath(action.path, limits.maxPathLength)) throw new SchemaValidationError(`action ${name} path is invalid`); if ((action.type === "refresh-source" || action.type === "invalidate") && !view.sources?.[action.source as string]) throw new SchemaValidationError(`action ${name} source is invalid`); validateValues(action.input, view, limits); if (action.value !== undefined) validateValue(action.value, view, limits, 0); @@ -120,7 +125,8 @@ function validateAction(name: string, action: unknown, view: ViewSchema, limits: function validateEffect(effect: unknown, view: ViewSchema, limits: ValidationLimits): void { if (!isRecord(effect) || !["set-state", "merge-state", "invalidate", "refresh-source", "refresh-view", "patch-view", "navigate", "toast", "dialog", "close-dialog"].includes(effect.type)) throw new SchemaValidationError("effect type is invalid"); - if ((effect.type === "set-state" || effect.type === "merge-state") && !isSafePath(effect.path)) throw new SchemaValidationError("state effect path is invalid"); + if ((effect.type === "set-state" || effect.type === "merge-state") && !isSafePath(effect.path, limits.maxPathLength)) throw new SchemaValidationError("state effect path is invalid"); + if (effect.path !== undefined && effect.path !== "" && !isSafePath(effect.path, limits.maxPathLength)) throw new SchemaValidationError("effect path is invalid"); if ((effect.type === "invalidate" || effect.type === "refresh-source") && !view.sources?.[effect.source as string]) throw new SchemaValidationError("effect source is invalid"); if (effect.type === "toast" && !isText(effect.message)) throw new SchemaValidationError("toast effect message is required"); if (effect.type === "navigate" && !isText(effect.to)) throw new SchemaValidationError("navigate effect target is required"); @@ -138,7 +144,7 @@ function validateNode(node: unknown, region: string, depth: number, view: ViewSc if (node.events !== undefined) { if (!isRecord(node.events)) throw new SchemaValidationError("node events are invalid"); for (const [event, handler] of Object.entries(node.events)) { - if (!isSafeName(event) || !isRecord(handler) || (!handler.action && !Array.isArray(handler.steps))) throw new SchemaValidationError("event handler is invalid"); + if (!isSafeName(event) || !isRecord(handler) || (!handler.action && (!Array.isArray(handler.steps) || handler.steps.length === 0))) throw new SchemaValidationError("event handler is invalid"); if (handler.action && !view.actions?.[handler.action]) throw new SchemaValidationError("event action is not defined"); if (handler.steps) for (const step of handler.steps) { if (!isRecord(step) || step.type !== "action" || !isSafeName(step.action) || !view.actions?.[step.action]) throw new SchemaValidationError("event step is invalid"); @@ -193,16 +199,17 @@ function validateValue(value: unknown, view: ViewSchema, limits: ValidationLimit if (!isRecord(value)) throw new SchemaValidationError("value is not JSON-compatible"); for (const key of Object.keys(value)) if (["__proto__", "prototype", "constructor"].includes(key)) throw new SchemaValidationError("unsafe value key"); const entries = Object.entries(value); + if (entries.length > limits.maxObjectKeys) throw new SchemaValidationError("expression object is too large"); if (entries.length === 1) { const [key, raw] = entries[0]!; if (["$state", "$context", "$event", "$result"].includes(key)) { - if (!isSafePath(raw)) throw new SchemaValidationError("reference path is unsafe"); + if (!isSafePath(raw, limits.maxPathLength)) throw new SchemaValidationError("reference path is unsafe"); return; } if (key === "$source") { - if (!isSafePath(raw)) throw new SchemaValidationError("source path is unsafe"); - const sourceName = raw.split(".", 1)[0]!; - if (!view.sources?.[sourceName]) throw new SchemaValidationError("source reference is not defined"); + if (!isSafePath(raw, limits.maxPathLength)) throw new SchemaValidationError("source path is unsafe"); + const sourceName = sourceForPath(raw, view.sources); + if (!sourceName) throw new SchemaValidationError("source reference is not defined"); return; } const arity = expressionArity[key]; @@ -240,9 +247,12 @@ function walkSerialized(value: unknown, depth: number, limits: ValidationLimits, if (value.length > limits.maxStringLength) throw new SchemaValidationError("string length limit exceeded"); onString(); } else if (Array.isArray(value)) { + if (value.length > limits.maxArrayItems) throw new SchemaValidationError("array item limit exceeded"); for (const item of value) walkSerialized(item, depth + 1, limits, onString); } else if (isRecord(value)) { + if (Object.keys(value).length > limits.maxObjectKeys) throw new SchemaValidationError("object key limit exceeded"); for (const [key, nested] of Object.entries(value)) { + if (["__proto__", "prototype", "constructor"].includes(key)) throw new SchemaValidationError("unsafe value key"); walkSerialized(key, depth + 1, limits, onString); walkSerialized(nested, depth + 1, limits, onString); } @@ -265,8 +275,18 @@ function isSafeName(value: unknown): value is string { return typeof value === "string" && value.length > 0 && !/[\r\n]/.test(value); } -function isSafePath(value: unknown): value is string { - return typeof value === "string" && value.length > 0 && value.length <= 1024 && value.split(".").every((segment) => segment.length > 0 && !["__proto__", "prototype", "constructor"].includes(segment)); +function isSafePath(value: unknown, maxLength: number): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maxLength && value.split(".").every((segment) => segment.length > 0 && !["__proto__", "prototype", "constructor"].includes(segment)); +} + +function sourceForPath(path: string, sources: Record | undefined): string | undefined { + for (let name = path; name; ) { + if (sources && Object.prototype.hasOwnProperty.call(sources, name)) return name; + const index = name.lastIndexOf("."); + if (index < 0) break; + name = name.slice(0, index); + } + return undefined; } function isComponentName(value: unknown): value is string { diff --git a/react-sdk/ui-react/src/renderer.tsx b/react-sdk/ui-react/src/renderer.tsx index ec3af0f..653e11a 100644 --- a/react-sdk/ui-react/src/renderer.tsx +++ b/react-sdk/ui-react/src/renderer.tsx @@ -1,5 +1,6 @@ import { useEffect, useState, type CSSProperties, type ReactElement, type ReactNode } from "react"; -import type { EventHandler, RegionName, UINode, ViewSchema } from "@osspkg/ui-core"; +import { uiRPCMethods } from "@osspkg/ui-core"; +import type { EventHandler, RegionName, UIGetViewResponse, UINode, ViewSchema } from "@osspkg/ui-core"; import { validateView } from "@osspkg/ui-core"; import { useUIRuntime, useUIViewRuntime, ViewRuntimeProvider } from "./runtime.js"; @@ -24,7 +25,7 @@ export function PluginView(props: PluginViewProps): ReactElement { setError(undefined); if (!transport || !props.plugin) return; let active = true; - void transport.call<{ schema: ViewSchema }>("ui.get", { plugin: props.plugin, view: props.view, context: props.context }).then((response) => { + void transport.call(uiRPCMethods.getView, { plugin: props.plugin, view: props.view, context: props.context }).then((response) => { if (active) setSchema(response.schema); }).catch((reason: unknown) => { if (active) setError(reason instanceof Error ? reason : new Error("view loading failed")); }); return () => { active = false; }; diff --git a/react-sdk/ui-react/src/runtime.test.tsx b/react-sdk/ui-react/src/runtime.test.tsx index 8f7202f..48a6345 100644 --- a/react-sdk/ui-react/src/runtime.test.tsx +++ b/react-sdk/ui-react/src/runtime.test.tsx @@ -118,6 +118,8 @@ describe("view runtime bindings", () => { close: () => undefined, }; const toasts: string[] = []; + const refreshedViews: string[] = []; + const patchedViews: string[] = []; const registry = createComponentRegistry(); registry.register("button", { component: (props: Record) => { @@ -134,7 +136,7 @@ describe("view runtime bindings", () => { type: "tool", tool: "users.save", input: { name: { $event: "value" } }, - effects: [{ type: "toast", variant: "success", message: "Saved" }], + effects: [{ type: "toast", variant: "success", message: "Saved" }, { type: "refresh-view", revision: "rev-2" }, { type: "patch-view", baseRevision: "rev-1", revision: "rev-2" }], }, }, sources: { users: { type: "tool", tool: "users.list", refreshOn: ["save"] } }, @@ -148,7 +150,7 @@ describe("view runtime bindings", () => { }; renderToStaticMarkup( - { toasts.push(message); } }}> + { toasts.push(message); }, refreshView: (effect) => { refreshedViews.push(effect.type); }, patchView: (effect) => { patchedViews.push(effect.type); } }}> , ); @@ -159,5 +161,7 @@ describe("view runtime bindings", () => { { method: "data.call", params: { plugin: "users", view: "actions", source: "users", operation: "users.list", input: {} } }, ]); expect(toasts).toEqual(["Saved"]); + expect(refreshedViews).toEqual(["refresh-view"]); + expect(patchedViews).toEqual(["patch-view"]); }); }); diff --git a/react-sdk/ui-react/src/runtime.tsx b/react-sdk/ui-react/src/runtime.tsx index 33c973b..ea89486 100644 --- a/react-sdk/ui-react/src/runtime.tsx +++ b/react-sdk/ui-react/src/runtime.tsx @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type Dispatch, type PropsWithChildren, type ReactElement, type SetStateAction } from "react"; -import { createRequestTracker, resolveValue, resolvePath, setPath, type Effect, type ResolveScope, type SourceRuntimeState, type UIValue, type ViewSchema } from "@osspkg/ui-core"; +import { createRequestTracker, resolveValue, resolvePath, setPath, uiRPCMethods, type Effect, type ResolveScope, type SourceRuntimeState, type UIValue, type ViewSchema } from "@osspkg/ui-core"; import type { RPCTransport } from "@osspkg/ui-transport"; import { ComponentRegistry } from "./registry.js"; @@ -8,6 +8,8 @@ export interface UIEffectHandlers { navigate?(to: string): void | Promise; dialog?(effect: Effect): void | Promise; closeDialog?(effect: Effect): void | Promise; + refreshView?(effect: Effect): void | Promise; + patchView?(effect: Effect): void | Promise; } export interface UIRuntime { @@ -88,7 +90,7 @@ export function ViewRuntimeProvider({ schema, plugin, context, children }: Props setSources((current) => ({ ...current, [name]: { status: "loading", requestId: String(requestID) } })); try { const input = resolveValue(source.input ?? {}, { state, context, sources }); - const data = await base.transport.call("data.call", { plugin, view: schema.id, source: name, operation: source.tool, input }, { signal: controller.signal }); + const data = await base.transport.call(uiRPCMethods.dataCall, { plugin, view: schema.id, source: name, operation: source.tool, input }, { signal: controller.signal }); if (!requestIDs.current.isCurrent(name, requestID)) return; setSources((current) => ({ ...current, [name]: { status: "success", data, updatedAt: Date.now(), requestId: String(requestID) } })); } catch (error) { @@ -188,7 +190,7 @@ async function executeAction(name: string, schema: ViewSchema, transport: RPCTra } if (action.type !== "tool" || !transport) throw new Error(`action ${name} requires a transport`); const input = resolveValue(action.input ?? {}, scope); - const response = await transport.call("ui.action", { plugin, view: schema.id, action: name, input }); + const response = await transport.call(uiRPCMethods.action, { plugin, view: schema.id, action: name, input }); for (const effect of action.effects ?? []) await executeEffect(effect, response, scope, handlers, setState, refreshSource); await refreshTriggeredSources(name, schema, refreshSource); return response; @@ -219,6 +221,8 @@ async function executeEffect(effect: Effect, result: unknown, scope: ResolveScop if (effect.type === "navigate" && effect.to && handlers?.navigate) await handlers.navigate(effect.to); if (effect.type === "dialog" && handlers?.dialog) await handlers.dialog(effect); if (effect.type === "close-dialog" && handlers?.closeDialog) await handlers.closeDialog(effect); + if (effect.type === "refresh-view" && handlers?.refreshView) await handlers.refreshView(effect); + if (effect.type === "patch-view" && handlers?.patchView) await handlers.patchView(effect); } function isRecord(value: unknown): value is Record { From b2655a8fcfd54342636e695e52b7e03efa2711c9 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sat, 26 Sep 2026 03:20:09 +0300 Subject: [PATCH 10/26] change --- example/react/package.json | 1 + example/react/preview.css | 69 ------------------------ example/react/vite.config.ts | 2 + pnpm-lock.yaml | 15 ++++++ react-sdk/ui-react/src/renderer.test.tsx | 1 + react-sdk/ui-react/src/renderer.tsx | 10 ++-- 6 files changed, 24 insertions(+), 74 deletions(-) diff --git a/example/react/package.json b/example/react/package.json index 8f9bd27..da4bfda 100644 --- a/example/react/package.json +++ b/example/react/package.json @@ -13,6 +13,7 @@ "@osspkg/ui-react": "workspace:*" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "react": "^19.0.0", diff --git a/example/react/preview.css b/example/react/preview.css index c1d284d..f329ec8 100644 --- a/example/react/preview.css +++ b/example/react/preview.css @@ -40,49 +40,10 @@ textarea { #root { min-height: 100vh; } -/* Keep the example page readable even when a consuming Vite app does not run - Tailwind's CSS transform over imported package stylesheets. */ -.min-h-screen { min-height: 100vh; } -.bg-ui-muted { background: #f5f6f8; } -.text-ui-foreground { color: #171717; } -.px-4 { padding-inline: 1rem; } -.py-8 { padding-block: 2rem; } -.sm\:px-8 { padding-inline: 2rem; } -.sm\:py-12 { padding-block: 3rem; } -.mx-auto { margin-inline: auto; } -.flex { display: flex; } -.w-full { width: 100%; } -.max-w-6xl { max-width: 72rem; } -.flex-col { flex-direction: column; } -.flex-wrap { flex-wrap: wrap; } -.gap-2 { gap: .5rem; } -.gap-8 { gap: 2rem; } -.text-sm { font-size: .875rem; line-height: 1.25rem; } -.font-medium { font-weight: 500; } -.font-semibold { font-weight: 600; } -.tracking-tight { letter-spacing: -.025em; } -.text-3xl { font-size: 1.875rem; line-height: 2.25rem; } -.sm\:text-4xl { font-size: 2.25rem; line-height: 2.5rem; } -.max-w-2xl { max-width: 42rem; } -.text-ui-muted-foreground { color: #737373; } -.rounded-xl { border-radius: .75rem; } -.rounded-md { border-radius: .375rem; } -.border { border-width: 1px; border-style: solid; } -.border-ui-border { border-color: #e5e7eb; } -.bg-ui-card { background: #fff; } -.p-2 { padding: .5rem; } -.p-4 { padding: 1rem; } -.sm\:p-6 { padding: 1.5rem; } -.shadow-sm { box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 1px 3px rgb(0 0 0 / .08); } -.px-3 { padding-inline: .75rem; } -.py-2 { padding-block: .5rem; } -.transition-colors { transition: color .15s, background-color .15s, border-color .15s; } - button { cursor: pointer; } -button:hover { background: #f3f4f6; } button[aria-pressed="true"] { border-color: #171717; background: #171717; @@ -93,37 +54,7 @@ button:focus-visible, input:focus-visible { outline-offset: 2px; } -/* The renderer provides semantic data attributes, so layout stays reliable - without requiring application-specific utility compilation. */ -[data-ui-region] { - display: grid; - grid-template-columns: repeat(12, minmax(0, 1fr)); - gap: 1rem; -} -[data-ui-node] { - grid-column: var(--ui-start, span 12) / span var(--ui-cols, 12); - grid-row: var(--ui-row, auto); - min-width: 0; -} [data-ui-node] h1 { margin: 0 0 1rem; font-size: 1.5rem; line-height: 1.3; font-weight: 650; letter-spacing: -.025em; } -[data-ui-node] p { margin: .75rem 0 0; line-height: 1.6; } -[data-ui-node] form { display: grid; gap: .75rem; } -[data-ui-node] input { - width: 100%; box-sizing: border-box; border: 1px solid #d1d5db; border-radius: .5rem; - background: #fff; padding: .65rem .75rem; color: #171717; -} -[data-ui-node] button:not([aria-pressed]) { - width: fit-content; border: 1px solid #171717; border-radius: .5rem; background: #171717; - padding: .65rem .9rem; color: #fff; font-weight: 550; -} -[data-ui-node] button:not([aria-pressed]):hover { background: #333; } - -@media (min-width: 640px) { - .sm\:px-8 { padding-inline: 2rem; } - .sm\:py-12 { padding-block: 3rem; } - .sm\:p-6 { padding: 1.5rem; } - .sm\:text-4xl { font-size: 2.25rem; line-height: 2.5rem; } -} @media (max-width: 640px) { [data-ui-node] { grid-column: 1 / -1; } diff --git a/example/react/vite.config.ts b/example/react/vite.config.ts index 92648de..e2defb6 100644 --- a/example/react/vite.config.ts +++ b/example/react/vite.config.ts @@ -1,9 +1,11 @@ import { fileURLToPath, URL } from "node:url"; +import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"; const source = (path: string) => fileURLToPath(new URL(path, import.meta.url)); export default defineConfig({ + plugins: [tailwindcss()], resolve: { alias: [ { find: "@osspkg/ui-react/styles.css", replacement: source("../../react-sdk/ui-react/src/styles.css") }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a189901..5da421b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: specifier: workspace:* version: link:../../react-sdk/ui-react devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)) '@types/react': specifier: ^19.0.0 version: 19.3.0 @@ -605,6 +608,11 @@ packages: resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} engines: {node: '>= 20'} + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1339,6 +1347,13 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + '@tailwindcss/vite@4.3.3(vite@7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 diff --git a/react-sdk/ui-react/src/renderer.test.tsx b/react-sdk/ui-react/src/renderer.test.tsx index 81fc47c..9e13035 100644 --- a/react-sdk/ui-react/src/renderer.test.tsx +++ b/react-sdk/ui-react/src/renderer.test.tsx @@ -46,6 +46,7 @@ describe("static renderer", () => { expect(markup).toContain("grid grid-cols-12 gap-4"); expect(markup).toContain("col-span-[var(--ui-cols)]"); expect(markup).toContain("--ui-row:1;--ui-cols:8;--ui-start:3"); + expect(markup).toContain("--ui-row:auto;--ui-cols:12;--ui-start:1"); }); it("renders declared slots and rejects undeclared slots", () => { diff --git a/react-sdk/ui-react/src/renderer.tsx b/react-sdk/ui-react/src/renderer.tsx index 653e11a..dedb5a5 100644 --- a/react-sdk/ui-react/src/renderer.tsx +++ b/react-sdk/ui-react/src/renderer.tsx @@ -61,11 +61,11 @@ function NodeRenderer({ node }: { node: UINode }): ReactNode { const slots = renderSlots(node.slots, definition.slots); const Component = definition.component as (props: Record) => ReactElement; const layout = node.layout; - const layoutStyle = layout ? { - "--ui-row": layout.row, - "--ui-cols": layout.cols ?? 12, - "--ui-start": (layout.offset ?? 0) + 1, - } as CSSProperties : undefined; + const layoutStyle = { + "--ui-row": layout?.row ?? "auto", + "--ui-cols": layout?.cols ?? 12, + "--ui-start": (layout?.offset ?? 0) + 1, + } as CSSProperties; return
{children}
; } From c8e28d9786eb32e95943e1e4a3f52b5ae80f3bf1 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sat, 26 Sep 2026 03:27:14 +0300 Subject: [PATCH 11/26] change --- example/react/README.md | 4 ++++ example/react/basic.tsx | 4 ++-- example/react/html-form.tsx | 9 +++++---- react-sdk/ui-react/src/renderer.test.tsx | 1 - react-sdk/ui-react/src/renderer.tsx | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/example/react/README.md b/example/react/README.md index 959fb91..dc96429 100644 --- a/example/react/README.md +++ b/example/react/README.md @@ -26,6 +26,10 @@ buttons at the top of the page to switch between the examples. - [`dashboard.tsx`](dashboard.tsx) demonstrates shadcn defaults and the 12-column `row`/`cols`/`offset` layout. +Each node with children renders them in their own 12-column grid. A child node +can use `layout` to choose its row, width, and offset within that parent. Nodes +without a layout fill the available row. + Render one exported component from an application entrypoint, for example: ```tsx diff --git a/example/react/basic.tsx b/example/react/basic.tsx index d8115d5..49d7ca9 100644 --- a/example/react/basic.tsx +++ b/example/react/basic.tsx @@ -21,8 +21,8 @@ const schema: ViewSchema = { component: "card", layout: { row: 1, cols: 8, offset: 2 }, children: [ - { id: "title", component: "h1", props: { text: "Welcome" } }, - { id: "body", component: "p", props: { text: "This view is rendered from a declarative schema." } }, + { id: "title", component: "h1", layout: { row: 1, cols: 12 }, props: { text: "Welcome" } }, + { id: "body", component: "p", layout: { row: 2, cols: 8, offset: 2 }, props: { text: "This view is rendered from a declarative schema." } }, ], }, ], diff --git a/example/react/html-form.tsx b/example/react/html-form.tsx index 9d67254..da3d237 100644 --- a/example/react/html-form.tsx +++ b/example/react/html-form.tsx @@ -24,21 +24,22 @@ const schema: ViewSchema = { component: "article", layout: { row: 1, cols: 8, offset: 2 }, children: [ - { id: "heading", component: "h1", props: { text: "Edit profile" } }, + { id: "heading", component: "h1", layout: { row: 1, cols: 12 }, props: { text: "Edit profile" } }, { id: "profile-form", component: "form", events: { submit: { action: "save" } }, children: [ - { id: "name-label", component: "label", props: { text: "Name" } }, - { id: "name", component: "input", props: { name: "name", placeholder: "Ada Lovelace" } }, - { id: "save", component: "button", props: { type: "submit", text: "Save profile" } }, + { id: "name-label", component: "label", layout: { row: 1, cols: 12 }, props: { text: "Name" } }, + { id: "name", component: "input", layout: { row: 2, cols: 8, offset: 2 }, props: { name: "name", placeholder: "Ada Lovelace" } }, + { id: "save", component: "button", layout: { row: 3, cols: 8, offset: 2 }, props: { type: "submit", text: "Save profile" } }, ], }, { id: "confirmation", component: "p", when: { $eq: [{ $state: "submitted" }, true] }, + layout: { row: 4, cols: 8, offset: 2 }, props: { text: "Profile saved." }, }, ], diff --git a/react-sdk/ui-react/src/renderer.test.tsx b/react-sdk/ui-react/src/renderer.test.tsx index 9e13035..81fc47c 100644 --- a/react-sdk/ui-react/src/renderer.test.tsx +++ b/react-sdk/ui-react/src/renderer.test.tsx @@ -46,7 +46,6 @@ describe("static renderer", () => { expect(markup).toContain("grid grid-cols-12 gap-4"); expect(markup).toContain("col-span-[var(--ui-cols)]"); expect(markup).toContain("--ui-row:1;--ui-cols:8;--ui-start:3"); - expect(markup).toContain("--ui-row:auto;--ui-cols:12;--ui-start:1"); }); it("renders declared slots and rejects undeclared slots", () => { diff --git a/react-sdk/ui-react/src/renderer.tsx b/react-sdk/ui-react/src/renderer.tsx index dedb5a5..a19cca1 100644 --- a/react-sdk/ui-react/src/renderer.tsx +++ b/react-sdk/ui-react/src/renderer.tsx @@ -57,7 +57,7 @@ function NodeRenderer({ node }: { node: UINode }): ReactNode { const rawProps = Object.fromEntries(Object.entries(node.props ?? {}).map(([key, value]) => [key, runtime.resolve(value)])); const props = validateProps(rawProps, definition.allowedProps, definition.propsSchema); const events = bindEvents(node.events, definition.events, runtime); - const children = definition.children === false ? undefined : node.children?.map((child) => ); + const children = definition.children === false || !node.children?.length ? undefined :
{node.children.map((child) => )}
; const slots = renderSlots(node.slots, definition.slots); const Component = definition.component as (props: Record) => ReactElement; const layout = node.layout; From df2e1929b9777c425e39b5066e2c54bb706c112d Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 02:45:35 +0300 Subject: [PATCH 12/26] change --- go-sdk/ui/builder.go | 6 +- go-sdk/ui/builder_test.go | 12 +- go-sdk/ui/schema.go | 8 +- go-sdk/ui/schema_test.go | 25 +++ go-sdk/ui/validation.go | 2 +- react-sdk/ui-core/src/core.test.ts | 7 +- react-sdk/ui-core/src/validate.ts | 8 +- schemas/actions.schema.json | 45 +++++ schemas/effects.schema.json | 46 +++++ schemas/layout.schema.json | 255 ++++++++++++++++++++++++ schemas/manifest.schema.json | 78 ++++++++ schemas/node.schema.json | 304 +++++++++++++++++++++++++++++ schemas/view.schema.json | 58 ++++++ 13 files changed, 844 insertions(+), 10 deletions(-) create mode 100644 schemas/actions.schema.json create mode 100644 schemas/effects.schema.json create mode 100644 schemas/layout.schema.json create mode 100644 schemas/manifest.schema.json create mode 100644 schemas/node.schema.json create mode 100644 schemas/view.schema.json diff --git a/go-sdk/ui/builder.go b/go-sdk/ui/builder.go index adc6f6e..240341d 100644 --- a/go-sdk/ui/builder.go +++ b/go-sdk/ui/builder.go @@ -74,11 +74,15 @@ func (app *App) AddView(view *ViewBuilder) error { } app.views[schema.ID] = snapshot + title := schema.Title + if title == "" { + title = schema.ID + } app.manifest.Views = append( app.manifest.Views, UIViewDescriptor{ ID: schema.ID, - Title: schema.Title, + Title: title, Schema: "ui://views/" + schema.ID, }, ) diff --git a/go-sdk/ui/builder_test.go b/go-sdk/ui/builder_test.go index a3e7bdd..03a004a 100644 --- a/go-sdk/ui/builder_test.go +++ b/go-sdk/ui/builder_test.go @@ -26,7 +26,7 @@ func TestUnit_BuilderCreatesView(t *testing.T) { t.Fatal(err) } manifest := app.Manifest() - if len(manifest.Views) != 1 || manifest.Views[0].ID != "users.list" { + if len(manifest.Views) != 1 || manifest.Views[0].ID != "users.list" || manifest.Views[0].Title != "Users" { t.Fatalf("manifest = %#v", manifest) } payload, err := json.Marshal(view.Schema()) @@ -52,6 +52,16 @@ func TestUnit_BuilderCreatesView(t *testing.T) { } } +func TestUnit_AppUsesViewIDWhenTitleIsEmpty(t *testing.T) { + app := New(AppID("users"), AppTitle("Users"), AppVersion("1.0.0")) + if err := app.AddView(View("users.list")); err != nil { + t.Fatal(err) + } + if got := app.Manifest().Views[0].Title; got != "users.list" { + t.Fatalf("manifest view title = %q, want users.list", got) + } +} + func TestUnit_AppSnapshotsViews(t *testing.T) { view := View("users.list", Title("Users")).Action( "initial", diff --git a/go-sdk/ui/schema.go b/go-sdk/ui/schema.go index f5132c4..8f942a3 100644 --- a/go-sdk/ui/schema.go +++ b/go-sdk/ui/schema.go @@ -211,8 +211,8 @@ func (m Manifest) ValidateWithLimits(limits Limits) error { return fmt.Errorf("%w: unsupported manifest protocol version %q", ErrInvalidSchema, m.ProtocolVersion) } - if !validName(m.Plugin.ID) { - return fmt.Errorf("%w: plugin id is required", ErrInvalidSchema) + if !validName(m.Plugin.ID) || m.Plugin.Title == "" || m.Plugin.Version == "" { + return fmt.Errorf("%w: plugin id, title, and version are required", ErrInvalidSchema) } if m.Views == nil { @@ -221,8 +221,8 @@ func (m Manifest) ValidateWithLimits(limits Limits) error { seen := make(map[string]struct{}, len(m.Views)) for _, view := range m.Views { - if !validName(view.ID) || view.Schema == "" { - return fmt.Errorf("%w: view id and schema are required", ErrInvalidSchema) + if !validName(view.ID) || view.Title == "" || view.Schema == "" { + return fmt.Errorf("%w: view id, title, and schema are required", ErrInvalidSchema) } if _, ok := seen[view.ID]; ok { diff --git a/go-sdk/ui/schema_test.go b/go-sdk/ui/schema_test.go index f255f43..3cc5f42 100644 --- a/go-sdk/ui/schema_test.go +++ b/go-sdk/ui/schema_test.go @@ -128,6 +128,11 @@ func TestUnit_SemanticValidation(t *testing.T) { regions.Content = []Node{{ID: "n", Component: "@scope/button"}} return (ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Regions: regions}).Validate() }, want: ErrInvalidSchema}, + {name: "component name starts with hyphen", value: func() error { + regions := emptyRegions() + regions.Content = []Node{{ID: "n", Component: "-button"}} + return (ViewSchema{ProtocolVersion: ProtocolVersion, ID: "users.list", Regions: regions}).Validate() + }, want: ErrInvalidSchema}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -229,6 +234,26 @@ func TestUnit_ManifestValidationParity(t *testing.T) { t.Fatalf("Validate() error = %v, want ErrInvalidSchema for nil views", err) } + base.Views = []UIViewDescriptor{{ID: "users.list", Title: "Users", Schema: "ui://views/users.list"}} + invalidManifests := []struct { + name string + edit func(*Manifest) + }{ + {name: "empty plugin title", edit: func(m *Manifest) { m.Plugin.Title = "" }}, + {name: "empty plugin version", edit: func(m *Manifest) { m.Plugin.Version = "" }}, + {name: "empty view title", edit: func(m *Manifest) { m.Views[0].Title = "" }}, + } + for _, test := range invalidManifests { + t.Run(test.name, func(t *testing.T) { + manifest := base + manifest.Views = append([]UIViewDescriptor(nil), base.Views...) + test.edit(&manifest) + if err := manifest.Validate(); !errors.Is(err, ErrInvalidSchema) { + t.Fatalf("Validate() error = %v, want ErrInvalidSchema", err) + } + }) + } + base.RequiredComponents = []string{"/button"} if err := base.Validate(); !errors.Is(err, ErrInvalidSchema) { t.Fatalf("Validate() error = %v, want ErrInvalidSchema for invalid component", err) diff --git a/go-sdk/ui/validation.go b/go-sdk/ui/validation.go index 4870fd2..f374e7d 100644 --- a/go-sdk/ui/validation.go +++ b/go-sdk/ui/validation.go @@ -596,7 +596,7 @@ func validComponentName(value string) bool { } for index, char := range value { - if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9' && index > 0) || char == '-' { + if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9' && index > 0) || (char == '-' && index > 0) { continue } diff --git a/react-sdk/ui-core/src/core.test.ts b/react-sdk/ui-core/src/core.test.ts index f1e2428..d65c1e9 100644 --- a/react-sdk/ui-core/src/core.test.ts +++ b/react-sdk/ui-core/src/core.test.ts @@ -75,13 +75,18 @@ describe("ui core", () => { }); it("parses manifests and views only after validation", () => { - expect(parseManifest({ protocolVersion: "1.0", plugin: { id: "users", title: "Users", version: "1.0" }, views: [] }).plugin.id).toBe("users"); + const manifest = { protocolVersion: "1.0", plugin: { id: "users", title: "Users", version: "1.0" }, views: [] }; + expect(parseManifest(manifest).plugin.id).toBe("users"); + expect(() => parseManifest({ ...manifest, plugin: { ...manifest.plugin, title: "" } })).toThrow(SchemaValidationError); + expect(() => parseManifest({ ...manifest, plugin: { ...manifest.plugin, version: "" } })).toThrow(SchemaValidationError); + expect(() => parseManifest({ ...manifest, views: [{ id: "users.list", title: "", schema: "ui://views/users.list" }] })).toThrow(SchemaValidationError); expect(parseView({ protocolVersion: "1.0", id: "users.list", regions: { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] }, }).id).toBe("users.list"); expect(() => parseView({ protocolVersion: "1.0", id: "broken", regions: { content: [] } })).toThrow(SchemaValidationError); + expect(() => parseView({ protocolVersion: "1.0", id: "broken", regions: { "top-header": [], "left-panel": [], content: [{ id: "node", component: "-button" }], "right-panel": [], bottom: [] } })).toThrow(SchemaValidationError); }); it("rejects references to unknown sources", () => { diff --git a/react-sdk/ui-core/src/validate.ts b/react-sdk/ui-core/src/validate.ts index 6168fbb..0282b06 100644 --- a/react-sdk/ui-core/src/validate.ts +++ b/react-sdk/ui-core/src/validate.ts @@ -57,13 +57,13 @@ export function parseView(input: unknown, limits: Partial = {} export function validateManifest(manifest: Manifest, customLimits: Partial = {}): void { const limits = withDefaults(customLimits); if (!isRecord(manifest) || manifest.protocolVersion !== "1.0") throw new SchemaValidationError("unsupported manifest protocol version"); - if (!isRecord(manifest.plugin) || !isSafeName(manifest.plugin.id) || !isText(manifest.plugin.title) || !isText(manifest.plugin.version)) { + if (!isRecord(manifest.plugin) || !isSafeName(manifest.plugin.id) || !isNonEmptyText(manifest.plugin.title) || !isNonEmptyText(manifest.plugin.version)) { throw new SchemaValidationError("manifest plugin metadata is required"); } if (!Array.isArray(manifest.views)) throw new SchemaValidationError("manifest views must be an array"); const ids = new Set(); for (const view of manifest.views) { - if (!isRecord(view) || !isSafeName(view.id) || !isText(view.title) || !isText(view.schema) || ids.has(view.id)) { + if (!isRecord(view) || !isSafeName(view.id) || !isNonEmptyText(view.title) || !isNonEmptyText(view.schema) || ids.has(view.id)) { throw new SchemaValidationError("manifest view is invalid"); } ids.add(view.id); @@ -271,6 +271,10 @@ function isText(value: unknown): value is string { return typeof value === "string"; } +function isNonEmptyText(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + function isSafeName(value: unknown): value is string { return typeof value === "string" && value.length > 0 && !/[\r\n]/.test(value); } diff --git a/schemas/actions.schema.json b/schemas/actions.schema.json new file mode 100644 index 0000000..0fe3598 --- /dev/null +++ b/schemas/actions.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "UI Actions", + "description": "A name-keyed map of actions available to a view.", + "type": "object", + "propertyNames": { "$ref": "node.schema.json#/$defs/SafeName" }, + "additionalProperties": { "$ref": "#/$defs/Action" }, + "$defs": { + "SafePath": { "$ref": "node.schema.json#/$defs/SafePath" }, + "UIValue": { "$ref": "node.schema.json#/$defs/UIValue" }, + "Action": { + "type": "object", + "properties": { + "type": { "enum": ["tool", "set-state", "merge-state", "refresh-source", "invalidate"] }, + "tool": { "$ref": "node.schema.json#/$defs/SafeName" }, + "input": { + "type": "object", + "additionalProperties": { "$ref": "node.schema.json#/$defs/UIValue" } + }, + "path": { "$ref": "node.schema.json#/$defs/SafePath" }, + "value": { "$ref": "node.schema.json#/$defs/UIValue" }, + "source": { "$ref": "node.schema.json#/$defs/SafeName" }, + "to": { "type": "string" }, + "variant": { "type": "string" }, + "message": { "type": "string" }, + "effects": { "$ref": "effects.schema.json" } + }, + "required": ["type"], + "allOf": [ + { + "if": { "properties": { "type": { "const": "tool" } }, "required": ["type"] }, + "then": { "required": ["tool"] } + }, + { + "if": { "properties": { "type": { "enum": ["set-state", "merge-state"] } }, "required": ["type"] }, + "then": { "required": ["path"] } + }, + { + "if": { "properties": { "type": { "enum": ["refresh-source", "invalidate"] } }, "required": ["type"] }, + "then": { "required": ["source"] } + } + ] + } + } +} diff --git a/schemas/effects.schema.json b/schemas/effects.schema.json new file mode 100644 index 0000000..25b4379 --- /dev/null +++ b/schemas/effects.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "UI Action Effects", + "description": "An ordered list of client-side effects emitted by an action.", + "type": "array", + "items": { "$ref": "#/$defs/Effect" }, + "$defs": { + "SafePath": { "$ref": "node.schema.json#/$defs/SafePath" }, + "UIValue": { "$ref": "node.schema.json#/$defs/UIValue" }, + "Effect": { + "type": "object", + "properties": { + "type": { + "enum": ["set-state", "merge-state", "invalidate", "refresh-source", "refresh-view", "patch-view", "navigate", "toast", "dialog", "close-dialog"] + }, + "path": { "$ref": "node.schema.json#/$defs/SafePath" }, + "source": { "$ref": "node.schema.json#/$defs/SafeName" }, + "value": { "$ref": "node.schema.json#/$defs/UIValue" }, + "baseRevision": { "type": "string" }, + "revision": { "type": "string" }, + "to": { "type": "string", "minLength": 1 }, + "variant": { "type": "string" }, + "message": { "type": "string", "minLength": 1 } + }, + "required": ["type"], + "allOf": [ + { + "if": { "properties": { "type": { "enum": ["set-state", "merge-state"] } }, "required": ["type"] }, + "then": { "required": ["path"] } + }, + { + "if": { "properties": { "type": { "enum": ["invalidate", "refresh-source"] } }, "required": ["type"] }, + "then": { "required": ["source"] } + }, + { + "if": { "properties": { "type": { "const": "navigate" } }, "required": ["type"] }, + "then": { "required": ["to"] } + }, + { + "if": { "properties": { "type": { "const": "toast" } }, "required": ["type"] }, + "then": { "required": ["message"] } + } + ] + } + } +} diff --git a/schemas/layout.schema.json b/schemas/layout.schema.json new file mode 100644 index 0000000..c1d6b12 --- /dev/null +++ b/schemas/layout.schema.json @@ -0,0 +1,255 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "UI Grid Layout", + "description": "Places a node in the protocol's 12-column grid. Region-specific rules for side panels are enforced by the SDK validators.", + "type": "object", + "properties": { + "row": { + "type": "integer", + "minimum": 1 + }, + "cols": { + "type": "integer", + "minimum": 1, + "maximum": 12 + }, + "offset": { + "type": "integer", + "minimum": 0, + "maximum": 11 + } + }, + "required": [ + "row" + ], + "allOf": [ + { + "if": { + "properties": { + "cols": { + "const": 1 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 11 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 2 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 10 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 3 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 9 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 4 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 8 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 5 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 7 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 6 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 6 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 7 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 5 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 8 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 4 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 9 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 3 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 10 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 2 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 11 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 1 + } + } + } + }, + { + "if": { + "properties": { + "cols": { + "const": 12 + } + }, + "required": [ + "cols" + ] + }, + "then": { + "properties": { + "offset": { + "maximum": 0 + } + } + } + } + ] +} diff --git a/schemas/manifest.schema.json b/schemas/manifest.schema.json new file mode 100644 index 0000000..74bb59c --- /dev/null +++ b/schemas/manifest.schema.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "UI Manifest", + "description": "Lists a plugin's UI views and their schema resource URIs. The SDK validators additionally require view IDs to be unique.", + "type": "object", + "properties": { + "protocolVersion": { + "const": "1.0" + }, + "plugin": { + "type": "object", + "properties": { + "id": { + "$ref": "node.schema.json#/$defs/SafeName" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "version": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "version" + ] + }, + "views": { + "type": "array", + "items": { + "$ref": "#/$defs/ViewDescriptor" + } + }, + "requiredComponents": { + "type": "array", + "items": { + "$ref": "node.schema.json#/$defs/ComponentName" + } + } + }, + "required": [ + "protocolVersion", + "plugin", + "views" + ], + "$defs": { + "ViewDescriptor": { + "type": "object", + "properties": { + "id": { + "$ref": "node.schema.json#/$defs/SafeName" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "route": { + "type": "string" + }, + "schema": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "id", + "title", + "schema" + ] + } + } +} diff --git a/schemas/node.schema.json b/schemas/node.schema.json new file mode 100644 index 0000000..2e490b3 --- /dev/null +++ b/schemas/node.schema.json @@ -0,0 +1,304 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "UI Node", + "description": "A declarative component node. Node trees are recursive; IDs must be unique within their view (checked by the SDK validators).", + "$ref": "#/$defs/Node", + "$defs": { + "SafeName": { + "type": "string", + "minLength": 1, + "pattern": "^[^\\r\\n]+$" + }, + "ComponentName": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]*$" + }, + "SafePath": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "pattern": "^(?!\\.)(?!.*\\.$)(?!.*(?:^|\\.)(?:__proto__|prototype|constructor)(?:\\.|$)).+$" + }, + "JSONValue": { + "anyOf": [ + { + "type": [ + "string", + "number", + "integer", + "boolean", + "null" + ] + }, + { + "type": "array", + "items": { + "$ref": "#/$defs/UIValue" + } + }, + { + "type": "object", + "not": { + "minProperties": 1, + "maxProperties": 1, + "propertyNames": { + "pattern": "^\\$" + } + }, + "propertyNames": { + "not": { + "enum": [ + "__proto__", + "prototype", + "constructor" + ] + } + }, + "additionalProperties": { + "$ref": "#/$defs/UIValue" + } + } + ] + }, + "Reference": { + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "properties": { + "$state": { + "$ref": "#/$defs/SafePath" + }, + "$context": { + "$ref": "#/$defs/SafePath" + }, + "$source": { + "$ref": "#/$defs/SafePath" + }, + "$event": { + "$ref": "#/$defs/SafePath" + }, + "$result": { + "$ref": "#/$defs/SafePath" + } + }, + "additionalProperties": false + }, + "Expression": { + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "properties": { + "$eq": { + "$ref": "#/$defs/TwoValues" + }, + "$ne": { + "$ref": "#/$defs/TwoValues" + }, + "$gt": { + "$ref": "#/$defs/TwoValues" + }, + "$gte": { + "$ref": "#/$defs/TwoValues" + }, + "$lt": { + "$ref": "#/$defs/TwoValues" + }, + "$lte": { + "$ref": "#/$defs/TwoValues" + }, + "$and": { + "$ref": "#/$defs/OneOrMoreValues" + }, + "$or": { + "$ref": "#/$defs/OneOrMoreValues" + }, + "$not": { + "$ref": "#/$defs/OneValue" + }, + "$in": { + "$ref": "#/$defs/TwoValues" + }, + "$exists": { + "$ref": "#/$defs/OneValue" + }, + "$concat": { + "$ref": "#/$defs/ZeroOrMoreValues" + }, + "$coalesce": { + "$ref": "#/$defs/OneOrMoreValues" + }, + "$if": { + "$ref": "#/$defs/ThreeValues" + } + }, + "additionalProperties": false + }, + "UIValue": { + "description": "JSON literal, protocol reference, or expression. Path safety, source existence, and aggregate limits are checked by the SDK validators.", + "anyOf": [ + { + "$ref": "#/$defs/JSONValue" + }, + { + "$ref": "#/$defs/Reference" + }, + { + "$ref": "#/$defs/Expression" + } + ] + }, + "OneValue": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/UIValue" + } + ], + "items": false, + "minItems": 1 + }, + "TwoValues": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/UIValue" + }, + { + "$ref": "#/$defs/UIValue" + } + ], + "items": false, + "minItems": 2 + }, + "ThreeValues": { + "type": "array", + "prefixItems": [ + { + "$ref": "#/$defs/UIValue" + }, + { + "$ref": "#/$defs/UIValue" + }, + { + "$ref": "#/$defs/UIValue" + } + ], + "items": false, + "minItems": 3 + }, + "OneOrMoreValues": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/UIValue" + } + }, + "ZeroOrMoreValues": { + "type": "array", + "items": { + "$ref": "#/$defs/UIValue" + } + }, + "EventHandler": { + "type": "object", + "properties": { + "action": { + "$ref": "#/$defs/SafeName" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/ActionStep" + } + } + }, + "anyOf": [ + { + "required": [ + "action" + ] + }, + { + "required": [ + "steps" + ], + "properties": { + "steps": { + "minItems": 1 + } + } + } + ] + }, + "ActionStep": { + "type": "object", + "properties": { + "type": { + "const": "action" + }, + "action": { + "$ref": "#/$defs/SafeName" + }, + "input": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/UIValue" + } + } + }, + "required": [ + "type", + "action" + ] + }, + "Node": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/SafeName" + }, + "component": { + "$ref": "#/$defs/ComponentName" + }, + "layout": { + "$ref": "layout.schema.json" + }, + "props": { + "type": "object", + "maxProperties": 64, + "additionalProperties": { + "$ref": "#/$defs/UIValue" + } + }, + "events": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/EventHandler" + } + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/Node" + } + }, + "slots": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "$ref": "#/$defs/Node" + } + } + }, + "when": { + "$ref": "#/$defs/UIValue" + } + }, + "required": [ + "id", + "component" + ] + } + } +} diff --git a/schemas/view.schema.json b/schemas/view.schema.json new file mode 100644 index 0000000..5dd0d6d --- /dev/null +++ b/schemas/view.schema.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "UI View", + "description": "A protocol 1.0 declarative view. Referential integrity and region-specific layout rules are additionally enforced by the SDK validators.", + "type": "object", + "properties": { + "protocolVersion": { "const": "1.0" }, + "id": { "$ref": "node.schema.json#/$defs/SafeName" }, + "revision": { "type": "string" }, + "title": { "type": "string" }, + "state": { "type": "object" }, + "sources": { + "type": "object", + "propertyNames": { "$ref": "node.schema.json#/$defs/SafeName" }, + "additionalProperties": { "$ref": "#/$defs/DataSource" } + }, + "actions": { "$ref": "actions.schema.json" }, + "regions": { + "type": "object", + "properties": { + "top-header": { "$ref": "#/$defs/NodeList" }, + "left-panel": { "$ref": "#/$defs/NodeList" }, + "content": { "$ref": "#/$defs/NodeList" }, + "right-panel": { "$ref": "#/$defs/NodeList" }, + "bottom": { "$ref": "#/$defs/NodeList" } + }, + "required": ["top-header", "left-panel", "content", "right-panel", "bottom"] + } + }, + "required": ["protocolVersion", "id", "regions"], + "$defs": { + "NodeList": { + "type": "array", + "items": { "$ref": "node.schema.json" } + }, + "DataSource": { + "type": "object", + "properties": { + "type": { "const": "tool" }, + "tool": { "$ref": "node.schema.json#/$defs/SafeName" }, + "input": { + "type": "object", + "additionalProperties": { "$ref": "node.schema.json#/$defs/UIValue" } + }, + "policy": { "enum": ["manual", "on-mount"] }, + "cache": { + "type": "object", + "properties": { "ttl": { "type": "integer", "minimum": 0 } } + }, + "refreshOn": { + "type": "array", + "items": { "$ref": "node.schema.json#/$defs/SafeName" } + } + }, + "required": ["type", "tool"] + } + } +} From df3cb78240cee7169223809639a8bcd0b3affefa Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 02:56:13 +0300 Subject: [PATCH 13/26] change --- Makefile | 15 +++++ package.json | 2 +- pnpm-lock.yaml | 158 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d61e100..12040ac 100755 --- a/Makefile +++ b/Makefile @@ -33,3 +33,18 @@ ci: pre-commit examples-dev: pnpm --filter @osspkg/ui-examples dev +.PHONY: npm-install +npm-install: + pnpm install --frozen-lockfile + +.PHONY: npm-build +npm-build: npm-install + pnpm --filter @osspkg/ui-core build + pnpm --filter @osspkg/ui-transport build + pnpm --filter @osspkg/ui-react build + +.PHONY: npm-publish +npm-publish: npm-build + pnpm --filter @osspkg/ui-core publish --access public + pnpm --filter @osspkg/ui-transport publish --access public + pnpm --filter @osspkg/ui-react publish --access public diff --git a/package.json b/package.json index 7729d8f..d06bca9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@osspkg/ui-workspace", "private": true, - "packageManager": "pnpm@11.23.0", + "packageManager": "pnpm@12.6.0", "scripts": { "build": "pnpm --recursive --if-present build", "test": "pnpm --recursive --if-present test", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5da421b..710eaed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,3 +1,161 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.6.0 + version: 12.6.0 + +packages: + + '@pnpm/exe.android-arm64@12.6.0': + resolution: {integrity: sha512-kviIHft9h02q+7N2In7tSL9T/HRUSGwYmU74YPtsWU5ee7vgqPyC0JT6RRX6miW+NxX1CvHgQxg69qq1qTLJYQ==} + cpu: [arm64] + os: [android] + + '@pnpm/exe.android-x64@12.6.0': + resolution: {integrity: sha512-CT8aJKLq2mtZFE71pr4E5Z2xHL8uGnRGr+NclDqsXlVF4SVcQ2QAs14mWi0C8gjT4hbmi+Q0lJRRIkMAWfjjng==} + cpu: [x64] + os: [android] + + '@pnpm/exe.darwin-arm64@12.6.0': + resolution: {integrity: sha512-rafpVkjzugKBMxSvGQd5wK1x5eOTkD/+lcJIFHeQhvd+BJ8kDisOOvwhQDrpGd4vd2Fx+hhW0P2Ptt638OlhFg==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.6.0': + resolution: {integrity: sha512-72Jpuv1m24gI8zUNcaylYpBWEhBzz1VPhnDIU2GYM2eRP+KsOqVLwV78i+JrihI39zY2NNXmaN4eAKX1inpaDQ==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.freebsd-x64@12.6.0': + resolution: {integrity: sha512-LON4QgNy1w/XF4uySZIyC/5hEB0oGdelXsY9tSKqstER+2j2X2oHz67skD11RRd/HWFRtMr64shQZ4I9ZoZ0zg==} + cpu: [x64] + os: [freebsd] + + '@pnpm/exe.linux-arm64-musl@12.6.0': + resolution: {integrity: sha512-BdDpX+DeaMUc5x6WNcE6FkmFy36NwrBbBvwsZU737Zt3u1fTY+iKFPchjEQNCM4rT6q6xvF2oN3ZwExGsJCkxQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.6.0': + resolution: {integrity: sha512-8h2sNoIhHDpHDYqZZCh9PXr6krqW10btb0GHVGuw710muDHlFAHMtUG2ogmaHge6zJG1xgY4Y7mwX5g/LWvrYg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-ppc64@12.6.0': + resolution: {integrity: sha512-ld6xgcEhFCFrsVsJhbmdmR714PnsEspO/Ij8w0/D7OSiz2hYGu3fZd5tKRxOI5m6H6Gl0/RRFAPncTHbsgXVjQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-riscv64@12.6.0': + resolution: {integrity: sha512-HIbImydoC3J8NFt+8MXfhTCf5rO6NjCMyinSAHac0VmV8uCHw+kbQkyAzxV84rzz9ZOzmBOnj0b1N461UIYGsw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-s390x@12.6.0': + resolution: {integrity: sha512-DjSqT7+BZ/lWcDHwNMuzsRVfyxUimh+D54Xw4tSWbdVVQhtWtNMUyI93LVnwCmsoEpar2gC3SFeAgywx37svKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.6.0': + resolution: {integrity: sha512-31lKeGPmRE6xfV6I3VjaEGVFRei14pl/zgoFDK4w+UkbJqOuNl2Ht3O1GuqNIg3Gl14qTEb4kZRAYFHXifDx9A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.6.0': + resolution: {integrity: sha512-qFWBneHJAJ73W4whtbaFOL1M/7DBC6ILHXuxc7ZPtEhfPuT1zeZiGrmKHoMAfJA+mcm6xhOFljqVTUS+00Jabw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.6.0': + resolution: {integrity: sha512-OhfefXEEykZlslSUhR8PPRe6MVPV3Oink/dVfIecE5velpc7j80cZYsHlaDrtRDcm3TPwQHkaRW1SJ1efX9oFg==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.6.0': + resolution: {integrity: sha512-L2tuyrD2+Imgxs3VK/ST/2L3xD6vDP0ktJQxM0TaGw7TbAQEMq+RMc8iK6Ew7Id579uzjJe8jSfJID2ZGsOHCA==} + cpu: [x64] + os: [win32] + + pnpm@12.6.0: + resolution: {integrity: sha512-PvaPlRyxEawgS0paFvCy3fDaVqluBBPoHYVdnwtV75JnFHCQKOHNAMQFwsX7e56OxNxGd3yAXQNzwvL/AP0g7A==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.android-arm64@12.6.0': + optional: true + + '@pnpm/exe.android-x64@12.6.0': + optional: true + + '@pnpm/exe.darwin-arm64@12.6.0': + optional: true + + '@pnpm/exe.darwin-x64@12.6.0': + optional: true + + '@pnpm/exe.freebsd-x64@12.6.0': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.6.0': + optional: true + + '@pnpm/exe.linux-arm64@12.6.0': + optional: true + + '@pnpm/exe.linux-ppc64@12.6.0': + optional: true + + '@pnpm/exe.linux-riscv64@12.6.0': + optional: true + + '@pnpm/exe.linux-s390x@12.6.0': + optional: true + + '@pnpm/exe.linux-x64-musl@12.6.0': + optional: true + + '@pnpm/exe.linux-x64@12.6.0': + optional: true + + '@pnpm/exe.win32-arm64@12.6.0': + optional: true + + '@pnpm/exe.win32-x64@12.6.0': + optional: true + + pnpm@12.6.0: + optionalDependencies: + '@pnpm/exe.android-arm64': 12.6.0 + '@pnpm/exe.android-x64': 12.6.0 + '@pnpm/exe.darwin-arm64': 12.6.0 + '@pnpm/exe.darwin-x64': 12.6.0 + '@pnpm/exe.freebsd-x64': 12.6.0 + '@pnpm/exe.linux-arm64': 12.6.0 + '@pnpm/exe.linux-arm64-musl': 12.6.0 + '@pnpm/exe.linux-ppc64': 12.6.0 + '@pnpm/exe.linux-riscv64': 12.6.0 + '@pnpm/exe.linux-s390x': 12.6.0 + '@pnpm/exe.linux-x64': 12.6.0 + '@pnpm/exe.linux-x64-musl': 12.6.0 + '@pnpm/exe.win32-arm64': 12.6.0 + '@pnpm/exe.win32-x64': 12.6.0 + +--- lockfileVersion: '9.0' settings: From 3e869c5087105d514fea7f07b27de6de3c1de0a3 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 03:12:56 +0300 Subject: [PATCH 14/26] change --- AGENTS.md | 37 +++++ README.md | 157 +++++++++++++++++- skills/go-ui-development/SKILL.md | 30 ++++ skills/go-ui-development/agents/openai.yaml | 3 + .../go-ui-development/references/examples.md | 143 ++++++++++++++++ .../references/protocol-map.md | 100 +++++++++++ 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 AGENTS.md create mode 100644 skills/go-ui-development/SKILL.md create mode 100644 skills/go-ui-development/agents/openai.yaml create mode 100644 skills/go-ui-development/references/examples.md create mode 100644 skills/go-ui-development/references/protocol-map.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2aff5f0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,37 @@ +# Repository instructions + +## Project map + +- `go-sdk/ui` contains the Go protocol types, builders, and validation. +- `go-sdk/mcp` adapts UI manifests and views to MCP resources. +- `react-sdk/ui-core`, `ui-transport`, and `ui-react` contain the TypeScript SDK packages. +- `example/go` and `example/react` are working examples; `schemas/` contains protocol JSON Schemas. + +Keep the protocol transport-independent. When changing protocol fields or behavior, check the Go schema and validation, React schema and validation, JSON Schemas, fixtures, and relevant examples for consistency. + +## Commands + +Run commands from the repository root: + +| Command | Purpose | +| --- | --- | +| `make npm-install` | Install the pnpm workspace from the frozen lockfile | +| `make npm-build` | Build the core, transport, and React npm packages | +| `make examples-dev` | Start the Vite React examples | +| `make tests` | Run the Go test workflow through goppy | +| `make lint` | Run the Go lint workflow through goppy | +| `make build` | Run the Go build workflow through goppy | +| `make ci` | Run `pre-commit` (tool setup, license, lint, tests, and build) | + +For focused TypeScript workflows, use `pnpm --filter typecheck`, `test`, or `build` where that script exists. Package names and scripts are defined in each workspace `package.json`. + +`make install` installs `goppy@latest` and runs `goppy setup-lib`; `make ci` includes this setup through `pre-commit`. These workflows can update local/generated files, so inspect `git status` and the Makefile before using them when preserving a worktree matters. + +`make npm-publish` publishes all three `@osspkg` packages publicly. Never run a publish command unless the user explicitly asked for a release. + +## Change guidance + +- Preserve the Go and TypeScript representations of the protocol in sync, including layout behavior for nested nodes. +- Keep JSON Schema documents and valid/invalid fixtures aligned with runtime validation. +- Prefer extending the existing SDK APIs and examples over introducing duplicate abstractions. +- Do not commit, publish, or run broad mutating workflows unless the user requests it. diff --git a/README.md b/README.md index b8a6e43..9715991 100644 --- a/README.md +++ b/README.md @@ -1 +1,156 @@ -# go-ui \ No newline at end of file +# go-ui + +[![Go Reference](https://pkg.go.dev/badge/go.osspkg.com/ui.svg)](https://pkg.go.dev/go.osspkg.com/ui) + +`go-ui` is a declarative UI protocol and SDK for describing plugin interfaces as JSON schemas. The Go SDK builds and validates manifests and views; the React SDK validates those schemas and renders them with registered HTML and shadcn components. The UI model is independent of the transport, with an MCP adapter available in `go-sdk/mcp`. + +## Features + +- Declarative manifests, views, regions, nodes, layouts, data sources, actions, and effects. +- Go builders for composing and validating UI schemas. +- React packages for schema validation, transport, and rendering. +- Default HTML and shadcn component registries, with support for custom components. +- Child layout using `row`, `cols`, and `offset` in a 12-column grid. +- JSON Schemas for protocol objects in [`schemas/`](schemas/). + +## Repository layout + +| Path | Description | +| --- | --- | +| [`go-sdk/ui`](go-sdk/ui) | Go schema types, builders, validation, and component definitions | +| [`go-sdk/mcp`](go-sdk/mcp) | Adapter that registers the UI manifest and views as MCP resources | +| [`react-sdk/ui-core`](react-sdk/ui-core) | TypeScript protocol types, validation, and runtime helpers | +| [`react-sdk/ui-transport`](react-sdk/ui-transport) | HTTP, WebSocket, and RPC transport helpers | +| [`react-sdk/ui-react`](react-sdk/ui-react) | React renderer, registries, and default components | +| [`example/go`](example/go) | Go examples for an HTML form and dashboard | +| [`example/react`](example/react) | Vite app with interactive React examples | +| [`schemas`](schemas) | JSON Schema documents for protocol entities | + +## Requirements + +- Go 1.26.8 or newer. +- Node.js and pnpm 12.6.0, as declared by the workspace package manifest. + +## Go usage + +The root Go module is `go.osspkg.com/ui`. This example builds a profile view with a card and individually positioned child nodes: + +```go +package main + +import "go.osspkg.com/ui/go-sdk/ui" + +func newProfileApp() (*ui.App, error) { + app := ui.New( + ui.AppID("profile"), + ui.AppTitle("Profile"), + ui.AppVersion("1.0.0"), + ) + view := ui.View( + "profile.edit", + ui.Title("Edit profile"), + ui.Content( + ui.HTML(ui.HTMLArticle, "profile-card").Row(1).Cols(8).Offset(2).Children( + ui.HTML(ui.HTMLH1, "heading").Row(1).Cols(12).Prop("text", "Edit profile"), + ui.HTML(ui.HTMLInput, "name").Row(2).Cols(8).Offset(2).Prop("placeholder", "Ada Lovelace"), + ), + ), + ) + if err := app.AddView(view); err != nil { + return nil, err + } + return app, nil +} +``` + +For an MCP server, pass the app to `go-sdk/mcp.Register` and add `go-sdk/mcp.Capabilities()` to the server options. See the [dashboard example](example/go/dashboard/dashboard.go) for sources, actions, and effects, and the [MCP adapter](go-sdk/mcp/register.go) for resource registration. + +## React usage + +Install the packages used by your application: + +```bash +pnpm add @osspkg/ui-core @osspkg/ui-react +``` + +Register the built-in component sets, load the package stylesheet, and render a `ViewSchema`: + +```tsx +import { + createComponentRegistry, + registerDefaultHTMLComponents, + registerDefaultShadcnComponents, + UIProvider, + ViewRenderer, +} from "@osspkg/ui-react"; +import type { ViewSchema } from "@osspkg/ui-core"; +import "@osspkg/ui-react/styles.css"; + +const components = createComponentRegistry(); +registerDefaultHTMLComponents(components); +registerDefaultShadcnComponents(components); + +const schema: ViewSchema = { + protocolVersion: "1.0", + id: "welcome", + title: "Welcome", + regions: { + "top-header": [], + "left-panel": [], + "right-panel": [], + bottom: [], + content: [ + { + id: "welcome-card", + component: "card", + layout: { row: 1, cols: 8, offset: 2 }, + children: [ + { id: "title", component: "h1", layout: { row: 1, cols: 12 }, props: { text: "Welcome" } }, + { id: "body", component: "p", layout: { row: 2, cols: 8, offset: 2 }, props: { text: "Rendered from a schema." } }, + ], + }, + ], + }, +}; + +export function WelcomeView() { + return ( + + + + ); +} +``` + +Nodes with children create their own 12-column grid. Set a child's `layout` to control its row, width (`cols`), and horizontal offset; nodes without a layout use the available row. The [interactive example](example/react/README.md) demonstrates forms, default registries, and nested layouts. + +## Development + +Install the JavaScript workspace dependencies and build the publishable React packages: + +```bash +make npm-install +make npm-build +``` + +Run the interactive example with: + +```bash +make examples-dev +``` + +The Makefile also provides `make tests`, `make lint`, `make build`, and `make ci` for Go project workflows. `make ci` runs the `pre-commit` prerequisites, including dependency/tool setup and generated license work; consult the [Makefile](Makefile) before running it. + +## Packages + +The JavaScript workspace contains three publishable packages, currently version `0.1.0`: + +- [`@osspkg/ui-core`](react-sdk/ui-core/package.json) +- [`@osspkg/ui-transport`](react-sdk/ui-transport/package.json) +- [`@osspkg/ui-react`](react-sdk/ui-react/package.json) + +`make npm-publish` builds and publishes these packages to the public npm registry. Run it only as part of an intentional release. + +## License + +This project is licensed under the [BSD 3-Clause License](LICENSE). diff --git a/skills/go-ui-development/SKILL.md b/skills/go-ui-development/SKILL.md new file mode 100644 index 0000000..a63ce1e --- /dev/null +++ b/skills/go-ui-development/SKILL.md @@ -0,0 +1,30 @@ +--- +name: go-ui-development +description: Implement and integrate features in the go-ui declarative protocol, Go SDK, React SDK, and examples while keeping their behavior aligned. +--- + +# go-ui development + +Use this skill for development involving the `go-ui` protocol, Go or React SDKs, MCP adapter, transports, or examples. + +## Principles + +- Treat the wire contract below as shared by Go and TypeScript. Preserve parity in field names, enum values, reference resolution, validation, limits, and layout behavior. +- Keep the UI schema independent of transport. MCP resource publication and host RPC handling are separate concerns. +- A node's `layout` positions that node. Each node with children owns a new 12-column grid, so each child chooses its own `row`, `cols`, and `offset` within that immediate parent. +- Keep validation and safe value/path handling at the schema boundary. React component props and events must pass through registry allowlists; never forward arbitrary plugin props. +- For implementation work, inspect the existing corresponding Go and TypeScript behavior before changing the contract. Update schemas, fixtures, examples, and behavior checks together when applicable. +- Read [the protocol reference](references/protocol-map.md) for the complete model and [the examples reference](references/examples.md) for Go, React, and host usage snippets. + +## Development commands + +Run from the repository root. The workspace uses Go 1.26.8 and pnpm 12.6.0. + +- `make npm-install`: frozen pnpm workspace install. +- `make npm-build`: build core, transport, and React packages. +- `make examples-dev`: start the React example app. +- `make tests`, `make lint`, `make build`: Go workflows via goppy. +- `make ci`: setup, license, lint, tests, and build; setup/license steps may modify generated/local files. +- `pnpm --filter @osspkg/ui-core typecheck|test|build`, likewise `@osspkg/ui-transport` and `@osspkg/ui-react`, for focused package scripts. + +Inspect the worktree before workflows that generate or update files. `make npm-publish` publishes all three packages publicly; do not run it unless the user explicitly requests a release. diff --git a/skills/go-ui-development/agents/openai.yaml b/skills/go-ui-development/agents/openai.yaml new file mode 100644 index 0000000..6bc2844 --- /dev/null +++ b/skills/go-ui-development/agents/openai.yaml @@ -0,0 +1,3 @@ +interface: + display_name: "Go UI Development" + short_description: "Help with Go UI Development tasks" diff --git a/skills/go-ui-development/references/examples.md b/skills/go-ui-development/references/examples.md new file mode 100644 index 0000000..986ae80 --- /dev/null +++ b/skills/go-ui-development/references/examples.md @@ -0,0 +1,143 @@ +# go-ui code examples + +These examples are included here so the skill does not depend on documentation outside this directory. Confirm details against the current project API when using them after a protocol or SDK change. + +## Compose and validate a Go view + +```go +package profile + +import "go.osspkg.com/ui/go-sdk/ui" + +func NewApp() (*ui.App, error) { + app := ui.New( + ui.AppID("profile"), + ui.AppTitle("Profile"), + ui.AppVersion("1.0.0"), + ) + view := ui.View( + "profile.edit", + ui.Title("Edit profile"), + ui.Content( + ui.HTML(ui.HTMLArticle, "profile-card").Row(1).Cols(8).Offset(2).Children( + ui.HTML(ui.HTMLH1, "heading").Row(1).Cols(12).Prop("text", "Edit profile"), + ui.HTML(ui.HTMLInput, "name").Row(2).Cols(8).Offset(2).Prop("placeholder", "Ada Lovelace"), + ui.HTML(ui.HTMLButton, "save").Row(3).Cols(4).Offset(4).Prop("text", "Save"), + ), + ), + ) + if err := app.AddView(view); err != nil { + return nil, err + } + return app, nil +} +``` + +`App.AddView` validates the schema and stores a copy. Node builders support `.Row(n)`, `.Cols(n)`, `.Offset(n)`, `.Prop(key, value)`, `.Children(...)`, and `.On(event, ui.ActionRef(name))`. View options include `ui.Title`, `ui.State`, `ui.Source`, `ui.Content`, `ui.TopHeader`, `ui.LeftPanel`, `ui.RightPanel`, and `ui.Bottom`. + +A local state action can be declared as: + +```go +ui.View("profile.edit").Action( + "profile.submit", + ui.SetState("submitted", true).Then(ui.ToastSuccess("Profile saved")), +) +``` + +For a tool source, use `ui.Source("metrics", ui.Tool("metrics.summary").OnMount().TTL(30))`. Use the typed builders rather than hand-constructing action JSON when the builder supports the operation. + +## Register UI resources on an MCP server + +```go +import ( + goMCP "go.osspkg.com/mcp" + uiMCP "go.osspkg.com/ui/go-sdk/mcp" +) + +server, err := goMCP.New( + goMCP.ServerInfo{Name: "profile", Version: "1.0.0"}, + uiMCP.Capabilities(), +) +if err != nil { + return err +} +if err := uiMCP.Register(server, app); err != nil { + return err +} +``` + +Registration publishes the manifest and view resources. Implement `ui.get`, `data.call`, and `ui.action` in the host if the React runtime needs RPC calls. + +## Render a static schema in React + +```tsx +import { + createComponentRegistry, + registerDefaultHTMLComponents, + registerDefaultShadcnComponents, + UIProvider, + ViewRenderer, +} from "@osspkg/ui-react"; +import type { ViewSchema } from "@osspkg/ui-core"; +import "@osspkg/ui-react/styles.css"; + +const components = createComponentRegistry(); +registerDefaultHTMLComponents(components); +registerDefaultShadcnComponents(components); + +const schema: ViewSchema = { + protocolVersion: "1.0", + id: "welcome", + regions: { + "top-header": [], + "left-panel": [], + "right-panel": [], + bottom: [], + content: [ + { + id: "card", + component: "card", + layout: { row: 1, cols: 8, offset: 2 }, + children: [ + { id: "title", component: "h1", layout: { row: 1, cols: 12 }, props: { text: "Welcome" } }, + { id: "body", component: "p", layout: { row: 2, cols: 8, offset: 2 }, props: { text: "A schema rendered by React." } }, + ], + }, + ], + }, +}; + +export function WelcomeView() { + return ; +} +``` + +Default component registries provide dependency-free HTML and shadcn-name renderers. A host can register its own implementation first; the default registration functions leave existing registry entries intact. Each custom component definition declares allowed props, events, slots, and whether it accepts children. + +## Connect a host RPC transport + +```tsx +import { PluginView, UIProvider, createComponentRegistry, registerDefaultHTMLComponents, registerDefaultShadcnComponents } from "@osspkg/ui-react"; +import { HTTPTransport } from "@osspkg/ui-transport"; + +const components = createComponentRegistry(); +registerDefaultHTMLComponents(components); +registerDefaultShadcnComponents(components); +const transport = new HTTPTransport("/rpc"); + +export function RemoteProfile() { + return ( + + + + ); +} +``` + +`HTTPTransport` sends JSON-RPC calls to its endpoint. `WebSocketTransport` supports calls, subscriptions, and optional reconnect handling. Both implement `RPCTransport`. Close long-lived transports when their owning application lifecycle ends. + +The server must return `{ "schema": }` for `ui.get`, accept source requests for `data.call`, and handle declared tool actions for `ui.action`. React local actions such as `set-state` execute in the runtime; host calls are required for tool-backed behavior. + +## Reference project patterns + +For an HTML form, use component names such as `article`, `form`, `label`, `input`, and `button`, bind the form `submit` event to an action, and use `when` to conditionally show a result. For data dashboards, use a source with `on-mount`, bind displayed values to `$source` references, and refresh sources from action effects. In both cases, place children with their own layouts when they should not simply stack at default width. diff --git a/skills/go-ui-development/references/protocol-map.md b/skills/go-ui-development/references/protocol-map.md new file mode 100644 index 0000000..c28baca --- /dev/null +++ b/skills/go-ui-development/references/protocol-map.md @@ -0,0 +1,100 @@ +# go-ui protocol reference + +This file is a self-contained working reference for protocol 1.0. The protocol is JSON and independent of Go, React, and MCP. The Go module import path is `go.osspkg.com/ui`; SDK packages are `go.osspkg.com/ui/go-sdk/ui` and `go.osspkg.com/ui/go-sdk/mcp`. The npm packages are `@osspkg/ui-core`, `@osspkg/ui-transport`, and `@osspkg/ui-react`. + +## Object model + +### Manifest + +```json +{ + "protocolVersion": "1.0", + "plugin": { "id": "profile", "title": "Profile", "version": "1.0.0" }, + "views": [{ "id": "profile.edit", "title": "Edit profile", "schema": "ui://views/profile.edit" }], + "requiredComponents": ["card"] +} +``` + +`plugin.id`, `plugin.title`, `plugin.version`, and the `views` array are required. A view descriptor has `id`, `title`, and `schema`; `route` is optional. `requiredComponents` is optional. + +### View + +A view contains `protocolVersion`, `id`, and all five `regions`. Optional members are `revision`, `title`, `state`, `sources`, and `actions`. + +Region keys are exactly: + +- `top-header` +- `left-panel` +- `content` +- `right-panel` +- `bottom` + +Each region is an array of nodes. + +### Node and layout + +A node has `id` and `component`. Optional members are `layout`, `props`, `events`, `children`, `slots`, and `when`. + +```json +{ + "id": "profile-card", + "component": "card", + "layout": { "row": 1, "cols": 8, "offset": 2 }, + "children": [ + { "id": "heading", "component": "h1", "layout": { "row": 1, "cols": 12 }, "props": { "text": "Profile" } }, + { "id": "name", "component": "input", "layout": { "row": 2, "cols": 8, "offset": 2 }, "props": { "placeholder": "Ada Lovelace" } } + ] +} +``` + +A layout is a 12-column placement: `row` identifies its grid row; `cols` is the width; `offset` is the number of preceding columns. Children render in their parent's own grid. Nodes without a layout use the available row/width defaults. + +### Values and references + +`UIValue` is a JSON scalar, array, object, reference, or expression. A reference is a single-key object: + +- `{ "$state": "path" }` +- `{ "$context": "path" }` +- `{ "$source": "sourceName.path" }` +- `{ "$event": "path" }` +- `{ "$result": "path" }` + +Expressions use one operator key, for example `{ "$eq": [{ "$state": "saved" }, true] }` or `{ "$concat": ["Hello, ", { "$state": "name" }] }`. Preserve safe-path checks and rejection of prototype-sensitive keys such as `__proto__`, `prototype`, and `constructor`. + +### Sources, actions, and effects + +A source describes a host tool operation: + +```json +{ "type": "tool", "tool": "profile.read", "policy": "on-mount", "cache": { "ttl": 30 } } +``` + +`policy` is `manual` or `on-mount`; optional `input`, `cache.ttl`, and `refreshOn` configure its behavior. + +Action types: `tool`, `set-state`, `merge-state`, `refresh-source`, `invalidate`. Common fields include `tool`, `input`, `path`, `value`, `source`, and `effects`. + +Effect types: `set-state`, `merge-state`, `invalidate`, `refresh-source`, `refresh-view`, `patch-view`, `navigate`, `toast`, `dialog`, and `close-dialog`. Effects can use `path`, `source`, `value`, `to`, `variant`, and `message` depending on type. + +An event handler can name one `action` or a list of `steps`. Each step has `type: "action"`, an action name, and optional input. A view condition `when` takes a `UIValue` expression. + +## Package responsibilities + +- `@osspkg/ui-core`: protocol types, validation, value/expression resolution, request tracking, and RPC method/request types. +- `@osspkg/ui-transport`: `RPCTransport`, JSON-RPC primitives, `HTTPTransport`, and `WebSocketTransport`. +- `@osspkg/ui-react`: component registry, default HTML/shadcn component definitions, `UIProvider`, `PluginView`, `ViewRenderer`, hooks, and runtime actions/effects. +- Go `ui` package: protocol types, builders, validation, values, and shared RPC method names/types. +- Go `mcp` package: advertises `osspkg.ui` capability and registers `ui://manifest` plus each view schema as MCP resources. It does not implement the host's UI RPC handlers. + +Shared RPC method strings are `ui.get`, `data.call`, and `ui.action`. The host implements these methods for remote/runtime use. `PluginView` requests `{ plugin?, view, context? }` through `ui.get`; source and tool actions use `data.call` and `ui.action` respectively. Do not assume registering MCP resources creates these RPC handlers. + +## Compatibility checklist + +For a wire or runtime behavior change, review all applicable layers: + +1. Go protocol structs, builders, validation, and JSON serialization. +2. TypeScript types, validation, resolution, and React runtime/rendering. +3. JSON Schemas for manifest, view, node, layout, actions, and effects. +4. Valid/invalid fixtures and relevant Go/TypeScript tests. +5. Go and React examples, including nested layout placement. + +JSON Schema validation covers structural constraints; SDK validators also enforce semantic checks such as references, safe paths, supported actions, and limits. Keep both layers consistent. From 2ad8fce74941691be22d00dcd6a3313c863fb2b2 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 03:17:53 +0300 Subject: [PATCH 15/26] change --- README.md | 33 ++++++++++++++++++++++++++++++--- go-sdk/mcp/register.go | 8 ++++++-- go-sdk/ui/builder.go | 31 ++++++++++++++++++++----------- go-sdk/ui/schema.go | 5 +++-- go-sdk/ui/validation.go | 14 +++++++++----- go-sdk/ui/value.go | 15 +++++++++++---- 6 files changed, 79 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9715991..d8882ae 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # go-ui -[![Go Reference](https://pkg.go.dev/badge/go.osspkg.com/ui.svg)](https://pkg.go.dev/go.osspkg.com/ui) +[![Go Reference](https://pkg.go.dev/badge/go.osspkg.com/ui/go-sdk/ui.svg)](https://pkg.go.dev/go.osspkg.com/ui/go-sdk/ui) `go-ui` is a declarative UI protocol and SDK for describing plugin interfaces as JSON schemas. The Go SDK builds and validates manifests and views; the React SDK validates those schemas and renders them with registered HTML and shadcn components. The UI model is independent of the transport, with an MCP adapter available in `go-sdk/mcp`. @@ -33,7 +33,13 @@ ## Go usage -The root Go module is `go.osspkg.com/ui`. This example builds a profile view with a card and individually positioned child nodes: +The root Go module is `go.osspkg.com/ui`. Add it to a Go module with: + +```bash +go get go.osspkg.com/ui +``` + +This example builds a profile view with a card and individually positioned child nodes: ```go package main @@ -63,7 +69,28 @@ func newProfileApp() (*ui.App, error) { } ``` -For an MCP server, pass the app to `go-sdk/mcp.Register` and add `go-sdk/mcp.Capabilities()` to the server options. See the [dashboard example](example/go/dashboard/dashboard.go) for sources, actions, and effects, and the [MCP adapter](go-sdk/mcp/register.go) for resource registration. +To expose the manifest and views as MCP resources, register the app with the MCP adapter: + +```go +import ( + goMCP "go.osspkg.com/mcp" + "go.osspkg.com/ui/go-sdk/ui" + uiMCP "go.osspkg.com/ui/go-sdk/mcp" +) + +func registerUI(app *ui.App) error { + server, err := goMCP.New( + goMCP.ServerInfo{Name: "profile", Version: "1.0.0"}, + uiMCP.Capabilities(), + ) + if err != nil { + return err + } + return uiMCP.Register(server, app) +} +``` + +The adapter publishes resources; a host that uses the React runtime must separately handle `ui.get`, `data.call`, and `ui.action`. See the [dashboard example](example/go/dashboard/dashboard.go) for sources, actions, and effects. ## React usage diff --git a/go-sdk/mcp/register.go b/go-sdk/mcp/register.go index f63f5b7..c2e5c9a 100644 --- a/go-sdk/mcp/register.go +++ b/go-sdk/mcp/register.go @@ -16,7 +16,8 @@ import ( "go.osspkg.com/ui/go-sdk/ui" ) -// Capabilities returns the MCP option advertising the UI capability. +// Capabilities returns an MCP option that advertises the UI protocol version +// and the ui://manifest resource as an experimental osspkg.ui capability. func Capabilities() ossmcp.Option { return ossmcp.WithCapabilities( map[string]any{ @@ -30,7 +31,10 @@ func Capabilities() ossmcp.Option { ) } -// Register publishes an app manifest and its view resources on server. +// Register validates app and registers its manifest and declared views as MCP +// resources on server. It returns an error for nil arguments, invalid app data, +// missing manifest views, or resource registration failures. Resources already +// registered before a failure are not rolled back. func Register(server *ossmcp.Server, app *ui.App) error { if server == nil || app == nil { return errors.New("ui/mcp: server and app are required") diff --git a/go-sdk/ui/builder.go b/go-sdk/ui/builder.go index 240341d..0eff0c7 100644 --- a/go-sdk/ui/builder.go +++ b/go-sdk/ui/builder.go @@ -57,7 +57,8 @@ func New(options ...AppOption) *App { return app } -// AddView validates and registers a view in the application. +// AddView validates and registers a snapshot of view in the application. +// It returns ErrInvalidSchema when the view ID is already registered. func (app *App) AddView(view *ViewBuilder) error { schema := view.Schema() if err := schema.Validate(); err != nil { @@ -95,12 +96,15 @@ func (app *App) Manifest() Manifest { return cloneManifest(app.manifest) } -// ManifestJSON serializes a copy of the application manifest. +// ManifestJSON serializes the application manifest as JSON. It returns an +// error if the manifest contains a value that cannot be encoded. func (app *App) ManifestJSON() ([]byte, error) { return json.Marshal(app.Manifest()) } -// View returns a copy of a registered view by ID. +// View returns a copy of the registered view with id. The boolean is false +// when no view is registered under that ID or the stored schema cannot be +// cloned. func (app *App) View(id string) (ViewSchema, bool) { view, ok := app.views[id] if !ok { @@ -230,24 +234,26 @@ func Bottom(nodes ...*NodeBuilder) ViewOption { } } -// Action adds a named action to the view. +// Action adds or replaces the named action in the view. func (view *ViewBuilder) Action(name string, action *ActionBuilder) *ViewBuilder { view.schema.Actions[name] = action.action return view } -// Schema returns the current view schema. +// Schema returns the current view schema. The returned value shares its maps +// and slices with the builder; use it as a read-only snapshot unless you +// intend to change the builder's data. func (view *ViewBuilder) Schema() ViewSchema { return view.schema } -// Revision sets the view revision. +// Revision sets the revision identifier used to distinguish view versions. func (view *ViewBuilder) Revision(value string) *ViewBuilder { view.schema.Revision = value return view } -// Source adds a named data source to the view. +// Source adds or replaces the named data source in the view. func (view *ViewBuilder) Source(name string, source *SourceBuilder) *ViewBuilder { view.schema.Sources[name] = source.source return view @@ -323,7 +329,9 @@ func (node *NodeBuilder) When(value Value) *NodeBuilder { return node } -// Build returns the current node value. +// Build returns the current node value. The returned node shares maps and +// slices with the builder; use it as a read-only snapshot unless you intend to +// change the builder's data. func (node *NodeBuilder) Build() Node { return node.node } @@ -381,7 +389,8 @@ func (source *SourceBuilder) OnMount() *SourceBuilder { return source } -// TTL sets the source cache lifetime in seconds. +// TTL sets the source cache lifetime in seconds. A negative value is retained +// by the builder but causes view validation to fail. func (source *SourceBuilder) TTL(value int64) *SourceBuilder { source.source.Cache = &CachePolicy{TTL: value} return source @@ -401,7 +410,7 @@ func CallTool(name string) *ActionBuilder { } } -// SetState creates an action that replaces a state path. +// SetState creates an action that replaces a state path with value. func SetState(path string, value any) *ActionBuilder { return &ActionBuilder{ action: Action{ @@ -412,7 +421,7 @@ func SetState(path string, value any) *ActionBuilder { } } -// MergeState creates an action that merges a value into a state path. +// MergeState creates an action that merges value into a state path. func MergeState(path string, value any) *ActionBuilder { return &ActionBuilder{ action: Action{ diff --git a/go-sdk/ui/schema.go b/go-sdk/ui/schema.go index 8f942a3..6ba23b2 100644 --- a/go-sdk/ui/schema.go +++ b/go-sdk/ui/schema.go @@ -198,12 +198,13 @@ type Effect struct { Message string `json:"message,omitempty"` } -// Validate checks whether the manifest contains valid plugin and view entries. +// Validate checks the manifest against DefaultLimits. func (m Manifest) Validate() error { return m.ValidateWithLimits(DefaultLimits()) } -// ValidateWithLimits checks the manifest against caller-provided limits. +// ValidateWithLimits checks the manifest against limits. Any non-positive +// limit field is replaced with its DefaultLimits value. func (m Manifest) ValidateWithLimits(limits Limits) error { limits = withDefaultLimits(limits) diff --git a/go-sdk/ui/validation.go b/go-sdk/ui/validation.go index f374e7d..eab7485 100644 --- a/go-sdk/ui/validation.go +++ b/go-sdk/ui/validation.go @@ -35,7 +35,8 @@ const ( actionStepTypeAction = "action" ) -// Limits bounds the size and complexity of a view schema. +// Limits bounds the size and complexity of a manifest or view schema. Fields +// set to zero or less use the corresponding DefaultLimits value. type Limits struct { MaxBytes int MaxNodes int @@ -51,7 +52,7 @@ type Limits struct { MaxPathLength int } -// DefaultLimits returns the default schema validation limits. +// DefaultLimits returns the default bounds used by schema validation. func DefaultLimits() Limits { return Limits{ MaxBytes: defaultMaxBytes, @@ -69,12 +70,13 @@ func DefaultLimits() Limits { } } -// Validate checks the schema against the default validation limits. +// Validate checks the view schema against DefaultLimits. func (v ViewSchema) Validate() error { return v.ValidateWithLimits(DefaultLimits()) } -// ValidateWithLimits checks the schema against caller-provided limits. +// ValidateWithLimits checks the view schema against limits. Any non-positive +// limit field is replaced with its DefaultLimits value. func (v ViewSchema) ValidateWithLimits(limits Limits) error { limits = withDefaultLimits(limits) @@ -376,7 +378,9 @@ func (context *validationContext) validateNode(node Node, region string, depth i return nil } -// Validate checks whether the layout fits the specified region. +// Validate checks whether the layout fits region. Side panels accept only a +// positive row and require columns and offset to be zero; other regions use a +// twelve-column grid. func (l Layout) Validate(region string) error { if l.Row < 1 { return fmt.Errorf("%w: row must be positive", ErrInvalidLayout) diff --git a/go-sdk/ui/value.go b/go-sdk/ui/value.go index 8e2743c..8890b48 100644 --- a/go-sdk/ui/value.go +++ b/go-sdk/ui/value.go @@ -25,9 +25,12 @@ const ( ValueContext ValueKind = "context" // ValueSource references source data. ValueSource ValueKind = "source" - ValueEvent ValueKind = "event" + // ValueEvent references the current event payload. + ValueEvent ValueKind = "event" + // ValueResult references the result produced by a preceding action step. ValueResult ValueKind = "result" - ValueExpr ValueKind = "expression" + // ValueExpr represents an expression evaluated by the UI runtime. + ValueExpr ValueKind = "expression" ) // Value is a literal, reference, or expression used by the declarative UI model. @@ -87,6 +90,7 @@ func ResultRef(path string) Value { } // Expr creates a value representing an expression operator and its arguments. +// The operator must be supported by Validate; argument count is checked there. func Expr(operator string, args ...Value) Value { return Value{Kind: ValueExpr, Path: operator, Data: append([]Value(nil), args...)} } @@ -136,7 +140,8 @@ func (v Value) Validate() error { } } -// MarshalJSON encodes the value in the declarative UI wire format. +// MarshalJSON encodes the value in the declarative UI wire format. Invalid +// values return an error wrapping ErrInvalidValue. func (v Value) MarshalJSON() ([]byte, error) { if err := v.Validate(); err != nil { return nil, err @@ -205,7 +210,9 @@ func (v *Value) unmarshalSpecialEntry(key string, raw json.RawMessage) (bool, er return true, v.Validate() } -// UnmarshalJSON decodes a literal, reference, or expression value. +// UnmarshalJSON decodes a literal, reference, or expression value. Special +// objects must use one recognized reference or expression operator; decoded +// references and expressions are validated before they are stored. func (v *Value) UnmarshalJSON(data []byte) error { if v == nil { return fmt.Errorf("%w: nil destination", ErrInvalidValue) From 20aa6412dabf7fbca593da4fe0742e0b5ff9404b Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 03:29:14 +0300 Subject: [PATCH 16/26] change --- example/react/dashboard.tsx | 2 +- example/react/favicon.svg | 4 ++++ example/react/html-form.tsx | 6 +++--- example/react/index.html | 3 +++ example/react/main.tsx | 2 +- example/react/preview.css | 22 ++++++++++++++++++---- react-sdk/ui-react/src/defaults.tsx | 24 ++++++++++++++++++------ 7 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 example/react/favicon.svg diff --git a/example/react/dashboard.tsx b/example/react/dashboard.tsx index 8967917..0a59642 100644 --- a/example/react/dashboard.tsx +++ b/example/react/dashboard.tsx @@ -23,7 +23,7 @@ const schema: ViewSchema = { id: "events", component: "data-table", layout: { row: 2, cols: 12 }, - props: { rows: [{ event: "user.created", status: "success" }, { event: "user.deleted", status: "success" }] }, + props: { "aria-label": "Recent events", rows: [{ event: "user.created", status: "success" }, { event: "user.deleted", status: "success" }] }, }, ], }, diff --git a/example/react/favicon.svg b/example/react/favicon.svg new file mode 100644 index 0000000..0bff326 --- /dev/null +++ b/example/react/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/example/react/html-form.tsx b/example/react/html-form.tsx index da3d237..005efbf 100644 --- a/example/react/html-form.tsx +++ b/example/react/html-form.tsx @@ -30,8 +30,8 @@ const schema: ViewSchema = { component: "form", events: { submit: { action: "save" } }, children: [ - { id: "name-label", component: "label", layout: { row: 1, cols: 12 }, props: { text: "Name" } }, - { id: "name", component: "input", layout: { row: 2, cols: 8, offset: 2 }, props: { name: "name", placeholder: "Ada Lovelace" } }, + { id: "name-label", component: "label", layout: { row: 1, cols: 12 }, props: { htmlFor: "profile-name", text: "Name" } }, + { id: "name", component: "input", layout: { row: 2, cols: 8, offset: 2 }, props: { id: "profile-name", name: "name", placeholder: "Ada Lovelace" } }, { id: "save", component: "button", layout: { row: 3, cols: 8, offset: 2 }, props: { type: "submit", text: "Save profile" } }, ], }, @@ -40,7 +40,7 @@ const schema: ViewSchema = { component: "p", when: { $eq: [{ $state: "submitted" }, true] }, layout: { row: 4, cols: 8, offset: 2 }, - props: { text: "Profile saved." }, + props: { role: "status", text: "Profile saved." }, }, ], }, diff --git a/example/react/index.html b/example/react/index.html index 5d65e7a..ebd978b 100644 --- a/example/react/index.html +++ b/example/react/index.html @@ -3,6 +3,9 @@ + + + UI React examples diff --git a/example/react/main.tsx b/example/react/main.tsx index 66a721e..cc416d4 100644 --- a/example/react/main.tsx +++ b/example/react/main.tsx @@ -29,7 +29,7 @@ function ExamplesApp() { key={exampleName} type="button" aria-pressed={name === exampleName} - className="rounded-md border border-ui-border bg-ui-card px-3 py-2 text-sm font-medium transition-colors hover:bg-ui-muted aria-pressed:border-ui-primary aria-pressed:bg-ui-primary aria-pressed:text-ui-primary-foreground" + className="min-h-11 min-w-11 rounded-md border border-ui-border bg-ui-card px-3 py-2 text-sm font-medium transition-colors hover:bg-ui-muted aria-pressed:border-ui-primary aria-pressed:bg-ui-primary aria-pressed:text-ui-primary-foreground" onClick={() => setName(exampleName)} > {exampleName} diff --git a/example/react/preview.css b/example/react/preview.css index f329ec8..215c606 100644 --- a/example/react/preview.css +++ b/example/react/preview.css @@ -49,13 +49,27 @@ button[aria-pressed="true"] { background: #171717; color: #fff; } -button:focus-visible, input:focus-visible { - outline: 2px solid #737373; - outline-offset: 2px; +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible { + outline: 3px solid #2563eb; + outline-offset: 3px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } } [data-ui-node] h1 { margin: 0 0 1rem; font-size: 1.5rem; line-height: 1.3; font-weight: 650; letter-spacing: -.025em; } @media (max-width: 640px) { - [data-ui-node] { grid-column: 1 / -1; } + [data-ui-node] { grid-column: 1 / -1; grid-row: auto !important; } } diff --git a/react-sdk/ui-react/src/defaults.tsx b/react-sdk/ui-react/src/defaults.tsx index d1a992d..dc486ff 100644 --- a/react-sdk/ui-react/src/defaults.tsx +++ b/react-sdk/ui-react/src/defaults.tsx @@ -92,8 +92,8 @@ function DOMElement({ tag, props, className, children }: { tag: string; props: P function htmlClass(tag: string): string { if (tag === "a") return "text-ui-primary underline-offset-4 hover:underline"; - if (tag === "button") return "inline-flex items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; - if (["input", "textarea", "select"].includes(tag)) return "flex w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; + if (tag === "button") return "inline-flex min-h-11 items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; + if (["input", "textarea", "select"].includes(tag)) return "flex min-h-11 w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; if (tag === "table") return "w-full caption-bottom text-sm"; if (["th", "td"].includes(tag)) return "border-b border-ui-border px-4 py-3 text-left align-middle"; if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) return "font-semibold tracking-tight text-ui-foreground"; @@ -117,8 +117,8 @@ const shadcnElement: Record = { }; function shadcnClass(name: string): string { - if (name === "button") return "inline-flex items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; - if (["input", "textarea", "select", "native-select"].includes(name)) return "flex w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; + if (name === "button") return "inline-flex min-h-11 items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; + if (["input", "textarea", "select", "native-select"].includes(name)) return "flex min-h-11 w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; if (name === "card") return "rounded-xl border border-ui-border bg-ui-card p-6 text-ui-card-foreground shadow-sm"; if (["badge", "marker"].includes(name)) return "inline-flex items-center rounded-md bg-ui-muted px-2 py-1 text-xs font-medium text-ui-muted-foreground"; if (["alert", "empty", "message", "toast"].includes(name)) return "rounded-lg border border-ui-border bg-ui-card p-4 text-ui-card-foreground"; @@ -129,11 +129,23 @@ function shadcnClass(name: string): string { return "rounded-md border border-ui-border bg-ui-card p-4 text-ui-card-foreground"; } -function DataTable({ rows, loading, error }: Props): ReactNode { +function DataTable({ rows, loading, error, "aria-label": ariaLabel }: Props): ReactNode { if (loading) return Loading…; if (error) return {String(error)}; const items = Array.isArray(rows) ? rows : []; - return {items.map((row, index) => {Object.values(row as Record).map((value, cell) => )})}
{String(value ?? "")}
; + const firstRow = items[0]; + const columns = firstRow && typeof firstRow === "object" && !Array.isArray(firstRow) + ? Object.keys(firstRow as Record) + : []; + return ( + + {columns.length > 0 && {columns.map((column) => )}} + {items.map((row, index) => { + const record = row && typeof row === "object" ? row as Record : {}; + return {columns.map((column) => )}; + })} +
{column}
{String(record[column] ?? "")}
+ ); } export function createDefaultShadcnDefinition(name: string): ComponentDefinition { From 62c0ba3b1e88a8dad34ab0356c56dd972e4a3b89 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 03:32:36 +0300 Subject: [PATCH 17/26] change --- example/react/basic.tsx | 3 ++- example/react/dashboard.tsx | 3 ++- example/react/html-form.tsx | 3 ++- example/react/main.tsx | 22 ++++++++++++++-------- react-sdk/ui-react/src/defaults.tsx | 2 +- 5 files changed, 21 insertions(+), 12 deletions(-) diff --git a/example/react/basic.tsx b/example/react/basic.tsx index 49d7ca9..0d3f480 100644 --- a/example/react/basic.tsx +++ b/example/react/basic.tsx @@ -1,3 +1,4 @@ +import type { ReactElement } from "react"; import { createComponentRegistry, registerDefaultHTMLComponents, registerDefaultShadcnComponents, UIProvider, ViewRenderer } from "@osspkg/ui-react"; import type { ViewSchema } from "@osspkg/ui-core"; import "@osspkg/ui-react/styles.css"; @@ -29,6 +30,6 @@ const schema: ViewSchema = { }, }; -export function BasicExample() { +export function BasicExample(): ReactElement { return ; } diff --git a/example/react/dashboard.tsx b/example/react/dashboard.tsx index 0a59642..c1ffea5 100644 --- a/example/react/dashboard.tsx +++ b/example/react/dashboard.tsx @@ -1,3 +1,4 @@ +import type { ReactElement } from "react"; import { createComponentRegistry, registerDefaultHTMLComponents, registerDefaultShadcnComponents, UIProvider, ViewRenderer } from "@osspkg/ui-react"; import type { ViewSchema } from "@osspkg/ui-core"; import "@osspkg/ui-react/styles.css"; @@ -29,6 +30,6 @@ const schema: ViewSchema = { }, }; -export function DashboardExample() { +export function DashboardExample(): ReactElement { return ; } diff --git a/example/react/html-form.tsx b/example/react/html-form.tsx index 005efbf..2f6fe51 100644 --- a/example/react/html-form.tsx +++ b/example/react/html-form.tsx @@ -1,3 +1,4 @@ +import type { ReactElement } from "react"; import { createComponentRegistry, registerDefaultHTMLComponents, registerDefaultShadcnComponents, UIProvider, ViewRenderer } from "@osspkg/ui-react"; import type { ViewSchema } from "@osspkg/ui-core"; import "@osspkg/ui-react/styles.css"; @@ -48,6 +49,6 @@ const schema: ViewSchema = { }, }; -export function HTMLFormExample() { +export function HTMLFormExample(): ReactElement { return ; } diff --git a/example/react/main.tsx b/example/react/main.tsx index cc416d4..dd3e7fd 100644 --- a/example/react/main.tsx +++ b/example/react/main.tsx @@ -1,19 +1,22 @@ import { createRoot } from "react-dom/client"; -import { useState, type ComponentType } from "react"; +import { useState, type ComponentType, type ReactElement } from "react"; import { BasicExample } from "./basic.js"; import { DashboardExample } from "./dashboard.js"; import { HTMLFormExample } from "./html-form.js"; import "./preview.css"; -const examples: Record = { +const examples = { Basic: BasicExample, "HTML form": HTMLFormExample, Dashboard: DashboardExample, -}; +} satisfies Record; -function ExamplesApp() { - const [name, setName] = useState("Basic"); - const Example = examples[name] ?? BasicExample; +type ExampleName = keyof typeof examples; +const exampleNames = Object.keys(examples) as ExampleName[]; + +function ExamplesApp(): ReactElement { + const [name, setName] = useState("Basic"); + const Example = examples[name]; return (
@@ -24,7 +27,7 @@ function ExamplesApp() {

Preview declarative schemas rendered with the default HTML and shadcn component registries.

-
+
diff --git a/example/react/preview.css b/example/react/preview.css index 215c606..f999fb6 100644 --- a/example/react/preview.css +++ b/example/react/preview.css @@ -16,7 +16,14 @@ } :root { - font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; color: #171717; background: #f5f6f8; font-synthesis: none; @@ -38,7 +45,9 @@ textarea { font: inherit; } -#root { min-height: 100vh; } +#root { + min-height: 100vh; +} button { cursor: pointer; @@ -68,8 +77,17 @@ textarea:focus-visible { } } -[data-ui-node] h1 { margin: 0 0 1rem; font-size: 1.5rem; line-height: 1.3; font-weight: 650; letter-spacing: -.025em; } +[data-ui-node] h1 { + margin: 0 0 1rem; + font-size: 1.5rem; + line-height: 1.3; + font-weight: 650; + letter-spacing: -0.025em; +} @media (max-width: 640px) { - [data-ui-node] { grid-column: 1 / -1; grid-row: auto !important; } + [data-ui-node] { + grid-column: 1 / -1; + grid-row: auto !important; + } } diff --git a/example/react/vite.config.ts b/example/react/vite.config.ts index e2defb6..337a06f 100644 --- a/example/react/vite.config.ts +++ b/example/react/vite.config.ts @@ -8,10 +8,16 @@ export default defineConfig({ plugins: [tailwindcss()], resolve: { alias: [ - { find: "@osspkg/ui-react/styles.css", replacement: source("../../react-sdk/ui-react/src/styles.css") }, + { + find: "@osspkg/ui-react/styles.css", + replacement: source("../../react-sdk/ui-react/src/styles.css"), + }, { find: "@osspkg/ui-core", replacement: source("../../react-sdk/ui-core/src/index.ts") }, { find: "@osspkg/ui-react", replacement: source("../../react-sdk/ui-react/src/index.ts") }, - { find: "@osspkg/ui-transport", replacement: source("../../react-sdk/ui-transport/src/index.ts") }, + { + find: "@osspkg/ui-transport", + replacement: source("../../react-sdk/ui-transport/src/index.ts"), + }, ], }, }); diff --git a/package.json b/package.json index d06bca9..f19d954 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,27 @@ "scripts": { "build": "pnpm --recursive --if-present build", "test": "pnpm --recursive --if-present test", - "typecheck": "pnpm --recursive --if-present typecheck" + "typecheck": "pnpm --recursive --if-present typecheck", + "lint": "eslint \"react-sdk/**/*.{ts,tsx}\" \"example/react/**/*.{ts,tsx}\"", + "format": "prettier --write \"react-sdk/**/*.{ts,tsx,css}\" \"example/react/**/*.{ts,tsx,css,html}\"", + "format:check": "prettier --check \"react-sdk/**/*.{ts,tsx,css}\" \"example/react/**/*.{ts,tsx,css,html}\"" }, "devDependencies": { + "@eslint/eslintrc": "^3.3.7", + "@eslint/js": "^8.57.1", "@types/node": "^24.0.0", + "eslint": "^8.57.1", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^4.6.2", + "globals": "^17.12.0", + "prettier": "^3.9.9", "typescript": "^5.9.0", + "typescript-eslint": "^8.70.1", "vitest": "^3.2.0" - } + }, + "type": "module" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 710eaed..a393acf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,15 +166,51 @@ importers: .: devDependencies: + '@eslint/eslintrc': + specifier: ^3.3.7 + version: 3.3.7(supports-color@7.2.0) + '@eslint/js': + specifier: ^8.57.1 + version: 8.57.1 '@types/node': specifier: ^24.0.0 version: 24.13.6 + eslint: + specifier: ^8.57.1 + version: 8.57.1(supports-color@7.2.0) + eslint-config-airbnb: + specifier: ^19.0.4 + version: 19.0.4(eslint-plugin-import@2.32.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1(supports-color@7.2.0)))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1(supports-color@7.2.0)))(eslint-plugin-react@7.37.5(eslint@8.57.1(supports-color@7.2.0)))(eslint@8.57.1(supports-color@7.2.0)) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@8.57.1(supports-color@7.2.0)) + eslint-plugin-import: + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-jsx-a11y: + specifier: ^6.10.2 + version: 6.10.2(eslint@8.57.1(supports-color@7.2.0)) + eslint-plugin-react: + specifier: ^7.37.5 + version: 7.37.5(eslint@8.57.1(supports-color@7.2.0)) + eslint-plugin-react-hooks: + specifier: ^4.6.2 + version: 4.6.2(eslint@8.57.1(supports-color@7.2.0)) + globals: + specifier: ^17.12.0 + version: 17.12.0 + prettier: + specifier: ^3.9.9 + version: 3.9.9 typescript: specifier: ^5.9.0 version: 5.9.3 + typescript-eslint: + specifier: ^8.70.1 + version: 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0) example/react: dependencies: @@ -217,7 +253,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0) react-sdk/ui-react: dependencies: @@ -251,7 +287,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0) react-sdk/ui-transport: dependencies: @@ -264,10 +300,19 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + version: 3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0) packages: + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} @@ -424,6 +469,41 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -447,6 +527,25 @@ packages: os: [linux] libc: [glibc] + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -673,6 +772,9 @@ packages: cpu: [x64] os: [win32] + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@tailwindcss/cli@4.3.3': resolution: {integrity: sha512-ZvS/n1ZHOBKcVlhkt8l5NNr1EDXk1NboYO5CYDOs6NUmvT9z6bzkwsosaJftY57T/3gWNzWMJzIXLodZC8ssdw==} hasBin: true @@ -771,6 +873,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -780,6 +885,9 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@24.13.6': resolution: {integrity: sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==} @@ -791,6 +899,188 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@typescript-eslint/eslint-plugin@8.70.1': + resolution: {integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.70.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.70.1': + resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.70.1': + resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.70.1': + resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.70.1': + resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.70.1': + resolution: {integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.70.1': + resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.70.1': + resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.70.1': + resolution: {integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.70.1': + resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + '@vitest/expect@3.2.7': resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} @@ -820,10 +1110,103 @@ packages: '@vitest/utils@3.2.7': resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -832,17 +1215,77 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confusing-browser-globals@1.0.11: + resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -856,6 +1299,17 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + detect-libc@1.0.3: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} @@ -865,25 +1319,235 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + enhanced-resolve@5.25.1: resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} engines: {node: '>=10.13.0'} + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} hasBin: true + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-airbnb-base@15.0.0: + resolution: {integrity: sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==} + engines: {node: ^10.12.0 || >=12.0.0} + peerDependencies: + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.2 + + eslint-config-airbnb@19.0.4: + resolution: {integrity: sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==} + engines: {node: ^10.12.0 || ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^7.32.0 || ^8.2.0 + eslint-plugin-import: ^2.25.3 + eslint-plugin-jsx-a11y: ^6.5.1 + eslint-plugin-react: ^7.28.0 + eslint-plugin-react-hooks: ^4.3.0 + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-import-context@0.1.9: + resolution: {integrity: sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + peerDependencies: + unrs-resolver: ^1.0.0 + peerDependenciesMeta: + unrs-resolver: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@4.4.5: + resolution: {integrity: sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==} + engines: {node: ^16.17.0 || >=18.6.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@4.6.2: + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -893,37 +1557,320 @@ packages: picomatch: optional: true + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} - jiti@2.7.0: + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@17.12.0: + resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -998,16 +1945,41 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1020,9 +1992,91 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1041,27 +2095,142 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.9.9: + resolution: {integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==} + engines: {node: '>=14'} + hasBin: true + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: react: ^19.3.0 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + rollup@4.63.4: resolution: {integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + scheduler@0.28.0: resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1069,15 +2238,66 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stable-hash-x@0.2.0: + resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} + engines: {node: '>=12.0.0'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1085,6 +2305,9 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1111,14 +2334,67 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.70.1: + resolution: {integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -1192,13 +2468,61 @@ packages: jsdom: optional: true + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true @@ -1277,6 +2601,55 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1(supports-color@7.2.0))': + dependencies: + eslint: 8.57.1(supports-color@7.2.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4(supports-color@7.2.0)': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@7.2.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/eslintrc@3.3.7(supports-color@7.2.0)': + dependencies: + ajv: 6.15.0 + debug: 4.4.3(supports-color@7.2.0) + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + + '@humanwhocodes/config-array@0.13.0(supports-color@7.2.0)': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -1299,6 +2672,25 @@ snapshots: '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -1434,6 +2826,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.4': optional: true + '@rtsao/scc@1.1.0': {} + '@tailwindcss/cli@4.3.3': dependencies: '@parcel/watcher': 2.5.1 @@ -1512,6 +2906,11 @@ snapshots: tailwindcss: 4.3.3 vite: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -1521,6 +2920,8 @@ snapshots: '@types/estree@1.0.9': {} + '@types/json5@0.0.29': {} + '@types/node@24.13.6': dependencies: undici-types: 7.18.2 @@ -1533,13 +2934,176 @@ snapshots: dependencies: csstype: 3.2.3 - '@vitest/expect@3.2.7': + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': dependencies: - '@types/chai': 5.2.3 - '@vitest/spy': 3.2.7 - '@vitest/utils': 3.2.7 - chai: 5.3.3 - tinyrainbow: 2.0.0 + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/type-utils': 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.1 + eslint: 8.57.1(supports-color@7.2.0) + ignore: 7.0.10 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.1 + debug: 4.4.3(supports-color@7.2.0) + eslint: 8.57.1(supports-color@7.2.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.70.1(supports-color@7.2.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) + '@typescript-eslint/types': 8.70.1 + debug: 4.4.3(supports-color@7.2.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.70.1': + dependencies: + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 + + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + debug: 4.4.3(supports-color@7.2.0) + eslint: 8.57.1(supports-color@7.2.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.70.1': {} + + '@typescript-eslint/typescript-estree@8.70.1(supports-color@7.2.0)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 + debug: 4.4.3(supports-color@7.2.0) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1(supports-color@7.2.0)) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + eslint: 8.57.1(supports-color@7.2.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.70.1': + dependencies: + '@typescript-eslint/types': 8.70.1 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.4.0': {} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0))': dependencies: @@ -1575,14 +3139,148 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.13.0: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + chai@5.3.3: dependencies: assertion-error: 2.0.1 @@ -1591,27 +3289,217 @@ snapshots: loupe: 3.2.1 pathval: 2.0.1 + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + check-error@2.1.3: {} + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + confusing-browser-globals@1.0.11: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + csstype@3.2.3: {} - debug@4.4.3: + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7(supports-color@7.2.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 + + debug@4.4.3(supports-color@7.2.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 7.2.0 deep-eql@5.0.2: {} + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + detect-libc@1.0.3: {} detect-libc@2.1.2: {} + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + emoji-regex@9.2.2: {} + enhanced-resolve@5.25.1: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.5 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.24 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.4.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -1641,37 +3529,576 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + escape-string-regexp@4.0.0: {} + + eslint-config-airbnb-base@15.0.0(eslint-plugin-import@2.32.0)(eslint@8.57.1(supports-color@7.2.0)): + dependencies: + confusing-browser-globals: 1.0.11 + eslint: 8.57.1(supports-color@7.2.0) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0) + object.assign: 4.1.7 + object.entries: 1.1.9 + semver: 6.3.1 + + eslint-config-airbnb@19.0.4(eslint-plugin-import@2.32.0)(eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1(supports-color@7.2.0)))(eslint-plugin-react-hooks@4.6.2(eslint@8.57.1(supports-color@7.2.0)))(eslint-plugin-react@7.37.5(eslint@8.57.1(supports-color@7.2.0)))(eslint@8.57.1(supports-color@7.2.0)): + dependencies: + eslint: 8.57.1(supports-color@7.2.0) + eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.32.0)(eslint@8.57.1(supports-color@7.2.0)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0) + eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1(supports-color@7.2.0)) + eslint-plugin-react: 7.37.5(eslint@8.57.1(supports-color@7.2.0)) + eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1(supports-color@7.2.0)) + object.assign: 4.1.7 + object.entries: 1.1.9 + + eslint-config-prettier@10.1.8(eslint@8.57.1(supports-color@7.2.0)): + dependencies: + eslint: 8.57.1(supports-color@7.2.0) + + eslint-import-context@0.1.9(unrs-resolver@1.12.2): + dependencies: + get-tsconfig: 4.14.3 + stable-hash-x: 0.2.0 + optionalDependencies: + unrs-resolver: 1.12.2 + optional: true + + eslint-import-resolver-node@0.3.10(supports-color@7.2.0): + dependencies: + debug: 3.2.7(supports-color@7.2.0) + is-core-module: 2.17.0 + resolve: 2.0.0-next.7 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@4.4.5(eslint-plugin-import@2.32.0)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + debug: 4.4.3(supports-color@7.2.0) + eslint: 8.57.1(supports-color@7.2.0) + eslint-import-context: 0.1.9(unrs-resolver@1.12.2) + get-tsconfig: 4.14.3 + is-bun-module: 2.0.0 + stable-hash-x: 0.2.0 + tinyglobby: 0.2.17 + unrs-resolver: 1.12.2 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + optional: true + + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + debug: 3.2.7(supports-color@7.2.0) + optionalDependencies: + '@typescript-eslint/parser': 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + eslint: 8.57.1(supports-color@7.2.0) + eslint-import-resolver-node: 0.3.10(supports-color@7.2.0) + eslint-import-resolver-typescript: 4.4.5(eslint-plugin-import@2.32.0)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.2.0 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7(supports-color@7.2.0) + doctrine: 2.1.0 + eslint: 8.57.1(supports-color@7.2.0) + eslint-import-resolver-node: 0.3.10(supports-color@7.2.0) + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10(supports-color@7.2.0))(eslint-import-resolver-typescript@4.4.5)(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0) + hasown: 2.0.4 + is-core-module: 2.17.0 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.10 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1(supports-color@7.2.0)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.2.0 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.13.0 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 8.57.1(supports-color@7.2.0) + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@4.6.2(eslint@8.57.1(supports-color@7.2.0)): + dependencies: + eslint: 8.57.1(supports-color@7.2.0) + + eslint-plugin-react@7.37.5(eslint@8.57.1(supports-color@7.2.0)): + dependencies: + array-includes: 3.2.0 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.4.0 + eslint: 8.57.1(supports-color@7.2.0) + estraverse: 5.3.0 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.1.0 + string.prototype.repeat: 1.0.0 + + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@8.57.1(supports-color@7.2.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1(supports-color@7.2.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4(supports-color@7.2.0) + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0(supports-color@7.2.0) + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.4.0 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@7.2.0) + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.3.2 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + espree@9.6.1: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.4.4: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fs.realpath@1.0.0: {} + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + + globals@14.0.0: {} + + globals@17.12.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + ignore@5.3.2: {} + + ignore@7.0.10: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.8.5 + optional: true + + is-callable@1.2.7: {} + + is-core-module@2.17.0: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + is-extglob@2.1.1: {} + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-path-inside@3.0.3: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.24 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + jiti@2.7.0: {} + js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.2.0 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: optional: true @@ -1721,25 +4148,141 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + loupe@3.2.1: {} magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + math-intrinsics@1.1.0: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.2 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimist@1.2.8: {} + mri@1.2.0: {} ms@2.1.3: {} nanoid@3.3.19: {} + napi-postinstall@0.3.4: + optional: true + + natural-compare@1.4.0: {} + node-addon-api@7.1.1: {} + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -1750,19 +4293,77 @@ snapshots: picomatch@4.0.7: {} + possible-typed-array-names@1.1.0: {} + postcss@8.5.28: dependencies: nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + + prettier@3.9.9: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 scheduler: 0.28.0 + react-is@16.13.1: {} + react@19.3.0: {} + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: + optional: true + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.17.0 + node-exports-info: 1.6.2 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + rollup@4.63.4: dependencies: '@types/estree': 1.0.9 @@ -1795,24 +4396,182 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.63.4 fsevents: 2.3.3 + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + scheduler@0.28.0: {} + semver@6.3.1: {} + + semver@7.8.5: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} source-map-js@1.2.1: {} + stable-hash-x@0.2.0: + optional: true + stackback@0.0.2: {} std-env@3.10.0: {} + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.1.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + tailwindcss@4.3.3: {} tapable@2.3.3: {} + text-table@0.2.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -1832,14 +4591,116 @@ snapshots: dependencies: is-number: 7.0.0 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.5: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + eslint: 8.57.1(supports-color@7.2.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + typescript@5.9.3: {} + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + undici-types@7.18.2: {} - vite-node@3.2.4(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0): + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + optional: true + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@3.2.4(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) @@ -1871,7 +4732,7 @@ snapshots: jiti: 2.7.0 lightningcss: 1.32.0 - vitest@3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0): + vitest@3.2.7(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -1882,7 +4743,7 @@ snapshots: '@vitest/spy': 3.2.7 '@vitest/utils': 3.2.7 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@7.2.0) expect-type: 1.4.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -1894,7 +4755,7 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 7.3.6(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) - vite-node: 3.2.4(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0) + vite-node: 3.2.4(@types/node@24.13.6)(jiti@2.7.0)(lightningcss@1.32.0)(supports-color@7.2.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.6 @@ -1912,7 +4773,58 @@ snapshots: - tsx - yaml + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.24 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.24: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrappy@1.0.2: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 40f096a..bbf42e7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,3 +5,4 @@ packages: allowBuilds: '@parcel/watcher': false esbuild: true + unrs-resolver: false diff --git a/react-sdk/ui-core/src/core.test.ts b/react-sdk/ui-core/src/core.test.ts index d65c1e9..26f4e37 100644 --- a/react-sdk/ui-core/src/core.test.ts +++ b/react-sdk/ui-core/src/core.test.ts @@ -8,7 +8,9 @@ import { createRequestTracker } from "./request.js"; import { parseManifest, parseView, validateView, SchemaValidationError } from "./validate.js"; function fixture(name: string): unknown { - return JSON.parse(readFileSync(new URL(`../../../fixtures/${name}`, import.meta.url), "utf8")) as unknown; + return JSON.parse( + readFileSync(new URL(`../../../fixtures/${name}`, import.meta.url), "utf8"), + ) as unknown; } const scope = { @@ -51,7 +53,10 @@ describe("ui core", () => { it("maps source errors and data paths", () => { const error = { code: "FAILED", message: "failed" }; - const sourceScope = { ...scope, sources: { users: { status: "error" as const, error, data: { items: [] } } } }; + const sourceScope = { + ...scope, + sources: { users: { status: "error" as const, error, data: { items: [] } } }, + }; expect(resolveValue({ $source: "users.$error" }, sourceScope)).toEqual(error); expect(resolveValue({ $source: "users.items" }, sourceScope)).toEqual([]); }); @@ -67,34 +72,81 @@ describe("ui core", () => { }); it("rejects invalid layouts", () => { - expect(() => validateView({ - protocolVersion: "1.0", - id: "broken", - regions: { "top-header": [], "left-panel": [], content: [{ id: "n", component: "card", layout: { row: 1, cols: 8, offset: 5 } }], "right-panel": [], bottom: [] }, - })).toThrow(SchemaValidationError); + expect(() => + validateView({ + protocolVersion: "1.0", + id: "broken", + regions: { + "top-header": [], + "left-panel": [], + content: [{ id: "n", component: "card", layout: { row: 1, cols: 8, offset: 5 } }], + "right-panel": [], + bottom: [], + }, + }), + ).toThrow(SchemaValidationError); }); it("parses manifests and views only after validation", () => { - const manifest = { protocolVersion: "1.0", plugin: { id: "users", title: "Users", version: "1.0" }, views: [] }; - expect(parseManifest(manifest).plugin.id).toBe("users"); - expect(() => parseManifest({ ...manifest, plugin: { ...manifest.plugin, title: "" } })).toThrow(SchemaValidationError); - expect(() => parseManifest({ ...manifest, plugin: { ...manifest.plugin, version: "" } })).toThrow(SchemaValidationError); - expect(() => parseManifest({ ...manifest, views: [{ id: "users.list", title: "", schema: "ui://views/users.list" }] })).toThrow(SchemaValidationError); - expect(parseView({ + const manifest = { protocolVersion: "1.0", - id: "users.list", - regions: { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] }, - }).id).toBe("users.list"); - expect(() => parseView({ protocolVersion: "1.0", id: "broken", regions: { content: [] } })).toThrow(SchemaValidationError); - expect(() => parseView({ protocolVersion: "1.0", id: "broken", regions: { "top-header": [], "left-panel": [], content: [{ id: "node", component: "-button" }], "right-panel": [], bottom: [] } })).toThrow(SchemaValidationError); + plugin: { id: "users", title: "Users", version: "1.0" }, + views: [], + }; + expect(parseManifest(manifest).plugin.id).toBe("users"); + expect(() => parseManifest({ ...manifest, plugin: { ...manifest.plugin, title: "" } })).toThrow( + SchemaValidationError, + ); + expect(() => + parseManifest({ ...manifest, plugin: { ...manifest.plugin, version: "" } }), + ).toThrow(SchemaValidationError); + expect(() => + parseManifest({ + ...manifest, + views: [{ id: "users.list", title: "", schema: "ui://views/users.list" }], + }), + ).toThrow(SchemaValidationError); + expect( + parseView({ + protocolVersion: "1.0", + id: "users.list", + regions: { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] }, + }).id, + ).toBe("users.list"); + expect(() => + parseView({ protocolVersion: "1.0", id: "broken", regions: { content: [] } }), + ).toThrow(SchemaValidationError); + expect(() => + parseView({ + protocolVersion: "1.0", + id: "broken", + regions: { + "top-header": [], + "left-panel": [], + content: [{ id: "node", component: "-button" }], + "right-panel": [], + bottom: [], + }, + }), + ).toThrow(SchemaValidationError); }); it("rejects references to unknown sources", () => { - expect(() => validateView({ - protocolVersion: "1.0", - id: "users.list", - regions: { "top-header": [], "left-panel": [], content: [{ id: "table", component: "table", props: { rows: { $source: "missing.items" } } }], "right-panel": [], bottom: [] }, - })).toThrow(SchemaValidationError); + expect(() => + validateView({ + protocolVersion: "1.0", + id: "users.list", + regions: { + "top-header": [], + "left-panel": [], + content: [ + { id: "table", component: "table", props: { rows: { $source: "missing.items" } } }, + ], + "right-panel": [], + bottom: [], + }, + }), + ).toThrow(SchemaValidationError); }); it("accepts and rejects the shared Go fixtures identically", () => { @@ -103,11 +155,24 @@ describe("ui core", () => { }); it("enforces expression collection, object, and string limits", () => { - const limits: ExpressionLimits = { maxDepth: 4, maxArrayItems: 2, maxObjectKeys: 1, maxStringLength: 4 }; - expect(() => resolveValue([1, 2, 3], scope, 0, limits)).toThrow("expression array limit exceeded"); - expect(() => resolveValue({ first: 1, second: 2 }, scope, 0, limits)).toThrow("expression object limit exceeded"); - expect(() => resolveValue("12345", scope, 0, limits)).toThrow("expression string limit exceeded"); - expect(() => resolveValue({ $concat: ["123", "45"] }, scope, 0, limits)).toThrow("expression string limit exceeded"); + const limits: ExpressionLimits = { + maxDepth: 4, + maxArrayItems: 2, + maxObjectKeys: 1, + maxStringLength: 4, + }; + expect(() => resolveValue([1, 2, 3], scope, 0, limits)).toThrow( + "expression array limit exceeded", + ); + expect(() => resolveValue({ first: 1, second: 2 }, scope, 0, limits)).toThrow( + "expression object limit exceeded", + ); + expect(() => resolveValue("12345", scope, 0, limits)).toThrow( + "expression string limit exceeded", + ); + expect(() => resolveValue({ $concat: ["123", "45"] }, scope, 0, limits)).toThrow( + "expression string limit exceeded", + ); }); it.each([ @@ -137,15 +202,24 @@ describe("ui core", () => { }; for (let index = 0; index < 256; index += 1) { const token = next().toString(16); - expect(() => resolvePath({ value: token }, index % 2 === 0 ? `value.${token}` : "__proto__.x")).not.toThrow(TypeError); - expect(() => resolveValue({ $concat: [token, { $state: "missing" }] }, scope)).not.toThrow(TypeError); - expect(() => parseView(index % 2 === 0 ? { protocolVersion: "bad", id: token } : { [token]: next() })).not.toThrow(TypeError); + expect(() => + resolvePath({ value: token }, index % 2 === 0 ? `value.${token}` : "__proto__.x"), + ).not.toThrow(TypeError); + expect(() => resolveValue({ $concat: [token, { $state: "missing" }] }, scope)).not.toThrow( + TypeError, + ); + expect(() => + parseView(index % 2 === 0 ? { protocolVersion: "bad", id: token } : { [token]: next() }), + ).not.toThrow(TypeError); } }); it.each([ ["set", (runtime: ReturnType) => runtime.set("form.name", "Grace")], - ["merge", (runtime: ReturnType) => runtime.merge("form", { valid: true })], + [ + "merge", + (runtime: ReturnType) => runtime.merge("form", { valid: true }), + ], ])("supports local state action: %s", (_name, apply) => { const initial = { form: { name: "Ada", valid: false } }; const runtime = createStateRuntime(initial); @@ -154,9 +228,11 @@ describe("ui core", () => { expect(runtime.get()).not.toBe(initial); expect(initial).toEqual({ form: { name: "Ada", valid: false } }); - expect(runtime.get()).toEqual(_name === "set" - ? { form: { name: "Grace", valid: false } } - : { form: { name: "Ada", valid: true } }); + expect(runtime.get()).toEqual( + _name === "set" + ? { form: { name: "Grace", valid: false } } + : { form: { name: "Ada", valid: true } }, + ); }); it("resolves source names containing dots", () => { const dottedScope = { @@ -167,18 +243,52 @@ describe("ui core", () => { }); it("validates the shared value bounds", () => { - const regions = { "top-header": [], "left-panel": [], content: [{ id: "value", component: "text", props: { value: null } }], "right-panel": [], bottom: [] }; - const view = (value: unknown): ViewSchema => ({ protocolVersion: "1.0", id: "limits", regions: { ...regions, content: [{ id: "value", component: "text", props: { value: value as never } }] } }); + const regions = { + "top-header": [], + "left-panel": [], + content: [{ id: "value", component: "text", props: { value: null } }], + "right-panel": [], + bottom: [], + }; + const view = (value: unknown): ViewSchema => ({ + protocolVersion: "1.0", + id: "limits", + regions: { + ...regions, + content: [{ id: "value", component: "text", props: { value: value as never } }], + }, + }); const tooManyItems = Array.from({ length: 1001 }, () => null); expect(() => validateView(view(tooManyItems))).toThrow(SchemaValidationError); - const tooManyKeys = Object.fromEntries(Array.from({ length: 257 }, (_, index) => [`key-${index}`, index])); + const tooManyKeys = Object.fromEntries( + Array.from({ length: 257 }, (_, index) => [`key-${index}`, index]), + ); expect(() => validateView(view(tooManyKeys))).toThrow(SchemaValidationError); - expect(() => validateView(view(JSON.parse("{\"__proto__\":true}")))).toThrow(SchemaValidationError); + expect(() => validateView(view(JSON.parse('{"__proto__":true}')))).toThrow( + SchemaValidationError, + ); }); it("rejects empty event step lists and overlong paths", () => { - const base = { protocolVersion: "1.0" as const, id: "invalid", regions: { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] } }; - expect(() => validateView({ ...base, regions: { ...base.regions, content: [{ id: "button", component: "button", events: { click: { steps: [] } } }] } })).toThrow(SchemaValidationError); - expect(() => validateView({ ...base, actions: { save: { type: "set-state", path: "a".repeat(1025), value: true } } })).toThrow(SchemaValidationError); + const base = { + protocolVersion: "1.0" as const, + id: "invalid", + regions: { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] }, + }; + expect(() => + validateView({ + ...base, + regions: { + ...base.regions, + content: [{ id: "button", component: "button", events: { click: { steps: [] } } }], + }, + }), + ).toThrow(SchemaValidationError); + expect(() => + validateView({ + ...base, + actions: { save: { type: "set-state", path: "a".repeat(1025), value: true } }, + }), + ).toThrow(SchemaValidationError); }); }); diff --git a/react-sdk/ui-core/src/expression.ts b/react-sdk/ui-core/src/expression.ts index f120d32..2420c80 100644 --- a/react-sdk/ui-core/src/expression.ts +++ b/react-sdk/ui-core/src/expression.ts @@ -1,7 +1,22 @@ import { resolvePath } from "./path.js"; import type { ResolveScope, SourceRuntimeState, UIValue } from "./schema.js"; -const operators = new Set(["$eq", "$ne", "$gt", "$gte", "$lt", "$lte", "$and", "$or", "$not", "$in", "$exists", "$concat", "$coalesce", "$if"]); +const operators = new Set([ + "$eq", + "$ne", + "$gt", + "$gte", + "$lt", + "$lte", + "$and", + "$or", + "$not", + "$in", + "$exists", + "$concat", + "$coalesce", + "$if", +]); export interface ExpressionLimits { maxDepth: number; @@ -17,9 +32,15 @@ export const defaultExpressionLimits: ExpressionLimits = { maxStringLength: 16 * 1024, }; -export function resolveValue(value: UIValue, scope: ResolveScope, depth = 0, limits: ExpressionLimits = defaultExpressionLimits): unknown { +export function resolveValue( + value: UIValue, + scope: ResolveScope, + depth = 0, + limits: ExpressionLimits = defaultExpressionLimits, +): unknown { if (depth > limits.maxDepth) throw new Error("expression depth exceeded"); - if (typeof value === "string" && value.length > limits.maxStringLength) throw new Error("expression string limit exceeded"); + if (typeof value === "string" && value.length > limits.maxStringLength) + throw new Error("expression string limit exceeded"); if (Array.isArray(value)) { if (value.length > limits.maxArrayItems) throw new Error("expression array limit exceeded"); return value.map((item) => resolveValue(item, scope, depth + 1, limits)); @@ -30,13 +51,22 @@ export function resolveValue(value: UIValue, scope: ResolveScope, depth = 0, lim if (entries.length === 1) { const [key, raw] = entries[0]!; if (key === "$state" || key === "$context" || key === "$event" || key === "$result") { - const root = key === "$state" ? scope.state : key === "$context" ? scope.context : key === "$event" ? scope.event : scope.result; + const root = + key === "$state" + ? scope.state + : key === "$context" + ? scope.context + : key === "$event" + ? scope.event + : scope.result; return resolvePath(root, String(raw)); } if (key === "$source") return resolveSource(String(raw), scope); if (operators.has(key)) return evaluateExpression(key, raw, scope, depth + 1, limits); } - return Object.fromEntries(entries.map(([key, nested]) => [key, resolveValue(nested, scope, depth + 1, limits)])); + return Object.fromEntries( + entries.map(([key, nested]) => [key, resolveValue(nested, scope, depth + 1, limits)]), + ); } function resolveSource(path: string, scope: ResolveScope): unknown { @@ -51,8 +81,11 @@ function resolveSource(path: string, scope: ResolveScope): unknown { return resolvePath(source.data, match.rest); } -function sourceForPath(path: string, sources: Record): { name: string; rest: string } | undefined { - for (let name = path; name; ) { +function sourceForPath( + path: string, + sources: Record, +): { name: string; rest: string } | undefined { + for (let name = path; name;) { if (Object.prototype.hasOwnProperty.call(sources, name)) { return { name, rest: path === name ? "" : path.slice(name.length + 1) }; } @@ -63,30 +96,51 @@ function sourceForPath(path: string, sources: Record return undefined; } -function evaluateExpression(operator: string, raw: unknown, scope: ResolveScope, depth: number, limits: ExpressionLimits): unknown { +function evaluateExpression( + operator: string, + raw: unknown, + scope: ResolveScope, + depth: number, + limits: ExpressionLimits, +): unknown { if (!Array.isArray(raw)) throw new Error(`operator ${operator} requires an array`); if (raw.length > limits.maxArrayItems) throw new Error("expression array limit exceeded"); const args = raw.map((item) => resolveValue(item, scope, depth, limits)); switch (operator) { - case "$eq": return args[0] === args[1]; - case "$ne": return args[0] !== args[1]; - case "$gt": return comparable(args[0]) > comparable(args[1]); - case "$gte": return comparable(args[0]) >= comparable(args[1]); - case "$lt": return comparable(args[0]) < comparable(args[1]); - case "$lte": return comparable(args[0]) <= comparable(args[1]); - case "$and": return args.every(Boolean); - case "$or": return args.some(Boolean); - case "$not": return !args[0]; - case "$in": return Array.isArray(args[1]) && args[1].includes(args[0]); - case "$exists": return args[0] !== undefined && args[0] !== null; + case "$eq": + return args[0] === args[1]; + case "$ne": + return args[0] !== args[1]; + case "$gt": + return comparable(args[0]) > comparable(args[1]); + case "$gte": + return comparable(args[0]) >= comparable(args[1]); + case "$lt": + return comparable(args[0]) < comparable(args[1]); + case "$lte": + return comparable(args[0]) <= comparable(args[1]); + case "$and": + return args.every(Boolean); + case "$or": + return args.some(Boolean); + case "$not": + return !args[0]; + case "$in": + return Array.isArray(args[1]) && args[1].includes(args[0]); + case "$exists": + return args[0] !== undefined && args[0] !== null; case "$concat": { const result = args.map((item) => String(item ?? "")).join(""); - if (result.length > limits.maxStringLength) throw new Error("expression string limit exceeded"); + if (result.length > limits.maxStringLength) + throw new Error("expression string limit exceeded"); return result; } - case "$coalesce": return args.find((item) => item !== undefined && item !== null); - case "$if": return args[0] ? args[1] : args[2]; - default: throw new Error(`unsupported expression operator ${operator}`); + case "$coalesce": + return args.find((item) => item !== undefined && item !== null); + case "$if": + return args[0] ? args[1] : args[2]; + default: + throw new Error(`unsupported expression operator ${operator}`); } } diff --git a/react-sdk/ui-core/src/path.ts b/react-sdk/ui-core/src/path.ts index cfeaa6a..3bb2119 100644 --- a/react-sdk/ui-core/src/path.ts +++ b/react-sdk/ui-core/src/path.ts @@ -39,7 +39,9 @@ export function validatePath(path: string): string[] { function cloneValue(value: T): T { if (Array.isArray(value)) return value.map(cloneValue) as T; if (value && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)])) as T; + return Object.fromEntries( + Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)]), + ) as T; } return value; } diff --git a/react-sdk/ui-core/src/runtime.ts b/react-sdk/ui-core/src/runtime.ts index 094e60f..ba19250 100644 --- a/react-sdk/ui-core/src/runtime.ts +++ b/react-sdk/ui-core/src/runtime.ts @@ -1,5 +1,5 @@ import { resolvePath, setPath } from "./path.js"; -import type { RuntimeError, SourceRuntimeState, UIValue } from "./schema.js"; +import type { RuntimeError, SourceRuntimeState } from "./schema.js"; export interface StateRuntime { get(): T; @@ -11,17 +11,29 @@ export function createStateRuntime(initial: T): StateRuntime { let state = initial; return { get: () => state, - set: (path, value) => { state = setPath(state, path, value); }, + set: (path, value) => { + state = setPath(state, path, value); + }, merge: (path, value) => { const existing = resolvePath(state, path); const base = existing && typeof existing === "object" ? existing : {}; - state = setPath(state, path, {...base, ...value}); + state = setPath(state, path, { ...base, ...value }); }, }; } -export function runtimeError(code: string, message: string, details?: unknown, retryable?: boolean): RuntimeError { - return { code, message, ...(details === undefined ? {} : { details }), ...(retryable === undefined ? {} : { retryable }) }; +export function runtimeError( + code: string, + message: string, + details?: unknown, + retryable?: boolean, +): RuntimeError { + return { + code, + message, + ...(details === undefined ? {} : { details }), + ...(retryable === undefined ? {} : { retryable }), + }; } export function emptySource(): SourceRuntimeState { diff --git a/react-sdk/ui-core/src/schema.ts b/react-sdk/ui-core/src/schema.ts index 6bab04b..f002795 100644 --- a/react-sdk/ui-core/src/schema.ts +++ b/react-sdk/ui-core/src/schema.ts @@ -3,9 +3,33 @@ export const protocolVersion = "1.0" as const; export type RegionName = "top-header" | "left-panel" | "content" | "right-panel" | "bottom"; export type SourcePolicy = "manual" | "on-mount"; export type ValueReference = "$state" | "$context" | "$source" | "$event" | "$result"; -export type ExpressionOperator = "$eq" | "$ne" | "$gt" | "$gte" | "$lt" | "$lte" | "$and" | "$or" | "$not" | "$in" | "$exists" | "$concat" | "$coalesce" | "$if"; +export type ExpressionOperator = + | "$eq" + | "$ne" + | "$gt" + | "$gte" + | "$lt" + | "$lte" + | "$and" + | "$or" + | "$not" + | "$in" + | "$exists" + | "$concat" + | "$coalesce" + | "$if"; export type ActionType = "tool" | "set-state" | "merge-state" | "refresh-source" | "invalidate"; -export type EffectType = "set-state" | "merge-state" | "invalidate" | "refresh-source" | "refresh-view" | "patch-view" | "navigate" | "toast" | "dialog" | "close-dialog"; +export type EffectType = + | "set-state" + | "merge-state" + | "invalidate" + | "refresh-source" + | "refresh-view" + | "patch-view" + | "navigate" + | "toast" + | "dialog" + | "close-dialog"; export interface Manifest { protocolVersion: string; @@ -94,8 +118,14 @@ export interface Effect { message?: string; } -export type JSONValue = string | number | boolean | null | readonly JSONValue[] | { [key: string]: JSONValue }; -export type UIReference = { $state: string } | { $context: string } | { $source: string } | { $event: string } | { $result: string }; +export type JSONValue = + string | number | boolean | null | readonly JSONValue[] | { [key: string]: JSONValue }; +export type UIReference = + | { $state: string } + | { $context: string } + | { $source: string } + | { $event: string } + | { $result: string }; export type UIExpression = { [operator in ExpressionOperator]?: readonly UIValue[] }; export type UIValue = JSONValue | UIReference | UIExpression; @@ -122,4 +152,10 @@ export interface RuntimeError { retryable?: boolean; } -export const regionNames: readonly RegionName[] = ["top-header", "left-panel", "content", "right-panel", "bottom"]; +export const regionNames: readonly RegionName[] = [ + "top-header", + "left-panel", + "content", + "right-panel", + "bottom", +]; diff --git a/react-sdk/ui-core/src/validate.ts b/react-sdk/ui-core/src/validate.ts index 0282b06..0f05305 100644 --- a/react-sdk/ui-core/src/validate.ts +++ b/react-sdk/ui-core/src/validate.ts @@ -36,39 +36,63 @@ export class SchemaValidationError extends Error { constructor(message: string, details?: unknown) { super(message); this.name = "SchemaValidationError"; - this.runtimeError = { code: "INVALID_UI_SCHEMA", message, ...(details === undefined ? {} : { details }) }; + this.runtimeError = { + code: "INVALID_UI_SCHEMA", + message, + ...(details === undefined ? {} : { details }), + }; } } export function parseManifest(input: unknown, limits: Partial = {}): Manifest { if (!isRecord(input)) throw new SchemaValidationError("manifest must be an object"); - const manifest = input as Manifest; + const manifest = input as unknown as Manifest; validateManifest(manifest, limits); return manifest; } export function parseView(input: unknown, limits: Partial = {}): ViewSchema { if (!isRecord(input)) throw new SchemaValidationError("view must be an object"); - const view = input as ViewSchema; + const view = input as unknown as ViewSchema; validateView(view, limits); return view; } -export function validateManifest(manifest: Manifest, customLimits: Partial = {}): void { +export function validateManifest( + manifest: Manifest, + customLimits: Partial = {}, +): void { const limits = withDefaults(customLimits); - if (!isRecord(manifest) || manifest.protocolVersion !== "1.0") throw new SchemaValidationError("unsupported manifest protocol version"); - if (!isRecord(manifest.plugin) || !isSafeName(manifest.plugin.id) || !isNonEmptyText(manifest.plugin.title) || !isNonEmptyText(manifest.plugin.version)) { + if (!isRecord(manifest) || manifest.protocolVersion !== "1.0") + throw new SchemaValidationError("unsupported manifest protocol version"); + if ( + !isRecord(manifest.plugin) || + !isSafeName(manifest.plugin.id) || + !isNonEmptyText(manifest.plugin.title) || + !isNonEmptyText(manifest.plugin.version) + ) { throw new SchemaValidationError("manifest plugin metadata is required"); } - if (!Array.isArray(manifest.views)) throw new SchemaValidationError("manifest views must be an array"); + if (!Array.isArray(manifest.views)) + throw new SchemaValidationError("manifest views must be an array"); const ids = new Set(); for (const view of manifest.views) { - if (!isRecord(view) || !isSafeName(view.id) || !isNonEmptyText(view.title) || !isNonEmptyText(view.schema) || ids.has(view.id)) { + if ( + !isRecord(view) || + !isSafeName(view.id) || + !isNonEmptyText(view.title) || + !isNonEmptyText(view.schema) || + ids.has(view.id) + ) { throw new SchemaValidationError("manifest view is invalid"); } ids.add(view.id); } - if (manifest.requiredComponents !== undefined && (!Array.isArray(manifest.requiredComponents) || manifest.requiredComponents.some((name) => !isComponentName(name)))) { + if ( + manifest.requiredComponents !== undefined && + (!Array.isArray(manifest.requiredComponents) || + manifest.requiredComponents.some((name) => !isComponentName(name))) + ) { throw new SchemaValidationError("manifest required components are invalid"); } validateSerializedLimits(manifest, limits); @@ -76,17 +100,24 @@ export function validateManifest(manifest: Manifest, customLimits: Partial = {}): void { const limits = withDefaults(customLimits); - if (!isRecord(view) || view.protocolVersion !== "1.0") throw new SchemaValidationError("unsupported view protocol version"); + if (!isRecord(view) || view.protocolVersion !== "1.0") + throw new SchemaValidationError("unsupported view protocol version"); if (!isSafeName(view.id)) throw new SchemaValidationError("view id is required"); - if (view.title !== undefined && !isText(view.title)) throw new SchemaValidationError("view title is invalid"); - if (view.sources !== undefined && !isRecord(view.sources)) throw new SchemaValidationError("sources must be an object"); - if (view.actions !== undefined && !isRecord(view.actions)) throw new SchemaValidationError("actions must be an object"); + if (view.title !== undefined && !isText(view.title)) + throw new SchemaValidationError("view title is invalid"); + if (view.sources !== undefined && !isRecord(view.sources)) + throw new SchemaValidationError("sources must be an object"); + if (view.actions !== undefined && !isRecord(view.actions)) + throw new SchemaValidationError("actions must be an object"); if (!isRecord(view.regions)) throw new SchemaValidationError("regions are required"); validateSerializedLimits(view, limits); const actions = view.actions ?? {}; const sources = view.sources ?? {}; - if (Object.keys(actions).length > limits.maxActions || Object.keys(sources).length > limits.maxSources) { + if ( + Object.keys(actions).length > limits.maxActions || + Object.keys(sources).length > limits.maxSources + ) { throw new SchemaValidationError("source or action limit exceeded"); } for (const [name, source] of Object.entries(sources)) validateSource(name, source, view, limits); @@ -96,86 +127,222 @@ export function validateView(view: ViewSchema, customLimits: Partial(); for (const region of regionNames) { const nodesInRegion = view.regions[region]; - if (!Array.isArray(nodesInRegion)) throw new SchemaValidationError(`region ${region} must be an array`); - for (const node of nodesInRegion) nodes = validateNode(node, region, 1, view, limits, nodeIDs, nodes); + if (!Array.isArray(nodesInRegion)) + throw new SchemaValidationError(`region ${region} must be an array`); + for (const node of nodesInRegion) + nodes = validateNode(node, region, 1, view, limits, nodeIDs, nodes); } } -function validateSource(name: string, source: unknown, view: ViewSchema, limits: ValidationLimits): void { - if (!isSafeName(name) || !isRecord(source) || source.type !== "tool" || !isSafeName(source.tool)) throw new SchemaValidationError(`source ${name} is invalid`); - if (source.policy !== undefined && source.policy !== "manual" && source.policy !== "on-mount") throw new SchemaValidationError(`source ${name} policy is invalid`); - if (source.cache !== undefined && (!isRecord(source.cache) || (source.cache.ttl !== undefined && (!Number.isInteger(source.cache.ttl) || source.cache.ttl < 0)))) throw new SchemaValidationError(`source ${name} cache is invalid`); +function validateSource( + name: string, + source: unknown, + view: ViewSchema, + limits: ValidationLimits, +): void { + if (!isSafeName(name) || !isRecord(source) || source.type !== "tool" || !isSafeName(source.tool)) + throw new SchemaValidationError(`source ${name} is invalid`); + if (source.policy !== undefined && source.policy !== "manual" && source.policy !== "on-mount") + throw new SchemaValidationError(`source ${name} policy is invalid`); + if ( + source.cache !== undefined && + (!isRecord(source.cache) || + (source.cache.ttl !== undefined && + (typeof source.cache.ttl !== "number" || + !Number.isInteger(source.cache.ttl) || + source.cache.ttl < 0))) + ) + throw new SchemaValidationError(`source ${name} cache is invalid`); validateValues(source.input, view, limits); - if (source.refreshOn !== undefined && (!Array.isArray(source.refreshOn) || source.refreshOn.some((action) => !view.actions?.[action]))) throw new SchemaValidationError(`source ${name} refresh action is invalid`); + if ( + source.refreshOn !== undefined && + (!Array.isArray(source.refreshOn) || source.refreshOn.some((action) => !view.actions?.[action])) + ) + throw new SchemaValidationError(`source ${name} refresh action is invalid`); } -function validateAction(name: string, action: unknown, view: ViewSchema, limits: ValidationLimits): void { - if (!isSafeName(name) || !isRecord(action) || !["tool", "set-state", "merge-state", "refresh-source", "invalidate"].includes(action.type)) throw new SchemaValidationError(`action ${name} is invalid`); - if (action.type === "tool" && !isSafeName(action.tool)) throw new SchemaValidationError(`tool action ${name} requires a tool`); - if ((action.type === "set-state" || action.type === "merge-state") && !isSafePath(action.path, limits.maxPathLength)) throw new SchemaValidationError(`action ${name} requires a safe path`); - if (action.path !== undefined && action.path !== "" && !isSafePath(action.path, limits.maxPathLength)) throw new SchemaValidationError(`action ${name} path is invalid`); - if ((action.type === "refresh-source" || action.type === "invalidate") && !view.sources?.[action.source as string]) throw new SchemaValidationError(`action ${name} source is invalid`); +function validateAction( + name: string, + action: unknown, + view: ViewSchema, + limits: ValidationLimits, +): void { + if ( + !isSafeName(name) || + !isRecord(action) || + typeof action.type !== "string" || + !["tool", "set-state", "merge-state", "refresh-source", "invalidate"].includes(action.type) + ) + throw new SchemaValidationError(`action ${name} is invalid`); + if (action.type === "tool" && !isSafeName(action.tool)) + throw new SchemaValidationError(`tool action ${name} requires a tool`); + if ( + (action.type === "set-state" || action.type === "merge-state") && + !isSafePath(action.path, limits.maxPathLength) + ) + throw new SchemaValidationError(`action ${name} requires a safe path`); + if ( + action.path !== undefined && + action.path !== "" && + !isSafePath(action.path, limits.maxPathLength) + ) + throw new SchemaValidationError(`action ${name} path is invalid`); + if ( + (action.type === "refresh-source" || action.type === "invalidate") && + !view.sources?.[action.source as string] + ) + throw new SchemaValidationError(`action ${name} source is invalid`); validateValues(action.input, view, limits); if (action.value !== undefined) validateValue(action.value, view, limits, 0); if (action.effects !== undefined) { - if (!Array.isArray(action.effects)) throw new SchemaValidationError(`action ${name} effects are invalid`); + if (!Array.isArray(action.effects)) + throw new SchemaValidationError(`action ${name} effects are invalid`); for (const effect of action.effects) validateEffect(effect, view, limits); } } function validateEffect(effect: unknown, view: ViewSchema, limits: ValidationLimits): void { - if (!isRecord(effect) || !["set-state", "merge-state", "invalidate", "refresh-source", "refresh-view", "patch-view", "navigate", "toast", "dialog", "close-dialog"].includes(effect.type)) throw new SchemaValidationError("effect type is invalid"); - if ((effect.type === "set-state" || effect.type === "merge-state") && !isSafePath(effect.path, limits.maxPathLength)) throw new SchemaValidationError("state effect path is invalid"); - if (effect.path !== undefined && effect.path !== "" && !isSafePath(effect.path, limits.maxPathLength)) throw new SchemaValidationError("effect path is invalid"); - if ((effect.type === "invalidate" || effect.type === "refresh-source") && !view.sources?.[effect.source as string]) throw new SchemaValidationError("effect source is invalid"); - if (effect.type === "toast" && !isText(effect.message)) throw new SchemaValidationError("toast effect message is required"); - if (effect.type === "navigate" && !isText(effect.to)) throw new SchemaValidationError("navigate effect target is required"); + if ( + !isRecord(effect) || + typeof effect.type !== "string" || + ![ + "set-state", + "merge-state", + "invalidate", + "refresh-source", + "refresh-view", + "patch-view", + "navigate", + "toast", + "dialog", + "close-dialog", + ].includes(effect.type) + ) + throw new SchemaValidationError("effect type is invalid"); + if ( + (effect.type === "set-state" || effect.type === "merge-state") && + !isSafePath(effect.path, limits.maxPathLength) + ) + throw new SchemaValidationError("state effect path is invalid"); + if ( + effect.path !== undefined && + effect.path !== "" && + !isSafePath(effect.path, limits.maxPathLength) + ) + throw new SchemaValidationError("effect path is invalid"); + if ( + (effect.type === "invalidate" || effect.type === "refresh-source") && + !view.sources?.[effect.source as string] + ) + throw new SchemaValidationError("effect source is invalid"); + if (effect.type === "toast" && !isText(effect.message)) + throw new SchemaValidationError("toast effect message is required"); + if (effect.type === "navigate" && !isText(effect.to)) + throw new SchemaValidationError("navigate effect target is required"); if (effect.value !== undefined) validateValue(effect.value, view, limits, 0); } -function validateNode(node: unknown, region: string, depth: number, view: ViewSchema, limits: ValidationLimits, ids: Set, count: number): number { - if (!isRecord(node) || !isSafeName(node.id) || !isComponentName(node.component) || ids.has(node.id)) throw new SchemaValidationError("node id or component is invalid"); +function validateNode( + node: unknown, + region: string, + depth: number, + view: ViewSchema, + limits: ValidationLimits, + ids: Set, + count: number, +): number { + if ( + !isRecord(node) || + !isSafeName(node.id) || + !isComponentName(node.component) || + ids.has(node.id) + ) + throw new SchemaValidationError("node id or component is invalid"); count += 1; - if (count > limits.maxNodes || depth > limits.maxDepth) throw new SchemaValidationError("schema complexity limit exceeded"); + if (count > limits.maxNodes || depth > limits.maxDepth) + throw new SchemaValidationError("schema complexity limit exceeded"); ids.add(node.id); if (node.layout !== undefined) validateLayout(node.layout, region); - if (node.props !== undefined && (!isRecord(node.props) || Object.keys(node.props).length > limits.maxPropsPerNode)) throw new SchemaValidationError("node props are invalid"); + if ( + node.props !== undefined && + (!isRecord(node.props) || Object.keys(node.props).length > limits.maxPropsPerNode) + ) + throw new SchemaValidationError("node props are invalid"); validateValues(node.props, view, limits); if (node.events !== undefined) { if (!isRecord(node.events)) throw new SchemaValidationError("node events are invalid"); for (const [event, handler] of Object.entries(node.events)) { - if (!isSafeName(event) || !isRecord(handler) || (!handler.action && (!Array.isArray(handler.steps) || handler.steps.length === 0))) throw new SchemaValidationError("event handler is invalid"); - if (handler.action && !view.actions?.[handler.action]) throw new SchemaValidationError("event action is not defined"); - if (handler.steps) for (const step of handler.steps) { - if (!isRecord(step) || step.type !== "action" || !isSafeName(step.action) || !view.actions?.[step.action]) throw new SchemaValidationError("event step is invalid"); - validateValues(step.input, view, limits); - } + if ( + !isSafeName(event) || + !isRecord(handler) || + (!handler.action && (!Array.isArray(handler.steps) || handler.steps.length === 0)) + ) + throw new SchemaValidationError("event handler is invalid"); + if ( + handler.action !== undefined && + (!isSafeName(handler.action) || !view.actions?.[handler.action]) + ) + throw new SchemaValidationError("event action is not defined"); + if (handler.steps !== undefined && !Array.isArray(handler.steps)) + throw new SchemaValidationError("event steps are invalid"); + if (Array.isArray(handler.steps)) + for (const step of handler.steps) { + if ( + !isRecord(step) || + step.type !== "action" || + !isSafeName(step.action) || + !view.actions?.[step.action] + ) + throw new SchemaValidationError("event step is invalid"); + validateValues(step.input, view, limits); + } } } if (node.when !== undefined) validateValue(node.when, view, limits, 0); if (node.children !== undefined) { if (!Array.isArray(node.children)) throw new SchemaValidationError("children must be an array"); - for (const child of node.children) count = validateNode(child, region, depth + 1, view, limits, ids, count); + for (const child of node.children) + count = validateNode(child, region, depth + 1, view, limits, ids, count); } if (node.slots !== undefined) { if (!isRecord(node.slots)) throw new SchemaValidationError("slots must be an object"); for (const children of Object.values(node.slots)) { - if (!Array.isArray(children)) throw new SchemaValidationError("slot children must be an array"); - for (const child of children) count = validateNode(child, region, depth + 1, view, limits, ids, count); + if (!Array.isArray(children)) + throw new SchemaValidationError("slot children must be an array"); + for (const child of children) + count = validateNode(child, region, depth + 1, view, limits, ids, count); } } return count; } function validateLayout(layout: unknown, region: string): void { - if (!isRecord(layout) || !Number.isInteger(layout.row) || layout.row < 1) throw new SchemaValidationError("layout row is invalid"); + if ( + !isRecord(layout) || + typeof layout.row !== "number" || + !Number.isInteger(layout.row) || + layout.row < 1 + ) + throw new SchemaValidationError("layout row is invalid"); if (region === "left-panel" || region === "right-panel") { - if (layout.cols !== undefined || layout.offset !== undefined) throw new SchemaValidationError("side panel layout is invalid"); + if (layout.cols !== undefined || layout.offset !== undefined) + throw new SchemaValidationError("side panel layout is invalid"); return; } const cols = layout.cols ?? 0; const offset = layout.offset ?? 0; - if (!Number.isInteger(cols) || cols < 1 || cols > 12 || !Number.isInteger(offset) || offset < 0 || offset > 11 || cols + offset > 12) throw new SchemaValidationError("grid layout is invalid"); + if ( + typeof cols !== "number" || + typeof offset !== "number" || + !Number.isInteger(cols) || + cols < 1 || + cols > 12 || + !Number.isInteger(offset) || + offset < 0 || + offset > 11 || + cols + offset > 12 + ) + throw new SchemaValidationError("grid layout is invalid"); } function validateValues(values: unknown, view: ViewSchema, limits: ValidationLimits): void { @@ -184,8 +351,14 @@ function validateValues(values: unknown, view: ViewSchema, limits: ValidationLim for (const value of Object.values(values)) validateValue(value, view, limits, 0); } -function validateValue(value: unknown, view: ViewSchema, limits: ValidationLimits, depth: number): void { - if (depth > limits.maxExpressionDepth) throw new SchemaValidationError("expression depth exceeded"); +function validateValue( + value: unknown, + view: ViewSchema, + limits: ValidationLimits, + depth: number, +): void { + if (depth > limits.maxExpressionDepth) + throw new SchemaValidationError("expression depth exceeded"); if (value === null || typeof value === "boolean" || typeof value === "number") return; if (typeof value === "string") { if (value.length > limits.maxStringLength) throw new SchemaValidationError("value is too long"); @@ -197,36 +370,54 @@ function validateValue(value: unknown, view: ViewSchema, limits: ValidationLimit return; } if (!isRecord(value)) throw new SchemaValidationError("value is not JSON-compatible"); - for (const key of Object.keys(value)) if (["__proto__", "prototype", "constructor"].includes(key)) throw new SchemaValidationError("unsafe value key"); + for (const key of Object.keys(value)) + if (["__proto__", "prototype", "constructor"].includes(key)) + throw new SchemaValidationError("unsafe value key"); const entries = Object.entries(value); - if (entries.length > limits.maxObjectKeys) throw new SchemaValidationError("expression object is too large"); + if (entries.length > limits.maxObjectKeys) + throw new SchemaValidationError("expression object is too large"); if (entries.length === 1) { const [key, raw] = entries[0]!; if (["$state", "$context", "$event", "$result"].includes(key)) { - if (!isSafePath(raw, limits.maxPathLength)) throw new SchemaValidationError("reference path is unsafe"); + if (!isSafePath(raw, limits.maxPathLength)) + throw new SchemaValidationError("reference path is unsafe"); return; } if (key === "$source") { - if (!isSafePath(raw, limits.maxPathLength)) throw new SchemaValidationError("source path is unsafe"); + if (!isSafePath(raw, limits.maxPathLength)) + throw new SchemaValidationError("source path is unsafe"); const sourceName = sourceForPath(raw, view.sources); if (!sourceName) throw new SchemaValidationError("source reference is not defined"); return; } const arity = expressionArity[key]; if (arity) { - if (!Array.isArray(raw) || raw.length < arity[0] || (arity[1] >= 0 && raw.length > arity[1])) throw new SchemaValidationError(`operator ${key} has invalid arity`); + if (!Array.isArray(raw) || raw.length < arity[0] || (arity[1] >= 0 && raw.length > arity[1])) + throw new SchemaValidationError(`operator ${key} has invalid arity`); for (const item of raw) validateValue(item, view, limits, depth + 1); return; } - if (key.startsWith("$")) throw new SchemaValidationError(`unsupported expression operator ${key}`); + if (key.startsWith("$")) + throw new SchemaValidationError(`unsupported expression operator ${key}`); } for (const nested of Object.values(value)) validateValue(nested, view, limits, depth + 1); } const expressionArity: Record = { - $eq: [2, 2], $ne: [2, 2], $gt: [2, 2], $gte: [2, 2], $lt: [2, 2], $lte: [2, 2], - $and: [1, -1], $or: [1, -1], $not: [1, 1], $in: [2, 2], $exists: [1, 1], - $concat: [0, -1], $coalesce: [1, -1], $if: [3, 3], + $eq: [2, 2], + $ne: [2, 2], + $gt: [2, 2], + $gte: [2, 2], + $lt: [2, 2], + $lte: [2, 2], + $and: [1, -1], + $or: [1, -1], + $not: [1, 1], + $in: [2, 2], + $exists: [1, 1], + $concat: [0, -1], + $coalesce: [1, -1], + $if: [3, 3], }; function validateSerializedLimits(value: unknown, limits: ValidationLimits): void { @@ -236,23 +427,36 @@ function validateSerializedLimits(value: unknown, limits: ValidationLimits): voi } catch { throw new SchemaValidationError("schema is not JSON serializable"); } - if (new TextEncoder().encode(encoded).length > limits.maxBytes) throw new SchemaValidationError("schema byte limit exceeded"); + if (new TextEncoder().encode(encoded).length > limits.maxBytes) + throw new SchemaValidationError("schema byte limit exceeded"); let strings = 0; - walkSerialized(value, 0, limits, () => { strings += 1; if (strings > limits.maxStrings) throw new SchemaValidationError("string count limit exceeded"); }); + walkSerialized(value, 0, limits, () => { + strings += 1; + if (strings > limits.maxStrings) throw new SchemaValidationError("string count limit exceeded"); + }); } -function walkSerialized(value: unknown, depth: number, limits: ValidationLimits, onString: () => void): void { +function walkSerialized( + value: unknown, + depth: number, + limits: ValidationLimits, + onString: () => void, +): void { if (depth > limits.maxDepth) throw new SchemaValidationError("schema depth limit exceeded"); if (typeof value === "string") { - if (value.length > limits.maxStringLength) throw new SchemaValidationError("string length limit exceeded"); + if (value.length > limits.maxStringLength) + throw new SchemaValidationError("string length limit exceeded"); onString(); } else if (Array.isArray(value)) { - if (value.length > limits.maxArrayItems) throw new SchemaValidationError("array item limit exceeded"); + if (value.length > limits.maxArrayItems) + throw new SchemaValidationError("array item limit exceeded"); for (const item of value) walkSerialized(item, depth + 1, limits, onString); } else if (isRecord(value)) { - if (Object.keys(value).length > limits.maxObjectKeys) throw new SchemaValidationError("object key limit exceeded"); + if (Object.keys(value).length > limits.maxObjectKeys) + throw new SchemaValidationError("object key limit exceeded"); for (const [key, nested] of Object.entries(value)) { - if (["__proto__", "prototype", "constructor"].includes(key)) throw new SchemaValidationError("unsafe value key"); + if (["__proto__", "prototype", "constructor"].includes(key)) + throw new SchemaValidationError("unsafe value key"); walkSerialized(key, depth + 1, limits, onString); walkSerialized(nested, depth + 1, limits, onString); } @@ -263,7 +467,9 @@ function withDefaults(custom: Partial): ValidationLimits { return { ...defaultLimits, ...custom }; } -function isRecord(value: unknown): value is Record { +function isRecord(value: T): value is T; +function isRecord(value: unknown): value is Record; +function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -280,11 +486,24 @@ function isSafeName(value: unknown): value is string { } function isSafePath(value: unknown, maxLength: number): value is string { - return typeof value === "string" && value.length > 0 && value.length <= maxLength && value.split(".").every((segment) => segment.length > 0 && !["__proto__", "prototype", "constructor"].includes(segment)); + return ( + typeof value === "string" && + value.length > 0 && + value.length <= maxLength && + value + .split(".") + .every( + (segment) => + segment.length > 0 && !["__proto__", "prototype", "constructor"].includes(segment), + ) + ); } -function sourceForPath(path: string, sources: Record | undefined): string | undefined { - for (let name = path; name; ) { +function sourceForPath( + path: string, + sources: Record | undefined, +): string | undefined { + for (let name = path; name;) { if (sources && Object.prototype.hasOwnProperty.call(sources, name)) return name; const index = name.lastIndexOf("."); if (index < 0) break; diff --git a/react-sdk/ui-react/src/defaults.test.tsx b/react-sdk/ui-react/src/defaults.test.tsx index 3af6e11..9822884 100644 --- a/react-sdk/ui-react/src/defaults.test.tsx +++ b/react-sdk/ui-react/src/defaults.test.tsx @@ -31,12 +31,36 @@ describe("default component registrations", () => { protocolVersion: "1.0", id: "defaults", regions: { - "top-header": [], "left-panel": [], "right-panel": [], bottom: [], + "top-header": [], + "left-panel": [], + "right-panel": [], + bottom: [], content: [ { id: "link", component: "a", props: { href: "https://example.com", text: "Example" } }, - { id: "profile-form", component: "form", props: { method: "post", enctype: "multipart/form-data", novalidate: true, autocomplete: "off" } }, + { + id: "profile-form", + component: "form", + props: { + method: "post", + enctype: "multipart/form-data", + novalidate: true, + autocomplete: "off", + }, + }, { id: "email-label", component: "label", props: { for: "email", text: "Email" } }, - { id: "email", component: "input", props: { id: "email", type: "email", autocomplete: "email", inputmode: "email", enterkeyhint: "next", maxlength: 255, spellcheck: false } }, + { + id: "email", + component: "input", + props: { + id: "email", + type: "email", + autocomplete: "email", + inputmode: "email", + enterkeyhint: "next", + maxlength: 255, + spellcheck: false, + }, + }, { id: "notes", component: "textarea", props: { dirname: "notes.dir", wrap: "soft" } }, { id: "submit", component: "button", props: { type: "submit", text: "Save" } }, { id: "users", component: "data-table", props: { rows: [{ name: "Ada" }] } }, @@ -44,7 +68,11 @@ describe("default component registrations", () => { }, }; - const markup = renderToStaticMarkup(); + const markup = renderToStaticMarkup( + + + , + ); expect(markup).toContain('href="https://example.com"'); expect(markup).toContain('method="post"'); expect(markup).toContain('encType="multipart/form-data"'); @@ -58,11 +86,24 @@ describe("default component registrations", () => { expect(markup).toContain('type="submit"'); expect(markup).toContain("Ada"); - schema.regions.content[0]!.props = { href: "javascript:alert(1)", text: "unsafe" }; - expect(() => renderToStaticMarkup()).toThrow("href must be a safe URL"); + const unsafeURL = ["java", "script:alert(1)"].join(""); + schema.regions.content[0]!.props = { href: unsafeURL, text: "unsafe" }; + expect(() => + renderToStaticMarkup( + + + , + ), + ).toThrow("href must be a safe URL"); schema.regions.content[0]!.component = "form"; schema.regions.content[0]!.props = { action: "https://attacker.example" }; - expect(() => renderToStaticMarkup()).toThrow("prop action is not allowed"); + expect(() => + renderToStaticMarkup( + + + , + ), + ).toThrow("prop action is not allowed"); }); }); diff --git a/react-sdk/ui-react/src/defaults.tsx b/react-sdk/ui-react/src/defaults.tsx index c147ebe..0d8f417 100644 --- a/react-sdk/ui-react/src/defaults.tsx +++ b/react-sdk/ui-react/src/defaults.tsx @@ -4,13 +4,82 @@ import type { ComponentDefinition } from "./registry.js"; type Props = Record; const globalProps = [ - "id", "title", "hidden", "dir", "lang", "role", "tabIndex", "contentEditable", "draggable", "translate", - "aria-label", "aria-describedby", "aria-labelledby", "aria-live", "aria-current", "aria-expanded", "aria-controls", "aria-pressed", "aria-description", "aria-details", "aria-errormessage", "aria-invalid", "aria-placeholder", "aria-required", + "id", + "title", + "hidden", + "dir", + "lang", + "role", + "tabIndex", + "contentEditable", + "draggable", + "translate", + "aria-label", + "aria-describedby", + "aria-labelledby", + "aria-live", + "aria-current", + "aria-expanded", + "aria-controls", + "aria-pressed", + "aria-description", + "aria-details", + "aria-errormessage", + "aria-invalid", + "aria-placeholder", + "aria-required", ] as const; -const commonProps = ["text", "type", "value", "defaultValue", "name", "placeholder", "disabled", "readOnly", "required", "autoComplete", "autoFocus", "min", "max", "minLength", "maxLength", "step", "pattern", "multiple", "checked", "selected", "rows", "cols", "width", "height", "loading", "error"] as const; +const commonProps = [ + "text", + "type", + "value", + "defaultValue", + "name", + "placeholder", + "disabled", + "readOnly", + "required", + "autoComplete", + "autoFocus", + "min", + "max", + "minLength", + "maxLength", + "step", + "pattern", + "multiple", + "checked", + "selected", + "rows", + "cols", + "width", + "height", + "loading", + "error", +] as const; const linkProps = ["href", "target", "rel", "download", "src", "alt", "poster"] as const; -const formProps = ["accept", "acceptCharset", "capture", "dirname", "encType", "enterKeyHint", "form", "formEncType", "formMethod", "formNoValidate", "formTarget", "htmlFor", "inputMode", "list", "method", "noValidate", "size", "spellCheck", "wrap"] as const; +const formProps = [ + "accept", + "acceptCharset", + "capture", + "dirname", + "encType", + "enterKeyHint", + "form", + "formEncType", + "formMethod", + "formNoValidate", + "formTarget", + "htmlFor", + "inputMode", + "list", + "method", + "noValidate", + "size", + "spellCheck", + "wrap", +] as const; const htmlPropAliases = { "accept-charset": "acceptCharset", autocomplete: "autoComplete", @@ -34,8 +103,27 @@ const htmlPropAliases = { } as const; const htmlAliases = Object.keys(htmlPropAliases); const events = ["click", "change", "input", "submit", "focus", "blur"] as const; -const htmlProps = [...globalProps, ...commonProps, ...linkProps, ...formProps, ...htmlAliases] as const; -const shadcnProps = [...htmlProps, "variant", "size", "orientation", "side", "align", "open", "defaultOpen", "modal", "label", "description", "status"] as const; +const htmlProps = [ + ...globalProps, + ...commonProps, + ...linkProps, + ...formProps, + ...htmlAliases, +] as const; +const shadcnProps = [ + ...htmlProps, + "variant", + "size", + "orientation", + "side", + "align", + "open", + "defaultOpen", + "modal", + "label", + "description", + "status", +] as const; const domProps = new Set([...globalProps, ...commonProps, ...linkProps, ...formProps]); const eventProps = new Set(["onClick", "onChange", "onInput", "onSubmit", "onFocus", "onBlur"]); const urlProps = new Set(["href", "src", "poster"]); @@ -61,7 +149,8 @@ function sanitizeProps(props: Props): Props { continue; } if (urlProps.has(normalizedName)) { - if (typeof value !== "string" || !safeURL(value)) throw new Error(`${normalizedName} must be a safe URL`); + if (typeof value !== "string" || !safeURL(value)) + throw new Error(`${normalizedName} must be a safe URL`); result[normalizedName] = value; continue; } @@ -74,10 +163,28 @@ function sanitizeProps(props: Props): Props { return result; } -function DOMElement({ tag, props, className, children }: { tag: string; props: Props; className: string; children?: ReactNode }): ReactNode { +function DOMElement({ + tag, + props, + className, + children, +}: { + tag: string; + props: Props; + className: string; + children?: ReactNode; +}): ReactNode { const elementProps: Props = {}; for (const [name, value] of Object.entries(props)) { - if (eventProps.has(name) || (domProps.has(name) && name !== "text" && name !== "rows" && name !== "loading" && name !== "error")) elementProps[name] = value; + if ( + eventProps.has(name) || + (domProps.has(name) && + name !== "text" && + name !== "rows" && + name !== "loading" && + name !== "error") + ) + elementProps[name] = value; } elementProps.className = className; const content = props.text ?? children; @@ -92,18 +199,26 @@ function DOMElement({ tag, props, className, children }: { tag: string; props: P function htmlClass(tag: string): string { if (tag === "a") return "text-ui-primary underline-offset-4 hover:underline"; - if (tag === "button") return "inline-flex min-h-11 items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; - if (["input", "textarea", "select"].includes(tag)) return "flex min-h-11 w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; + if (tag === "button") + return "inline-flex min-h-11 items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; + if (["input", "textarea", "select"].includes(tag)) + return "flex min-h-11 w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; if (tag === "table") return "w-full caption-bottom text-sm"; - if (["th", "td"].includes(tag)) return "border-b border-ui-border px-4 py-3 text-left align-middle"; - if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) return "font-semibold tracking-tight text-ui-foreground"; + if (["th", "td"].includes(tag)) + return "border-b border-ui-border px-4 py-3 text-left align-middle"; + if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(tag)) + return "font-semibold tracking-tight text-ui-foreground"; if (tag === "img") return "h-auto max-w-full rounded-md"; return "text-ui-foreground"; } export function createDefaultHTMLDefinition(tag: string): ComponentDefinition { return { - component: ((props: Props) => {props.children as ReactNode}) as ComponentType, + component: ((props: Props) => ( + + {props.children as ReactNode} + + )) as ComponentType, allowedProps: htmlProps, propsSchema: sanitizeProps, events, @@ -111,54 +226,111 @@ export function createDefaultHTMLDefinition(tag: string): ComponentDefinition { } const shadcnElement: Record = { - button: "button", checkbox: "input", input: "input", "native-select": "select", select: "select", slider: "input", switch: "button", textarea: "textarea", - table: "table", "data-table": "table", progress: "progress", separator: "hr", label: "label", kbd: "kbd", typography: "p", spinner: "output", - accordion: "details", collapsible: "details", dialog: "dialog", "alert-dialog": "dialog", + button: "button", + checkbox: "input", + input: "input", + "native-select": "select", + select: "select", + slider: "input", + switch: "button", + textarea: "textarea", + table: "table", + "data-table": "table", + progress: "progress", + separator: "hr", + label: "label", + kbd: "kbd", + typography: "p", + spinner: "output", + accordion: "details", + collapsible: "details", + dialog: "dialog", + "alert-dialog": "dialog", }; function shadcnClass(name: string): string { - if (name === "button") return "inline-flex min-h-11 items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; - if (["input", "textarea", "select", "native-select"].includes(name)) return "flex min-h-11 w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; - if (name === "card") return "rounded-xl border border-ui-border bg-ui-card p-6 text-ui-card-foreground shadow-sm"; - if (["badge", "marker"].includes(name)) return "inline-flex items-center rounded-md bg-ui-muted px-2 py-1 text-xs font-medium text-ui-muted-foreground"; - if (["alert", "empty", "message", "toast"].includes(name)) return "rounded-lg border border-ui-border bg-ui-card p-4 text-ui-card-foreground"; + if (name === "button") + return "inline-flex min-h-11 items-center justify-center rounded-md bg-ui-primary px-3 py-2 text-sm font-medium text-ui-primary-foreground hover:bg-ui-primary/90 disabled:pointer-events-none disabled:opacity-50"; + if (["input", "textarea", "select", "native-select"].includes(name)) + return "flex min-h-11 w-full rounded-md border border-ui-border bg-ui-background px-3 py-2 text-sm text-ui-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-ui-ring disabled:cursor-not-allowed disabled:opacity-50"; + if (name === "card") + return "rounded-xl border border-ui-border bg-ui-card p-6 text-ui-card-foreground shadow-sm"; + if (["badge", "marker"].includes(name)) + return "inline-flex items-center rounded-md bg-ui-muted px-2 py-1 text-xs font-medium text-ui-muted-foreground"; + if (["alert", "empty", "message", "toast"].includes(name)) + return "rounded-lg border border-ui-border bg-ui-card p-4 text-ui-card-foreground"; if (name === "separator") return "my-4 border-ui-border"; - if (name === "spinner") return "inline-flex size-4 animate-spin rounded-full border-2 border-ui-muted border-t-ui-primary"; + if (name === "spinner") + return "inline-flex size-4 animate-spin rounded-full border-2 border-ui-muted border-t-ui-primary"; if (name === "skeleton") return "animate-pulse rounded-md bg-ui-muted"; if (name === "table") return "w-full caption-bottom text-sm"; return "rounded-md border border-ui-border bg-ui-card p-4 text-ui-card-foreground"; } function DataTable({ rows, loading, error, "aria-label": ariaLabel }: Props): ReactNode { - if (loading) return Loading…; - if (error) return {String(error)}; + if (loading) return Loading…; + if (error) + return ( + + {String(error)} + + ); const items: unknown[] = Array.isArray(rows) ? rows : []; const firstRow = items[0]; - const columns = firstRow && typeof firstRow === "object" && !Array.isArray(firstRow) - ? Object.keys(firstRow as Record) - : []; + const columns = + firstRow && typeof firstRow === "object" && !Array.isArray(firstRow) + ? Object.keys(firstRow as Record) + : []; return ( - - {columns.length > 0 && {columns.map((column) => )}} - {items.map((row, index) => { - const record = row && typeof row === "object" ? row as Record : {}; - return {columns.map((column) => )}; - })} +
{column}
{String(record[column] ?? "")}
+ {columns.length > 0 && ( + + + {columns.map((column) => ( + + ))} + + + )} + + {items.map((row, index) => { + const record = row && typeof row === "object" ? (row as Record) : {}; + return ( + + {columns.map((column) => ( + + ))} + + ); + })} +
+ {column} +
+ {String(record[column] ?? "")} +
); } export function createDefaultShadcnDefinition(name: string): ComponentDefinition { - if (name === "data-table") return { - component: DataTable as ComponentType, - allowedProps: shadcnProps, - propsSchema: sanitizeProps, - events, - children: false, - }; + if (name === "data-table") + return { + component: DataTable as ComponentType, + allowedProps: shadcnProps, + propsSchema: sanitizeProps, + events, + children: false, + }; const tag = shadcnElement[name] ?? "div"; return { - component: ((props: Props) => {props.children as ReactNode}) as ComponentType, + component: ((props: Props) => ( + + {props.children as ReactNode} + + )) as ComponentType, allowedProps: shadcnProps, propsSchema: sanitizeProps, events, diff --git a/react-sdk/ui-react/src/demo.tsx b/react-sdk/ui-react/src/demo.tsx index a5f7493..1a794f0 100644 --- a/react-sdk/ui-react/src/demo.tsx +++ b/react-sdk/ui-react/src/demo.tsx @@ -10,23 +10,69 @@ export interface UsersDemoProps { export function createUsersDemoRegistry(): ComponentRegistry { const registry = createComponentRegistry(); - registry.register("heading", { component: ({ text }: { text?: unknown }) =>

{String(text ?? "")}

, allowedProps: ["text"] }); - registry.register("text", { component: ({ text }: { text?: unknown }) =>

{String(text ?? "")}

, allowedProps: ["text"] }); - registry.register("button", { component: ({ text, children }: { text?: unknown; children?: ReactNode }) => , allowedProps: ["text"], events: ["click"] }); - registry.register("text-field", { component: ({ value }: { value?: unknown }) => , allowedProps: ["value"] }); - registry.register("form", { component: ({ children }: { children?: ReactNode }) =>
{children}
}); - registry.register("table", { component: UsersTable, allowedProps: ["rows", "loading", "error"], children: false }); + registry.register("heading", { + component: ({ text }: { text?: unknown }) =>

{String(text ?? "")}

, + allowedProps: ["text"], + }); + registry.register("text", { + component: ({ text }: { text?: unknown }) =>

{String(text ?? "")}

, + allowedProps: ["text"], + }); + registry.register("button", { + component: ({ text, children }: { text?: unknown; children?: ReactNode }) => ( + + ), + allowedProps: ["text"], + events: ["click"], + }); + registry.register("text-field", { + component: ({ value }: { value?: unknown }) => , + allowedProps: ["value"], + }); + registry.register("form", { + component: ({ children }: { children?: ReactNode }) =>
{children}
, + }); + registry.register("table", { + component: UsersTable, + allowedProps: ["rows", "loading", "error"], + children: false, + }); return registry; } -function UsersTable({ rows, loading, error }: { rows?: unknown; loading?: unknown; error?: unknown }): ReactElement { +function UsersTable({ + rows, + loading, + error, +}: { + rows?: unknown; + loading?: unknown; + error?: unknown; +}): ReactElement { if (loading) return
Loading users…
; if (error) return
Unable to load users
; const items = Array.isArray(rows) ? rows : []; - return {items.map((row, index) => )}
{String((row as Record).name ?? "")}
; + return ( + + + {items.map((row) => ( + ).name ?? "user")}> + + + ))} + +
{String((row as Record).name ?? "")}
+ ); } export function UsersDemo({ transport }: UsersDemoProps): ReactElement { const components = useMemo(() => createUsersDemoRegistry(), []); - return ; + return ( + + + + ); } diff --git a/react-sdk/ui-react/src/html.ts b/react-sdk/ui-react/src/html.ts index 3da621b..32d7af5 100644 --- a/react-sdk/ui-react/src/html.ts +++ b/react-sdk/ui-react/src/html.ts @@ -2,10 +2,106 @@ import type { ComponentDefinition, ComponentRegistry } from "./registry.js"; import { createDefaultHTMLDefinition } from "./defaults.js"; export const htmlTagNames = [ - "a", "abbr", "address", "area", "article", "aside", "audio", "b", "bdi", "bdo", "blockquote", "br", "button", "canvas", "caption", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt", "em", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hgroup", "hr", "i", "img", "input", "ins", "kbd", "label", "legend", "li", "main", "map", "mark", "menu", "meter", "nav", "ol", "optgroup", "option", "output", "p", "picture", "pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "search", "section", "select", "small", "source", "span", "strong", "sub", "summary", "sup", "table", "tbody", "td", "textarea", "tfoot", "th", "thead", "time", "tr", "track", "u", "ul", "var", "video", "wbr", + "a", + "abbr", + "address", + "area", + "article", + "aside", + "audio", + "b", + "bdi", + "bdo", + "blockquote", + "br", + "button", + "canvas", + "caption", + "cite", + "code", + "col", + "colgroup", + "data", + "datalist", + "dd", + "del", + "details", + "dfn", + "dialog", + "div", + "dl", + "dt", + "em", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hgroup", + "hr", + "i", + "img", + "input", + "ins", + "kbd", + "label", + "legend", + "li", + "main", + "map", + "mark", + "menu", + "meter", + "nav", + "ol", + "optgroup", + "option", + "output", + "p", + "picture", + "pre", + "progress", + "q", + "rp", + "rt", + "ruby", + "s", + "samp", + "search", + "section", + "select", + "small", + "source", + "span", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "textarea", + "tfoot", + "th", + "thead", + "time", + "tr", + "track", + "u", + "ul", + "var", + "video", + "wbr", ] as const; -export type HTMLTagName = typeof htmlTagNames[number]; +export type HTMLTagName = (typeof htmlTagNames)[number]; const htmlTagSet = new Set(htmlTagNames); @@ -14,7 +110,10 @@ export function isHTMLTagName(name: string): name is HTMLTagName { } // registerHTMLTags registers host-owned implementations for safe HTML tags. -export function registerHTMLTags(registry: ComponentRegistry, definitions: Partial>): void { +export function registerHTMLTags( + registry: ComponentRegistry, + definitions: Partial>, +): void { for (const [name, definition] of Object.entries(definitions)) { if (!isHTMLTagName(name) || !definition) throw new Error(`unsupported html tag ${name}`); registry.register(name, definition); @@ -24,5 +123,6 @@ export function registerHTMLTags(registry: ComponentRegistry, definitions: Parti // registerDefaultHTMLComponents registers safe native React implementations for every supported HTML tag. // Existing host registrations take precedence. export function registerDefaultHTMLComponents(registry: ComponentRegistry): void { - for (const name of htmlTagNames) if (!registry.has(name)) registry.register(name, createDefaultHTMLDefinition(name)); + for (const name of htmlTagNames) + if (!registry.has(name)) registry.register(name, createDefaultHTMLDefinition(name)); } diff --git a/react-sdk/ui-react/src/registry.test.tsx b/react-sdk/ui-react/src/registry.test.tsx index 4cd587b..c58d2b6 100644 --- a/react-sdk/ui-react/src/registry.test.tsx +++ b/react-sdk/ui-react/src/registry.test.tsx @@ -13,6 +13,8 @@ describe("ComponentRegistry", () => { it("rejects duplicate logical component names", () => { const registry = createComponentRegistry(); registry.register("text", { component: () => null }); - expect(() => registry.register("text", { component: () => null })).toThrow("already registered"); + expect(() => registry.register("text", { component: () => null })).toThrow( + "already registered", + ); }); }); diff --git a/react-sdk/ui-react/src/registry.ts b/react-sdk/ui-react/src/registry.ts index 499deef..f77ffed 100644 --- a/react-sdk/ui-react/src/registry.ts +++ b/react-sdk/ui-react/src/registry.ts @@ -13,12 +13,17 @@ export class ComponentRegistry { private readonly definitions = new Map(); register(name: string, definition: ComponentDefinition): void { - if (!name || this.definitions.has(name)) throw new Error(`component ${name} is already registered`); + if (!name || this.definitions.has(name)) + throw new Error(`component ${name} is already registered`); this.definitions.set(name, definition); } - get(name: string): ComponentDefinition | undefined { return this.definitions.get(name); } - has(name: string): boolean { return this.definitions.has(name); } + get(name: string): ComponentDefinition | undefined { + return this.definitions.get(name); + } + has(name: string): boolean { + return this.definitions.has(name); + } } export function createComponentRegistry(): ComponentRegistry { diff --git a/react-sdk/ui-react/src/renderer.test.tsx b/react-sdk/ui-react/src/renderer.test.tsx index 81fc47c..582468f 100644 --- a/react-sdk/ui-react/src/renderer.test.tsx +++ b/react-sdk/ui-react/src/renderer.test.tsx @@ -4,17 +4,39 @@ import type { ReactNode } from "react"; import type { ViewSchema } from "@osspkg/ui-core"; import { createComponentRegistry } from "./registry.js"; import { UIProvider } from "./runtime.js"; -import { ViewRenderer } from "./renderer.js"; +import { PluginView, ViewRenderer } from "./renderer.js"; function emptyRegions() { return { "top-header": [], "left-panel": [], content: [], "right-panel": [], bottom: [] }; } describe("static renderer", () => { + it("renders a schema passed directly to PluginView", () => { + const registry = createComponentRegistry(); + const schema: ViewSchema = { + protocolVersion: "1.0", + id: "provided", + regions: emptyRegions(), + }; + + const markup = renderToStaticMarkup( + + + , + ); + + expect(markup).toContain('data-ui-region="content"'); + }); + it("renders registered components, children, and grid metadata", () => { const registry = createComponentRegistry(); registry.register("card", { - component: (props: Record) =>
{String(props.title)}{props.children as ReactNode}
, + component: (props: Record) => ( +
+ {String(props.title)} + {props.children as ReactNode} +
+ ), allowedProps: ["title"], }); registry.register("text", { @@ -27,17 +49,23 @@ describe("static renderer", () => { id: "renderer", regions: { ...emptyRegions(), - content: [{ - id: "card", - component: "card", - layout: { row: 1, cols: 8, offset: 2 }, - props: { title: "Users" }, - children: [{ id: "text", component: "text", props: { value: "Ada" } }], - }], + content: [ + { + id: "card", + component: "card", + layout: { row: 1, cols: 8, offset: 2 }, + props: { title: "Users" }, + children: [{ id: "text", component: "text", props: { value: "Ada" } }], + }, + ], }, }; - const markup = renderToStaticMarkup(); + const markup = renderToStaticMarkup( + + + , + ); expect(markup).toContain("Users"); expect(markup).toContain("Ada"); @@ -51,20 +79,46 @@ describe("static renderer", () => { it("renders declared slots and rejects undeclared slots", () => { const registry = createComponentRegistry(); registry.register("card", { - component: (props: Record) =>
{(props.slots as Record).footer}
, + component: (props: Record) => ( +
{(props.slots as Record).footer}
+ ), slots: ["footer"], }); - registry.register("text", { component: (props: Record) => {String(props.value)}, allowedProps: ["value"] }); + registry.register("text", { + component: (props: Record) => {String(props.value)}, + allowedProps: ["value"], + }); const schema: ViewSchema = { protocolVersion: "1.0", id: "slots", - regions: { ...emptyRegions(), content: [{ id: "card", component: "card", slots: { footer: [{ id: "footer", component: "text", props: { value: "Footer" } }] } }] }, + regions: { + ...emptyRegions(), + content: [ + { + id: "card", + component: "card", + slots: { footer: [{ id: "footer", component: "text", props: { value: "Footer" } }] }, + }, + ], + }, }; - expect(renderToStaticMarkup()).toContain("Footer"); + expect( + renderToStaticMarkup( + + + , + ), + ).toContain("Footer"); schema.regions.content[0]!.slots = { other: [] }; - expect(() => renderToStaticMarkup()).toThrow("slot other is not allowed"); + expect(() => + renderToStaticMarkup( + + + , + ), + ).toThrow("slot other is not allowed"); }); it("renders a safe fallback for unsupported components", () => { @@ -74,7 +128,11 @@ describe("static renderer", () => { regions: { ...emptyRegions(), content: [{ id: "missing", component: "missing-component" }] }, }; - const markup = renderToStaticMarkup(); + const markup = renderToStaticMarkup( + + + , + ); expect(markup).toContain("Unsupported component: missing-component"); expect(markup).toContain('data-unsupported-component="missing-component"'); @@ -86,10 +144,19 @@ describe("static renderer", () => { const schema: ViewSchema = { protocolVersion: "1.0", id: "props", - regions: { ...emptyRegions(), content: [{ id: "text", component: "text", props: { value: "unsafe" } }] }, + regions: { + ...emptyRegions(), + content: [{ id: "text", component: "text", props: { value: "unsafe" } }], + }, }; - expect(() => renderToStaticMarkup()).toThrow("component props are not declared"); + expect(() => + renderToStaticMarkup( + + + , + ), + ).toThrow("component props are not declared"); }); it("rejects events without a host allowlist", () => { @@ -99,10 +166,19 @@ describe("static renderer", () => { protocolVersion: "1.0", id: "events", actions: { save: { type: "set-state", path: "saved", value: true } }, - regions: { ...emptyRegions(), content: [{ id: "button", component: "button", events: { click: { action: "save" } } }] }, + regions: { + ...emptyRegions(), + content: [{ id: "button", component: "button", events: { click: { action: "save" } } }], + }, }; - expect(() => renderToStaticMarkup()).toThrow("component events are not declared"); + expect(() => + renderToStaticMarkup( + + + , + ), + ).toThrow("component events are not declared"); }); it("rejects plugin styling and raw React props", () => { @@ -111,9 +187,18 @@ describe("static renderer", () => { const schema: ViewSchema = { protocolVersion: "1.0", id: "unsafe-props", - regions: { ...emptyRegions(), content: [{ id: "card", component: "card", props: { style: { color: "red" } } }] }, + regions: { + ...emptyRegions(), + content: [{ id: "card", component: "card", props: { style: { color: "red" } } }], + }, }; - expect(() => renderToStaticMarkup()).toThrow("prop style is forbidden"); + expect(() => + renderToStaticMarkup( + + + , + ), + ).toThrow("prop style is forbidden"); }); }); diff --git a/react-sdk/ui-react/src/renderer.tsx b/react-sdk/ui-react/src/renderer.tsx index a19cca1..4fc1d1c 100644 --- a/react-sdk/ui-react/src/renderer.tsx +++ b/react-sdk/ui-react/src/renderer.tsx @@ -1,7 +1,13 @@ import { useEffect, useState, type CSSProperties, type ReactElement, type ReactNode } from "react"; -import { uiRPCMethods } from "@osspkg/ui-core"; -import type { EventHandler, RegionName, UIGetViewResponse, UINode, ViewSchema } from "@osspkg/ui-core"; -import { validateView } from "@osspkg/ui-core"; +import { + uiRPCMethods, + validateView, + type EventHandler, + type RegionName, + type UIGetViewResponse, + type UINode, + type ViewSchema, +} from "@osspkg/ui-core"; import { useUIRuntime, useUIViewRuntime, ViewRuntimeProvider } from "./runtime.js"; export interface PluginViewProps { @@ -12,64 +18,156 @@ export interface PluginViewProps { } export function PluginView(props: PluginViewProps): ReactElement { + const { plugin, view, context, schema: providedSchema } = props; const { transport } = useUIRuntime(); - const [schema, setSchema] = useState(props.schema); - const [error, setError] = useState(); + const [loaded, setLoaded] = useState<{ + plugin?: string; + view: string; + context: unknown; + transport: typeof transport; + schema?: ViewSchema; + error?: Error; + }>(); useEffect(() => { - if (props.schema) { - setSchema(props.schema); - setError(undefined); - return; - } - setSchema(undefined); - setError(undefined); - if (!transport || !props.plugin) return; + if (providedSchema || !transport || !plugin) return; let active = true; - void transport.call(uiRPCMethods.getView, { plugin: props.plugin, view: props.view, context: props.context }).then((response) => { - if (active) setSchema(response.schema); - }).catch((reason: unknown) => { if (active) setError(reason instanceof Error ? reason : new Error("view loading failed")); }); - return () => { active = false; }; - }, [props.context, props.plugin, props.schema, props.view, transport]); + void transport + .call(uiRPCMethods.getView, { + plugin, + view, + context, + }) + .then((response) => { + if (active) { + setLoaded({ + plugin, + view, + context, + transport, + schema: response.schema, + }); + } + }) + .catch((reason: unknown) => { + if (active) { + setLoaded({ + plugin, + view, + context, + transport, + error: reason instanceof Error ? reason : new Error("view loading failed"), + }); + } + }); + return () => { + active = false; + }; + }, [context, plugin, providedSchema, view, transport]); + + const loadedMatchesProps = + loaded !== undefined && + loaded.plugin === plugin && + loaded.view === view && + loaded.context === context && + loaded.transport === transport; + const schema = providedSchema ?? (loadedMatchesProps ? loaded.schema : undefined); + const error = !providedSchema && loadedMatchesProps ? loaded.error : undefined; + if (error) return
{error.message}
; if (!schema) return
Loading…
; - return ; + return ; } -export function ViewRenderer({ schema, plugin, context = {} }: { schema: ViewSchema; plugin?: string; context?: unknown }): ReactElement { +export function ViewRenderer({ + schema, + plugin, + context = {}, +}: { + schema: ViewSchema; + plugin?: string; + context?: unknown; +}): ReactElement { validateView(schema); const runtimeKey = `${plugin ?? ""}:${schema.id}:${schema.revision ?? ""}`; - return ; + return ( + + + + ); } function ViewRegions(): ReactElement { const runtime = useUIViewRuntime(); const regions: RegionName[] = ["top-header", "left-panel", "content", "right-panel", "bottom"]; - return <> - {regions.map((region) =>
{runtime.schema.regions[region].map((node) => )}
)} - ; + return ( + <> + {regions.map((region) => ( +
+ {runtime.schema.regions[region].map((node) => ( + + ))} +
+ ))} + + ); } function NodeRenderer({ node }: { node: UINode }): ReactNode { const runtime = useUIViewRuntime(); if (node.when !== undefined && !runtime.resolve(node.when)) return null; const definition = runtime.components.get(node.component); - if (!definition) return
Unsupported component: {node.component}
; - const rawProps = Object.fromEntries(Object.entries(node.props ?? {}).map(([key, value]) => [key, runtime.resolve(value)])); + if (!definition) + return ( +
+ Unsupported component: {node.component} +
+ ); + const rawProps = Object.fromEntries( + Object.entries(node.props ?? {}).map(([key, value]) => [key, runtime.resolve(value)]), + ); const props = validateProps(rawProps, definition.allowedProps, definition.propsSchema); const events = bindEvents(node.events, definition.events, runtime); - const children = definition.children === false || !node.children?.length ? undefined :
{node.children.map((child) => )}
; + const children = + definition.children === false || !node.children?.length ? undefined : ( +
+ {node.children.map((child) => ( + + ))} +
+ ); const slots = renderSlots(node.slots, definition.slots); const Component = definition.component as (props: Record) => ReactElement; - const layout = node.layout; + const { layout } = node; const layoutStyle = { "--ui-row": layout?.row ?? "auto", "--ui-cols": layout?.cols ?? 12, "--ui-start": (layout?.offset ?? 0) + 1, } as CSSProperties; - return
{children}
; + return ( +
+ + {children} + +
+ ); } -function renderSlots(slots: Record | undefined, allowedSlots: readonly string[] | undefined): Record | undefined { +function renderSlots( + slots: Record | undefined, + allowedSlots: readonly string[] | undefined, +): Record | undefined { if (!slots) return undefined; if (!allowedSlots) throw new Error("component slots are not declared"); const rendered: Record = {}; @@ -80,33 +178,58 @@ function renderSlots(slots: Record | undefined, allowedSlots: return rendered; } -function validateProps(rawProps: Record, allowedProps: readonly string[] | undefined, schema: ((props: Record) => unknown) | undefined): Record { +function validateProps( + rawProps: Record, + allowedProps: readonly string[] | undefined, + schema: ((props: Record) => unknown) | undefined, +): Record { for (const key of Object.keys(rawProps)) { - if (forbiddenPluginProps.has(key) || key.startsWith("on")) throw new Error(`prop ${key} is forbidden`); + if (forbiddenPluginProps.has(key) || key.startsWith("on")) + throw new Error(`prop ${key} is forbidden`); } - if (!allowedProps && !schema && Object.keys(rawProps).length > 0) throw new Error("component props are not declared"); - if (allowedProps) for (const key of Object.keys(rawProps)) if (!allowedProps.includes(key)) throw new Error(`prop ${key} is not allowed`); - return schema ? schema(rawProps) as Record : rawProps; + if (!allowedProps && !schema && Object.keys(rawProps).length > 0) + throw new Error("component props are not declared"); + if (allowedProps) + for (const key of Object.keys(rawProps)) + if (!allowedProps.includes(key)) throw new Error(`prop ${key} is not allowed`); + return schema ? (schema(rawProps) as Record) : rawProps; } -const forbiddenPluginProps = new Set(["dangerouslySetInnerHTML", "style", "className", "ref", "key"]); +const forbiddenPluginProps = new Set([ + "dangerouslySetInnerHTML", + "style", + "className", + "ref", + "key", +]); -function bindEvents(handlers: Record | undefined, allowedEvents: readonly string[] | undefined, runtime: ReturnType): Record Promise> { +function bindEvents( + handlers: Record | undefined, + allowedEvents: readonly string[] | undefined, + runtime: ReturnType, +): Record Promise> { const entries = Object.entries(handlers ?? {}); if (entries.length > 0 && !allowedEvents) throw new Error("component events are not declared"); - return Object.fromEntries(entries.map(([name, handler]) => { - if (!/^[a-z][a-z0-9-]*$/.test(name) || !allowedEvents?.includes(name)) throw new Error(`event ${name} is not allowed`); - return [eventProp(name), createEventHandler(handler, runtime)]; - })); + return Object.fromEntries( + entries.map(([name, handler]) => { + if (!/^[a-z][a-z0-9-]*$/.test(name) || !allowedEvents?.includes(name)) + throw new Error(`event ${name} is not allowed`); + return [eventProp(name), createEventHandler(handler, runtime)]; + }), + ); } function eventProp(name: string): string { return `on${name.slice(0, 1).toUpperCase()}${name.slice(1)}`; } -function createEventHandler(handler: EventHandler, runtime: ReturnType): (event: unknown) => Promise { +function createEventHandler( + handler: EventHandler, + runtime: ReturnType, +): (event: unknown) => Promise { return async (event) => { if (handler.action) await runtime.runAction(handler.action, event); - for (const step of handler.steps ?? []) if (step.action) await runtime.runAction(step.action, event); + for (const step of handler.steps ?? []) + if (step.action) await runtime.runAction(step.action, event); }; } diff --git a/react-sdk/ui-react/src/runtime.test.tsx b/react-sdk/ui-react/src/runtime.test.tsx index 48a6345..8e1c5da 100644 --- a/react-sdk/ui-react/src/runtime.test.tsx +++ b/react-sdk/ui-react/src/runtime.test.tsx @@ -1,10 +1,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import type { ViewSchema } from "@osspkg/ui-core"; +import type { RPCTransport } from "@osspkg/ui-transport"; import { createComponentRegistry } from "./registry.js"; import { ViewRenderer } from "./renderer.js"; import { UIProvider, useUISource, type UISource } from "./runtime.js"; -import type { RPCTransport } from "@osspkg/ui-transport"; describe("view runtime bindings", () => { it("resolves state and context values before rendering and binds events", () => { @@ -13,7 +13,11 @@ describe("view runtime bindings", () => { registry.register("probe", { component: (props: Record) => { received = props; - return {String(props.name)}:{String(props.workspace)}; + return ( + + {String(props.name)}:{String(props.workspace)} + + ); }, allowedProps: ["name", "workspace"], events: ["click"], @@ -26,12 +30,14 @@ describe("view runtime bindings", () => { regions: { "top-header": [], "left-panel": [], - content: [{ - id: "probe", - component: "probe", - props: { name: { $state: "name" }, workspace: { $context: "workspace.id" } }, - events: { click: { action: "submit" } }, - }], + content: [ + { + id: "probe", + component: "probe", + props: { name: { $state: "name" }, workspace: { $context: "workspace.id" } }, + events: { click: { action: "submit" } }, + }, + ], "right-panel": [], bottom: [], }, @@ -77,12 +83,11 @@ describe("view runtime bindings", () => { it("exposes idle source state and manual refresh", () => { let source: UISource | undefined; const registry = createComponentRegistry(); - registry.register("source-probe", { - component: () => { - source = useUISource("users"); - return {source.status}; - }, - }); + function SourceProbe() { + source = useUISource("users"); + return {source.status}; + } + registry.register("source-probe", { component: SourceProbe }); const schema: ViewSchema = { protocolVersion: "1.0", id: "source", @@ -136,7 +141,11 @@ describe("view runtime bindings", () => { type: "tool", tool: "users.save", input: { name: { $event: "value" } }, - effects: [{ type: "toast", variant: "success", message: "Saved" }, { type: "refresh-view", revision: "rev-2" }, { type: "patch-view", baseRevision: "rev-1", revision: "rev-2" }], + effects: [ + { type: "toast", variant: "success", message: "Saved" }, + { type: "refresh-view", revision: "rev-2" }, + { type: "patch-view", baseRevision: "rev-1", revision: "rev-2" }, + ], }, }, sources: { users: { type: "tool", tool: "users.list", refreshOn: ["save"] } }, @@ -150,15 +159,41 @@ describe("view runtime bindings", () => { }; renderToStaticMarkup( - { toasts.push(message); }, refreshView: (effect) => { refreshedViews.push(effect.type); }, patchView: (effect) => { patchedViews.push(effect.type); } }}> + { + toasts.push(message); + }, + refreshView: (effect) => { + refreshedViews.push(effect.type); + }, + patchView: (effect) => { + patchedViews.push(effect.type); + }, + }} + > , ); await (received?.onClick as (event: unknown) => Promise)({ value: "Ada" }); expect(calls).toEqual([ - { method: "ui.action", params: { plugin: "users", view: "actions", action: "save", input: { name: "Ada" } } }, - { method: "data.call", params: { plugin: "users", view: "actions", source: "users", operation: "users.list", input: {} } }, + { + method: "ui.action", + params: { plugin: "users", view: "actions", action: "save", input: { name: "Ada" } }, + }, + { + method: "data.call", + params: { + plugin: "users", + view: "actions", + source: "users", + operation: "users.list", + input: {}, + }, + }, ]); expect(toasts).toEqual(["Saved"]); expect(refreshedViews).toEqual(["refresh-view"]); diff --git a/react-sdk/ui-react/src/runtime.tsx b/react-sdk/ui-react/src/runtime.tsx index ea89486..97e52a7 100644 --- a/react-sdk/ui-react/src/runtime.tsx +++ b/react-sdk/ui-react/src/runtime.tsx @@ -1,5 +1,28 @@ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type Dispatch, type PropsWithChildren, type ReactElement, type SetStateAction } from "react"; -import { createRequestTracker, resolveValue, resolvePath, setPath, uiRPCMethods, type Effect, type ResolveScope, type SourceRuntimeState, type UIValue, type ViewSchema } from "@osspkg/ui-core"; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type Dispatch, + type PropsWithChildren, + type ReactElement, + type SetStateAction, +} from "react"; +import { + createRequestTracker, + resolveValue, + resolvePath, + setPath, + uiRPCMethods, + type Effect, + type ResolveScope, + type SourceRuntimeState, + type UIValue, + type ViewSchema, +} from "@osspkg/ui-core"; import type { RPCTransport } from "@osspkg/ui-transport"; import { ComponentRegistry } from "./registry.js"; @@ -38,8 +61,16 @@ export interface UISource extends SourceRuntimeState { const runtimeContext = createContext(undefined); const viewRuntimeContext = createContext(undefined); -export function UIProvider({ components, transport, effects, children }: PropsWithChildren): ReactElement { - const value = useMemo(() => ({ components, ...(transport ? { transport } : {}), ...(effects ? { effects } : {}) }), [components, effects, transport]); +export function UIProvider({ + components, + transport, + effects, + children, +}: PropsWithChildren): ReactElement { + const value = useMemo( + () => ({ components, ...(transport ? { transport } : {}), ...(effects ? { effects } : {}) }), + [components, effects, transport], + ); return {children}; } @@ -59,7 +90,10 @@ export function useUISource(name: string): UISource { const runtime = useUIViewRuntime(); const source = runtime.sources[name] as SourceRuntimeState | undefined; const refresh = useCallback(() => runtime.refreshSource(name), [name, runtime]); - return useMemo(() => ({ ...(source ?? { status: "idle" as const }), refresh }), [refresh, source]); + return useMemo( + () => ({ ...(source ?? { status: "idle" as const }), refresh }), + [refresh, source], + ); } export function useUIAction(name: string): (event?: unknown) => Promise { @@ -67,10 +101,17 @@ export function useUIAction(name: string): (event?: unknown) => Promise return useCallback((event?: unknown) => runtime.runAction(name, event), [name, runtime]); } -export function ViewRuntimeProvider({ schema, plugin, context, children }: PropsWithChildren<{ schema: ViewSchema; plugin?: string; context: unknown }>): ReactElement { +export function ViewRuntimeProvider({ + schema, + plugin, + context, + children, +}: PropsWithChildren<{ schema: ViewSchema; plugin?: string; context: unknown }>): ReactElement { const base = useUIRuntime(); const [state, setState] = useState(schema.state ?? {}); - const [sources, setSources] = useState>(() => initialSourceStates(schema)); + const [sources, setSources] = useState>(() => + initialSourceStates(schema), + ); const requestIDs = useRef(createRequestTracker()); const controllers = useRef(new Map()); @@ -80,32 +121,59 @@ export function ViewRuntimeProvider({ schema, plugin, context, children }: Props setSources(initialSourceStates(schema)); }, [schema]); - const refreshSource = useCallback(async (name: string): Promise => { - const source = schema.sources?.[name]; - if (!source || !base.transport) return; - controllers.current.get(name)?.abort(); - const controller = new AbortController(); - controllers.current.set(name, controller); - const requestID = requestIDs.current.next(name); - setSources((current) => ({ ...current, [name]: { status: "loading", requestId: String(requestID) } })); - try { - const input = resolveValue(source.input ?? {}, { state, context, sources }); - const data = await base.transport.call(uiRPCMethods.dataCall, { plugin, view: schema.id, source: name, operation: source.tool, input }, { signal: controller.signal }); - if (!requestIDs.current.isCurrent(name, requestID)) return; - setSources((current) => ({ ...current, [name]: { status: "success", data, updatedAt: Date.now(), requestId: String(requestID) } })); - } catch (error) { - if (controller.signal.aborted || !requestIDs.current.isCurrent(name, requestID)) return; - setSources((current) => ({ ...current, [name]: { status: "error", error: { code: "SOURCE_FAILED", message: error instanceof Error ? error.message : "source failed", retryable: true }, requestId: String(requestID) } })); - } finally { - if (controllers.current.get(name) === controller) controllers.current.delete(name); - } - }, [base.transport, context, plugin, schema, sources, state]); + const refreshSource = useCallback( + async (name: string): Promise => { + const source = schema.sources?.[name]; + if (!source || !base.transport) return; + controllers.current.get(name)?.abort(); + const controller = new AbortController(); + controllers.current.set(name, controller); + const requestID = requestIDs.current.next(name); + setSources((current) => ({ + ...current, + [name]: { status: "loading", requestId: String(requestID) }, + })); + try { + const input = resolveValue(source.input ?? {}, { state, context, sources }); + const data = await base.transport.call( + uiRPCMethods.dataCall, + { plugin, view: schema.id, source: name, operation: source.tool, input }, + { signal: controller.signal }, + ); + if (!requestIDs.current.isCurrent(name, requestID)) return; + setSources((current) => ({ + ...current, + [name]: { status: "success", data, updatedAt: Date.now(), requestId: String(requestID) }, + })); + } catch (error) { + if (controller.signal.aborted || !requestIDs.current.isCurrent(name, requestID)) return; + setSources((current) => ({ + ...current, + [name]: { + status: "error", + error: { + code: "SOURCE_FAILED", + message: error instanceof Error ? error.message : "source failed", + retryable: true, + }, + requestId: String(requestID), + }, + })); + } finally { + if (controllers.current.get(name) === controller) controllers.current.delete(name); + } + }, + [base.transport, context, plugin, schema, sources, state], + ); useEffect(() => { for (const [name, source] of Object.entries(schema.sources ?? {})) { if (source.policy === "on-mount") void refreshSource(name); } - return () => { for (const controller of controllers.current.values()) controller.abort(); }; + const activeControllers = controllers.current; + return () => { + for (const controller of activeControllers.values()) controller.abort(); + }; // on-mount execution belongs to one schema lifecycle; source updates must not retrigger it. // eslint-disable-next-line react-hooks/exhaustive-deps }, [schema, base.transport, plugin]); @@ -121,7 +189,7 @@ export function ViewRuntimeProvider({ schema, plugin, context, children }: Props }, [base.transport, refreshSource, schema]); useEffect(() => { - const transport = base.transport; + const { transport } = base; if (!transport) return; return transport.subscribe((notification) => { const params = isRecord(notification.params) ? notification.params : {}; @@ -139,47 +207,91 @@ export function ViewRuntimeProvider({ schema, plugin, context, children }: Props } } }); - }, [base.transport, plugin, refreshSource, schema]); - - const runtime = useMemo(() => ({ - ...base, - schema, - ...(plugin ? { plugin } : {}), - context, - state, - sources, - setState: (path, value) => setState((current: unknown) => setPath(current, path, value)), - mergeState: (path, value) => setState((current: unknown) => { - const currentValue = resolvePath(current, path); - const merged = currentValue && typeof currentValue === "object" ? { ...currentValue, ...value } : value; - return setPath(current, path, merged); + }, [base, plugin, refreshSource, schema]); + + const runtime = useMemo( + () => ({ + ...base, + schema, + ...(plugin ? { plugin } : {}), + context, + state, + sources, + setState: (path, value) => setState((current: unknown) => setPath(current, path, value)), + mergeState: (path, value) => + setState((current: unknown) => { + const currentValue = resolvePath(current, path); + const merged = + currentValue && typeof currentValue === "object" + ? { ...currentValue, ...value } + : value; + return setPath(current, path, merged); + }), + resolve: (value, event, result) => + resolveValue(value as UIValue, { state, context, sources, event, result }), + runAction: (name, event) => + executeAction( + name, + schema, + base.transport, + base.effects, + plugin, + state, + context, + sources, + setState, + refreshSource, + event, + ), + refreshSource, }), - resolve: (value, event, result) => resolveValue(value as UIValue, { state, context, sources, event, result }), - runAction: (name, event) => executeAction(name, schema, base.transport, base.effects, plugin, state, context, sources, setState, refreshSource, event), - refreshSource, - }), [base, context, plugin, refreshSource, schema, sources, state]); + [base, context, plugin, refreshSource, schema, sources, state], + ); return {children}; } function initialSourceStates(schema: ViewSchema): Record { - return Object.fromEntries(Object.keys(schema.sources ?? {}).map((name) => [name, { status: "idle" as const }])); + return Object.fromEntries( + Object.keys(schema.sources ?? {}).map((name) => [name, { status: "idle" as const }]), + ); } -async function executeAction(name: string, schema: ViewSchema, transport: RPCTransport | undefined, handlers: UIEffectHandlers | undefined, plugin: string | undefined, state: unknown, context: unknown, sources: Record, setState: Dispatch>, refreshSource: (name: string) => Promise, event?: unknown): Promise { +async function executeAction( + name: string, + schema: ViewSchema, + transport: RPCTransport | undefined, + handlers: UIEffectHandlers | undefined, + plugin: string | undefined, + state: unknown, + context: unknown, + sources: Record, + setState: Dispatch>, + refreshSource: (name: string) => Promise, + event?: unknown, +): Promise { const action = schema.actions?.[name]; if (!action) throw new Error(`action ${name} is not defined`); const scope: ResolveScope = { state, context, sources, event }; if (action.type === "set-state") { - setState((current: unknown) => setPath(current, action.path ?? "", resolveValue(action.value ?? null, scope))); - for (const effect of action.effects ?? []) await executeEffect(effect, undefined, scope, handlers, setState, refreshSource); + setState((current: unknown) => + setPath(current, action.path ?? "", resolveValue(action.value ?? null, scope)), + ); + for (const effect of action.effects ?? []) + await executeEffect(effect, undefined, scope, handlers, setState, refreshSource); await refreshTriggeredSources(name, schema, refreshSource); return undefined; } if (action.type === "merge-state") { const current = resolvePath(state, action.path ?? ""); const next = resolveValue(action.value ?? null, scope); - setState((value: unknown) => setPath(value, action.path ?? "", { ...(current && typeof current === "object" ? current : {}), ...(next && typeof next === "object" ? next : {}) })); - for (const effect of action.effects ?? []) await executeEffect(effect, undefined, scope, handlers, setState, refreshSource); + setState((value: unknown) => + setPath(value, action.path ?? "", { + ...(current && typeof current === "object" ? current : {}), + ...(next && typeof next === "object" ? next : {}), + }), + ); + for (const effect of action.effects ?? []) + await executeEffect(effect, undefined, scope, handlers, setState, refreshSource); await refreshTriggeredSources(name, schema, refreshSource); return undefined; } @@ -190,19 +302,36 @@ async function executeAction(name: string, schema: ViewSchema, transport: RPCTra } if (action.type !== "tool" || !transport) throw new Error(`action ${name} requires a transport`); const input = resolveValue(action.input ?? {}, scope); - const response = await transport.call(uiRPCMethods.action, { plugin, view: schema.id, action: name, input }); - for (const effect of action.effects ?? []) await executeEffect(effect, response, scope, handlers, setState, refreshSource); + const response = await transport.call(uiRPCMethods.action, { + plugin, + view: schema.id, + action: name, + input, + }); + for (const effect of action.effects ?? []) + await executeEffect(effect, response, scope, handlers, setState, refreshSource); await refreshTriggeredSources(name, schema, refreshSource); return response; } -async function refreshTriggeredSources(name: string, schema: ViewSchema, refreshSource: (name: string) => Promise): Promise { +async function refreshTriggeredSources( + name: string, + schema: ViewSchema, + refreshSource: (name: string) => Promise, +): Promise { for (const [sourceName, source] of Object.entries(schema.sources ?? {})) { if (source.refreshOn?.includes(name)) await refreshSource(sourceName); } } -async function executeEffect(effect: Effect, result: unknown, scope: ResolveScope, handlers: UIEffectHandlers | undefined, setState: Dispatch>, refreshSource: (name: string) => Promise): Promise { +async function executeEffect( + effect: Effect, + result: unknown, + scope: ResolveScope, + handlers: UIEffectHandlers | undefined, + setState: Dispatch>, + refreshSource: (name: string) => Promise, +): Promise { const effectScope = { ...scope, result }; if (effect.type === "set-state" && effect.path) { const value = effect.value === undefined ? result : resolveValue(effect.value, effectScope); @@ -212,13 +341,19 @@ async function executeEffect(effect: Effect, result: unknown, scope: ResolveScop const value = resolveValue(effect.value ?? {}, effectScope); setState((current: unknown) => { const existing = resolvePath(current, effect.path!); - const merged = existing && typeof existing === "object" ? { ...existing, ...(value && typeof value === "object" ? value : {}) } : value; + const merged = + existing && typeof existing === "object" + ? { ...existing, ...(value && typeof value === "object" ? value : {}) } + : value; return setPath(current, effect.path!, merged); }); } - if ((effect.type === "invalidate" || effect.type === "refresh-source") && effect.source) await refreshSource(effect.source); - if (effect.type === "toast" && effect.message && handlers?.toast) await handlers.toast(effect.message, effect.variant); - if (effect.type === "navigate" && effect.to && handlers?.navigate) await handlers.navigate(effect.to); + if ((effect.type === "invalidate" || effect.type === "refresh-source") && effect.source) + await refreshSource(effect.source); + if (effect.type === "toast" && effect.message && handlers?.toast) + await handlers.toast(effect.message, effect.variant); + if (effect.type === "navigate" && effect.to && handlers?.navigate) + await handlers.navigate(effect.to); if (effect.type === "dialog" && handlers?.dialog) await handlers.dialog(effect); if (effect.type === "close-dialog" && handlers?.closeDialog) await handlers.closeDialog(effect); if (effect.type === "refresh-view" && handlers?.refreshView) await handlers.refreshView(effect); diff --git a/react-sdk/ui-react/src/shadcn.ts b/react-sdk/ui-react/src/shadcn.ts index cd9c2af..1e802c6 100644 --- a/react-sdk/ui-react/src/shadcn.ts +++ b/react-sdk/ui-react/src/shadcn.ts @@ -2,10 +2,73 @@ import type { ComponentDefinition, ComponentRegistry } from "./registry.js"; import { createDefaultShadcnDefinition } from "./defaults.js"; export const shadcnComponentNames = [ - "accordion", "alert", "alert-dialog", "aspect-ratio", "attachment", "avatar", "badge", "breadcrumb", "bubble", "button", "button-group", "calendar", "card", "carousel", "chart", "checkbox", "collapsible", "combobox", "command", "context-menu", "data-table", "date-picker", "dialog", "direction", "drawer", "dropdown-menu", "empty", "field", "hover-card", "input", "input-group", "input-otp", "item", "kbd", "label", "marker", "menubar", "message", "message-scroller", "native-select", "navigation-menu", "pagination", "popover", "progress", "questionnaire", "radio-group", "resizable", "scroll-area", "select", "separator", "sheet", "sidebar", "skeleton", "slider", "spinner", "switch", "table", "tabs", "textarea", "toast", "toggle", "toggle-group", "tooltip", "typography", + "accordion", + "alert", + "alert-dialog", + "aspect-ratio", + "attachment", + "avatar", + "badge", + "breadcrumb", + "bubble", + "button", + "button-group", + "calendar", + "card", + "carousel", + "chart", + "checkbox", + "collapsible", + "combobox", + "command", + "context-menu", + "data-table", + "date-picker", + "dialog", + "direction", + "drawer", + "dropdown-menu", + "empty", + "field", + "hover-card", + "input", + "input-group", + "input-otp", + "item", + "kbd", + "label", + "marker", + "menubar", + "message", + "message-scroller", + "native-select", + "navigation-menu", + "pagination", + "popover", + "progress", + "questionnaire", + "radio-group", + "resizable", + "scroll-area", + "select", + "separator", + "sheet", + "sidebar", + "skeleton", + "slider", + "spinner", + "switch", + "table", + "tabs", + "textarea", + "toast", + "toggle", + "toggle-group", + "tooltip", + "typography", ] as const; -export type ShadcnComponentName = typeof shadcnComponentNames[number]; +export type ShadcnComponentName = (typeof shadcnComponentNames)[number]; const shadcnComponentSet = new Set(shadcnComponentNames); @@ -15,9 +78,13 @@ export function isShadcnComponentName(name: string): name is ShadcnComponentName // registerShadcnComponents registers host implementations for shadcn/ui names. // The host deliberately supplies the implementations and their prop allowlists. -export function registerShadcnComponents(registry: ComponentRegistry, definitions: Partial>): void { +export function registerShadcnComponents( + registry: ComponentRegistry, + definitions: Partial>, +): void { for (const [name, definition] of Object.entries(definitions)) { - if (!isShadcnComponentName(name) || !definition) throw new Error(`unsupported shadcn component ${name}`); + if (!isShadcnComponentName(name) || !definition) + throw new Error(`unsupported shadcn component ${name}`); registry.register(name, definition); } } @@ -26,5 +93,6 @@ export function registerShadcnComponents(registry: ComponentRegistry, definition // Install and register host-owned shadcn/ui source components to replace these defaults where richer behavior is needed. // Existing host registrations take precedence. export function registerDefaultShadcnComponents(registry: ComponentRegistry): void { - for (const name of shadcnComponentNames) if (!registry.has(name)) registry.register(name, createDefaultShadcnDefinition(name)); + for (const name of shadcnComponentNames) + if (!registry.has(name)) registry.register(name, createDefaultShadcnDefinition(name)); } diff --git a/react-sdk/ui-transport/src/http.ts b/react-sdk/ui-transport/src/http.ts index 826d56b..1c0ec0d 100644 --- a/react-sdk/ui-transport/src/http.ts +++ b/react-sdk/ui-transport/src/http.ts @@ -1,4 +1,10 @@ -import { createRequest, readResponse, type CallOptions, type RPCNotification, type RPCTransport } from "./rpc.js"; +import { + createRequest, + readResponse, + type CallOptions, + type RPCNotification, + type RPCTransport, +} from "./rpc.js"; export interface HTTPTransportOptions { fetch?: typeof globalThis.fetch; @@ -11,7 +17,10 @@ export class HTTPTransport implements RPCTransport { private readonly headers: Record; private readonly timeoutMs: number; - constructor(private readonly endpoint: string, options: HTTPTransportOptions = {}) { + constructor( + private readonly endpoint: string, + options: HTTPTransportOptions = {}, + ) { this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis); this.headers = { "content-type": "application/json", ...(options.headers ?? {}) }; this.timeoutMs = options.timeoutMs ?? 30_000; @@ -24,13 +33,14 @@ export class HTTPTransport implements RPCTransport { const abort = () => controller.abort(); options.signal?.addEventListener("abort", abort, { once: true }); try { + if (options.signal?.aborted) throw new DOMException("request was cancelled", "AbortError"); const response = await this.fetcher(this.endpoint, { method: "POST", headers: this.headers, body: JSON.stringify(request), signal: controller.signal, }); - const payload = await response.json() as unknown; + const payload = (await response.json()) as unknown; if (!response.ok) throw new Error(`http request failed with status ${response.status}`); return readResponse(payload, request.id); } finally { diff --git a/react-sdk/ui-transport/src/rpc.ts b/react-sdk/ui-transport/src/rpc.ts index 95f95e6..f92613d 100644 --- a/react-sdk/ui-transport/src/rpc.ts +++ b/react-sdk/ui-transport/src/rpc.ts @@ -48,13 +48,22 @@ let nextCorrelationID = 1; export function createRequest(method: string, params: unknown): RPCRequest { const id = nextRequestID++; - return { jsonrpc: "2.0", id, method, params, meta: { correlationId: `ui-${nextCorrelationID++}` } }; + return { + jsonrpc: "2.0", + id, + method, + params, + meta: { correlationId: `ui-${nextCorrelationID++}` }, + }; } export function readResponse(value: unknown, expectedID: number): T { - if (!value || typeof value !== "object") throw new RPCError("INVALID_RESPONSE", "rpc response is invalid"); + if (!value || typeof value !== "object") + throw new RPCError("INVALID_RESPONSE", "rpc response is invalid"); const response = value as RPCResponse; - if (response.jsonrpc !== "2.0" || response.id !== expectedID) throw new RPCError("INVALID_RESPONSE", "rpc response id is invalid"); - if (response.error) throw new RPCError(response.error.code, response.error.message, response.error.data); + if (response.jsonrpc !== "2.0" || response.id !== expectedID) + throw new RPCError("INVALID_RESPONSE", "rpc response id is invalid"); + if (response.error) + throw new RPCError(response.error.code, response.error.message, response.error.data); return response.result as T; } diff --git a/react-sdk/ui-transport/src/transport.test.ts b/react-sdk/ui-transport/src/transport.test.ts index fdfc91b..60cf4e0 100644 --- a/react-sdk/ui-transport/src/transport.test.ts +++ b/react-sdk/ui-transport/src/transport.test.ts @@ -6,18 +6,30 @@ describe("HTTPTransport", () => { let requestInit: RequestInit | undefined; const fetcher = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { requestInit = init; - return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } }), { status: 200 }); + return new Response(JSON.stringify({ jsonrpc: "2.0", id: 1, result: { ok: true } }), { + status: 200, + }); }); const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher }); await expect(transport.call("ping", { value: 1 })).resolves.toEqual({ ok: true }); expect(fetcher).toHaveBeenCalledOnce(); - expect(JSON.parse(String(requestInit?.body))).toMatchObject({ method: "ping", params: { value: 1 } }); + expect(JSON.parse(String(requestInit?.body))).toMatchObject({ + method: "ping", + params: { value: 1 }, + }); }); it("propagates caller cancellation to fetch", async () => { - const fetcher = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true }); - })); + const fetcher = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("aborted", "AbortError")), + { once: true }, + ); + }), + ); const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher }); const controller = new AbortController(); const request = transport.call("slow", {}, { signal: controller.signal }); @@ -27,12 +39,34 @@ describe("HTTPTransport", () => { await expect(request).rejects.toMatchObject({ name: "AbortError" }); }); + it("does not send a request when the caller signal is already aborted", async () => { + const fetcher = vi.fn(); + const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher }); + const controller = new AbortController(); + controller.abort(); + + await expect( + transport.call("cancelled", {}, { signal: controller.signal }), + ).rejects.toMatchObject({ name: "AbortError" }); + expect(fetcher).not.toHaveBeenCalled(); + }); + it("aborts requests that exceed the timeout", async () => { vi.useFakeTimers(); - const fetcher = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener("abort", () => reject(new DOMException("timed out", "AbortError")), { once: true }); - })); - const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher, timeoutMs: 10 }); + const fetcher = vi.fn( + (_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(new DOMException("timed out", "AbortError")), + { once: true }, + ); + }), + ); + const transport = new HTTPTransport("https://example.test/rpc", { + fetch: fetcher, + timeoutMs: 10, + }); const request = transport.call("slow", {}); const assertion = expect(request).rejects.toMatchObject({ name: "AbortError" }); @@ -43,9 +77,15 @@ describe("HTTPTransport", () => { }); it("rejects malformed JSON-RPC responses", async () => { - const fetcher = vi.fn(async () => new Response(JSON.stringify({ jsonrpc: "2.0", id: 999, result: true }), { status: 200 })); + const fetcher = vi.fn( + async () => + new Response(JSON.stringify({ jsonrpc: "2.0", id: 999, result: true }), { status: 200 }), + ); const transport = new HTTPTransport("https://example.test/rpc", { fetch: fetcher }); - await expect(transport.call("ping", {})).rejects.toMatchObject({ name: "RPCError", code: "INVALID_RESPONSE" }); + await expect(transport.call("ping", {})).rejects.toMatchObject({ + name: "RPCError", + code: "INVALID_RESPONSE", + }); }); }); diff --git a/react-sdk/ui-transport/src/websocket.test.ts b/react-sdk/ui-transport/src/websocket.test.ts index 4a17f9c..133b0c8 100644 --- a/react-sdk/ui-transport/src/websocket.test.ts +++ b/react-sdk/ui-transport/src/websocket.test.ts @@ -52,13 +52,19 @@ describe("WebSocketTransport", () => { await expect(result).resolves.toEqual({ ok: true }); socket.receive({ jsonrpc: "2.0", method: "ui.invalidate", params: { view: "users" } }); - expect(notifications).toEqual([{ jsonrpc: "2.0", method: "ui.invalidate", params: { view: "users" } }]); + expect(notifications).toEqual([ + { jsonrpc: "2.0", method: "ui.invalidate", params: { view: "users" } }, + ]); transport.close(); }); it("reconnects and notifies the runtime after a new socket opens", async () => { vi.useFakeTimers(); - const transport = new WebSocketTransport("wss://example.test/rpc", { socket: FakeSocket, reconnect: true, reconnectDelayMs: 10 }); + const transport = new WebSocketTransport("wss://example.test/rpc", { + socket: FakeSocket, + reconnect: true, + reconnectDelayMs: 10, + }); const reconnected = vi.fn(); transport.onReconnect(reconnected); const result = transport.call("ping", {}); @@ -79,7 +85,10 @@ describe("WebSocketTransport", () => { }); it("rejects a call when the socket closes before opening", async () => { - const transport = new WebSocketTransport("wss://example.test/rpc", { socket: FakeSocket, reconnect: true }); + const transport = new WebSocketTransport("wss://example.test/rpc", { + socket: FakeSocket, + reconnect: true, + }); const result = transport.call("ping", {}); FakeSocket.instances[0]!.close(); diff --git a/react-sdk/ui-transport/src/websocket.ts b/react-sdk/ui-transport/src/websocket.ts index 7d46e22..fb4a70b 100644 --- a/react-sdk/ui-transport/src/websocket.ts +++ b/react-sdk/ui-transport/src/websocket.ts @@ -1,4 +1,10 @@ -import { createRequest, readResponse, type CallOptions, type RPCNotification, type RPCTransport } from "./rpc.js"; +import { + createRequest, + readResponse, + type CallOptions, + type RPCNotification, + type RPCTransport, +} from "./rpc.js"; interface SocketLike { readyState: number; @@ -40,7 +46,10 @@ export class WebSocketTransport implements RPCTransport { private reconnectTimer: ReturnType | undefined; private hasConnected = false; - constructor(private readonly endpoint: string, private readonly options: WebSocketTransportOptions = {}) { + constructor( + private readonly endpoint: string, + private readonly options: WebSocketTransportOptions = {}, + ) { this.Socket = options.socket ?? (WebSocket as unknown as SocketConstructor); } @@ -50,12 +59,17 @@ export class WebSocketTransport implements RPCTransport { const request = createRequest(method, params); const timeoutMs = callOptions.timeoutMs ?? this.options.timeoutMs ?? 30_000; return new Promise((resolve, reject) => { - const pending: PendingCall = { resolve, reject, timer: setTimeout(() => { - if (this.pending.get(request.id) !== pending) return; - this.pending.delete(request.id); - this.cleanupPending(pending); - reject(new Error("rpc request timed out")); - }, timeoutMs), ...(callOptions.signal ? { signal: callOptions.signal } : {}) }; + const pending: PendingCall = { + resolve, + reject, + timer: setTimeout(() => { + if (this.pending.get(request.id) !== pending) return; + this.pending.delete(request.id); + this.cleanupPending(pending); + reject(new Error("rpc request timed out")); + }, timeoutMs), + ...(callOptions.signal ? { signal: callOptions.signal } : {}), + }; const abort = () => { if (this.pending.get(request.id) !== pending) return; this.pending.delete(request.id); @@ -141,7 +155,11 @@ export class WebSocketTransport implements RPCTransport { private handleMessage(data: string): void { let message: unknown; - try { message = JSON.parse(data); } catch { return; } + try { + message = JSON.parse(data); + } catch { + return; + } if (!message || typeof message !== "object") return; const record = message as { id?: unknown; method?: unknown }; if (typeof record.id === "number") { @@ -149,10 +167,15 @@ export class WebSocketTransport implements RPCTransport { if (!pending) return; this.pending.delete(record.id); this.cleanupPending(pending); - try { pending.resolve(readResponse(message, record.id)); } catch (error) { pending.reject(error); } + try { + pending.resolve(readResponse(message, record.id)); + } catch (error) { + pending.reject(error); + } return; } - if (typeof record.method === "string") for (const listener of this.listeners) listener(message as RPCNotification); + if (typeof record.method === "string") + for (const listener of this.listeners) listener(message as RPCNotification); } private scheduleReconnect(): void { From e0bafbaecdc4645446cb2b499b58e0fef6f7977b Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 04:00:37 +0300 Subject: [PATCH 19/26] change --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7270150..46fe9da 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,9 @@ name: CI +permissions: + contents: read + on: push: branches: [ master ] @@ -14,10 +17,10 @@ jobs: matrix: go: [ '1.26' ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v7 - name: Setup Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v7 with: go-version: ${{ matrix.go }} @@ -25,3 +28,37 @@ jobs: env: COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} run: make ci + + react: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 12.6.0 + + - name: Setup Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint React workspace + run: make npm-lint + + - name: Check formatting + run: make npm-format-check + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build From ac7b6a9d0d4482579caa66fb054bda011cb0cf6b Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 04:20:44 +0300 Subject: [PATCH 20/26] change --- .github/workflows/ci.yml | 48 +++------- Makefile | 30 ++---- README.md | 57 ++---------- go-sdk/ui/builder_test.go | 20 ++-- go-sdk/ui/literal_validation.go | 4 +- go-sdk/ui/rpc.go | 2 +- go-sdk/ui/schema.go | 6 +- go-sdk/ui/schema_easyjson.go | 156 ++++++++++++++++---------------- go-sdk/ui/schema_test.go | 2 +- go-sdk/ui/validation.go | 2 +- go-sdk/ui/value.go | 8 +- 11 files changed, 133 insertions(+), 202 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46fe9da..b2c4953 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,3 @@ - name: CI permissions: @@ -6,38 +5,20 @@ permissions: on: push: - branches: [ master ] + branches: [master] pull_request: - branches: [ master ] + branches: [master] jobs: build: runs-on: ubuntu-latest - strategy: - matrix: - go: [ '1.26' ] steps: - uses: actions/checkout@v7 - name: Setup Go uses: actions/setup-go@v7 with: - go-version: ${{ matrix.go }} - - - name: Run CI - env: - COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} - run: make ci - - react: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 12.6.0 + go-version: 1.26 - name: Setup Node.js uses: actions/setup-node@v7 @@ -45,20 +26,19 @@ jobs: node-version: 24 cache: pnpm - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Lint React workspace - run: make npm-lint + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 12.6.0 - - name: Check formatting - run: make npm-format-check + - name: Install dependencies + run: make install - - name: Typecheck - run: pnpm typecheck + - name: Lint + run: make lint - - name: Test - run: pnpm test + - name: Tests + run: make tests - name: Build - run: pnpm build + run: make build diff --git a/Makefile b/Makefile index 6aa7509..1ba5533 100755 --- a/Makefile +++ b/Makefile @@ -4,24 +4,16 @@ SHELL=/bin/bash .PHONY: install install: + pnpm install --frozen-lockfile go install go.osspkg.com/goppy/v3/cmd/goppy@latest goppy setup-lib .PHONY: lint lint: - goppy lint - -.PHONY: npm-lint -npm-lint: pnpm lint - -.PHONY: npm-format -npm-format: pnpm format - -.PHONY: npm-format-check -npm-format-check: pnpm format:check + goppy lint .PHONY: license license: @@ -29,32 +21,24 @@ license: .PHONY: build build: + pnpm --filter @osspkg/ui-core build + pnpm --filter @osspkg/ui-transport build + pnpm --filter @osspkg/ui-react build + pnpm typecheck goppy build --arch=amd64 .PHONY: tests tests: + pnpm test goppy test .PHONY: pre-commit pre-commit: install license lint tests build -.PHONY: ci -ci: pre-commit - .PHONY: examples-dev examples-dev: pnpm --filter @osspkg/ui-examples dev -.PHONY: npm-install -npm-install: - pnpm install --frozen-lockfile - -.PHONY: npm-build -npm-build: npm-install - pnpm --filter @osspkg/ui-core build - pnpm --filter @osspkg/ui-transport build - pnpm --filter @osspkg/ui-react build - .PHONY: npm-publish npm-publish: npm-build pnpm --filter @osspkg/ui-core publish --access public diff --git a/README.md b/README.md index 3687798..3e1221b 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,16 @@ ## Repository layout -| Path | Description | -| --- | --- | -| [`go-sdk/ui`](go-sdk/ui) | Go schema types, builders, validation, and component definitions | -| [`go-sdk/mcp`](go-sdk/mcp) | Adapter that registers the UI manifest and views as MCP resources | -| [`react-sdk/ui-core`](react-sdk/ui-core) | TypeScript protocol types, validation, and runtime helpers | -| [`react-sdk/ui-transport`](react-sdk/ui-transport) | HTTP, WebSocket, and RPC transport helpers | -| [`react-sdk/ui-react`](react-sdk/ui-react) | React renderer, registries, and default components | -| [`example/go`](example/go) | Go examples for an HTML form and dashboard | -| [`example/react`](example/react) | Vite app with interactive React examples | -| [`schemas`](schemas) | JSON Schema documents for protocol entities | +| Path | Description | +| -------------------------------------------------- | ----------------------------------------------------------------- | +| [`go-sdk/ui`](go-sdk/ui) | Go schema types, builders, validation, and component definitions | +| [`go-sdk/mcp`](go-sdk/mcp) | Adapter that registers the UI manifest and views as MCP resources | +| [`react-sdk/ui-core`](react-sdk/ui-core) | TypeScript protocol types, validation, and runtime helpers | +| [`react-sdk/ui-transport`](react-sdk/ui-transport) | HTTP, WebSocket, and RPC transport helpers | +| [`react-sdk/ui-react`](react-sdk/ui-react) | React renderer, registries, and default components | +| [`example/go`](example/go) | Go examples for an HTML form and dashboard | +| [`example/react`](example/react) | Vite app with interactive React examples | +| [`schemas`](schemas) | JSON Schema documents for protocol entities | ## Requirements @@ -151,43 +151,6 @@ export function WelcomeView() { Nodes with children create their own 12-column grid. Set a child's `layout` to control its row, width (`cols`), and horizontal offset; nodes without a layout use the available row. The [interactive example](example/react/README.md) demonstrates forms, default registries, and nested layouts. -## Development - -Install the JavaScript workspace dependencies and build the publishable React packages: - -```bash -make npm-install -make npm-build -``` - -Run the interactive example with: - -```bash -make examples-dev -``` - -Check and format the React SDK and example sources with: - -```bash -make npm-lint -make npm-format-check -make npm-format -``` - -`make npm-lint` applies the Airbnb JavaScript/React rules with TypeScript and React Hooks compatibility adjustments. `make npm-format` applies Prettier; use `make npm-format-check` in CI or before committing to verify formatting without changing files. These targets are separate from `make lint`, which runs the Go linter. - -The Makefile also provides `make tests`, `make lint`, `make build`, and `make ci` for Go project workflows. `make ci` runs the `pre-commit` prerequisites, including dependency/tool setup and generated license work; consult the [Makefile](Makefile) before running it. - -## Packages - -The JavaScript workspace contains three publishable packages, currently version `0.1.0`: - -- [`@osspkg/ui-core`](react-sdk/ui-core/package.json) -- [`@osspkg/ui-transport`](react-sdk/ui-transport/package.json) -- [`@osspkg/ui-react`](react-sdk/ui-react/package.json) - -`make npm-publish` builds and publishes these packages to the public npm registry. Run it only as part of an intentional release. - ## License This project is licensed under the [BSD 3-Clause License](LICENSE). diff --git a/go-sdk/ui/builder_test.go b/go-sdk/ui/builder_test.go index 03a004a..b90f268 100644 --- a/go-sdk/ui/builder_test.go +++ b/go-sdk/ui/builder_test.go @@ -5,11 +5,13 @@ import ( "testing" ) +const usersListViewID = "users.list" + func TestUnit_BuilderCreatesView(t *testing.T) { view := View( - "users.list", + usersListViewID, Title("Users"), - Source("users", Tool("users.list").OnMount()), + Source("users", Tool(usersListViewID).OnMount()), Content( DataTable("users-table").Row(1).Cols(12).Prop( "rows", @@ -26,7 +28,7 @@ func TestUnit_BuilderCreatesView(t *testing.T) { t.Fatal(err) } manifest := app.Manifest() - if len(manifest.Views) != 1 || manifest.Views[0].ID != "users.list" || manifest.Views[0].Title != "Users" { + if len(manifest.Views) != 1 || manifest.Views[0].ID != usersListViewID || manifest.Views[0].Title != "Users" { t.Fatalf("manifest = %#v", manifest) } payload, err := json.Marshal(view.Schema()) @@ -54,16 +56,16 @@ func TestUnit_BuilderCreatesView(t *testing.T) { func TestUnit_AppUsesViewIDWhenTitleIsEmpty(t *testing.T) { app := New(AppID("users"), AppTitle("Users"), AppVersion("1.0.0")) - if err := app.AddView(View("users.list")); err != nil { + if err := app.AddView(View(usersListViewID)); err != nil { t.Fatal(err) } - if got := app.Manifest().Views[0].Title; got != "users.list" { + if got := app.Manifest().Views[0].Title; got != usersListViewID { t.Fatalf("manifest view title = %q, want users.list", got) } } func TestUnit_AppSnapshotsViews(t *testing.T) { - view := View("users.list", Title("Users")).Action( + view := View(usersListViewID, Title("Users")).Action( "initial", CallTool("initial"), ) @@ -74,18 +76,18 @@ func TestUnit_AppSnapshotsViews(t *testing.T) { manifest := app.Manifest() manifest.Views[0].ID = "changed" - if got := app.Manifest().Views[0].ID; got != "users.list" { + if got := app.Manifest().Views[0].ID; got != usersListViewID { t.Fatalf("stored manifest view id = %q, want users.list", got) } view.Action("late", CallTool("late")) - returned, ok := app.View("users.list") + returned, ok := app.View(usersListViewID) if !ok { t.Fatal("view is missing") } returned.Actions["returned"] = Action{Type: "tool", Tool: "returned"} - stored, ok := app.View("users.list") + stored, ok := app.View(usersListViewID) if !ok { t.Fatal("view is missing after mutation") } diff --git a/go-sdk/ui/literal_validation.go b/go-sdk/ui/literal_validation.go index c1cec90..b64d3c1 100644 --- a/go-sdk/ui/literal_validation.go +++ b/go-sdk/ui/literal_validation.go @@ -1,6 +1,6 @@ /* * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. - * Use of this source code is governed by a BSD-3-Clause license that can be found in the LICENSE file. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ package ui @@ -52,7 +52,7 @@ func validateLiteralJSON(value any, limits Limits, depth int) error { return errors.New("expression object limit exceeded") } for key, item := range typed { - if key == "__proto__" || key == "prototype" || key == "constructor" { + if key == unsafePrototypeKey || key == unsafePrototypeProperty || key == unsafeConstructorKey { return errors.New("unsafe object key") } if err := validateLiteralJSON(item, limits, depth+1); err != nil { diff --git a/go-sdk/ui/rpc.go b/go-sdk/ui/rpc.go index 7d5eb8a..6e88ec2 100644 --- a/go-sdk/ui/rpc.go +++ b/go-sdk/ui/rpc.go @@ -1,6 +1,6 @@ /* * Copyright (c) 2026 Mikhail Knyazhev . All rights reserved. - * Use of this source code is governed by a BSD-3-Clause license that can be found in the LICENSE file. + * Use of this source code is governed by a BSD 3-Clause license that can be found in the LICENSE file. */ package ui diff --git a/go-sdk/ui/schema.go b/go-sdk/ui/schema.go index 6ba23b2..0cb8d20 100644 --- a/go-sdk/ui/schema.go +++ b/go-sdk/ui/schema.go @@ -239,11 +239,7 @@ func (m Manifest) ValidateWithLimits(limits Limits) error { } } - if err := validateSerializedLimits(m, limits); err != nil { - return err - } - - return nil + return validateSerializedLimits(m, limits) } func validName(name string) bool { diff --git a/go-sdk/ui/schema_easyjson.go b/go-sdk/ui/schema_easyjson.go index 239d0f0..14603b5 100644 --- a/go-sdk/ui/schema_easyjson.go +++ b/go-sdk/ui/schema_easyjson.go @@ -18,7 +18,7 @@ var ( _ easyjson.Marshaler ) -func easyjsonCef4e921DecodeGoOsspkgComUi(in *jlexer.Lexer, out *ViewSchema) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi(in *jlexer.Lexer, out *ViewSchema) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -146,7 +146,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi(in *jlexer.Lexer, out *ViewSchema) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi(out *jwriter.Writer, in ViewSchema) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi(out *jwriter.Writer, in ViewSchema) { out.RawByte('{') first := true _ = first @@ -244,27 +244,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi(out *jwriter.Writer, in ViewSchema) { // MarshalJSON supports json.Marshaler interface func (v ViewSchema) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ViewSchema) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ViewSchema) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ViewSchema) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi1(in *jlexer.Lexer, out *UIViewDescriptor) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi1(in *jlexer.Lexer, out *UIViewDescriptor) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -312,7 +312,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi1(in *jlexer.Lexer, out *UIViewDescripto in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi1(out *jwriter.Writer, in UIViewDescriptor) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi1(out *jwriter.Writer, in UIViewDescriptor) { out.RawByte('{') first := true _ = first @@ -342,27 +342,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi1(out *jwriter.Writer, in UIViewDescript // MarshalJSON supports json.Marshaler interface func (v UIViewDescriptor) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi1(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi1(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v UIViewDescriptor) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi1(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi1(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *UIViewDescriptor) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi1(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi1(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *UIViewDescriptor) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi1(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi1(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi2(in *jlexer.Lexer, out *Regions) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi2(in *jlexer.Lexer, out *Regions) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -521,7 +521,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi2(in *jlexer.Lexer, out *Regions) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi2(out *jwriter.Writer, in Regions) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi2(out *jwriter.Writer, in Regions) { out.RawByte('{') first := true _ = first @@ -611,27 +611,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi2(out *jwriter.Writer, in Regions) { // MarshalJSON supports json.Marshaler interface func (v Regions) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi2(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi2(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v Regions) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi2(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi2(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *Regions) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi2(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi2(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *Regions) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi2(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi2(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi3(in *jlexer.Lexer, out *PluginManifest) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi3(in *jlexer.Lexer, out *PluginManifest) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -679,7 +679,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi3(in *jlexer.Lexer, out *PluginManifest) in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi3(out *jwriter.Writer, in PluginManifest) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi3(out *jwriter.Writer, in PluginManifest) { out.RawByte('{') first := true _ = first @@ -709,27 +709,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi3(out *jwriter.Writer, in PluginManifest // MarshalJSON supports json.Marshaler interface func (v PluginManifest) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi3(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi3(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v PluginManifest) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi3(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi3(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *PluginManifest) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi3(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi3(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *PluginManifest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi3(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi3(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi4(in *jlexer.Lexer, out *Node) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi4(in *jlexer.Lexer, out *Node) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -917,7 +917,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi4(in *jlexer.Lexer, out *Node) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi4(out *jwriter.Writer, in Node) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi4(out *jwriter.Writer, in Node) { out.RawByte('{') first := true _ = first @@ -1029,27 +1029,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi4(out *jwriter.Writer, in Node) { // MarshalJSON supports json.Marshaler interface func (v Node) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi4(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi4(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v Node) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi4(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi4(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *Node) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi4(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi4(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *Node) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi4(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi4(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi5(in *jlexer.Lexer, out *Manifest) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi5(in *jlexer.Lexer, out *Manifest) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1139,7 +1139,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi5(in *jlexer.Lexer, out *Manifest) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi5(out *jwriter.Writer, in Manifest) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi5(out *jwriter.Writer, in Manifest) { out.RawByte('{') first := true _ = first @@ -1189,27 +1189,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi5(out *jwriter.Writer, in Manifest) { // MarshalJSON supports json.Marshaler interface func (v Manifest) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi5(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi5(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v Manifest) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi5(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi5(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *Manifest) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi5(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi5(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *Manifest) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi5(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi5(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi6(in *jlexer.Lexer, out *Layout) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi6(in *jlexer.Lexer, out *Layout) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1251,7 +1251,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi6(in *jlexer.Lexer, out *Layout) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi6(out *jwriter.Writer, in Layout) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi6(out *jwriter.Writer, in Layout) { out.RawByte('{') first := true _ = first @@ -1276,27 +1276,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi6(out *jwriter.Writer, in Layout) { // MarshalJSON supports json.Marshaler interface func (v Layout) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi6(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi6(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v Layout) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi6(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi6(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *Layout) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi6(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi6(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *Layout) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi6(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi6(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi7(in *jlexer.Lexer, out *EventHandler) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi7(in *jlexer.Lexer, out *EventHandler) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1353,7 +1353,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi7(in *jlexer.Lexer, out *EventHandler) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi7(out *jwriter.Writer, in EventHandler) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi7(out *jwriter.Writer, in EventHandler) { out.RawByte('{') first := true _ = first @@ -1388,27 +1388,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi7(out *jwriter.Writer, in EventHandler) // MarshalJSON supports json.Marshaler interface func (v EventHandler) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi7(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi7(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v EventHandler) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi7(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi7(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *EventHandler) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi7(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi7(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *EventHandler) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi7(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi7(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi8(in *jlexer.Lexer, out *Effect) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi8(in *jlexer.Lexer, out *Effect) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1496,7 +1496,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi8(in *jlexer.Lexer, out *Effect) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi8(out *jwriter.Writer, in Effect) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi8(out *jwriter.Writer, in Effect) { out.RawByte('{') first := true _ = first @@ -1551,27 +1551,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi8(out *jwriter.Writer, in Effect) { // MarshalJSON supports json.Marshaler interface func (v Effect) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi8(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi8(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v Effect) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi8(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi8(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *Effect) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi8(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi8(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *Effect) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi8(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi8(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi9(in *jlexer.Lexer, out *DataSource) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi9(in *jlexer.Lexer, out *DataSource) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1680,7 +1680,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi9(in *jlexer.Lexer, out *DataSource) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi9(out *jwriter.Writer, in DataSource) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi9(out *jwriter.Writer, in DataSource) { out.RawByte('{') first := true _ = first @@ -1743,27 +1743,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi9(out *jwriter.Writer, in DataSource) { // MarshalJSON supports json.Marshaler interface func (v DataSource) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi9(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi9(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v DataSource) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi9(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi9(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *DataSource) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi9(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi9(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *DataSource) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi9(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi9(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi10(in *jlexer.Lexer, out *CachePolicy) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi10(in *jlexer.Lexer, out *CachePolicy) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1793,7 +1793,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi10(in *jlexer.Lexer, out *CachePolicy) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi10(out *jwriter.Writer, in CachePolicy) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi10(out *jwriter.Writer, in CachePolicy) { out.RawByte('{') first := true _ = first @@ -1809,27 +1809,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi10(out *jwriter.Writer, in CachePolicy) // MarshalJSON supports json.Marshaler interface func (v CachePolicy) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi10(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi10(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v CachePolicy) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi10(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi10(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *CachePolicy) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi10(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi10(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *CachePolicy) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi10(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi10(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi11(in *jlexer.Lexer, out *ActionStep) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi11(in *jlexer.Lexer, out *ActionStep) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -1891,7 +1891,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi11(in *jlexer.Lexer, out *ActionStep) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi11(out *jwriter.Writer, in ActionStep) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi11(out *jwriter.Writer, in ActionStep) { out.RawByte('{') first := true _ = first @@ -1930,27 +1930,27 @@ func easyjsonCef4e921EncodeGoOsspkgComUi11(out *jwriter.Writer, in ActionStep) { // MarshalJSON supports json.Marshaler interface func (v ActionStep) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi11(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi11(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v ActionStep) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi11(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi11(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *ActionStep) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi11(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi11(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *ActionStep) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi11(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi11(l, v) } -func easyjsonCef4e921DecodeGoOsspkgComUi12(in *jlexer.Lexer, out *Action) { +func easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi12(in *jlexer.Lexer, out *Action) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { @@ -2085,7 +2085,7 @@ func easyjsonCef4e921DecodeGoOsspkgComUi12(in *jlexer.Lexer, out *Action) { in.Consumed() } } -func easyjsonCef4e921EncodeGoOsspkgComUi12(out *jwriter.Writer, in Action) { +func easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi12(out *jwriter.Writer, in Action) { out.RawByte('{') first := true _ = first @@ -2168,23 +2168,23 @@ func easyjsonCef4e921EncodeGoOsspkgComUi12(out *jwriter.Writer, in Action) { // MarshalJSON supports json.Marshaler interface func (v Action) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} - easyjsonCef4e921EncodeGoOsspkgComUi12(&w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi12(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v Action) MarshalEasyJSON(w *jwriter.Writer) { - easyjsonCef4e921EncodeGoOsspkgComUi12(w, v) + easyjsonCef4e921EncodeGoOsspkgComUiGoSdkUi12(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *Action) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} - easyjsonCef4e921DecodeGoOsspkgComUi12(&r, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi12(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *Action) UnmarshalEasyJSON(l *jlexer.Lexer) { - easyjsonCef4e921DecodeGoOsspkgComUi12(l, v) + easyjsonCef4e921DecodeGoOsspkgComUiGoSdkUi12(l, v) } diff --git a/go-sdk/ui/schema_test.go b/go-sdk/ui/schema_test.go index 3cc5f42..7dd610a 100644 --- a/go-sdk/ui/schema_test.go +++ b/go-sdk/ui/schema_test.go @@ -285,7 +285,7 @@ func TestUnit_ValueValidationParity(t *testing.T) { t.Fatalf("Validate() error = %v, want ErrInvalidSchema for object limit", err) } - if err := viewWith(Literal(map[string]any{"__proto__": true})).Validate(); !errors.Is(err, ErrInvalidSchema) { + if err := viewWith(Literal(map[string]any{unsafePrototypeKey: true})).Validate(); !errors.Is(err, ErrInvalidSchema) { t.Fatalf("Validate() error = %v, want ErrInvalidSchema for unsafe key", err) } diff --git a/go-sdk/ui/validation.go b/go-sdk/ui/validation.go index eab7485..692de5f 100644 --- a/go-sdk/ui/validation.go +++ b/go-sdk/ui/validation.go @@ -274,7 +274,7 @@ func walkLimitValue(value any, depth int, limits Limits, count *int) error { } for key, item := range typed { - if key == "__proto__" || key == "prototype" || key == "constructor" { + if key == unsafePrototypeKey || key == unsafePrototypeProperty || key == unsafeConstructorKey { return errors.New("unsafe object key") } diff --git a/go-sdk/ui/value.go b/go-sdk/ui/value.go index 8890b48..38f0cd2 100644 --- a/go-sdk/ui/value.go +++ b/go-sdk/ui/value.go @@ -33,6 +33,12 @@ const ( ValueExpr ValueKind = "expression" ) +const ( + unsafePrototypeKey = "__proto__" + unsafePrototypeProperty = "prototype" + unsafeConstructorKey = "constructor" +) + // Value is a literal, reference, or expression used by the declarative UI model. // //nolint:recvcheck // JSON values intentionally marshal by value and unmarshal by pointer. @@ -247,7 +253,7 @@ func safePathWithLimit(path string, maxLength int) bool { } for _, part := range strings.Split(path, ".") { - if part == "__proto__" || part == "prototype" || part == "constructor" || part == "" { + if part == unsafePrototypeKey || part == unsafePrototypeProperty || part == unsafeConstructorKey || part == "" { return false } } From 54363d73a787f4f462ab05b83d04d48dc320d922 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 04:22:55 +0300 Subject: [PATCH 21/26] change --- go-sdk/mcp/register_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/go-sdk/mcp/register_test.go b/go-sdk/mcp/register_test.go index 5205964..99862ff 100644 --- a/go-sdk/mcp/register_test.go +++ b/go-sdk/mcp/register_test.go @@ -42,7 +42,11 @@ func TestUnit_RegisterViewWithoutTitle(t *testing.T) { if err != nil { t.Fatal(err) } - app := ui.New(ui.AppID("users")) + app := ui.New( + ui.AppID("users"), + ui.AppTitle("Users"), + ui.AppVersion("1.0.0"), + ) if err := app.AddView(ui.View("users.list")); err != nil { t.Fatal(err) } From bfdfc5e4376fc5047218a4d5148b918a2938b379 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 04:25:33 +0300 Subject: [PATCH 22/26] change --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2c4953..4e94ade 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,17 +20,17 @@ jobs: with: go-version: 1.26 + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + version: 12.6.0 + - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 24 cache: pnpm - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - version: 12.6.0 - - name: Install dependencies run: make install From aa6bb42d26b5d38854ed0f991bf80628a7ce8f43 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 04:31:50 +0300 Subject: [PATCH 23/26] change --- .github/workflows/ci.yml | 6 +++--- AGENTS.md | 18 ++++++++++-------- Makefile | 8 ++++---- README.md | 19 +++++++++++++++++++ skills/go-ui-development/SKILL.md | 12 +++++++----- 5 files changed, 43 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e94ade..4ef52ee 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,8 @@ jobs: - name: Lint run: make lint - - name: Tests - run: make tests - - name: Build run: make build + + - name: Tests + run: make tests diff --git a/AGENTS.md b/AGENTS.md index 2aff5f0..f2f3438 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,19 +15,21 @@ Run commands from the repository root: | Command | Purpose | | --- | --- | -| `make npm-install` | Install the pnpm workspace from the frozen lockfile | -| `make npm-build` | Build the core, transport, and React npm packages | +| `make install` | Install frozen pnpm workspace dependencies, install `goppy@latest`, and run `goppy setup-lib` | +| `make npm-build` | Build core, transport, and React packages in dependency order | +| `make lint` | Run pnpm lint and formatting, then `goppy lint` | +| `make tests` | Run pnpm tests, then `goppy test` | +| `make build` | Build the three npm packages, run pnpm typecheck, then `goppy build --arch=amd64` | +| `make license` | Run `goppy license` | +| `make pre-commit` | Run install, license, lint, tests, and build in sequence | | `make examples-dev` | Start the Vite React examples | -| `make tests` | Run the Go test workflow through goppy | -| `make lint` | Run the Go lint workflow through goppy | -| `make build` | Run the Go build workflow through goppy | -| `make ci` | Run `pre-commit` (tool setup, license, lint, tests, and build) | +| `make npm-publish` | Build and publish the three `@osspkg` packages publicly | For focused TypeScript workflows, use `pnpm --filter typecheck`, `test`, or `build` where that script exists. Package names and scripts are defined in each workspace `package.json`. -`make install` installs `goppy@latest` and runs `goppy setup-lib`; `make ci` includes this setup through `pre-commit`. These workflows can update local/generated files, so inspect `git status` and the Makefile before using them when preserving a worktree matters. +`make install` installs `goppy@latest` and runs `goppy setup-lib`. `make lint` runs `pnpm format`, which rewrites files before checking formatting, and the Go lint workflow may also update generated or formatted files. Inspect `git status` before running these workflows when preserving a worktree matters. `make pre-commit` includes install, license, lint, tests, and build; there is no `make ci` target. -`make npm-publish` publishes all three `@osspkg` packages publicly. Never run a publish command unless the user explicitly asked for a release. +The `tests` and `build` targets depend on `npm-build` so package exports exist in clean checkouts. `npm-publish` also builds first and publishes publicly. Never run a publish command unless the user explicitly asked for a release. ## Change guidance diff --git a/Makefile b/Makefile index 1ba5533..d3aa4e3 100755 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ license: goppy license .PHONY: build -build: +build: pnpm --filter @osspkg/ui-core build pnpm --filter @osspkg/ui-transport build pnpm --filter @osspkg/ui-react build @@ -28,19 +28,19 @@ build: goppy build --arch=amd64 .PHONY: tests -tests: +tests: pnpm test goppy test .PHONY: pre-commit -pre-commit: install license lint tests build +pre-commit: install license lint build tests .PHONY: examples-dev examples-dev: pnpm --filter @osspkg/ui-examples dev .PHONY: npm-publish -npm-publish: npm-build +npm-publish: build pnpm --filter @osspkg/ui-core publish --access public pnpm --filter @osspkg/ui-transport publish --access public pnpm --filter @osspkg/ui-react publish --access public diff --git a/README.md b/README.md index 3e1221b..8f5f10d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,25 @@ - Go 1.26.8 or newer. - Node.js and pnpm 12.6.0, as declared by the workspace package manifest. +## Development + +Run these commands from the repository root. `make install` installs the pnpm workspace from the lockfile and installs/configures `goppy` for Go workflows. + +| Command | What it runs | +| --- | --- | +| `make install` | Frozen pnpm dependency install, `goppy@latest` installation, and `goppy setup-lib` | +| `make npm-build` | Build core, transport, and React packages in dependency order | +| `make lint` | pnpm lint, formatting, formatting check, and `goppy lint` | +| `make tests` | pnpm tests and `goppy test` | +| `make build` | Build the three npm packages, run pnpm typecheck, and run `goppy build --arch=amd64` | +| `make license` | Run `goppy license` | +| `make pre-commit` | Run install, license, lint, tests, and build | +| `make examples-dev` | Start the React example app with Vite | + +`make lint` runs `pnpm format`, which can rewrite files. The Go lint and setup workflows can also update generated files. Review `git status` after running them. + +`make tests` and `make build` build the npm packages first so package exports resolve in a clean checkout. `make npm-publish` builds those packages before publishing them publicly. + ## Go usage The root Go module is `go.osspkg.com/ui`. Add it to a Go module with: diff --git a/skills/go-ui-development/SKILL.md b/skills/go-ui-development/SKILL.md index a63ce1e..e87403e 100644 --- a/skills/go-ui-development/SKILL.md +++ b/skills/go-ui-development/SKILL.md @@ -20,11 +20,13 @@ Use this skill for development involving the `go-ui` protocol, Go or React SDKs, Run from the repository root. The workspace uses Go 1.26.8 and pnpm 12.6.0. -- `make npm-install`: frozen pnpm workspace install. -- `make npm-build`: build core, transport, and React packages. +- `make install`: install frozen pnpm dependencies, install `goppy@latest`, and run `goppy setup-lib`. +- `make lint`: run pnpm lint, rewrite files with `pnpm format`, check formatting, and run `goppy lint`. +- `make npm-build`: build the core, transport, and React packages in dependency order. `make tests` runs this first, then pnpm tests and `goppy test`. +- `make build`: build `@osspkg/ui-core`, `@osspkg/ui-transport`, and `@osspkg/ui-react` in order, run pnpm typecheck, and run `goppy build --arch=amd64`. +- `make license`: run `goppy license`. +- `make pre-commit`: run install, license, lint, tests, and build in sequence. There is no `make ci` target. - `make examples-dev`: start the React example app. -- `make tests`, `make lint`, `make build`: Go workflows via goppy. -- `make ci`: setup, license, lint, tests, and build; setup/license steps may modify generated/local files. - `pnpm --filter @osspkg/ui-core typecheck|test|build`, likewise `@osspkg/ui-transport` and `@osspkg/ui-react`, for focused package scripts. -Inspect the worktree before workflows that generate or update files. `make npm-publish` publishes all three packages publicly; do not run it unless the user explicitly requests a release. +Inspect the worktree before running `make install`, `make lint`, or `make pre-commit`: setup, formatting, lint, and build workflows can update local, generated, or formatted files. `make npm-publish` builds and publishes all three packages publicly. Do not run publishing unless the user explicitly requests a release. From a5a53fbefabedefcd6600b174703e2526425c745 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 04:34:14 +0300 Subject: [PATCH 24/26] change --- AGENTS.md | 9 ++++----- README.md | 5 ++--- skills/go-ui-development/SKILL.md | 6 +++--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2f3438..6a5408f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,20 +16,19 @@ Run commands from the repository root: | Command | Purpose | | --- | --- | | `make install` | Install frozen pnpm workspace dependencies, install `goppy@latest`, and run `goppy setup-lib` | -| `make npm-build` | Build core, transport, and React packages in dependency order | | `make lint` | Run pnpm lint and formatting, then `goppy lint` | | `make tests` | Run pnpm tests, then `goppy test` | | `make build` | Build the three npm packages, run pnpm typecheck, then `goppy build --arch=amd64` | | `make license` | Run `goppy license` | -| `make pre-commit` | Run install, license, lint, tests, and build in sequence | +| `make pre-commit` | Run install, license, lint, build, and tests in sequence | | `make examples-dev` | Start the Vite React examples | -| `make npm-publish` | Build and publish the three `@osspkg` packages publicly | +| `make npm-publish` | Run the full build, then publish the three `@osspkg` packages publicly | For focused TypeScript workflows, use `pnpm --filter typecheck`, `test`, or `build` where that script exists. Package names and scripts are defined in each workspace `package.json`. -`make install` installs `goppy@latest` and runs `goppy setup-lib`. `make lint` runs `pnpm format`, which rewrites files before checking formatting, and the Go lint workflow may also update generated or formatted files. Inspect `git status` before running these workflows when preserving a worktree matters. `make pre-commit` includes install, license, lint, tests, and build; there is no `make ci` target. +`make install` installs `goppy@latest` and runs `goppy setup-lib`. `make lint` runs `pnpm format`, which rewrites files before checking formatting, and the Go lint workflow may also update generated or formatted files. Inspect `git status` before running these workflows when preserving a worktree matters. `make pre-commit` runs install, license, lint, build, and tests in that order; there is no `make ci` target. -The `tests` and `build` targets depend on `npm-build` so package exports exist in clean checkouts. `npm-publish` also builds first and publishes publicly. Never run a publish command unless the user explicitly asked for a release. +`make tests` does not build npm packages. Run `make build` first in a clean checkout so package exports in `dist` exist. `npm-publish` runs the full build first and publishes publicly. Never run a publish command unless the user explicitly asked for a release. ## Change guidance diff --git a/README.md b/README.md index 8f5f10d..fb9c8a4 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,16 @@ Run these commands from the repository root. `make install` installs the pnpm wo | Command | What it runs | | --- | --- | | `make install` | Frozen pnpm dependency install, `goppy@latest` installation, and `goppy setup-lib` | -| `make npm-build` | Build core, transport, and React packages in dependency order | | `make lint` | pnpm lint, formatting, formatting check, and `goppy lint` | | `make tests` | pnpm tests and `goppy test` | | `make build` | Build the three npm packages, run pnpm typecheck, and run `goppy build --arch=amd64` | | `make license` | Run `goppy license` | -| `make pre-commit` | Run install, license, lint, tests, and build | +| `make pre-commit` | Run install, license, lint, build, and tests | | `make examples-dev` | Start the React example app with Vite | `make lint` runs `pnpm format`, which can rewrite files. The Go lint and setup workflows can also update generated files. Review `git status` after running them. -`make tests` and `make build` build the npm packages first so package exports resolve in a clean checkout. `make npm-publish` builds those packages before publishing them publicly. +`make tests` does not build the npm packages. In a clean checkout, run `make build` before `make tests` so package exports in `dist` exist. `make npm-publish` runs the full build before publishing the packages publicly. ## Go usage diff --git a/skills/go-ui-development/SKILL.md b/skills/go-ui-development/SKILL.md index e87403e..bdb77aa 100644 --- a/skills/go-ui-development/SKILL.md +++ b/skills/go-ui-development/SKILL.md @@ -22,11 +22,11 @@ Run from the repository root. The workspace uses Go 1.26.8 and pnpm 12.6.0. - `make install`: install frozen pnpm dependencies, install `goppy@latest`, and run `goppy setup-lib`. - `make lint`: run pnpm lint, rewrite files with `pnpm format`, check formatting, and run `goppy lint`. -- `make npm-build`: build the core, transport, and React packages in dependency order. `make tests` runs this first, then pnpm tests and `goppy test`. +- `make tests`: run pnpm tests and `goppy test`. In a clean checkout, run `make build` first because this target does not build npm packages. - `make build`: build `@osspkg/ui-core`, `@osspkg/ui-transport`, and `@osspkg/ui-react` in order, run pnpm typecheck, and run `goppy build --arch=amd64`. - `make license`: run `goppy license`. -- `make pre-commit`: run install, license, lint, tests, and build in sequence. There is no `make ci` target. +- `make pre-commit`: run install, license, lint, build, and tests in sequence. There is no `make ci` target. - `make examples-dev`: start the React example app. - `pnpm --filter @osspkg/ui-core typecheck|test|build`, likewise `@osspkg/ui-transport` and `@osspkg/ui-react`, for focused package scripts. -Inspect the worktree before running `make install`, `make lint`, or `make pre-commit`: setup, formatting, lint, and build workflows can update local, generated, or formatted files. `make npm-publish` builds and publishes all three packages publicly. Do not run publishing unless the user explicitly requests a release. +Inspect the worktree before running `make install`, `make lint`, or `make pre-commit`: setup, formatting, lint, and build workflows can update local, generated, or formatted files. `make npm-publish` runs the full build, then publishes all three packages publicly. Do not run publishing unless the user explicitly requests a release. From c1f7fc4d57bb5409294a1abf0d1c131f214e5681 Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 04:39:44 +0300 Subject: [PATCH 25/26] change --- Makefile | 8 +++++++- react-sdk/ui-core/package.json | 15 +++++++++++---- react-sdk/ui-react/package.json | 4 ++-- react-sdk/ui-transport/package.json | 19 ++++++++++++++----- 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/Makefile b/Makefile index d3aa4e3..9a6ab36 100755 --- a/Makefile +++ b/Makefile @@ -39,8 +39,14 @@ pre-commit: install license lint build tests examples-dev: pnpm --filter @osspkg/ui-examples dev +.PHONY: npm-version-patch +npm-version-patch: + cd react-sdk/ui-core && npm version patch --no-git-tag-version + cd react-sdk/ui-transport && npm version patch --no-git-tag-version + cd react-sdk/ui-react && npm version patch --no-git-tag-version + .PHONY: npm-publish -npm-publish: build +npm-publish: build npm-version-patch pnpm --filter @osspkg/ui-core publish --access public pnpm --filter @osspkg/ui-transport publish --access public pnpm --filter @osspkg/ui-react publish --access public diff --git a/react-sdk/ui-core/package.json b/react-sdk/ui-core/package.json index a4c8e4c..13517ee 100644 --- a/react-sdk/ui-core/package.json +++ b/react-sdk/ui-core/package.json @@ -1,11 +1,18 @@ { "name": "@osspkg/ui-core", - "version": "0.1.0", + "version": "0.0.0", "type": "module", - "files": ["dist"], + "files": [ + "dist" + ], "main": "dist/index.js", "types": "dist/index.d.ts", - "exports": {".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"}}, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run --config vitest.config.ts", @@ -15,4 +22,4 @@ "typescript": "^5.9.0", "vitest": "^3.2.0" } -} +} \ No newline at end of file diff --git a/react-sdk/ui-react/package.json b/react-sdk/ui-react/package.json index bc98f1e..d16d389 100644 --- a/react-sdk/ui-react/package.json +++ b/react-sdk/ui-react/package.json @@ -1,6 +1,6 @@ { "name": "@osspkg/ui-react", - "version": "0.1.0", + "version": "0.0.0", "type": "module", "files": [ "dist" @@ -40,4 +40,4 @@ "typescript": "^5.9.0", "vitest": "^3.2.0" } -} +} \ No newline at end of file diff --git a/react-sdk/ui-transport/package.json b/react-sdk/ui-transport/package.json index 2540280..a25dde7 100644 --- a/react-sdk/ui-transport/package.json +++ b/react-sdk/ui-transport/package.json @@ -1,19 +1,28 @@ { "name": "@osspkg/ui-transport", - "version": "0.1.0", + "version": "0.0.0", "type": "module", - "files": ["dist"], + "files": [ + "dist" + ], "main": "dist/index.js", "types": "dist/index.d.ts", - "exports": {".": {"types": "./dist/index.d.ts", "import": "./dist/index.js"}}, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, "scripts": { "build": "tsc -p tsconfig.json", "test": "vitest run --config vitest.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, - "dependencies": {"@osspkg/ui-core": "workspace:*"}, + "dependencies": { + "@osspkg/ui-core": "workspace:*" + }, "devDependencies": { "typescript": "^5.9.0", "vitest": "^3.2.0" } -} +} \ No newline at end of file From d757ec4cd50ceff369d7351cc3203727d44f449a Mon Sep 17 00:00:00 2001 From: Mikhail Knyazhev Date: Sun, 27 Sep 2026 04:48:17 +0300 Subject: [PATCH 26/26] change --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index 9a6ab36..8cfcb68 100755 --- a/Makefile +++ b/Makefile @@ -44,6 +44,7 @@ npm-version-patch: cd react-sdk/ui-core && npm version patch --no-git-tag-version cd react-sdk/ui-transport && npm version patch --no-git-tag-version cd react-sdk/ui-react && npm version patch --no-git-tag-version + git add . && git commit -m "update lib version" .PHONY: npm-publish npm-publish: build npm-version-patch