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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/facade/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# Run `make sync` after every Wippy Web Host version bump to pull fresh copies.

# Web Host CDN base — update version when Wippy Web Host releases
WEB_HOST_CDN = https://web-host.wippy.ai/webcomponents-1.0.26
WEB_HOST_CDN = https://web-host.wippy.ai/webcomponents-1.0.30

# Files to sync from CDN into public/@wippy-fe/
CDN_FILES = loading.js
Expand Down
36 changes: 33 additions & 3 deletions src/facade/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ These fields are NOT configurable via requirements — they are computed at runt

| Requirement | Default | Description |
|---|---|---|
| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.26` | CDN base URL for the Web Host frontend bundle |
| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.30` | CDN base URL for the Web Host frontend bundle |
| `fe_entry_path` | `/iframe.html` | Iframe HTML entry point path (appended to `fe_facade_url`) |
| `fe_mode` | `compat` | `compat` (default — loads `module.js`) or `managed` (loads `managed-layout.js` for declarative multi-panel apps). See [Modes](#modes) above |

Expand Down Expand Up @@ -146,6 +146,36 @@ Three theming scopes control which layers see which styles:

> **Merge rules:** Host sees `global + host` merged. Children see `global + children` merged. Host-scope styles never leak to children and vice versa. Icons are only in `global` and `host` scopes (children don't get their own icon sets).

### Managed-layout

| Requirement | Default | Config path | Description |
|---|---|---|---|
| `host_config_layout` | `{}` | `hostConfig.layout` | Managed-layout `HostLayoutDeclaration` as JSON string. Only relevant when `fe_mode = "managed"`. Empty (default) leaves `hostConfig.layout` unset, so the host falls back to URL-param / parent-SetConfig configuration paths. See [`gen-2-chat/managed-layout.md`](https://github.com/wippyai/gen-2-chat/blob/webcomponents/managed-layout.md) for the schema. |

Example — minimal 2-panel layout:

```yaml
- name: fe_mode
value: managed
- name: host_config_layout
value: |
{
"layouts": {
"default": {
"direction": "horizontal",
"children": [
{ "panel": "nav", "size": "240px" },
{ "panel": "main", "size": "1fr", "main": true }
]
}
},
"panels": {
"nav": { "kind": "builtin", "id": "@HOST/nav-sidebar" },
"main": { "kind": "page", "id": "home", "route": "/" }
}
}
```

## Usage

```yaml
Expand Down Expand Up @@ -232,9 +262,9 @@ Scripts are fetched in parallel and awaited before the Web Host bundle is import

```json
{
"facade_url": "https://web-host.wippy.ai/webcomponents-1.0.26",
"facade_url": "https://web-host.wippy.ai/webcomponents-1.0.30",
"iframe_origin": "https://web-host.wippy.ai",
"iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.26/iframe.html?waitForCustomConfig",
"iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.30/iframe.html?waitForCustomConfig",
"login_path": "/login.html",
"env": {
"APP_API_URL": "http://localhost:8085",
Expand Down
17 changes: 16 additions & 1 deletion src/facade/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ entries:
targets:
- entry: wippy.facade:fe_facade_url
path: .default
default: https://web-host.wippy.ai/webcomponents-1.0.26
default: https://web-host.wippy.ai/webcomponents-1.0.30

- name: fe_entry_path
kind: ns.requirement
Expand Down Expand Up @@ -289,6 +289,21 @@ entries:
path: .default
default: "{}"

- name: host_config_layout
kind: ns.requirement
meta:
description: |
Managed-layout HostLayoutDeclaration as JSON string. Only relevant
when fe_mode = "managed". Top-level keys: layouts (required),
breakpoints, panels, floating, modals, services, dragEnabled.
Empty (default `{}`) leaves hostConfig.layout unset so the host
falls back to its URL-param / parent-SetConfig configuration paths.
See gen-2-chat/managed-layout.md for the schema.
targets:
- entry: wippy.facade:host_config_layout
path: .default
default: "{}"

- name: axios_defaults
kind: ns.requirement
meta:
Expand Down
11 changes: 11 additions & 0 deletions src/facade/config_handler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,17 @@ local function handler()
host_config.chat = chat_config
end

-- Managed-layout HostLayoutDeclaration. Only attached when fe_mode is
-- "managed" — compat mode ignores it. Empty/missing decode skips
-- the field so the host falls back to its other configuration paths
-- (URL `?layout=` param, parent SetConfig postMessage handshake).
if fe_mode == "managed" then
local layout = non_empty_map_or_nil(get_req_json_any("host_config_layout"))
if layout then
host_config.layout = layout
end
end

local axios_defaults = non_empty_map_or_nil(get_req_json_any("axios_defaults"))
local extra_scripts = non_empty_array_or_nil(get_req_json_any("extra_scripts"))

Expand Down
31 changes: 29 additions & 2 deletions src/facade/config_handler_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ local REQ_NAMES: {string} = {
"app_title", "app_icon", "app_name", "login_path",
"api_routes", "additional_nav_items", "state_cache",
"allow_additional_tags", "chat", "axios_defaults",
"extra_scripts",
"extra_scripts", "host_config_layout",
}

local function setup_registry(overrides: {[string]: string}?)
Expand Down Expand Up @@ -48,6 +48,7 @@ local function setup_registry(overrides: {[string]: string}?)
chat = "{}",
axios_defaults = "{}",
extra_scripts = "[]",
host_config_layout = "{}",
}

if overrides then
Expand Down Expand Up @@ -114,7 +115,7 @@ local function define_tests()
end)

test.it("extracts iframe origin from facade URL", function()
local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.26"
local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.30"
local origin = facade_url:match("^(https?://[^/]+)")

test.eq(origin, "https://web-host.wippy.ai")
Expand Down Expand Up @@ -206,6 +207,32 @@ local function define_tests()
entry = registry.get(NS .. "history_mode")
test.eq(entry.data.default, "hash")
end)

test.it("host_config_layout requirement defaults to empty JSON object", function()
local entry = registry.get(NS .. "host_config_layout")
test.not_nil(entry)
test.eq(entry.data.default, "{}")
end)

test.it("host_config_layout decodes a valid HostLayoutDeclaration JSON", function()
local layout_json = '{"layouts":{"default":{"direction":"vertical","children":[{"panel":"main","size":"1fr"}]}},"panels":{"main":{"kind":"page","id":"home"}}}'
local snap = registry.snapshot()
local changes = snap:changes()
changes:update({
id = NS .. "host_config_layout",
kind = "ns.requirement",
data = { default = layout_json },
})
changes:apply()

local entry = registry.get(NS .. "host_config_layout")
local decoded, err = json.decode(entry.data.default :: string)
test.is_nil(err)
test.not_nil(decoded)
test.eq(decoded.layouts.default.direction, "vertical")
test.eq(decoded.panels.main.kind, "page")
test.eq(decoded.panels.main.id, "home")
end)
end)

test.describe("extra scripts", function()
Expand Down
4 changes: 4 additions & 0 deletions src/views/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ entries:
path: .meta.router
- entry: wippy.views.api:list_components.endpoint
path: .meta.router
- entry: wippy.views.api:find_by_tag
path: .meta.router
- entry: wippy.views.api:find_by_tag.endpoint
path: .meta.router
- entry: wippy.views.api:render
path: .meta.router
- entry: wippy.views.api:render.endpoint
Expand Down
24 changes: 24 additions & 0 deletions src/views/api/_index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ entries:
func: list_components
method: GET
path: /components/list
- name: find_by_tag
kind: function.lua
meta:
comment: Resolves a custom-element tag name to its registered view.component metadata
description: View component tag-name lookup endpoint
router: api_router
source: file://find_by_tag.lua
imports:
component_registry: wippy.views:component_registry
method: handler
modules:
- http
pool:
size: 4
workers: 4
- name: find_by_tag.endpoint
kind: http.endpoint
meta:
comment: Endpoint that resolves a tag_name to a single view.component
description: View component by-tag lookup endpoint
router: api_router
func: find_by_tag
method: GET
path: /components/by-tag/{tag}
- name: render
kind: function.lua
meta:
Expand Down
92 changes: 92 additions & 0 deletions src/views/api/find_by_tag.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
local http = require("http")
local component_registry = require("component_registry")

-- GET /components/by-tag/{tag} — resolve a custom-element tag name to its
-- registered view.component metadata. Used by the host proxy SDK's
-- loadByTagName() to load a peer WC without the consumer knowing any URL.
--
-- Response shape mirrors a single item from /components/list (id, name,
-- title, tag_name, base_url, entry_point, auto_register, props, events).

type ComponentResponse = {
id: string,
name: string,
title: string,
tag_name: string?,
base_url: string?,
entry_point: string?,
auto_register: boolean,
props: any?,
events: any?,
}

local function handler()
local res = http.response()
local req = http.request()

if not res or not req then
return nil, "Failed to get HTTP context"
end

local tag, param_err = req:param("tag")
if param_err or not tag or tag == "" then
res:set_status(http.STATUS.BAD_REQUEST)
res:write_json({
success = false,
error = "Tag name is required" .. (param_err and (": " .. tostring(param_err)) or ""),
})
return
end

local component, err = component_registry.find_by_tag_name(tag)
if err or not component then
res:set_status(http.STATUS.NOT_FOUND)
res:write_json({
success = false,
error = err or ("No view.component registered for tag: " .. tag),
})
return
end

-- Respect announced + access control, same as /components/list.
if not component.announced then
res:set_status(http.STATUS.NOT_FOUND)
res:write_json({
success = false,
error = "Component is not announced",
})
return
end

if component.secure and not component_registry.can_access(component) then
res:set_status(http.STATUS.FORBIDDEN)
res:write_json({
success = false,
error = "Access denied",
})
return
end

local component_info: ComponentResponse = {
id = type(component.id) == "string" and component.id or tostring(component.id),
name = type(component.name) == "string" and component.name or "",
title = type(component.title) == "string" and component.title or "",
tag_name = type(component.tag_name) == "string" and component.tag_name as string or nil,
base_url = component_registry.resolve_base_url(component),
entry_point = type(component.entry_point) == "string" and component.entry_point as string or nil,
auto_register = component.auto_register == true,
props = component.props,
events = component.events,
}

res:set_content_type(http.CONTENT.JSON)
res:set_status(http.STATUS.OK)
res:write_json({
success = true,
component = component_info,
})
end

return {
handler = handler
}
25 changes: 25 additions & 0 deletions src/views/component_registry.lua
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,31 @@ function components.get(component_id)
return extract_component_info(entry)
end

-- Find a single view.component by its meta.tag_name. Returns the first match
-- (registry tag_name SHOULD be unique across announced components, but the
-- registry does not enforce uniqueness — caller must accept first-wins).
-- Returns nil + error string if missing arg, query error, or no match.
function components.find_by_tag_name(tag_name)
if not tag_name or tag_name == "" then
return nil, "Tag name is required"
end

local entries, err = registry.find({
["meta.type"] = "view.component",
["meta.tag_name"] = tag_name,
})

if err then
return nil, "Failed to query registry: " .. tostring(err)
end

if not entries or #entries == 0 then
return nil, "No view.component registered for tag: " .. tag_name
end

return extract_component_info(entries[1])
end

-- Resolve a component URL to an absolute base URL with trailing slash.
-- When base_path is set, it is appended to the URL to form the project root.
-- Entry point paths are relative to this resolved base.
Expand Down
25 changes: 25 additions & 0 deletions src/views/component_registry_test.lua
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ local function setup_components()
secure = false,
public = true,
announced = true,
tag_name = "test-widget",
url = "https://cdn.example.com/widget/",
},
})
Expand Down Expand Up @@ -213,6 +214,30 @@ local function define_tests()
test.eq(page.entry_point, "index.html")
end)

test.it("find_by_tag_name returns the component for a known tag", function()
local comp, err = component_registry.find_by_tag_name("test-widget")
test.is_nil(err)
test.not_nil(comp)
test.eq(comp.id, NS .. "test_comp_widget")
test.eq(comp.tag_name, "test-widget")
end)

test.it("find_by_tag_name returns nil + error for unknown tag", function()
local comp, err = component_registry.find_by_tag_name("no-such-tag")
test.is_nil(comp)
test.not_nil(err)
end)

test.it("find_by_tag_name requires non-empty tag", function()
local comp1, err1 = component_registry.find_by_tag_name(nil)
test.is_nil(comp1)
test.not_nil(err1)

local comp2, err2 = component_registry.find_by_tag_name("")
test.is_nil(comp2)
test.not_nil(err2)
end)

test.it("find_all sorts by name", function()
local components, err = component_registry.find_all()
test.is_nil(err)
Expand Down
5 changes: 5 additions & 0 deletions src/wc-content-kit/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
frontend/*/node_modules/
frontend/*/dist/
frontend/*/*.tsbuildinfo
*.log
.DS_Store
Loading
Loading