@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 +21 -0
- package/README.md +198 -0
- package/dist/index.js +3 -0
- package/package.json +42 -0
- package/schema/uiview.v1.json +99 -0
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}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
|
+
}
|