@tosiiko/cup 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CUP contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # CUP
2
+
3
+ CUP is a protocol-driven UI runtime for modern browsers.
4
+
5
+ Your backend returns JSON views that conform to [`schema/uiview.v1.json`](./schema/uiview.v1.json), and the browser runtime validates, renders, and remounts them safely. The practical shape that worked best in the demos is:
6
+
7
+ - backend owns state, permissions, and mutations
8
+ - templates live in files, not giant strings
9
+ - views assemble state and choose templates
10
+ - the browser mounts plain protocol views and posts actions back
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @tosiiko/cup
16
+ ```
17
+
18
+ ## What CUP Is Best At
19
+
20
+ - Server-driven dashboards, admin panels, CRMs, portals, and authenticated workflows
21
+ - Apps where Python, Go, or another backend should own routing, authorization, and business rules
22
+ - Interfaces that benefit from HTML-like templating without locking into a frontend framework
23
+ - Systems where runtime validation and safe-by-default rendering matter
24
+
25
+ ## Best-Practice Use
26
+
27
+ The strongest pattern from the demos is:
28
+
29
+ 1. Keep transport, auth, sessions, and route resolution in backend modules.
30
+ 2. Keep CUP templates in a `templates/` folder.
31
+ 3. Keep view builders small and focused on state assembly.
32
+ 4. Keep the browser shell thin: load a protocol view, validate it, mount it, submit forms back to the server.
33
+
34
+ Avoid this:
35
+
36
+ - one giant `server.py` with routes, templates, auth, and data mixed together
37
+ - large inline template strings for every page
38
+ - putting permission logic in the browser
39
+ - using `|safe` with untrusted content
40
+
41
+ ## Recommended App Structure
42
+
43
+ This is the structure we now recommend for a real CUP app:
44
+
45
+ ```text
46
+ my-cup-app/
47
+ app/
48
+ server.py
49
+ routes.py
50
+ actions.py
51
+ sessions.py
52
+ security.py
53
+ data.py
54
+ views/
55
+ auth.py
56
+ overview.py
57
+ accounts.py
58
+ pipeline.py
59
+ security.py
60
+ templates/
61
+ login.html
62
+ shell.html
63
+ pages/
64
+ overview.html
65
+ pipeline.html
66
+ security.html
67
+ static/
68
+ app.js
69
+ app.css
70
+ README.md
71
+ ```
72
+
73
+ Why this works well:
74
+
75
+ - `server.py` stays thin and readable
76
+ - `routes.py` decides which view to return
77
+ - `actions.py` owns authenticated mutations
78
+ - `security.py` and `sessions.py` isolate security-critical behavior
79
+ - `views/` maps backend state into template state
80
+ - `templates/` lets you work on markup without bloating Python files
81
+
82
+ ## Runtime Example
83
+
84
+ The browser side should stay small.
85
+
86
+ ```ts
87
+ import { mountRemoteView, validateProtocolView } from '@tosiiko/cup';
88
+
89
+ async function loadView(url: string, root: HTMLElement) {
90
+ const response = await fetch(url, {
91
+ credentials: 'same-origin',
92
+ headers: { Accept: 'application/json' },
93
+ });
94
+
95
+ const payload = await response.json();
96
+ const view = validateProtocolView(payload);
97
+ mountRemoteView(view, root);
98
+ }
99
+ ```
100
+
101
+ For most CUP apps, this thin shell is enough:
102
+
103
+ - load current route
104
+ - intercept internal links
105
+ - submit forms as JSON
106
+ - remount the next protocol view
107
+
108
+ ## Template Rules
109
+
110
+ CUP templates intentionally stay small and predictable.
111
+
112
+ - `{{ value }}` escapes HTML by default
113
+ - `{{ value|safe }}` renders trusted HTML and must only be used with sanitized content
114
+ - `{% if %}`, `{% elif %}`, `{% else %}`, and `{% endif %}` are supported
115
+ - `{% for item in items %}` and `{% endfor %}` are supported
116
+ - unsupported tags fail with parser errors
117
+
118
+ Recommended template practice:
119
+
120
+ - keep page markup in template files
121
+ - keep logic in the backend, not in the template
122
+ - use templates for rendering, not for permission checks
123
+ - prefer fixed class names over dynamic class generation
124
+
125
+ ## Security Defaults And Guidance
126
+
127
+ CUP now ships with safer defaults, but production safety still depends on backend design.
128
+
129
+ Runtime defaults:
130
+
131
+ - `{{ value }}` escapes HTML by default
132
+ - `fetchView()` validates incoming protocol views by default
133
+ - the runtime targets modern evergreen browsers
134
+
135
+ Backend guidance:
136
+
137
+ - validate protocol views before sending or mounting them
138
+ - use signed server-side sessions for authenticated apps
139
+ - require CSRF tokens on every state-changing POST
140
+ - enforce authorization on the server for every protected route and action
141
+ - return no-store headers for authenticated HTML and JSON
142
+ - keep audit events and session controls outside the browser
143
+
144
+ ## Styling
145
+
146
+ CUP works with plain CSS, design systems, or utility frameworks like Tailwind.
147
+
148
+ Tailwind works well if you:
149
+
150
+ - scan your template files in Tailwind `content`
151
+ - include backend files if class names live there
152
+ - prefer literal class names over dynamic string construction
153
+ - keep a small custom stylesheet for app-level tokens and special components
154
+
155
+ ## Core Types
156
+
157
+ - `ProtocolView`: wire-format view returned by a backend
158
+ - `ClientView`: browser-local mounted view with function handlers
159
+ - `UIView`: alias of `ProtocolView` for the schema contract
160
+
161
+ ## Adapters
162
+
163
+ - Python: [`adapters/python`](./adapters/python)
164
+ - Go: [`adapters/go`](./adapters/go)
165
+
166
+ Both adapters emit the same protocol shape that the TypeScript runtime accepts, and both include validation helpers.
167
+
168
+ ## Official Starters
169
+
170
+ - Starter index: [`starters`](./starters)
171
+ - Python CRM starter: [`starters/python-crm`](./starters/python-crm)
172
+
173
+ If you want the best starting point for a new CUP app, begin with [`starters/python-crm`](./starters/python-crm).
174
+
175
+ ## Reference Demos
176
+
177
+ - Simple login demo: [`demo/login`](./demo/login)
178
+ - Structured CRM app: [`demo/dashboard2`](./demo/dashboard2)
179
+ - Financial dashboard prototype: [`demo/dashboard`](./demo/dashboard)
180
+
181
+ Use demos to study patterns and flows. Use starters when you want to begin a new project.
182
+
183
+ ## Development
184
+
185
+ ```bash
186
+ npm install
187
+ npm run build
188
+ npm run test
189
+ ```
190
+
191
+ Useful demo commands:
192
+
193
+ ```bash
194
+ python3 demo/login/server.py
195
+ python3 demo/dashboard/server.py
196
+ python3 demo/dashboard2/server.py
197
+ python3 starters/python-crm/server.py
198
+ ```
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ var Z=new Map,ne=/(\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\})/g,v=class extends Error{constructor(t){super(t),this.name="TemplateError"}};function K(e,t){let n=ee(e);return O(n,t)}function ee(e){let t=Z.get(e);if(t)return t;let n=re(e),{nodes:o,index:r}=U(n,0);if(r!==n.length)throw new v(`Unexpected trailing token near "${ue(n[r])}"`);return Z.set(e,o),o}function re(e){let t=[],n=0;for(let o of e.matchAll(ne)){let r=o[0],i=o.index??0;i>n&&t.push({type:"text",value:e.slice(n,i)}),r.startsWith("{{")?t.push(oe(r)):t.push({type:"tag",value:r.slice(2,-2).trim()}),n=i+r.length}return n<e.length&&t.push({type:"text",value:e.slice(n)}),t}function oe(e){let t=e.slice(2,-2).trim();if(!t)throw new v("Empty variable expression");let n=t.split("|").map(a=>a.trim()).filter(Boolean);if(n.length===0)throw new v("Empty variable expression");let[o,...r]=n,i=r.includes("safe"),s=r.filter(a=>a!=="safe");if(s.length>0)throw new v(`Unsupported filter(s): ${s.join(", ")}`);return{type:"var",expression:o,safe:i}}function U(e,t,n=[]){let o=[],r=t;for(;r<e.length;){let i=e[r];if(i.type==="text"){o.push({type:"text",value:i.value}),r+=1;continue}if(i.type==="var"){o.push({type:"var",expression:i.expression,safe:i.safe}),r+=1;continue}let s=D(i.value);if(n.includes(s.kind))return{nodes:o,index:r,closingTag:s.raw};switch(s.kind){case"if":{let a=ie(e,r,s.expression);o.push(a.node),r=a.index;break}case"for":{let a=se(e,r,s.itemName,s.listPath);o.push(a.node),r=a.index;break}default:throw new v(`Unexpected "${s.raw}"`)}}if(n.length>0)throw new v(`Expected one of ${n.join(", ")}`);return{nodes:o,index:r}}function ie(e,t,n){let o=[],r=null,i=t+1,s=n;for(;i<=e.length;){let a=U(e,i,["elif","else","endif"]);if(o.push({expression:s,body:a.nodes}),!a.closingTag)throw new v("Unclosed if block");let u=D(a.closingTag);if(u.kind==="endif")return{node:{type:"if",branches:o,elseBody:r},index:a.index+1};if(u.kind==="else"){let c=U(e,a.index+1,["endif"]);if(r=c.nodes,!c.closingTag||D(c.closingTag).kind!=="endif")throw new v("Unclosed else block");return{node:{type:"if",branches:o,elseBody:r},index:c.index+1}}if(u.kind!=="elif")throw new v(`Unexpected "${u.raw}" inside if block`);s=u.expression,i=a.index+1}throw new v("Unclosed if block")}function se(e,t,n,o){let r=U(e,t+1,["endfor"]);if(!r.closingTag||D(r.closingTag).kind!=="endfor")throw new v("Unclosed for block");return{node:{type:"for",itemName:n,listPath:o,body:r.nodes},index:r.index+1}}function D(e){if(e.startsWith("if "))return{kind:"if",expression:e.slice(3).trim(),raw:e};if(e.startsWith("elif "))return{kind:"elif",expression:e.slice(5).trim(),raw:e};if(e==="else")return{kind:"else",raw:e};if(e==="endif")return{kind:"endif",raw:e};let t=e.match(/^for\s+(\w+)\s+in\s+(.+)$/);if(t)return{kind:"for",itemName:t[1],listPath:t[2].trim(),raw:e};if(e==="endfor")return{kind:"endfor",raw:e};throw new v(`Unsupported tag "${e}"`)}function O(e,t){return e.map(n=>ae(n,t)).join("")}function ae(e,t){switch(e.type){case"text":return e.value;case"var":{let n=W(e.expression,t);if(n==null)return"";let o=String(n);return e.safe?o:le(o)}case"for":{let n=W(e.listPath,t);return Array.isArray(n)?n.map((o,r)=>{let i={...t,[e.itemName]:o,loop:{index:r,index1:r+1,first:r===0,last:r===n.length-1}};return O(e.body,i)}).join(""):""}case"if":{for(let n of e.branches)if(J(n.expression,t))return O(n.body,t);return e.elseBody?O(e.elseBody,t):""}}}function W(e,t){return e.trim().split(".").reduce((n,o)=>{if(n!==null&&typeof n=="object")return n[o]},t)}function J(e,t){let n=e.trim();if(!n)throw new v("Empty if condition");if(n.startsWith("not "))return!J(n.slice(4),t);if(n.startsWith("!"))return!J(n.slice(1),t);let o=n.match(/^(.+?)\s*(==|!=|>=|<=|>|<)\s*(.+)$/);if(o){let r=_(o[1].trim(),t),i=_(o[3].trim(),t);switch(o[2]){case"==":return r===i;case"!=":return r!==i;case">":return Number(r)>Number(i);case"<":return Number(r)<Number(i);case">=":return Number(r)>=Number(i);case"<=":return Number(r)<=Number(i)}}return ce(_(n,t))}function _(e,t){let n=e.trim();return n.startsWith('"')&&n.endsWith('"')||n.startsWith("'")&&n.endsWith("'")?n.slice(1,-1):n==="true"?!0:n==="false"?!1:n==="null"?null:/^-?\d+(?:\.\d+)?$/.test(n)?Number(n):W(n,t)}function ce(e){return!!e&&e!==""&&e!==0}function le(e){return e.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function ue(e){switch(e.type){case"text":return e.value.trim()||"[text]";case"var":return`{{ ${e.expression} }}`;case"tag":return`{% ${e.value} %}`}}var z=Symbol("cup-action-cleanup"),$=Symbol("cup-bind-cleanup");function de(e){return e?Array.isArray(e)?e:[e]:[z,$]}function fe(e){return[e,...Array.from(e.querySelectorAll("*"))]}function V(e,t,n){let o=e;o[t]??(o[t]=[]),o[t].push(n)}function L(e,t){let n=de(t);for(let o of fe(e))for(let r of n){let i=o[r];if(i){for(let s of i.splice(0,i.length))s();delete o[r]}}}function T(e,t){L(e);let n=K(t.template,t.state);e.innerHTML=n,t.actions&&pe(e,t.actions,t.state)}function me(e,t){return T(e,t),n=>{T(e,{...t,state:n})}}function pe(e,t,n){let o=new Set;e.querySelectorAll("[data-action]").forEach(r=>{o.add(r.dataset.event??"click")});for(let r of o){let i=s=>{let a=s.target instanceof Element?s.target.closest("[data-action]"):null;if(!a||!e.contains(a))return;let u=a.dataset.action;if(!u||(a.dataset.event??"click")!==r)return;let d=t[u];if(!d){console.warn(`[CUP] No action defined for "${u}"`);return}Promise.resolve(d(n,s)).catch(f=>{console.error(`[CUP] action "${u}" failed`,f)})};e.addEventListener(r,i),V(e,z,()=>{e.removeEventListener(r,i)})}}function I(e){L(e)}function ge(e,t){L(e,$),e.querySelectorAll("[data-bind]").forEach(n=>{let o=n.dataset.bind,r=t[o];if(!r){H(o,n);return}n.textContent=String(r.get()??"");let i=r.subscribe(s=>{n.textContent=String(s??"")});V(n,$,i)}),e.querySelectorAll("[data-bind-attr]").forEach(n=>{let o=n.dataset.bindAttr,[r,i]=o.split(":").map(u=>u.trim());if(!r||!i)return;let s=t[i];if(!s){H(i,n);return}n.setAttribute(r,String(s.get()??""));let a=s.subscribe(u=>{n.setAttribute(r,String(u??""))});V(n,$,a)}),e.querySelectorAll("[data-bind-class]").forEach(n=>{n.dataset.bindClass.split(/\s+/).forEach(r=>{let[i,s]=r.split(":").map(c=>c.trim());if(!i||!s)return;let a=t[s];if(!a){H(s,n);return}n.classList.toggle(i,!!a.get());let u=a.subscribe(c=>{n.classList.toggle(i,!!c)});V(n,$,u)})}),e.querySelectorAll("[data-bind-show]").forEach(n=>{let o=n.dataset.bindShow,r=t[o];if(!r){H(o,n);return}n.style.display=r.get()?"":"none";let i=r.subscribe(s=>{n.style.display=s?"":"none"});V(n,$,i)})}function H(e,t){console.warn(`[CUP] No signal named "${e}" for`,t)}function we(e,t,n){let o=typeof e=="string"?document.querySelector(e)??(()=>{throw new Error(`[CUP] cssState: no element matches "${e}"`)})():e,r=Object.values(t).flat(),i=Object.keys(t),s=n??i[0],a=new Set;u(s);function u(c){if(!(c in t)){console.warn(`[CUP] cssState: unknown state "${c}". Valid: ${i.join(", ")}`);return}o.classList.remove(...r);let d=t[c];d&&d.length&&o.classList.add(...d),s=c,a.forEach(f=>f(c))}return{set(c){u(c)},async transition(c){u(c),await M(o)},get(){return s},subscribe(c){return a.add(c),()=>a.delete(c)}}}async function he(e,t,n=2e3){e.classList.add(t),await Promise.race([te(e),new Promise(o=>setTimeout(o,n))]),e.classList.remove(t)}function ye(e,t=1e3){return M(e,{timeout:t,includeAnimations:!1})}function M(e,t={}){return new Promise(n=>{let o=t.timeout??1e3,r=t.includeTransitions??!0,i=t.includeAnimations??!0,s=0,a=!1,u=()=>{a=!0,s+=1},c=()=>{s=Math.max(0,s-1),a&&s===0&&f()},d=setTimeout(f,o);function f(){clearTimeout(d),r&&(e.removeEventListener("transitionstart",u),e.removeEventListener("transitionend",c),e.removeEventListener("transitioncancel",c)),i&&(e.removeEventListener("animationstart",u),e.removeEventListener("animationend",c),e.removeEventListener("animationcancel",c)),n()}r&&(e.addEventListener("transitionstart",u),e.addEventListener("transitionend",c),e.addEventListener("transitioncancel",c)),i&&(e.addEventListener("animationstart",u),e.addEventListener("animationend",c),e.addEventListener("animationcancel",c)),requestAnimationFrame(()=>{!a&&s===0&&f()})})}function te(e,t=2e3){return M(e,{timeout:t,includeAnimations:!0,includeTransitions:!1})}function ve(e,t=e[0],n=document.documentElement){let o=t;return n.classList.add(o),{set(r){n.classList.remove(...e),n.classList.add(r),o=r},toggle(){let r=e.find(i=>i!==o)??e[0];this.set(r)},get(){return o}}}var be=new Set(["type","url","method","payload","event","detail","replace"]),xe=new Set(["version","lang","generator","title","route"]),Ee=new Set(["template","state","actions","meta"]),Se=new Set(["GET","POST","PUT","PATCH","DELETE"]),q=class extends Error{constructor(t){super(`Invalid CUP protocol view:
2
+ ${t.map(n=>`- ${n}`).join(`
3
+ `)}`),this.name="ValidationError",this.issues=t}};function B(e){let t=[];if(Pe(e,"view",t),t.length>0)throw new q(t);return e}function Pe(e,t,n){if(!R(e)){n.push(`${t} must be an object`);return}if(Q(e,Ee,t,n),typeof e.template!="string"&&n.push(`${t}.template must be a string`),R(e.state)?Y(e.state,`${t}.state`,n):n.push(`${t}.state must be an object`),e.actions!==void 0)if(!R(e.actions))n.push(`${t}.actions must be an object`);else for(let[o,r]of Object.entries(e.actions))Ae(r,`${t}.actions.${o}`,n);e.meta!==void 0&&Ne(e.meta,`${t}.meta`,n)}function Ae(e,t,n){if(!R(e)){n.push(`${t} must be an object`);return}switch(Q(e,be,t,n),e.type){case"fetch":Te(e,t,n);return;case"emit":Ce(e,t,n);return;case"navigate":ke(e,t,n);return;default:n.push(`${t}.type must be one of fetch, emit, navigate`)}}function Te(e,t,n){typeof e.url!="string"&&n.push(`${t}.url must be a string`),e.method!==void 0&&!Se.has(e.method)&&n.push(`${t}.method must be one of GET, POST, PUT, PATCH, DELETE`),e.payload!==void 0&&(R(e.payload)?Y(e.payload,`${t}.payload`,n):n.push(`${t}.payload must be an object`))}function Ce(e,t,n){typeof e.event!="string"&&n.push(`${t}.event must be a string`),e.detail!==void 0&&(R(e.detail)?Y(e.detail,`${t}.detail`,n):n.push(`${t}.detail must be an object`))}function ke(e,t,n){typeof e.url!="string"&&n.push(`${t}.url must be a string`),e.replace!==void 0&&typeof e.replace!="boolean"&&n.push(`${t}.replace must be a boolean`)}function Ne(e,t,n){if(!R(e)){n.push(`${t} must be an object`);return}Q(e,xe,t,n);let o=e;o.version!==void 0&&o.version!=="1"&&n.push(`${t}.version must be "1"`);for(let r of["lang","generator","title","route"]){let i=o[r];i!==void 0&&typeof i!="string"&&n.push(`${t}.${r} must be a string`)}}function Y(e,t,n){for(let[o,r]of Object.entries(e))G(r,`${t}.${o}`,n)}function G(e,t,n){if(!(e===null||typeof e=="string"||typeof e=="number"||typeof e=="boolean")){if(Array.isArray(e)){e.forEach((o,r)=>G(o,`${t}[${r}]`,n));return}if(R(e)){for(let[o,r]of Object.entries(e))G(r,`${t}.${o}`,n);return}n.push(`${t} must be JSON-serializable`)}}function Q(e,t,n,o){for(let r of Object.keys(e))t.has(r)||o.push(`${n} contains unsupported property "${r}"`)}function R(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}async function Re(e,t,n={}){let o={},r=!1,i=n.fetchImpl??fetch;async function s(d,f="GET",b){let x=new AbortController,h=n.timeoutMs??5e3,A={"Content-Type":"application/json",...n.headers??{}},C={method:f,headers:A,signal:x.signal},m=setTimeout(()=>x.abort(),h);b!==void 0&&(f==="GET"?d=Le(d,b):C.body=JSON.stringify(b));try{let g=await i(d,C);if(!g.ok)throw new Error(`[CUP] fetchView: ${f} ${d} -> ${g.status} ${g.statusText}`);let w=await g.json();return n.validate===!1?w:B(w)}catch(g){let w=g instanceof Error?g:new Error(String(g));throw n.onError?.(w,{url:d,method:f}),w}finally{clearTimeout(m)}}function a(d){o=d.state,I(t);let f={template:d.template,state:d.state,actions:u(d,t)};T(t,f),d.meta?.lang&&console.debug(`[CUP] mounted view from ${d.meta.generator??d.meta.lang}`)}function u(d,f){if(!d.actions)return;let b={};for(let[x,h]of Object.entries(d.actions))switch(h.type){case"fetch":b[x]=async(A,C)=>{try{let m=await s(h.url,h.method??"POST",{...o,...h.payload??{}});a(m)}catch(m){let g=m instanceof Error?m:new Error(String(m));n.onError?.(g,{url:h.url,method:h.method??"POST"}),C instanceof Event||console.error(`[CUP] action "${x}" failed:`,g)}};break;case"emit":b[x]=()=>{f.dispatchEvent(new CustomEvent(h.event,{bubbles:!0,detail:h.detail??{}}))};break;case"navigate":b[x]=()=>{h.replace?history.replaceState(null,"",h.url):history.pushState(null,"",h.url),window.dispatchEvent(new PopStateEvent("popstate"))};break}return b}let c=await s(e);if(r)throw new Error("[CUP] remote mount destroyed before initial load completed");return a(c),{async refresh(){if(r)return;let d=await s(e);a(d)},destroy(){r=!0,I(t),t.innerHTML=""}}}function $e(e,t,n={}){let o=n.validate===!1?e:B(e),r={template:o.template,state:o.state,actions:Ve(o,t)};T(t,r)}function Ve(e,t){if(!e.actions)return;let n={};for(let[o,r]of Object.entries(e.actions))r.type==="emit"?n[o]=()=>{t.dispatchEvent(new CustomEvent(r.event,{bubbles:!0,detail:r.detail??{}}))}:r.type==="navigate"&&(n[o]=()=>{history.pushState(null,"",r.url),window.dispatchEvent(new PopStateEvent("popstate"))});return n}function Le(e,t){let n=new URL(e,window.location.origin);for(let[o,r]of Object.entries(t))n.searchParams.set(o,Me(r));return n.toString()}function Me(e){return e===null?"null":typeof e=="string"||typeof e=="number"||typeof e=="boolean"?String(e):JSON.stringify(e)}function P(e){let t=e,n=new Set;return{get(){return t},set(o){t=o;for(let r of n)r(t)},subscribe(o){return n.add(o),()=>n.delete(o)}}}function je(e="CUP"){return async(t,n)=>{let o=performance.now();console.debug(`[${e}] action "${t.name}" start`),await n();let r=(performance.now()-o).toFixed(1);t.isAborted()?console.debug(`[${e}] action "${t.name}" aborted (${r}ms)`):console.debug(`[${e}] action "${t.name}" done (${r}ms)`)}}function Oe(e){return async(t,n)=>{e.loading,e.loadingCount,e.activeActions,await n()}}function Ue(e){return async(t,n)=>{e.error.set(null);try{await n()}catch(o){throw e.error.set(o instanceof Error?o:new Error(String(o))),console.error(`[CUP] action "${t.name}" threw:`,o),o}}}function De(e){return async(t,n)=>{await new Promise(o=>setTimeout(o,e)),await n()}}function He(e,t){let n=[],o=new Map,r=new Map,i=new Set,s=!1,a=P(!1),u=P(0),c=P([]),d=P(null),f={...t.state};async function b(l,p){let y=!1;l.abort=()=>{y=!0},l.isAborted=()=>y;let S=[...n,async E=>{E.isAborted()||await p.handler(E)}],k=0,N=async()=>{if(y)return;let E=S[k++];E&&await E(l,N)};await N()}function x(l,p){if(!l.optimistic)return null;let y=l.optimistic({...f},p);return f=y,A(),y}function h(l){f=l,A()}function A(){let l={template:t.template,state:f,actions:C()};T(e,l)}function C(){let l={};for(let p of o.keys())l[p]=(y,S)=>{g(p,void 0,S)};return l}async function m(l,p,y){if(s)throw new Error("[CUP] dispatcher has been destroyed");if(i.has(l))if(p.queue)await new Promise(E=>{let X=r.get(l)??[];X.push(E),r.set(l,X)});else{console.warn(`[CUP] action "${l}" already running \u2014 call dropped. Set queue:true to serialise.`);return}i.add(l),w();let S={...f},k=x(p,y),N={name:l,state:f,payload:y,container:e,event:void 0,abort:()=>{},isAborted:()=>!1};try{await b(N,p),N.state!==f&&(f={...f,...N.state}),A()}catch(E){throw k&&h(S),E}finally{i.delete(l),w();let E=r.get(l)?.shift();E&&E()}}async function g(l,p,y){let S=o.get(l);if(!S){console.warn(`[CUP] dispatch: no handler for action "${l}"`);return}await m(l,{...S,handler:async k=>S.handler({...k,event:y})},p)}function w(){let l=Array.from(i.values());c.set(l),u.set(l.length),a.set(l.length>0)}return{loading:a,loadingCount:u,activeActions:c,error:d,use(l){return n.push(l),this},register(l,p){let y=typeof p=="function"?{handler:p}:p;return o.set(l,y),this},async dispatch(l,p){await g(l,p)},mount(){if(s)throw new Error("[CUP] dispatcher has been destroyed");f={...t.state},A()},destroy(){s=!0,L(e),o.clear(),r.clear(),i.clear(),w()}}}function Ie(e){let t=[];if(e.path==="*")return{regex:/.*/,paramNames:[],definition:e};let n=e.path.replace(/\//g,"\\/").replace(/:(\w+)/g,(o,r)=>(t.push(r),"([^\\/]+)"));return{regex:new RegExp(`^${n}$`),paramNames:t,definition:e}}function qe(e,t){for(let n of e){let o=t.match(n.regex);if(o){let r={};return n.paramNames.forEach((i,s)=>{r[i]=decodeURIComponent(o[s+1]??"")}),{route:n,params:r}}}return null}var Be={fade:{exit:"route-exit-fade",enter:"route-enter-fade"},slide:{exit:"route-exit-slide",enter:"route-enter-slide"},none:{exit:"",enter:""}};async function Fe(e,t,n){let{exit:o,enter:r}=Be[t];if(t==="none"){n();return}o&&(e.classList.add(o),await M(e,{timeout:400}),e.classList.remove(o)),n(),r&&(e.classList.add(r),e.offsetHeight,requestAnimationFrame(()=>{requestAnimationFrame(()=>{e.classList.remove(r)})}),await M(e,{timeout:400}))}function _e(e){let{routes:t,transition:n="fade",base:o=""}=e,r=t.map(m=>Ie(m)),i=We(o),s=P(F(location.pathname,i)),a=P({}),u=P(new URLSearchParams(location.search)),c=P(!1),d=null,f=Promise.resolve(),b=!1,x=m=>{if(m.defaultPrevented||m.button!==0||m.metaKey||m.ctrlKey||m.shiftKey||m.altKey)return;let g=m.target?.closest("a[data-link]");if(!g||g.target&&g.target!=="_self")return;let w=g.getAttribute("href");!w||w.startsWith("http://")||w.startsWith("https://")||w.startsWith("mailto:")||(m.preventDefault(),C.navigate(w))};async function h(m,g){let w=qe(r,m);if(!w)return;let{route:j,params:l}=w,p=j.definition,y=new URLSearchParams(g),S=typeof p.view=="function"?await p.view(l,y):p.view;s.set(m),a.set(l),u.set(y),p.title&&(document.title=p.title);let k=d;if(!k)return;let N=Ke(p.transition??n);c.set(!0),await Fe(k,N,()=>{T(k,S)}),c.set(!1),window.scrollTo({top:0,behavior:"smooth"})}function A(){f=f.then(()=>h(F(location.pathname,i),location.search))}let C={current:s,params:a,query:u,transitioning:c,async start(m){b||(b=!0,d=m,window.addEventListener("popstate",A),document.addEventListener("click",x),await h(F(location.pathname,i),location.search))},async navigate(m,{replace:g=!1,state:w=null}={}){let j=Je(m,i);return g?history.replaceState(w,"",j):history.pushState(w,"",j),f=f.then(()=>h(F(location.pathname,i),location.search)),f},back(){history.back()},forward(){history.forward()},destroy(){b=!1,window.removeEventListener("popstate",A),document.removeEventListener("click",x),d=null}};return C}function We(e){if(!e)return"";let t=e.startsWith("/")?e:`/${e}`;return t.endsWith("/")?t.slice(0,-1):t}function F(e,t){return t?e===t?"/":e.startsWith(`${t}/`)?e.slice(t.length):e:e}function Je(e,t){return t?e==="/"?t||"/":`${t}${e.startsWith("/")?e:`/${e}`}`:e}function Ke(e){return typeof e=="function"?e():e}export{v as TemplateError,q as ValidationError,he as animate,ge as bind,He as createDispatcher,me as createMountUpdater,_e as createRouter,P as createSignal,we as cssState,De as delayMiddleware,Ue as errorMiddleware,Re as fetchView,Oe as loadingMiddleware,je as loggerMiddleware,T as mount,$e as mountRemoteView,ee as parseTemplate,K as render,ve as theme,I as unbind,B as validateProtocolView,te as waitAnimation,M as waitForVisualCompletion,ye as waitTransition};
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@tosiiko/cup",
3
+ "version": "0.1.3",
4
+ "description": "CUP universal UI runtime for protocol-driven browser rendering",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "schema",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./schema/uiview.v1.json": "./schema/uiview.v1.json"
21
+ },
22
+ "sideEffects": false,
23
+ "scripts": {
24
+ "build": "node scripts/build.mjs",
25
+ "check": "npm run build && npm run test",
26
+ "demo:build": "vite build examples/demo --outDir ../../dist-demo",
27
+ "demo:dev": "vite examples/demo",
28
+ "pack:check": "npm_config_cache=/tmp/cup-npm-cache npm pack --dry-run",
29
+ "test": "npm run test:ts && npm run test:python && npm run test:go",
30
+ "test:go": "cd adapters/go && GOCACHE=/tmp/cup-go-cache go test ./...",
31
+ "test:python": "python3 -m unittest discover -s adapters/python/tests -p 'test_*.py'",
32
+ "test:ts": "vitest run"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.10.2",
36
+ "esbuild": "^0.21.5",
37
+ "jsdom": "^24.1.3",
38
+ "typescript": "^5.4.0",
39
+ "vite": "^5.2.0",
40
+ "vitest": "^1.6.1"
41
+ }
42
+ }
@@ -0,0 +1,99 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://cup-protocol.dev/schema/uiview/v1",
4
+ "title": "UIView",
5
+ "description": "CUP universal UI contract. Any backend language produces this JSON; the CUP TypeScript runtime renders it.",
6
+ "type": "object",
7
+ "required": ["template", "state"],
8
+ "additionalProperties": false,
9
+
10
+ "properties": {
11
+
12
+ "template": {
13
+ "type": "string",
14
+ "description": "Django-style template string. Supports {{ var }}, {% if %}, {% for %}, {% elif %}, {% else %}."
15
+ },
16
+
17
+ "state": {
18
+ "type": "object",
19
+ "description": "Flat or nested data model. All values must be JSON-serialisable.",
20
+ "additionalProperties": true
21
+ },
22
+
23
+ "actions": {
24
+ "type": "object",
25
+ "description": "Named actions bound to data-action attributes in the template. Values are ActionDescriptors.",
26
+ "additionalProperties": { "$ref": "#/$defs/ActionDescriptor" }
27
+ },
28
+
29
+ "meta": {
30
+ "$ref": "#/$defs/Meta"
31
+ }
32
+ },
33
+
34
+ "$defs": {
35
+
36
+ "ActionDescriptor": {
37
+ "description": "Describes what happens when a data-action is triggered.",
38
+ "oneOf": [
39
+ { "$ref": "#/$defs/FetchAction" },
40
+ { "$ref": "#/$defs/EmitAction" },
41
+ { "$ref": "#/$defs/NavigateAction" }
42
+ ]
43
+ },
44
+
45
+ "FetchAction": {
46
+ "type": "object",
47
+ "description": "POST/GET to a server endpoint. The response must be a UIView; the runtime remounts with it.",
48
+ "required": ["type", "url"],
49
+ "additionalProperties": false,
50
+ "properties": {
51
+ "type": { "const": "fetch" },
52
+ "url": { "type": "string", "description": "Endpoint URL (absolute or relative)." },
53
+ "method": { "type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], "default": "POST" },
54
+ "payload": {
55
+ "type": "object",
56
+ "description": "Extra key/value pairs merged into the request body alongside the current state snapshot.",
57
+ "additionalProperties": true
58
+ }
59
+ }
60
+ },
61
+
62
+ "EmitAction": {
63
+ "type": "object",
64
+ "description": "Dispatches a CustomEvent on the container element. Useful for parent-level listeners.",
65
+ "required": ["type", "event"],
66
+ "additionalProperties": false,
67
+ "properties": {
68
+ "type": { "const": "emit" },
69
+ "event": { "type": "string", "description": "CustomEvent name." },
70
+ "detail": { "type": "object", "additionalProperties": true }
71
+ }
72
+ },
73
+
74
+ "NavigateAction": {
75
+ "type": "object",
76
+ "description": "Client-side navigation via the History API.",
77
+ "required": ["type", "url"],
78
+ "additionalProperties": false,
79
+ "properties": {
80
+ "type": { "const": "navigate" },
81
+ "url": { "type": "string" },
82
+ "replace": { "type": "boolean", "default": false }
83
+ }
84
+ },
85
+
86
+ "Meta": {
87
+ "type": "object",
88
+ "description": "Optional metadata about this view.",
89
+ "additionalProperties": false,
90
+ "properties": {
91
+ "version": { "type": "string", "description": "Schema version. Must be '1'.", "const": "1" },
92
+ "lang": { "type": "string", "description": "Backend language that produced this view, e.g. 'python', 'go', 'rust'." },
93
+ "generator": { "type": "string", "description": "Adapter name + version, e.g. 'cup-python/0.1.0'." },
94
+ "title": { "type": "string", "description": "Human-readable view title for devtools." },
95
+ "route": { "type": "string", "description": "The server route that produced this view." }
96
+ }
97
+ }
98
+ }
99
+ }