diff --git a/src/embeddings/embedding_repo_test.lua b/src/embeddings/embedding_repo_test.lua
index 90f0190..02b9c20 100644
--- a/src/embeddings/embedding_repo_test.lua
+++ b/src/embeddings/embedding_repo_test.lua
@@ -254,7 +254,7 @@ local function define_tests()
test.is_nil(err)
test.ok(#results > 0)
- local entry_id = results[1].entry_id
+ local entry_id = (results :: any)[1].entry_id
local delete_result, del_err = embedding_repo.delete_by_entry(entry_id)
test.is_nil(del_err)
diff --git a/src/facade/Makefile b/src/facade/Makefile
index b2331c2..8280fac 100644
--- a/src/facade/Makefile
+++ b/src/facade/Makefile
@@ -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.21
+WEB_HOST_CDN = https://web-host.wippy.ai/webcomponents-1.0.23
# Files to sync from CDN into public/@wippy-fe/
CDN_FILES = loading.js
diff --git a/src/facade/README.md b/src/facade/README.md
index 6e76be5..94f63a1 100644
--- a/src/facade/README.md
+++ b/src/facade/README.md
@@ -47,7 +47,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.21` | CDN base URL for the Web Host frontend bundle |
+| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.23` | CDN base URL for the Web Host frontend bundle |
| `fe_entry_path` | `/iframe.html` | Iframe HTML entry point path (appended to `fe_facade_url`) |
### App Identity
@@ -189,9 +189,9 @@ Only override what differs from defaults.
```json
{
- "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.21",
+ "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.23",
"iframe_origin": "https://web-host.wippy.ai",
- "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.21/iframe.html?waitForCustomConfig",
+ "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.23/iframe.html?waitForCustomConfig",
"login_path": "/login.html",
"env": {
"APP_API_URL": "http://localhost:8085",
diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml
index 36a15bb..57b3cce 100644
--- a/src/facade/_index.yaml
+++ b/src/facade/_index.yaml
@@ -33,7 +33,7 @@ entries:
targets:
- entry: wippy.facade:fe_facade_url
path: .default
- default: https://web-host.wippy.ai/webcomponents-1.0.21
+ default: https://web-host.wippy.ai/webcomponents-1.0.23
- name: fe_entry_path
kind: ns.requirement
diff --git a/src/facade/config_handler.lua b/src/facade/config_handler.lua
index da84fb6..b9973cd 100644
--- a/src/facade/config_handler.lua
+++ b/src/facade/config_handler.lua
@@ -136,9 +136,6 @@ local function handler()
}
local api_routes = non_empty_map_or_nil(get_req_json_any("api_routes"))
- if api_routes then
- host_config.apiRoutes = api_routes
- end
local additional_nav = non_empty_map_or_nil(get_req_json_any("additional_nav_items"))
if additional_nav then
@@ -174,6 +171,7 @@ local function handler()
APP_WEBSOCKET_URL = ws_url,
},
routePrefix = non_empty_or_nil(api_url),
+ apiRoutes = api_routes,
axiosDefaults = axios_defaults,
theming = {
global = global_scope,
diff --git a/src/facade/config_handler_test.lua b/src/facade/config_handler_test.lua
index e157663..e719b6c 100644
--- a/src/facade/config_handler_test.lua
+++ b/src/facade/config_handler_test.lua
@@ -112,7 +112,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.21"
+ local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.23"
local origin = facade_url:match("^(https?://[^/]+)")
test.eq(origin, "https://web-host.wippy.ai")
diff --git a/src/facade/public/@wippy-fe/loading.js b/src/facade/public/@wippy-fe/loading.js
index a2ec20a..f40c151 100644
--- a/src/facade/public/@wippy-fe/loading.js
+++ b/src/facade/public/@wippy-fe/loading.js
@@ -1,4 +1,4 @@
-var WippyLoading=function(t){"use strict";var y=Object.defineProperty;var w=(t,i,o)=>i in t?y(t,i,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[i]=o;var n=(t,i,o)=>w(t,typeof i!="symbol"?i+"":i,o);const p={circle:'',triangle:'',sad:''},f=`
+var WippyLoading=(function(t){"use strict";var y=Object.defineProperty;var w=(t,i,o)=>i in t?y(t,i,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[i]=o;var n=(t,i,o)=>w(t,typeof i!="symbol"?i+"":i,o);const p={circle:'',triangle:'',sad:''},f=`
*, *::before, *::after { box-sizing: border-box; }
:host {
@@ -14,6 +14,8 @@ var WippyLoading=function(t){"use strict";var y=Object.defineProperty;var w=(t,i
height: 100dvh;
}
+ :host([no-bg]) .container { background: transparent; }
+
.container {
display: flex;
flex-direction: column;
@@ -83,7 +85,7 @@ var WippyLoading=function(t){"use strict";var y=Object.defineProperty;var w=(t,i
color: var(--p-text-muted-color, #a1a1aa);
}
}
-`;class s extends HTMLElement{constructor(){super(...arguments);n(this,"_iconEl",null);n(this,"_titleEl",null);n(this,"_messageEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const l=document.createElement("style");l.textContent=f,e.appendChild(l);const r=document.createElement("div");r.className="container",this._iconEl=document.createElement("div"),this._iconEl.className="icon",this._iconEl.setAttribute("part","icon"),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._messageEl=document.createElement("div"),this._messageEl.className="message",this._messageEl.setAttribute("part","message"),this._update(),r.append(this._iconEl,this._titleEl,this._messageEl),e.appendChild(r)}disconnectedCallback(){this._iconEl=null,this._titleEl=null,this._messageEl=null}attributeChangedCallback(){this._update()}_update(){if(this._iconEl){const e=this.getAttribute("icon")??"circle";this._iconEl.innerHTML=p[e]??p.circle}this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??"Something went wrong"),this._messageEl&&(this._messageEl.textContent=this.getAttribute("message")??"")}}n(s,"observedAttributes",["title","message","icon","severity"]);const m=3e3,g=2e3,E=`
+`;class l extends HTMLElement{constructor(){super(...arguments);n(this,"_iconEl",null);n(this,"_titleEl",null);n(this,"_messageEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const s=document.createElement("style");s.textContent=f,e.appendChild(s);const r=document.createElement("div");r.className="container",this._iconEl=document.createElement("div"),this._iconEl.className="icon",this._iconEl.setAttribute("part","icon"),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._messageEl=document.createElement("div"),this._messageEl.className="message",this._messageEl.setAttribute("part","message"),this._update(),r.append(this._iconEl,this._titleEl,this._messageEl),e.appendChild(r)}disconnectedCallback(){this._iconEl=null,this._titleEl=null,this._messageEl=null}attributeChangedCallback(){this._update()}_update(){if(this._iconEl){const e=this.getAttribute("icon")??"circle";this._iconEl.innerHTML=p[e]??p.circle}this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??"Something went wrong"),this._messageEl&&(this._messageEl.textContent=this.getAttribute("message")??"")}}n(l,"observedAttributes",["title","message","icon","severity"]);const m=3e3,g=2e3,E=`
*, *::before, *::after { box-sizing: border-box; }
:host {
@@ -191,4 +193,5 @@ var WippyLoading=function(t){"use strict";var y=Object.defineProperty;var w=(t,i
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(-360deg); }
}
-`;class a extends HTMLElement{constructor(){super(...arguments);n(this,"_titleEl",null);n(this,"_subtitleEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const l=document.createElement("style");l.textContent=E,e.appendChild(l);const r=document.createElement("div");r.className="container";const c=document.createElement("div");c.className="spinner";const d=document.createElement("div");d.className="ring outer";const h=document.createElement("div");h.className="ring inner",c.append(d,h),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._subtitleEl=document.createElement("div"),this._subtitleEl.className="subtitle",this._subtitleEl.setAttribute("part","subtitle"),this._updateText(),r.append(c,this._titleEl,this._subtitleEl),e.appendChild(r);const b=Date.now();d.style.animationDelay=`-${b%m}ms`,h.style.animationDelay=`-${b%g}ms`}disconnectedCallback(){this._titleEl=null,this._subtitleEl=null}attributeChangedCallback(){this._updateText()}_updateText(){this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??""),this._subtitleEl&&(this._subtitleEl.textContent=this.getAttribute("subtitle")??"")}}n(a,"observedAttributes",["title","subtitle"]);function u(){customElements.get("wippy-loading")||customElements.define("wippy-loading",a),customElements.get("wippy-error")||customElements.define("wippy-error",s)}return u(),t.WippyErrorElement=s,t.WippyLoadingElement=a,t.register=u,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t}({});
+`;class a extends HTMLElement{constructor(){super(...arguments);n(this,"_titleEl",null);n(this,"_subtitleEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const s=document.createElement("style");s.textContent=E,e.appendChild(s);const r=document.createElement("div");r.className="container";const c=document.createElement("div");c.className="spinner";const d=document.createElement("div");d.className="ring outer";const h=document.createElement("div");h.className="ring inner",c.append(d,h),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._subtitleEl=document.createElement("div"),this._subtitleEl.className="subtitle",this._subtitleEl.setAttribute("part","subtitle"),this._updateText(),r.append(c,this._titleEl,this._subtitleEl),e.appendChild(r);const b=Date.now();d.style.animationDelay=`-${b%m}ms`,h.style.animationDelay=`-${b%g}ms`}disconnectedCallback(){this._titleEl=null,this._subtitleEl=null}attributeChangedCallback(){this._updateText()}_updateText(){this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??""),this._subtitleEl&&(this._subtitleEl.textContent=this.getAttribute("subtitle")??"")}}n(a,"observedAttributes",["title","subtitle"]);function u(){customElements.get("wippy-loading")||customElements.define("wippy-loading",a),customElements.get("wippy-error")||customElements.define("wippy-error",l)}return u(),t.WippyErrorElement=l,t.WippyLoadingElement=a,t.register=u,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t})({});
+//# sourceMappingURL=loading.js.map
diff --git a/src/facade/public/@wippy-fe/loading.js.map b/src/facade/public/@wippy-fe/loading.js.map
new file mode 100644
index 0000000..f3c8cde
--- /dev/null
+++ b/src/facade/public/@wippy-fe/loading.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"loading.js","sources":["../../npm/@wippy-fe--loading/src/wippy-error.ts","../../npm/@wippy-fe--loading/src/wippy-loading.ts","../../npm/@wippy-fe--loading/src/index.ts"],"sourcesContent":["const ICON_CIRCLE = ''\n\nconst ICON_TRIANGLE = ''\n\nconst ICON_SAD = ''\n\nconst ICONS: Record = {\n circle: ICON_CIRCLE,\n triangle: ICON_TRIANGLE,\n sad: ICON_SAD,\n}\n\nconst STYLES = `\n *, *::before, *::after { box-sizing: border-box; }\n\n :host {\n display: block;\n width: 100%;\n height: 100%;\n min-height: 120px;\n overflow: hidden;\n }\n\n :host(:only-child) {\n height: 100vh;\n height: 100dvh;\n }\n\n :host([no-bg]) .container { background: transparent; }\n\n .container {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n background: var(--p-surface-0, #fff);\n font-family: system-ui, -apple-system, sans-serif;\n }\n\n .icon {\n width: 48px;\n height: 48px;\n margin-bottom: 16px;\n }\n\n .icon svg {\n width: 100%;\n height: 100%;\n }\n\n :host([severity=\"warning\"]) .icon,\n :host([severity=\"warning\"]) .title {\n color: var(--p-warn-500, rgb(249, 115, 22));\n }\n\n :host(:not([severity=\"warning\"])) .icon,\n :host(:not([severity=\"warning\"])) .title {\n color: var(--p-danger-500, rgb(239, 68, 68));\n }\n\n .title {\n font-size: 16px;\n font-weight: 600;\n text-align: center;\n padding: 0 16px;\n }\n\n .title:empty { display: none; }\n\n .message {\n font-size: 13px;\n color: var(--p-text-muted-color, #71717a);\n margin-top: 6px;\n text-align: center;\n padding: 0 16px;\n max-width: 480px;\n line-height: 1.4;\n }\n\n .message:empty { display: none; }\n\n @media (prefers-color-scheme: dark) {\n .container {\n background: var(--p-surface-900, #1c1a19);\n }\n :host([severity=\"warning\"]) .icon,\n :host([severity=\"warning\"]) .title {\n color: var(--p-warn-400, color-mix(in srgb, rgb(249, 115, 22) 40%, white));\n }\n :host(:not([severity=\"warning\"])) .icon,\n :host(:not([severity=\"warning\"])) .title {\n color: var(--p-danger-400, color-mix(in srgb, rgb(239, 68, 68) 40%, white));\n }\n .message {\n color: var(--p-text-muted-color, #a1a1aa);\n }\n }\n`\n\nexport class WippyErrorElement extends HTMLElement {\n static observedAttributes = ['title', 'message', 'icon', 'severity']\n\n private _iconEl: HTMLElement | null = null\n private _titleEl: HTMLElement | null = null\n private _messageEl: HTMLElement | null = null\n\n connectedCallback() {\n const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' })\n shadow.textContent = ''\n\n const style = document.createElement('style')\n style.textContent = STYLES\n shadow.appendChild(style)\n\n const container = document.createElement('div')\n container.className = 'container'\n\n this._iconEl = document.createElement('div')\n this._iconEl.className = 'icon'\n this._iconEl.setAttribute('part', 'icon')\n\n this._titleEl = document.createElement('div')\n this._titleEl.className = 'title'\n this._titleEl.setAttribute('part', 'title')\n\n this._messageEl = document.createElement('div')\n this._messageEl.className = 'message'\n this._messageEl.setAttribute('part', 'message')\n\n this._update()\n\n container.append(this._iconEl, this._titleEl, this._messageEl)\n shadow.appendChild(container)\n }\n\n disconnectedCallback() {\n this._iconEl = null\n this._titleEl = null\n this._messageEl = null\n }\n\n attributeChangedCallback() {\n this._update()\n }\n\n private _update() {\n if (this._iconEl) {\n const iconKey = this.getAttribute('icon') ?? 'circle'\n this._iconEl.innerHTML = ICONS[iconKey] ?? ICONS.circle\n }\n if (this._titleEl)\n this._titleEl.textContent = this.getAttribute('title') ?? 'Something went wrong'\n if (this._messageEl)\n this._messageEl.textContent = this.getAttribute('message') ?? ''\n }\n}\n","const OUTER_DURATION = 3000\nconst INNER_DURATION = 2000\n\nconst STYLES = `\n *, *::before, *::after { box-sizing: border-box; }\n\n :host {\n display: block;\n width: 100%;\n height: 100%;\n min-height: 120px;\n overflow: hidden;\n }\n\n :host(:only-child) {\n height: 100vh;\n height: 100dvh;\n }\n\n :host([no-bg]) .container { background: transparent; }\n\n .container {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n width: 100%;\n height: 100%;\n background: var(--p-surface-0, #fff);\n font-family: system-ui, -apple-system, sans-serif;\n }\n\n .spinner {\n position: relative;\n width: 48px;\n height: 48px;\n margin-bottom: 16px;\n }\n\n .ring {\n position: absolute;\n border-radius: 50%;\n box-sizing: border-box;\n }\n\n .ring.outer {\n width: 48px;\n height: 48px;\n border: 3px solid var(--p-surface-300, #d4d4d8);\n animation: spin-outer ${OUTER_DURATION}ms linear infinite;\n }\n\n .ring.inner {\n width: 56px;\n height: 56px;\n top: 50%;\n left: 50%;\n border: 3px solid transparent;\n border-top-color: var(--p-primary, rgb(0, 95, 178));\n border-bottom-color: var(--p-primary, rgb(0, 95, 178));\n animation: spin-inner ${INNER_DURATION}ms linear infinite;\n }\n\n .title {\n font-size: 14px;\n font-weight: 500;\n color: var(--p-text-color, #3f3f46);\n text-align: center;\n padding: 0 16px;\n }\n\n .title:empty { display: none; }\n\n .subtitle {\n font-size: 12px;\n color: var(--p-text-muted-color, #71717a);\n margin-top: 4px;\n text-align: center;\n padding: 0 16px;\n }\n\n .subtitle:empty { display: none; }\n\n @media (prefers-color-scheme: dark) {\n .container {\n background: var(--p-surface-900, #1c1a19);\n }\n .ring.outer {\n border-color: var(--p-surface-500, #71717a);\n }\n .ring.inner {\n border-top-color: var(--p-primary, rgb(0, 125, 178));\n border-bottom-color: var(--p-primary, rgb(0, 125, 178));\n }\n .title {\n color: var(--p-text-color, #fff);\n }\n .subtitle {\n color: var(--p-text-muted-color, #a1a1aa);\n }\n }\n\n @keyframes spin-outer {\n from { transform: rotate(0deg); }\n to { transform: rotate(360deg); }\n }\n\n @keyframes spin-inner {\n from { transform: translate(-50%, -50%) rotate(0deg); }\n to { transform: translate(-50%, -50%) rotate(-360deg); }\n }\n`\n\nexport class WippyLoadingElement extends HTMLElement {\n static observedAttributes = ['title', 'subtitle']\n\n private _titleEl: HTMLElement | null = null\n private _subtitleEl: HTMLElement | null = null\n\n connectedCallback() {\n const shadow = this.shadowRoot ?? this.attachShadow({ mode: 'open' })\n shadow.textContent = ''\n\n const style = document.createElement('style')\n style.textContent = STYLES\n shadow.appendChild(style)\n\n const container = document.createElement('div')\n container.className = 'container'\n\n const spinner = document.createElement('div')\n spinner.className = 'spinner'\n\n const outer = document.createElement('div')\n outer.className = 'ring outer'\n\n const inner = document.createElement('div')\n inner.className = 'ring inner'\n\n spinner.append(outer, inner)\n\n this._titleEl = document.createElement('div')\n this._titleEl.className = 'title'\n this._titleEl.setAttribute('part', 'title')\n\n this._subtitleEl = document.createElement('div')\n this._subtitleEl.className = 'subtitle'\n this._subtitleEl.setAttribute('part', 'subtitle')\n\n this._updateText()\n\n container.append(spinner, this._titleEl, this._subtitleEl)\n shadow.appendChild(container)\n\n // Time-synced animation — phase matches wall-clock time\n const now = Date.now()\n outer.style.animationDelay = `-${now % OUTER_DURATION}ms`\n inner.style.animationDelay = `-${now % INNER_DURATION}ms`\n }\n\n disconnectedCallback() {\n this._titleEl = null\n this._subtitleEl = null\n }\n\n attributeChangedCallback() {\n this._updateText()\n }\n\n private _updateText() {\n if (this._titleEl)\n this._titleEl.textContent = this.getAttribute('title') ?? ''\n if (this._subtitleEl)\n this._subtitleEl.textContent = this.getAttribute('subtitle') ?? ''\n }\n}\n","import { WippyErrorElement } from './wippy-error'\nimport { WippyLoadingElement } from './wippy-loading'\n\nexport { WippyErrorElement, WippyLoadingElement }\n\nexport function register() {\n if (!customElements.get('wippy-loading'))\n customElements.define('wippy-loading', WippyLoadingElement)\n if (!customElements.get('wippy-error'))\n customElements.define('wippy-error', WippyErrorElement)\n}\n\n// Auto-register when loaded as a script\nregister()\n"],"names":["ICONS","STYLES","WippyErrorElement","__publicField","shadow","style","container","iconKey","OUTER_DURATION","INNER_DURATION","WippyLoadingElement","spinner","outer","inner","now","register"],"mappings":"+MAMA,MAAMA,EAAgC,CACpC,OAPkB,0UAQlB,SANoB,mdAOpB,IALe,qbAMjB,EAEMC,EAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAyFR,MAAMC,UAA0B,WAAY,CAA5C,kCAGGC,EAAA,eAA8B,MAC9BA,EAAA,gBAA+B,MAC/BA,EAAA,kBAAiC,MAEzC,mBAAoB,CAClB,MAAMC,EAAS,KAAK,YAAc,KAAK,aAAa,CAAE,KAAM,OAAQ,EACpEA,EAAO,YAAc,GAErB,MAAMC,EAAQ,SAAS,cAAc,OAAO,EAC5CA,EAAM,YAAcJ,EACpBG,EAAO,YAAYC,CAAK,EAExB,MAAMC,EAAY,SAAS,cAAc,KAAK,EAC9CA,EAAU,UAAY,YAEtB,KAAK,QAAU,SAAS,cAAc,KAAK,EAC3C,KAAK,QAAQ,UAAY,OACzB,KAAK,QAAQ,aAAa,OAAQ,MAAM,EAExC,KAAK,SAAW,SAAS,cAAc,KAAK,EAC5C,KAAK,SAAS,UAAY,QAC1B,KAAK,SAAS,aAAa,OAAQ,OAAO,EAE1C,KAAK,WAAa,SAAS,cAAc,KAAK,EAC9C,KAAK,WAAW,UAAY,UAC5B,KAAK,WAAW,aAAa,OAAQ,SAAS,EAE9C,KAAK,QAAA,EAELA,EAAU,OAAO,KAAK,QAAS,KAAK,SAAU,KAAK,UAAU,EAC7DF,EAAO,YAAYE,CAAS,CAC9B,CAEA,sBAAuB,CACrB,KAAK,QAAU,KACf,KAAK,SAAW,KAChB,KAAK,WAAa,IACpB,CAEA,0BAA2B,CACzB,KAAK,QAAA,CACP,CAEQ,SAAU,CAChB,GAAI,KAAK,QAAS,CAChB,MAAMC,EAAU,KAAK,aAAa,MAAM,GAAK,SAC7C,KAAK,QAAQ,UAAYP,EAAMO,CAAO,GAAKP,EAAM,MACnD,CACI,KAAK,WACP,KAAK,SAAS,YAAc,KAAK,aAAa,OAAO,GAAK,wBACxD,KAAK,aACP,KAAK,WAAW,YAAc,KAAK,aAAa,SAAS,GAAK,GAClE,CACF,CAvDEG,EADWD,EACJ,qBAAqB,CAAC,QAAS,UAAW,OAAQ,UAAU,GCtGrE,MAAMM,EAAiB,IACjBC,EAAiB,IAEjBR,EAAS;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,4BA8CaO,CAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,4BAWdC,CAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAqDnC,MAAMC,UAA4B,WAAY,CAA9C,kCAGGP,EAAA,gBAA+B,MAC/BA,EAAA,mBAAkC,MAE1C,mBAAoB,CAClB,MAAMC,EAAS,KAAK,YAAc,KAAK,aAAa,CAAE,KAAM,OAAQ,EACpEA,EAAO,YAAc,GAErB,MAAMC,EAAQ,SAAS,cAAc,OAAO,EAC5CA,EAAM,YAAcJ,EACpBG,EAAO,YAAYC,CAAK,EAExB,MAAMC,EAAY,SAAS,cAAc,KAAK,EAC9CA,EAAU,UAAY,YAEtB,MAAMK,EAAU,SAAS,cAAc,KAAK,EAC5CA,EAAQ,UAAY,UAEpB,MAAMC,EAAQ,SAAS,cAAc,KAAK,EAC1CA,EAAM,UAAY,aAElB,MAAMC,EAAQ,SAAS,cAAc,KAAK,EAC1CA,EAAM,UAAY,aAElBF,EAAQ,OAAOC,EAAOC,CAAK,EAE3B,KAAK,SAAW,SAAS,cAAc,KAAK,EAC5C,KAAK,SAAS,UAAY,QAC1B,KAAK,SAAS,aAAa,OAAQ,OAAO,EAE1C,KAAK,YAAc,SAAS,cAAc,KAAK,EAC/C,KAAK,YAAY,UAAY,WAC7B,KAAK,YAAY,aAAa,OAAQ,UAAU,EAEhD,KAAK,YAAA,EAELP,EAAU,OAAOK,EAAS,KAAK,SAAU,KAAK,WAAW,EACzDP,EAAO,YAAYE,CAAS,EAG5B,MAAMQ,EAAM,KAAK,IAAA,EACjBF,EAAM,MAAM,eAAiB,IAAIE,EAAMN,CAAc,KACrDK,EAAM,MAAM,eAAiB,IAAIC,EAAML,CAAc,IACvD,CAEA,sBAAuB,CACrB,KAAK,SAAW,KAChB,KAAK,YAAc,IACrB,CAEA,0BAA2B,CACzB,KAAK,YAAA,CACP,CAEQ,aAAc,CAChB,KAAK,WACP,KAAK,SAAS,YAAc,KAAK,aAAa,OAAO,GAAK,IACxD,KAAK,cACP,KAAK,YAAY,YAAc,KAAK,aAAa,UAAU,GAAK,GACpE,CACF,CA7DEN,EADWO,EACJ,qBAAqB,CAAC,QAAS,UAAU,GC7G3C,SAASK,GAAW,CACpB,eAAe,IAAI,eAAe,GACrC,eAAe,OAAO,gBAAiBL,CAAmB,EACvD,eAAe,IAAI,aAAa,GACnC,eAAe,OAAO,cAAeR,CAAiB,CAC1D,CAGA,OAAAa,EAAA"}
\ No newline at end of file
diff --git a/src/facade/public/index.html b/src/facade/public/index.html
index f60b247..eb09f9e 100644
--- a/src/facade/public/index.html
+++ b/src/facade/public/index.html
@@ -99,6 +99,7 @@
auth: { token: token, expiresAt: new Date(Date.now() + 86400000).toISOString() },
env: cfg.env,
routePrefix: cfg.routePrefix,
+ apiRoutes: cfg.apiRoutes,
theming: cfg.theming,
hostConfig: cfg.hostConfig,
context: { resourceId: '', resourceType: 'page' },
diff --git a/src/llm/src/claude/client.lua b/src/llm/src/claude/client.lua
index ea02e6e..70991b2 100644
--- a/src/llm/src/claude/client.lua
+++ b/src/llm/src/claude/client.lua
@@ -205,7 +205,7 @@ function claude_client.request(endpoint_path, payload, options)
}
end
- local parsed, parse_err = json.decode(response.body)
+ local parsed, parse_err = json.decode(response.body or "")
if parse_err then
return nil, {
status_code = response.status_code,
diff --git a/src/llm/src/google/client.lua b/src/llm/src/google/client.lua
index 94355aa..1ff79bf 100644
--- a/src/llm/src/google/client.lua
+++ b/src/llm/src/google/client.lua
@@ -285,7 +285,7 @@ function client.request(method, url, http_options)
if response.status_code < 200 or response.status_code >= 300 then
if http_options.stream and response.stream and not response.body then
- local body_data = response.stream:read()
+ local body_data = response.stream:read(4096)
response.body = body_data
end
local parsed_error = parse_error_response(response)
@@ -297,7 +297,7 @@ function client.request(method, url, http_options)
return handle_stream_response(response, http_options)
end
- local parsed, parse_err = json.decode(response.body)
+ local parsed, parse_err = json.decode(response.body or "")
if parse_err then
local parse_error = {
status_code = response.status_code,
diff --git a/src/llm/src/google/mapper_test.lua b/src/llm/src/google/mapper_test.lua
index 8ae7ca7..ccb0f75 100644
--- a/src/llm/src/google/mapper_test.lua
+++ b/src/llm/src/google/mapper_test.lua
@@ -405,7 +405,7 @@ local function define_tests()
}
}
- local google_tools = mapper.map_tools(contract_tools)
+ local google_tools = mapper.map_tools(contract_tools) :: any
tests.eq(#google_tools, 2)
tests.eq(google_tools[1].name, "get_weather")
@@ -435,7 +435,7 @@ local function define_tests()
}
}
- local google_tools = mapper.map_tools(contract_tools)
+ local google_tools = mapper.map_tools(contract_tools) :: any
tests.eq(#google_tools, 1)
tests.is_nil(google_tools[1].parameters.properties.count.multipleOf)
@@ -457,7 +457,7 @@ local function define_tests()
}
}
- local google_tools = mapper.map_tools(contract_tools)
+ local google_tools = mapper.map_tools(contract_tools) :: any
tests.eq(#google_tools, 1)
tests.is_nil(google_tools[1].parameters.examples)
@@ -487,7 +487,7 @@ local function define_tests()
}
}
- local google_tools = mapper.map_tools(contract_tools)
+ local google_tools = mapper.map_tools(contract_tools) :: any
tests.eq(#google_tools, 1)
tests.is_nil(google_tools[1].parameters.properties.nested.multipleOf)
@@ -530,7 +530,7 @@ local function define_tests()
}
}
- local google_tools = mapper.map_tools(contract_tools)
+ local google_tools = mapper.map_tools(contract_tools) :: any
tests.eq(#google_tools, 1)
tests.eq(google_tools[1].name, "valid_tool")
@@ -570,7 +570,7 @@ local function define_tests()
}
}
- local google_tools = mapper.map_tools(contract_tools)
+ local google_tools = mapper.map_tools(contract_tools) :: any
tests.eq(#google_tools, 1)
local params = google_tools[1].parameters
@@ -618,7 +618,7 @@ local function define_tests()
}
}
- local google_tools = mapper.map_tools(contract_tools)
+ local google_tools = mapper.map_tools(contract_tools) :: any
tests.eq(#google_tools, 1)
local address = google_tools[1].parameters.properties.address
diff --git a/src/llm/src/google/oauth2/oauth2.lua b/src/llm/src/google/oauth2/oauth2.lua
index f0b575a..350f5ef 100644
--- a/src/llm/src/google/oauth2/oauth2.lua
+++ b/src/llm/src/google/oauth2/oauth2.lua
@@ -45,7 +45,7 @@ function oauth2.get_token()
return nil, "Failed to retrieve OAuth2 token: " .. tostring(err)
end
- return json.decode(response.body)
+ return json.decode(response.body or "")
end
return oauth2
diff --git a/src/llm/src/openai/client.lua b/src/llm/src/openai/client.lua
index 49f6cb9..763c00c 100644
--- a/src/llm/src/openai/client.lua
+++ b/src/llm/src/openai/client.lua
@@ -244,7 +244,7 @@ function openai_client.request(endpoint_path, payload, options)
end
-- Parse non-streaming response
- local parsed, parse_err = json.decode(response.body)
+ local parsed, parse_err = json.decode(response.body or "")
if parse_err then
local parse_error = {
status_code = response.status_code,
diff --git a/src/views/_index.yaml b/src/views/_index.yaml
index 8feda7f..bcdac07 100644
--- a/src/views/_index.yaml
+++ b/src/views/_index.yaml
@@ -28,6 +28,10 @@ entries:
path: .meta.router
- entry: wippy.views.api:public_url.endpoint
path: .meta.router
+ - entry: wippy.views.api:list_routes
+ path: .meta.router
+ - entry: wippy.views.api:list_routes.endpoint
+ path: .meta.router
- name: public_api_url
kind: env.variable
meta:
diff --git a/src/views/api/_index.yaml b/src/views/api/_index.yaml
index 1944417..aebd155 100644
--- a/src/views/api/_index.yaml
+++ b/src/views/api/_index.yaml
@@ -93,3 +93,27 @@ entries:
func: public_url
method: GET
path: /pages/public/{id}
+ - name: list_routes
+ kind: function.lua
+ meta:
+ comment: Returns mountRoute → pageId map for pages claiming top-level URLs
+ description: Pages mount route listing endpoint
+ router: api_router
+ source: file://list_routes.lua
+ imports:
+ page_registry: wippy.views:page_registry
+ method: handler
+ modules:
+ - http
+ pool:
+ size: 4
+ workers: 4
+ - name: list_routes.endpoint
+ kind: http.endpoint
+ meta:
+ comment: Endpoint that lists all accessible mount routes
+ description: View pages mount routes listing endpoint
+ router: api_router
+ func: list_routes
+ method: GET
+ path: /pages/routes
diff --git a/src/views/api/list_pages.lua b/src/views/api/list_pages.lua
index e2a48e4..7ac3203 100644
--- a/src/views/api/list_pages.lua
+++ b/src/views/api/list_pages.lua
@@ -17,6 +17,7 @@ type PageResponse = {
internal: string,
hidden: number,
configOverrides: {[string]: any}?,
+ mountRoute: string?,
}
local function handler()
@@ -56,6 +57,7 @@ local function handler()
internal = type(page.internal) == "string" and page.internal or "",
hidden = page.inline and 1 or 0,
configOverrides = page.config_overrides :: {[string]: any}?,
+ mountRoute = page.mount_route :: string?,
}
table.insert(pages, page_info)
diff --git a/src/views/api/list_routes.lua b/src/views/api/list_routes.lua
new file mode 100644
index 0000000..de1ef86
--- /dev/null
+++ b/src/views/api/list_routes.lua
@@ -0,0 +1,70 @@
+local http = require("http")
+local page_registry = require("page_registry")
+
+-- GET /pages/routes
+-- Returns { success, count, routes } where routes is a map of
+-- mountRoute pattern → page id. Only pages whose mountRoute passes v1
+-- syntax validation AND doesn't conflict with any other page appear.
+-- Unlike /pages/list, this endpoint does NOT filter by `announced` —
+-- a hidden page that claims a URL still needs its URL to resolve.
+--
+-- On validation failure (syntax error or duplicate mount route), responds
+-- with HTTP 500 and a detailed error body. The frontend treats this as a
+-- fatal state and renders a fullscreen.
+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
+
+ -- Single fetch + validation pass — avoids the two-call race.
+ local all_pages, list_err = page_registry.find_all()
+ if list_err then
+ res:set_status(http.STATUS.INTERNAL_ERROR)
+ res:write_json({
+ success = false,
+ error = list_err,
+ })
+ return
+ end
+
+ local routes_map, issues = page_registry.validate_mount_routes(all_pages or {})
+ if #issues > 0 then
+ res:set_status(http.STATUS.INTERNAL_ERROR)
+ res:write_json({
+ success = false,
+ error = table.concat(issues, "\n"),
+ })
+ return
+ end
+
+ -- Apply access control: a mount route on a secure page should not be
+ -- exposed to unauthorized callers. Does NOT filter by announced —
+ -- hidden pages can still claim URLs.
+ local accessible: {[string]: string} = {}
+ local count = 0
+ for _, page in ipairs(all_pages) do
+ local mr = page.mount_route
+ if mr and routes_map[mr] == page.id and (not page.secure or page_registry.can_access(page)) then
+ accessible[mr] = page.id
+ count = count + 1
+ end
+ end
+
+ res:set_content_type(http.CONTENT.JSON)
+ res:set_status(http.STATUS.OK)
+ -- Force-object marker: many Lua JSON libs serialize empty tables as `[]`.
+ -- We explicitly encode the map alongside an array of keys to give the
+ -- consumer a way to detect the empty case without ambiguity.
+ res:write_json({
+ success = true,
+ count = count,
+ routes = accessible,
+ })
+end
+
+return {
+ handler = handler,
+}
diff --git a/src/views/api/render.lua b/src/views/api/render.lua
index ac21cf8..be87331 100644
--- a/src/views/api/render.lua
+++ b/src/views/api/render.lua
@@ -92,7 +92,7 @@ local function handler()
primevue = css.prime_vue or false,
markdown = css.markdown or false,
customCss = css.custom_css or false,
- customVariabled = css.custom_variables or false,
+ customVariables = css.custom_variables or false,
},
tailwindConfig = proxy.tailwind_config or false,
resizeObserver = proxy.resize_observer or false,
diff --git a/src/views/page_registry.lua b/src/views/page_registry.lua
index 07cee57..3e4587d 100644
--- a/src/views/page_registry.lua
+++ b/src/views/page_registry.lua
@@ -39,6 +39,7 @@ type PageInfo = {
kind: string,
url: string?,
internal: string?,
+ mount_route: string?,
}
type PageDetail = PageInfo & {
@@ -134,6 +135,7 @@ local function extract_page_info(entry)
internal = meta.internal or "",
kind = kind,
config_overrides = meta.config_overrides,
+ mount_route = meta.mountRoute,
}
if kind == "component" then
@@ -211,6 +213,7 @@ function pages.get(page_id)
kind = kind,
content_type = entry.meta.content_type or "text/html",
config_overrides = entry.meta.config_overrides,
+ mount_route = entry.meta.mountRoute,
}
if kind == "template" then
@@ -277,6 +280,143 @@ function pages.resolve_base_url(page)
return base_url
end
+-- Mount route v1 syntax validator.
+-- Canonical forms:
+-- /:part(.*)* root mount
+-- //:part(.*)* prefix mount (one or more literal segments)
+-- ///:part(.*)* nested prefix mount
+-- Literal segments: lowercase alphanumerics plus hyphens.
+-- The `:part` param name matches the gen-2-chat frontend convention.
+local MOUNT_ROUTE_SUFFIX = "/:part(.*)*"
+
+local function validate_mount_route_syntax(route)
+ if type(route) ~= "string" or #route < #MOUNT_ROUTE_SUFFIX then
+ return false
+ end
+
+ -- Required catch-all suffix
+ local tail = route:sub(-#MOUNT_ROUTE_SUFFIX)
+ if tail ~= MOUNT_ROUTE_SUFFIX then
+ return false
+ end
+
+ -- Everything before the suffix is the literal prefix
+ local prefix = route:sub(1, #route - #MOUNT_ROUTE_SUFFIX)
+
+ -- Root mount: "/:part(.*)*" → prefix is empty, legal
+ if prefix == "" then
+ return true
+ end
+
+ -- Must start with /
+ if prefix:sub(1, 1) ~= "/" then
+ return false
+ end
+
+ -- Parse segments: each must match [a-z0-9-]+, joined by single /
+ local rebuilt = ""
+ for segment in prefix:gmatch("/([^/]+)") do
+ if not segment:match("^[a-z0-9%-]+$") then
+ return false
+ end
+ rebuilt = rebuilt .. "/" .. segment
+ end
+
+ -- Ensure rebuilt prefix matches input (catches trailing slash, //, etc.)
+ return rebuilt == prefix
+end
+
+-- Build an error message for an invalid-syntax mountRoute.
+local function mount_route_syntax_error(page_id, route)
+ return string.format(
+ '[views] page "%s" has invalid mountRoute "%s"'
+ .. ' — v1 only supports "//:part(.*)*" and "/:part(.*)*".'
+ .. ' Literal segments must be lowercase alphanumerics plus hyphens.'
+ .. ' Use ":part" (not ":pathMatch") to match the gen-2-chat system-route convention.',
+ tostring(page_id), tostring(route)
+ )
+end
+
+-- Build an error message for a conflicting mountRoute claimed by two pages.
+local function mount_route_conflict_error(route, page_a, page_b)
+ return string.format(
+ '[views] mount route conflict: pages "%s" and "%s" both claim "%s".'
+ .. ' Mount routes must be unique across all registered view.page entries.'
+ .. ' Remove mountRoute from one of the pages, pick a different path,'
+ .. ' or override in the top-level app by re-declaring the page id with a different mountRoute.',
+ tostring(page_a), tostring(page_b), tostring(route)
+ )
+end
+
+-- Validate all mountRoute fields in a page list.
+-- Returns (routes_map, issues_list).
+-- routes_map is a map from mountRoute → page_id (only populated for valid, non-conflicting entries).
+-- issues_list is an array of error message strings.
+--
+-- Nil / missing mountRoute is silently skipped (pages without the field are fine).
+-- Any non-nil, non-string value (number, boolean, table) is treated as a syntax
+-- error — we refuse to silently swallow `mountRoute: false` or `mountRoute: 42`
+-- because that would hide typos in the YAML.
+function pages.validate_mount_routes(all_pages)
+ local routes_map = {}
+ local issues = {}
+ local seen = {} -- route → page_id (first-seen)
+
+ if type(all_pages) ~= "table" then
+ return routes_map, issues
+ end
+
+ for _, page in ipairs(all_pages) do
+ local mr = page.mount_route
+ if mr ~= nil and mr ~= "" then
+ if type(mr) ~= "string" or not validate_mount_route_syntax(mr) then
+ table.insert(issues, mount_route_syntax_error(page.id, mr))
+ else
+ local page_id = page.id
+ if type(page_id) ~= "string" or page_id == "" then
+ -- Defensive: a page entry without a valid id would otherwise
+ -- collide with other id-less entries on the first dup check.
+ table.insert(issues, string.format(
+ '[views] page with mountRoute "%s" is missing a valid id.',
+ tostring(mr)
+ ))
+ else
+ local previous = seen[mr]
+ if previous and previous ~= page_id then
+ table.insert(issues, mount_route_conflict_error(mr, page_id, previous))
+ else
+ seen[mr] = page_id
+ routes_map[mr] = page_id
+ end
+ end
+ end
+ end
+ end
+
+ return routes_map, issues
+end
+
+-- Find mount routes across all pages.
+-- Returns (routes_map, error_string).
+-- On validation failure, routes_map is nil and error_string is a newline-joined
+-- list of every issue found (syntax errors AND conflicts).
+function pages.find_mount_routes()
+ local all_pages, err = pages.find_all()
+ if err then
+ return nil, err
+ end
+ if not all_pages or #all_pages == 0 then
+ return {}
+ end
+
+ local routes_map, issues = pages.validate_mount_routes(all_pages)
+ if #issues > 0 then
+ return nil, table.concat(issues, "\n")
+ end
+
+ return routes_map
+end
+
-- Check if the current actor can access a page
function pages.can_access(page)
if not page.secure then
diff --git a/src/views/page_registry_test.lua b/src/views/page_registry_test.lua
index 4504203..1e94611 100644
--- a/src/views/page_registry_test.lua
+++ b/src/views/page_registry_test.lua
@@ -466,6 +466,321 @@ local function define_tests()
test.eq(page.kind, "template")
end)
end)
+
+ test.describe("mount routes — pure validator", function()
+ test.it("valid: root mount /:part(.*)*", function()
+ local map, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/:part(.*)*" },
+ })
+ test.eq(#issues, 0)
+ test.eq(map["/:part(.*)*"], "ns:a")
+ end)
+
+ test.it("valid: single-segment prefix", function()
+ local map, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/keeper/:part(.*)*" },
+ })
+ test.eq(#issues, 0)
+ test.eq(map["/keeper/:part(.*)*"], "ns:a")
+ end)
+
+ test.it("valid: nested prefix", function()
+ local map, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/admin/users/:part(.*)*" },
+ })
+ test.eq(#issues, 0)
+ test.eq(map["/admin/users/:part(.*)*"], "ns:a")
+ end)
+
+ test.it("valid: hyphens in segments", function()
+ local map, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/my-app/sub-page/:part(.*)*" },
+ })
+ test.eq(#issues, 0)
+ test.eq(map["/my-app/sub-page/:part(.*)*"], "ns:a")
+ end)
+
+ test.it("valid: ignores pages without mount_route", function()
+ local map, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/foo/:part(.*)*" },
+ { id = "ns:b" },
+ { id = "ns:c", mount_route = "" },
+ })
+ test.eq(#issues, 0)
+ test.eq(map["/foo/:part(.*)*"], "ns:a")
+ end)
+
+ test.it("invalid: missing catch-all tail", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/keeper" },
+ })
+ test.eq(#issues, 1)
+ test.is_true(issues[1]:find("invalid mountRoute") ~= nil)
+ end)
+
+ test.it("invalid: alternative param name :pathMatch", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/:pathMatch(.*)*" },
+ })
+ test.eq(#issues, 1)
+ end)
+
+ test.it("invalid: custom named params", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/user/:id" },
+ })
+ test.eq(#issues, 1)
+ end)
+
+ test.it("invalid: trailing slash after catch-all", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/foo/:part(.*)*/" },
+ })
+ test.eq(#issues, 1)
+ end)
+
+ test.it("invalid: uppercase segments rejected", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/Admin/:part(.*)*" },
+ })
+ test.eq(#issues, 1)
+ end)
+
+ test.it("invalid: double slash rejected", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "//foo/:part(.*)*" },
+ })
+ test.eq(#issues, 1)
+ end)
+
+ test.it("invalid: underscore in segment rejected", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/my_app/:part(.*)*" },
+ })
+ test.eq(#issues, 1)
+ end)
+
+ test.it("duplicate: two pages claiming same route", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/keeper/:part(.*)*" },
+ { id = "ns:b", mount_route = "/keeper/:part(.*)*" },
+ })
+ test.eq(#issues, 1)
+ test.is_true(issues[1]:find("conflict") ~= nil)
+ test.is_true(issues[1]:find("ns:a") ~= nil)
+ test.is_true(issues[1]:find("ns:b") ~= nil)
+ end)
+
+ test.it("duplicate: same page id listed twice is idempotent", function()
+ local map, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/foo/:part(.*)*" },
+ { id = "ns:a", mount_route = "/foo/:part(.*)*" },
+ })
+ test.eq(#issues, 0)
+ test.eq(map["/foo/:part(.*)*"], "ns:a")
+ end)
+
+ test.it("duplicate: reports all conflicts, not just first", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = "/x/:part(.*)*" },
+ { id = "ns:b", mount_route = "/x/:part(.*)*" },
+ { id = "ns:c", mount_route = "/y/:part(.*)*" },
+ { id = "ns:d", mount_route = "/y/:part(.*)*" },
+ })
+ test.eq(#issues, 2)
+ end)
+
+ test.it("empty list returns empty map and no issues", function()
+ local map, issues = page_registry.validate_mount_routes({})
+ test.eq(#issues, 0)
+ local count = 0
+ for _ in pairs(map) do
+ count = count + 1
+ end
+ test.eq(count, 0)
+ end)
+
+ test.it("nil input returns empty map", function()
+ local map, issues = page_registry.validate_mount_routes(nil)
+ test.eq(#issues, 0)
+ test.not_nil(map)
+ end)
+
+ test.it("invalid type: boolean mount_route surfaces error", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = false },
+ })
+ -- `false` is not nil and not an empty string; the type check
+ -- catches it and emits a clear syntax error so YAML typos
+ -- (e.g. `mountRoute: false`) don't silently drop.
+ test.eq(#issues, 1)
+ test.is_true(issues[1]:find("invalid mountRoute") ~= nil)
+ end)
+
+ test.it("invalid type: number mount_route surfaces error", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = 42 },
+ })
+ test.eq(#issues, 1)
+ test.is_true(issues[1]:find("invalid mountRoute") ~= nil)
+ end)
+
+ test.it("invalid type: table mount_route surfaces error", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "ns:a", mount_route = { "/foo" } },
+ })
+ test.eq(#issues, 1)
+ end)
+
+ test.it("missing id with valid mount_route surfaces error", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { mount_route = "/foo/:part(.*)*" },
+ })
+ test.eq(#issues, 1)
+ test.is_true(issues[1]:find("missing a valid id") ~= nil)
+ end)
+
+ test.it("empty id with valid mount_route surfaces error", function()
+ local _, issues = page_registry.validate_mount_routes({
+ { id = "", mount_route = "/foo/:part(.*)*" },
+ })
+ test.eq(#issues, 1)
+ end)
+ end)
+
+ test.describe("mount routes — registry integration", function()
+ local MR_IDS = {
+ "mr_home", "mr_keeper", "mr_admin", "mr_no_mount",
+ }
+
+ local function setup_mr_pages()
+ local snap = registry.snapshot()
+ local changes = snap:changes()
+ changes:create({
+ id = NS .. "mr_home",
+ kind = "registry.entry",
+ meta = {
+ type = "view.page",
+ name = "home",
+ title = "Home",
+ announced = true,
+ public = true,
+ mountRoute = "/:part(.*)*",
+ url = "https://cdn.example.com/home/index.html",
+ },
+ })
+ changes:create({
+ id = NS .. "mr_keeper",
+ kind = "registry.entry",
+ meta = {
+ type = "view.page",
+ name = "keeper",
+ title = "Keeper",
+ announced = true,
+ public = true,
+ mountRoute = "/keeper/:part(.*)*",
+ url = "https://cdn.example.com/keeper/index.html",
+ },
+ })
+ changes:create({
+ id = NS .. "mr_admin",
+ kind = "registry.entry",
+ meta = {
+ type = "view.page",
+ name = "admin-users",
+ title = "Admin Users",
+ announced = true,
+ public = true,
+ mountRoute = "/admin/users/:part(.*)*",
+ url = "https://cdn.example.com/admin/users/index.html",
+ },
+ })
+ changes:create({
+ id = NS .. "mr_no_mount",
+ kind = "registry.entry",
+ meta = {
+ type = "view.page",
+ name = "plain",
+ title = "Plain",
+ announced = true,
+ public = true,
+ url = "https://cdn.example.com/plain/index.html",
+ },
+ })
+ changes:apply()
+ end
+
+ local function teardown_mr_pages()
+ local snap = registry.snapshot()
+ local changes = snap:changes()
+ for _, name in ipairs(MR_IDS) do
+ changes:delete(NS .. name)
+ end
+ changes:apply()
+ end
+
+ test.before_each(function()
+ setup_mr_pages()
+ end)
+ test.after_each(function()
+ teardown_mr_pages()
+ end)
+
+ test.it("find_all exposes mount_route when present", function()
+ local all, err = page_registry.find_all()
+ test.is_nil(err)
+ local found = {}
+ for _, page in ipairs(all) do
+ if page.id:find("^" .. NS .. "mr_") then
+ found[page.id] = page.mount_route
+ end
+ end
+ test.eq(found[NS .. "mr_home"], "/:part(.*)*")
+ test.eq(found[NS .. "mr_keeper"], "/keeper/:part(.*)*")
+ test.eq(found[NS .. "mr_admin"], "/admin/users/:part(.*)*")
+ end)
+
+ test.it("find_all returns nil mount_route when not set", function()
+ local all, err = page_registry.find_all()
+ test.is_nil(err)
+ for _, page in ipairs(all) do
+ if page.id == NS .. "mr_no_mount" then
+ test.is_nil(page.mount_route)
+ return
+ end
+ end
+ test.ok(false, "mr_no_mount not found")
+ end)
+
+ test.it("get returns mount_route on page detail", function()
+ local page, err = page_registry.get(NS .. "mr_keeper")
+ test.is_nil(err)
+ test.eq(page.mount_route, "/keeper/:part(.*)*")
+ end)
+
+ test.it("get returns nil mount_route when not set", function()
+ local page, err = page_registry.get(NS .. "mr_no_mount")
+ test.is_nil(err)
+ test.is_nil(page.mount_route)
+ end)
+
+ test.it("find_mount_routes returns full map for valid state", function()
+ local routes, err = page_registry.find_mount_routes()
+ test.is_nil(err)
+ routes = test.not_nil(routes)
+ test.eq(routes["/:part(.*)*"], NS .. "mr_home")
+ test.eq(routes["/keeper/:part(.*)*"], NS .. "mr_keeper")
+ test.eq(routes["/admin/users/:part(.*)*"], NS .. "mr_admin")
+ end)
+
+ test.it("find_mount_routes excludes pages without mount_route", function()
+ local routes, err = page_registry.find_mount_routes()
+ test.is_nil(err)
+ for _, pid in pairs(routes) do
+ test.is_true(pid ~= NS .. "mr_no_mount")
+ end
+ end)
+ end)
end)
end
diff --git a/src/views/renderer.lua b/src/views/renderer.lua
index dcb90fe..e651a9b 100644
--- a/src/views/renderer.lua
+++ b/src/views/renderer.lua
@@ -24,7 +24,7 @@ function renderer.get_page_data(page, params, query)
-- Use the functions module to call the data function
local executor = funcs.new()
- local result, err = executor:call(page.data_func, context)
+ local result, err = executor:call(page.data_func :: string, context)
if err then
-- Return nil for data, and the error message as the second value
@@ -111,7 +111,7 @@ function renderer.render(page_id, params, query)
end
-- Render the template using the correctly built context
- local content, render_err = tmpl:render(page.template_name, render_context)
+ local content, render_err = tmpl:render(page.template_name :: string, render_context)
-- IMPORTANT: Release the template resource now that we're done with it
tmpl:release()