tutuca 0.9.44 → 0.9.45
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/dist/tutuca-cli.js +3764 -81
- package/dist/tutuca-dev.js +3851 -109
- package/dist/tutuca-dev.min.js +4 -3
- package/dist/tutuca-extra.js +4 -0
- package/dist/tutuca-extra.min.js +1 -1
- package/dist/tutuca.js +4 -0
- package/dist/tutuca.min.js +1 -1
- package/package.json +3 -2
- package/skill/tutuca/advanced.md +3 -4
- package/skill/tutuca/cli.md +78 -7
- package/skill/tutuca/core.md +19 -6
- package/skill/SKILL.md +0 -46
- package/skill/advanced.md +0 -146
- package/skill/cli.md +0 -117
- package/skill/core.md +0 -766
package/skill/core.md
DELETED
|
@@ -1,766 +0,0 @@
|
|
|
1
|
-
# Tutuca Cheatsheet — Core
|
|
2
|
-
|
|
3
|
-
Tutuca is an immutable-state web framework: components have typed `fields`,
|
|
4
|
-
auto-generated mutators (`setX`, `pushInX`, …), HTML-template `view`s with
|
|
5
|
-
`@`-prefixed directives, and `bubble` / `receive` / `response` handlers for
|
|
6
|
-
orchestration. Read this file when authoring or reviewing
|
|
7
|
-
`component({...})` definitions, `view: html\`...\`` templates, macros, or
|
|
8
|
-
the `tutuca` CLI.
|
|
9
|
-
|
|
10
|
-
> Advanced topics (drag & drop, dynamic bindings `*x`, pseudo-`x` for
|
|
11
|
-
> `<select>`/`<table>`/`<tr>`, custom seq types, Tailwind/MargaUI
|
|
12
|
-
> compilation): see [advanced.md](./advanced.md). CLI commands, flags,
|
|
13
|
-
> exit codes, and full linter rule list: see [cli.md](./cli.md). Read
|
|
14
|
-
> those only when the task touches them.
|
|
15
|
-
|
|
16
|
-
## Verifying changes
|
|
17
|
-
|
|
18
|
-
After editing a Tutuca module, run two checks before declaring the edit
|
|
19
|
-
done:
|
|
20
|
-
|
|
21
|
-
1. **Lint the module** — catches undefined fields/handlers/macros/events
|
|
22
|
-
(all the `*_NOT_DEFINED` / `*_NOT_REFERENCED` codes):
|
|
23
|
-
|
|
24
|
-
tutuca <module-path> lint
|
|
25
|
-
|
|
26
|
-
Exits `2` on any error-level finding. Pass a component name to scope
|
|
27
|
-
it: `tutuca <module-path> lint Button`.
|
|
28
|
-
|
|
29
|
-
2. **Render the example(s) that exercise the feature you changed** —
|
|
30
|
-
confirms the component actually mounts in a headless DOM with the new
|
|
31
|
-
behavior. Pick the example whose `title` matches the feature, or
|
|
32
|
-
filter by component:
|
|
33
|
-
|
|
34
|
-
tutuca <module-path> render --title "Disabled state"
|
|
35
|
-
tutuca <module-path> render Button
|
|
36
|
-
|
|
37
|
-
Exits `3` if any render crashes. If no example covers the feature
|
|
38
|
-
you're adding, add one to `getExamples()` first — that's how the
|
|
39
|
-
feature becomes verifiable. Add `--pretty` when you need to read the
|
|
40
|
-
emitted HTML to verify structure (attributes, nesting, text); omit it
|
|
41
|
-
when you only care that the render didn't crash.
|
|
42
|
-
|
|
43
|
-
Full reference: [cli.md](./cli.md).
|
|
44
|
-
|
|
45
|
-
## Common pitfalls
|
|
46
|
-
|
|
47
|
-
- **Paths are not allowed in values.** `.foo` resolves a single field or
|
|
48
|
-
method on `this` — `@text=".foo.bar"`, `:value=".user.name"`,
|
|
49
|
-
`@show=".item.isOpen"` all fail. To reach into nested data: render the
|
|
50
|
-
child as a component (`<x render=".foo">` then `@text=".bar"` inside),
|
|
51
|
-
add a method (`fullName() { return this.user.name; }` and use
|
|
52
|
-
`.fullName`), or use `@enrich-with` for scope-level derivation.
|
|
53
|
-
- **Coercion is shallow.** `setItems([{a:1}])` stores plain objects inside
|
|
54
|
-
the `List`. Wrap each item in `Comp.make({...})` or run inputs through
|
|
55
|
-
immutable's `fromJS` if you need deep coercion. See *Component Skeleton*.
|
|
56
|
-
- **Multiple `@if.<attr>` on one element.** Every `@then`/`@else` after
|
|
57
|
-
the first must name the attr (`@then.title`, `@else.title`) — HTML
|
|
58
|
-
disallows duplicate attrs, so the second `@then=` is dropped silently.
|
|
59
|
-
- **Bare unquoted multi-word strings outside `{…}` return `null`.** Either
|
|
60
|
-
quote (`'flex gap-3'`) or use template form (`flex gap-3 {.color}`).
|
|
61
|
-
- **`<x>` is stripped inside `<select>` / `<table>` / `<tr>`.** Use the
|
|
62
|
-
`@x` pseudo-x trick (see [advanced.md](./advanced.md)).
|
|
63
|
-
- **`receive.init` is a convention, not a lifecycle hook.** Nothing calls it
|
|
64
|
-
automatically — dispatch via `app.sendAtRoot("init")` or from
|
|
65
|
-
another handler.
|
|
66
|
-
- **`app.state.set(...)` takes a component instance**, not plain data.
|
|
67
|
-
Build with `Comp.make({...})`.
|
|
68
|
-
- **`html\`` templates must start with the opening tag.** A leading
|
|
69
|
-
newline / indent before the first element renders blank silently.
|
|
70
|
-
Use `view: html\`<el ...>` (or `html\`<el<newline> attr<newline>>...`),
|
|
71
|
-
never `view: html\`<newline> <el ...>`. Same applies to macro bodies.
|
|
72
|
-
- **Macro registry keys are normalized to lowercase.** The HTML parser
|
|
73
|
-
lowercases custom tag names, so `<x:Card>` is read as `<x:card>`.
|
|
74
|
-
`registerMacros({ Card })` is fine — the key is stored as `card`.
|
|
75
|
-
Registering two different macros under the same lowercased name (e.g.
|
|
76
|
-
`Card` and `card`) warns via `console.assert`; the later one wins.
|
|
77
|
-
|
|
78
|
-
## Bootstrap
|
|
79
|
-
|
|
80
|
-
```js
|
|
81
|
-
import { component, html, tutuca } from "tutuca";
|
|
82
|
-
|
|
83
|
-
const Counter = component({
|
|
84
|
-
name: "Counter",
|
|
85
|
-
fields: { count: 0 },
|
|
86
|
-
methods: {
|
|
87
|
-
inc() {
|
|
88
|
-
return this.setCount(this.count + 1);
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
view: html`<button @on.click=".inc" @text=".count"></button>`,
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
const app = tutuca("#app");
|
|
95
|
-
app.registerComponents([Counter]);
|
|
96
|
-
app.state.set(Counter.make({}));
|
|
97
|
-
app.start();
|
|
98
|
-
```
|
|
99
|
-
|
|
100
|
-
`app.onChange((info) => ...)` fires after every state change with
|
|
101
|
-
`{ val, old, info, timestamp }` (logging, persistence). `app.stop()`
|
|
102
|
-
removes all listeners and cancels cache eviction; pair with
|
|
103
|
-
`app.start()` for teardown in tests or SPA navigation.
|
|
104
|
-
|
|
105
|
-
## Notation Reference
|
|
106
|
-
|
|
107
|
-
| Prefix | Means | Example |
|
|
108
|
-
| -------- | ----------------------------------------- | --------------------- |
|
|
109
|
-
| `.x` | field or method on `this` (single-level — no `.foo.bar` paths) | `.count`, `.inc` |
|
|
110
|
-
| `@x` | local binding (loop / scope) | `@key`, `@value` |
|
|
111
|
-
| `^x` | macro parameter | `^label` |
|
|
112
|
-
| `!x` | request handler | `!loadData` |
|
|
113
|
-
| `*x` | dynamic binding — see `advanced.md` | `*theme` |
|
|
114
|
-
| `Name` | component type (PascalCase) | `Item`, `JsonNull` |
|
|
115
|
-
| `name` | bare identifier — meaning depends on slot | `dec`, `value`, `ctx` |
|
|
116
|
-
| `'str'` | string literal | `'btn btn-success'` |
|
|
117
|
-
| `{expr}` | interpolation in attr text | `Hi {.name}` |
|
|
118
|
-
| `.s[.k]` | sequence/map item access | `.byKey[.currentKey]` |
|
|
119
|
-
|
|
120
|
-
A bare `name` (no prefix) in `@on.<event>="<handler> <arg> <arg>..."`
|
|
121
|
-
resolves by slot:
|
|
122
|
-
|
|
123
|
-
- **First slot** — handler name looked up in `input` / `alter` (use
|
|
124
|
-
`.name` for `methods`).
|
|
125
|
-
- **Subsequent slots** — built-in handler arg name (full list in
|
|
126
|
-
*Event Handling*); anything else triggers a lint warning.
|
|
127
|
-
|
|
128
|
-
```html
|
|
129
|
-
<button @on.click="addItem JsonSelector ctx">+</button>
|
|
130
|
-
<!-- ↑ handler ↑ Type ↑ built-in arg -->
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
## Quoting & String Literals
|
|
134
|
-
|
|
135
|
-
Tutuca's expression parser is context-sensitive. `:attr=` and
|
|
136
|
-
interpolation `{...}` accept string templates; `@if`, `@each`,
|
|
137
|
-
`<x render=>` do not.
|
|
138
|
-
|
|
139
|
-
| Form | Example | Where it works |
|
|
140
|
-
| ------------------- | ---------------------- | ------------------------------------------------ |
|
|
141
|
-
| `'string'` | `@then="'btn ok'"` | anywhere a value is allowed |
|
|
142
|
-
| Bare with `{...}` | `:class="btn {.kind}"` | `:attr=`, `@text`, `@title`, macro dynamic attrs |
|
|
143
|
-
| Bare without quotes | `flex gap-3` | **never** — returns `null` |
|
|
144
|
-
| Bare identifier | `dec`, `value` | name slots only (handler/arg, not as a value) |
|
|
145
|
-
|
|
146
|
-
```html
|
|
147
|
-
<!-- ✅ -->
|
|
148
|
-
<p :class="'flex gap-3'">x</p>
|
|
149
|
-
<p :class="flex {.color}">x</p> <!-- {…} enables template -->
|
|
150
|
-
<p :class="static-classes {''}">x</p> <!-- folds to a const -->
|
|
151
|
-
|
|
152
|
-
<!-- ❌ -->
|
|
153
|
-
<p :class="flex gap-3">x</p> <!-- null: no quotes, no braces -->
|
|
154
|
-
<x render="'foo bar'"></x> <!-- @render rejects string templates -->
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
## Component Skeleton
|
|
158
|
-
|
|
159
|
-
```js
|
|
160
|
-
component({
|
|
161
|
-
name: "MyComp",
|
|
162
|
-
fields: { // see "Field Types"
|
|
163
|
-
count: 0,
|
|
164
|
-
items: [],
|
|
165
|
-
nullable: null,
|
|
166
|
-
},
|
|
167
|
-
view: html`<p @text=".count"></p>`, // default view (named "main")
|
|
168
|
-
views: { // additional views
|
|
169
|
-
edit: html`<input :value=".count" @on.input=".setCount valueAsInt" />`,
|
|
170
|
-
big: {
|
|
171
|
-
view: html`<h1 @text=".count"></h1>`,
|
|
172
|
-
style: css`h1 { font-size: 4rem; }`,
|
|
173
|
-
},
|
|
174
|
-
},
|
|
175
|
-
style: css`p { color: blue; }`, // scoped to main view
|
|
176
|
-
commonStyle: css`p { font-family: sans-serif; }`, // scoped to all views of this component
|
|
177
|
-
globalStyle: css`body { margin: 0; }`, // injected globally, no scoping
|
|
178
|
-
methods: { inc() { return this.setCount(this.count + 1); } },
|
|
179
|
-
input: { onClick(ctx) { return this.inc(); } },
|
|
180
|
-
alter: { filterItem(_k, item) { return item.length > 0; } },
|
|
181
|
-
receive: { init(ctx) { ctx.request("loadData"); return this; } },
|
|
182
|
-
bubble: { itemPicked(item, ctx) { return this.setSelected(item); } },
|
|
183
|
-
response:{ loadData(res, err, ctx) { return this.setItems(res); } },
|
|
184
|
-
statics: { fromData(d) { return this.make({ count: d.n ?? 0 }); } },
|
|
185
|
-
// dynamic: { ... }, on: { stackEnter() {...} } // see advanced.md
|
|
186
|
-
});
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
`Comp.make({...})` builds an instance. Coercion is automatic but
|
|
190
|
-
**shallow**: arrays become `List`, plain objects become `IMap`, native
|
|
191
|
-
`Set` becomes `ISet`. Items inside a list/map field stay as-is —
|
|
192
|
-
`setItems([{a:1}])` gives `List<plainObject>`; access with `item.a`, not
|
|
193
|
-
`item.get("a")`. For deep coercion, run inputs through immutable's
|
|
194
|
-
`fromJS`, or wrap each item in `Comp.make({...})`.
|
|
195
|
-
|
|
196
|
-
## Field Types & Auto-generated API
|
|
197
|
-
|
|
198
|
-
`fields: { name: defaultValue }` — type inferred from the default.
|
|
199
|
-
|
|
200
|
-
| Default | Field type | Auto-generated methods (for field `x`) |
|
|
201
|
-
| -------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
|
202
|
-
| `"hi"` | text | `setX`, `updateX`, `resetX`, `isXTruthy`, `isXFalsy`, `isXEmpty`, `xLen` |
|
|
203
|
-
| `42` | float | `setX`, `updateX`, `resetX`, `isXTruthy`, `isXFalsy` |
|
|
204
|
-
| (`{type:"int"}`) | int | `setX`, `updateX`, `resetX`, `isXTruthy`, `isXFalsy` (no default-value form — declare explicitly via `classFromData`) |
|
|
205
|
-
| `true` | bool | `setX`, `toggleX`, `updateX`, `resetX`, `isXTruthy`, `isXFalsy` |
|
|
206
|
-
| `null` | any | `setX`, `updateX`, `resetX`, `isXNull`, `isXTruthy`, `isXFalsy` |
|
|
207
|
-
| `[]`/`List()` | list | `setX`, `pushInX`, `insertInXAt`, `setInXAt`, `getInXAt`, `updateInXAt`, `deleteInXAt`/`removeInXAt`, `isXEmpty`, `isXTruthy`, `isXFalsy`, `xLen`, `resetX` |
|
|
208
|
-
| `{}`/`IMap()` | map | `setInXAt`, `getInXAt`, `updateInXAt`, `deleteInXAt`, `isXEmpty`, `isXTruthy`, `isXFalsy`, `xLen`, `resetX` |
|
|
209
|
-
| `OMap()` | omap | same as map (preserves insertion order) |
|
|
210
|
-
| `ISet()`/`new Set()` | set | `addInX`, `deleteInX`, `hasInX`, `toggleInX`, `xLen`, `isXEmpty`, `isXTruthy`, `isXFalsy`, `resetX` |
|
|
211
|
-
|
|
212
|
-
Explicit field types via `classFromData`:
|
|
213
|
-
|
|
214
|
-
```js
|
|
215
|
-
fields: {
|
|
216
|
-
count: { type: "int", defaultValue: 10 }, // text/int/float/bool/list/map/omap/set/any
|
|
217
|
-
child: { component: "Item", args: { ... } }, // nested component instance
|
|
218
|
-
}
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
## Methods as Predicates & Computed Values
|
|
222
|
-
|
|
223
|
-
A method called via field syntax (`.name`, no args) is invoked and its
|
|
224
|
-
return value is used. Works anywhere a value is read — `@text`, `:attr`,
|
|
225
|
-
`@show` / `@hide`, `@if.<attr>`, and `{…}` interpolation.
|
|
226
|
-
|
|
227
|
-
```js
|
|
228
|
-
methods: {
|
|
229
|
-
canSubmit() { return this.title.length > 0 && !this.isLoading; },
|
|
230
|
-
buttonClass() { return this.isActive ? "btn btn-primary" : "btn"; },
|
|
231
|
-
fullName() { return `${this.first} ${this.last}`; },
|
|
232
|
-
}
|
|
233
|
-
```
|
|
234
|
-
|
|
235
|
-
```html
|
|
236
|
-
<button @show=".canSubmit" :class=".buttonClass">Save</button>
|
|
237
|
-
<p :title="Hello, {.fullName}" @text=".fullName"></p>
|
|
238
|
-
```
|
|
239
|
-
|
|
240
|
-
Auto-generated `isXTruthy` / `isXEmpty` / `isXNull` cover single-field
|
|
241
|
-
checks; reach for a method when the predicate spans multiple fields or
|
|
242
|
-
needs derivation. The method takes no args.
|
|
243
|
-
|
|
244
|
-
Tutuca expressions resolve a **single** name on `this` — there is no
|
|
245
|
-
path syntax. `@text=".user.name"` does not navigate; it fails. When the
|
|
246
|
-
value lives behind a field, your options are:
|
|
247
|
-
|
|
248
|
-
- **Render the child as a component** — `<x render=".user">` then
|
|
249
|
-
`@text=".name"` inside the child's view. Best when the nested thing is
|
|
250
|
-
already (or could be) a component.
|
|
251
|
-
- **Add a method** — `userName() { return this.user.name; }` then
|
|
252
|
-
`@text=".userName"`. Best for one-off derivations or formatting.
|
|
253
|
-
- **Use `@enrich-with`** — exposes computed values as `@`-bindings to a
|
|
254
|
-
subtree without putting them on the component. See *Scope Enrichment*.
|
|
255
|
-
|
|
256
|
-
Exceptions: `@each` / `render-each` accept `.field` or `*dynamic` only
|
|
257
|
-
(not a method call), and `<x render>` expects a component instance — for
|
|
258
|
-
a derived list, store it in a field or use `@when` with `alter`.
|
|
259
|
-
|
|
260
|
-
## Statics
|
|
261
|
-
|
|
262
|
-
`statics: {...}` adds methods to the component **class**, not instances.
|
|
263
|
-
Available as `Comp.Class.<name>(...)` alongside the auto-generated
|
|
264
|
-
`Comp.Class.make(...)` (which `Comp.make(...)` aliases). Inside a static,
|
|
265
|
-
`this` is the class itself.
|
|
266
|
-
|
|
267
|
-
Common use: a `fromData` factory that recursively builds instances from
|
|
268
|
-
plain JS data:
|
|
269
|
-
|
|
270
|
-
```js
|
|
271
|
-
statics: {
|
|
272
|
-
fromData({ items = [] }) {
|
|
273
|
-
return this.make({ items: items.map((v) => Item.Class.fromData(v)) });
|
|
274
|
-
},
|
|
275
|
-
}
|
|
276
|
-
// usage: TreeRoot.Class.fromData([...])
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
## Text Rendering
|
|
280
|
-
|
|
281
|
-
```html
|
|
282
|
-
<span @text=".str"></span> <!-- prepend text into span -->
|
|
283
|
-
<x text=".bool"></x> <!-- text-only, no DOM element -->
|
|
284
|
-
<x text=".getStrUpper"></x> <!-- methods are called -->
|
|
285
|
-
<x text="@value"></x> <!-- loop binding -->
|
|
286
|
-
```
|
|
287
|
-
|
|
288
|
-
## Attribute Binding
|
|
289
|
-
|
|
290
|
-
```html
|
|
291
|
-
<input :value=".str" @on.input=".setStr value" />
|
|
292
|
-
<a :href=".url" :title="Hi {.name}">link</a> <!-- string template -->
|
|
293
|
-
<button class="btn" :class="btn {.color}">x</button>
|
|
294
|
-
```
|
|
295
|
-
|
|
296
|
-
Plain attrs are static. `:attr="..."` is a dynamic expression. Boolean
|
|
297
|
-
HTML attributes (`disabled`, `checked`, `hidden`, …) are auto-recognized;
|
|
298
|
-
pass a boolean field.
|
|
299
|
-
|
|
300
|
-
The HTML parser lowercases attribute names before Tutuca sees them, so
|
|
301
|
-
`:mapId` arrives as `:mapid` and `<x:Card>` becomes `<x:card>`. Three
|
|
302
|
-
consequences:
|
|
303
|
-
|
|
304
|
-
- SVG attributes are case-sensitive. Tutuca special-cases `:viewbox` →
|
|
305
|
-
`viewBox` so SVG roots work; for other camelCased SVG attrs, wrap them
|
|
306
|
-
in components that emit raw markup.
|
|
307
|
-
- Custom-element property setters defined in camelCase **will not fire**.
|
|
308
|
-
`:mapId=".mapId"` runs `node.mapid = value`; if the
|
|
309
|
-
element defined `set mapId(...)`, the lookup misses and JS silently
|
|
310
|
-
creates an own data property `mapid` on the element instead of invoking
|
|
311
|
-
the setter — no error, no warning, the bound state stays null. Author
|
|
312
|
-
custom elements with kebab-case attributes plus lowercased property
|
|
313
|
-
setters (or aliases), and bind via `:kebab-name` from Tutuca templates.
|
|
314
|
-
- Macro registry keys are lowercased on insert for the same reason
|
|
315
|
-
(see "Macros" section below).
|
|
316
|
-
|
|
317
|
-
## Event Handling
|
|
318
|
-
|
|
319
|
-
```html
|
|
320
|
-
<!-- method (`.`) vs input handler (no dot) -->
|
|
321
|
-
<button @on.click=".inc">+</button>
|
|
322
|
-
<button @on.click="dec">-</button>
|
|
323
|
-
|
|
324
|
-
<!-- pass args by name -->
|
|
325
|
-
<input @on.input=".setStr value" />
|
|
326
|
-
<input @on.input=".setN valueAsInt" />
|
|
327
|
-
<button @on.click=".pick @key isAlt">pick</button>
|
|
328
|
-
<button @on.click=".addItem JsonSelector">+</button> <!-- type as arg -->
|
|
329
|
-
<button @on.click=".loadAnotherWay ctx">load</button> <!-- ctx -->
|
|
330
|
-
```
|
|
331
|
-
|
|
332
|
-
Built-in handler arg names: `value`, `valueAsInt`, `valueAsFloat`,
|
|
333
|
-
`target`, `event`, `isAlt`, `isShift`, `isCtrl`/`isCmd`, `key`, `keyCode`,
|
|
334
|
-
`isUpKey`, `isDownKey`, `isSend`, `isCancel`, `isTabKey`, `ctx`,
|
|
335
|
-
`dragInfo`.
|
|
336
|
-
|
|
337
|
-
The content of `value` depends on the event source:
|
|
338
|
-
|
|
339
|
-
| Source | What `value` resolves to |
|
|
340
|
-
|-----------------------------|--------------------------------------------------|
|
|
341
|
-
| `<input type="checkbox">` | `event.target.checked` (boolean) |
|
|
342
|
-
| `CustomEvent` | `event.detail` |
|
|
343
|
-
| anything else | `event.target.value` (string), or null if absent |
|
|
344
|
-
|
|
345
|
-
For numeric inputs, prefer `valueAsInt` / `valueAsFloat` to skip the
|
|
346
|
-
string parse.
|
|
347
|
-
|
|
348
|
-
### Event Modifiers
|
|
349
|
-
|
|
350
|
-
`@on.<event>+<mod>+<mod>=...`
|
|
351
|
-
|
|
352
|
-
- All events: `+ctrl`, `+cmd`/`+meta`, `+alt`
|
|
353
|
-
- `keydown` only: `+send` (Enter), `+cancel` (Escape)
|
|
354
|
-
|
|
355
|
-
```html
|
|
356
|
-
<input @on.keydown+send=".submit value" @on.keydown+cancel=".reset" />
|
|
357
|
-
<button @on.click+ctrl=".soloOnly">ctrl-click</button>
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
### Web Components & Custom Events
|
|
361
|
-
|
|
362
|
-
Custom elements just work, and any `CustomEvent` they fire is reachable
|
|
363
|
-
via `@on.<event-name>`. The event's `detail` surfaces as `value`:
|
|
364
|
-
|
|
365
|
-
```js
|
|
366
|
-
import "https://esm.sh/emoji-picker-element";
|
|
367
|
-
|
|
368
|
-
input: { onPick(detail) { return this.setCurrent(detail.unicode); } }
|
|
369
|
-
view: html`<emoji-picker @on.emoji-click="onPick value"></emoji-picker>`,
|
|
370
|
-
```
|
|
371
|
-
|
|
372
|
-
Pitfall: binding camelCase JS properties on a custom element silently
|
|
373
|
-
fails. `:mapId=".id"` does *not* invoke a `set mapId` setter
|
|
374
|
-
— the HTML parser lowercased the attribute name, so the framework assigns
|
|
375
|
-
to `node.mapid` instead, creating an own property and bypassing the
|
|
376
|
-
setter. Use kebab-case attributes / lowercased setters when authoring
|
|
377
|
-
custom elements for use with Tutuca. See "Attribute Binding" above.
|
|
378
|
-
|
|
379
|
-
## Conditional Display
|
|
380
|
-
|
|
381
|
-
```html
|
|
382
|
-
<div @show=".isLoading">Loading...</div>
|
|
383
|
-
<div @hide=".isLoading">content</div>
|
|
384
|
-
|
|
385
|
-
<!-- show / hide also work as wrapper attrs on `<x>` render ops:
|
|
386
|
-
wraps the produced node, no extra DOM element. Allowed on
|
|
387
|
-
text / render / render-it / render-each. First attr in
|
|
388
|
-
source order becomes the outermost wrapper. -->
|
|
389
|
-
<x text=".name" show=".isOpen"></x>
|
|
390
|
-
<x render-it hide=".isHidden"></x>
|
|
391
|
-
<x render-each=".items" when="filter" show=".isOpen"></x>
|
|
392
|
-
|
|
393
|
-
<!-- Single @if: shorthand @then/@else (attr inferred) -->
|
|
394
|
-
<button @if.class=".isActive" @then="'btn btn-success'" @else="'btn btn-ghost'">
|
|
395
|
-
...
|
|
396
|
-
</button>
|
|
397
|
-
|
|
398
|
-
<!-- Multiple @if on same element: name the attr explicitly -->
|
|
399
|
-
<button
|
|
400
|
-
@if.class=".isActive"
|
|
401
|
-
@then="'on'"
|
|
402
|
-
@else="'off'"
|
|
403
|
-
@if.title=".isActive"
|
|
404
|
-
@then.title="'On'"
|
|
405
|
-
@else.title="'Off'"
|
|
406
|
-
>
|
|
407
|
-
...
|
|
408
|
-
</button>
|
|
409
|
-
```
|
|
410
|
-
|
|
411
|
-
> HTML disallows duplicate attrs, so with multiple `@if.<attr>` on one
|
|
412
|
-
> element every `@then`/`@else` after the first **must** include the attr
|
|
413
|
-
> name — otherwise the parser drops it before tutuca sees it.
|
|
414
|
-
|
|
415
|
-
## List Iteration
|
|
416
|
-
|
|
417
|
-
`@each` accepts: `.field`, `*dynamic`.
|
|
418
|
-
|
|
419
|
-
```html
|
|
420
|
-
<!-- iterate plain values -->
|
|
421
|
-
<li @each=".items"><span @text="@key"></span>: <x text="@value"></x></li>
|
|
422
|
-
|
|
423
|
-
<!-- filter -->
|
|
424
|
-
<li @each=".items" @when="filterItem">...</li>
|
|
425
|
-
|
|
426
|
-
<!-- per-item enrichment (binds.X => @X in template) -->
|
|
427
|
-
<li @each=".items" @enrich-with="enrichItem">
|
|
428
|
-
<x text="@count"></x>
|
|
429
|
-
</li>
|
|
430
|
-
|
|
431
|
-
<!-- shared per-loop data (computed once before iteration) -->
|
|
432
|
-
<li @each=".items" @loop-with="getIterData" @when="filterItem">...</li>
|
|
433
|
-
|
|
434
|
-
<!-- render a list of components -->
|
|
435
|
-
<x render-each=".items"></x>
|
|
436
|
-
<x render-each=".items" as="edit"></x> <!-- specific view -->
|
|
437
|
-
<x render-each=".items" when="filterItem"></x> <!-- with filter -->
|
|
438
|
-
<x render-each=".items" loop-with="getIterData" when="filterItem"></x>
|
|
439
|
-
<x render-each=".items" show=".isOpen"></x> <!-- wrap in show -->
|
|
440
|
-
```
|
|
441
|
-
|
|
442
|
-
On `<li @each>` / `<div @each>` and other host-element loops the
|
|
443
|
-
filters are written `@when` / `@enrich-with` / `@loop-with` (the `@`
|
|
444
|
-
prefix is the element-directive convention). On `<x render-each>` the
|
|
445
|
-
same filters drop the prefix — `when=` / `enrich-with=` / `loop-with=`
|
|
446
|
-
— because `<x>` carries plain attributes, not directives. Both forms
|
|
447
|
-
share the handler-name resolution rules below.
|
|
448
|
-
|
|
449
|
-
```js
|
|
450
|
-
alter: {
|
|
451
|
-
filterItem(_key, item, iterData) { return item.includes(iterData.q); },
|
|
452
|
-
enrichItem(binds, _key, item, iterData) { binds.count = item.length; },
|
|
453
|
-
getIterData(seq) { return { q: this.query.toLowerCase() }; },
|
|
454
|
-
}
|
|
455
|
-
```
|
|
456
|
-
|
|
457
|
-
### Lifecycle of `@each`
|
|
458
|
-
|
|
459
|
-
For each render of an element with `@each=".items"`:
|
|
460
|
-
|
|
461
|
-
1. **Resolve sequence** — evaluate `.items`. Lists, IMaps, OMaps, ISets,
|
|
462
|
-
and any class declaring a `SEQ_INFO` walker are recognized.
|
|
463
|
-
2. **`@loop-with`** (once per render) — `getIterData.call(this, seq)` is
|
|
464
|
-
called and its return becomes `iterData`. Skipped if no `@loop-with`;
|
|
465
|
-
in that case `iterData` is `{ seq }` from the default.
|
|
466
|
-
3. For each `(key, value)` pair in the sequence:
|
|
467
|
-
1. **`@when`** — `filterItem.call(this, key, value, iterData)`; if it
|
|
468
|
-
returns `false`, the item is skipped.
|
|
469
|
-
2. **`@enrich-with`** — `enrichItem.call(this, binds, key, value, iterData)`.
|
|
470
|
-
`binds` is a **mutable object** seeded with `{ key, value }`;
|
|
471
|
-
mutating it (`binds.count = ...`) creates `@`-prefixed bindings
|
|
472
|
-
available in the templated children. The return value is ignored.
|
|
473
|
-
3. **Render** the element with the new bindings on the stack.
|
|
474
|
-
|
|
475
|
-
Auto-bound names inside the loop are always `@key` and `@value` (or
|
|
476
|
-
whatever you wrote into `binds`).
|
|
477
|
-
|
|
478
|
-
### Handler resolution
|
|
479
|
-
|
|
480
|
-
`@when` / `@enrich-with` / `@loop-with` resolve like event handler names:
|
|
481
|
-
bare `filterItem` → `alter.filterItem` (idiomatic); `.filterItem` →
|
|
482
|
-
method on `this` (works, not idiomatic — `alter` keeps iteration helpers
|
|
483
|
-
grouped).
|
|
484
|
-
|
|
485
|
-
## Scope Enrichment
|
|
486
|
-
|
|
487
|
-
Without an `@each` on the same element, `@enrich-with` becomes a scope
|
|
488
|
-
enricher: it takes no `binds` arg, and its **return value** is the
|
|
489
|
-
bindings object whose keys become `@`-prefixed bindings for descendants.
|
|
490
|
-
|
|
491
|
-
```js
|
|
492
|
-
alter: { enrichScope() { return { len: this.text.length }; } }
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
```html
|
|
496
|
-
<div @enrich-with="enrichScope">Length: <x text="@len"></x></div>
|
|
497
|
-
```
|
|
498
|
-
|
|
499
|
-
## Rendering Components
|
|
500
|
-
|
|
501
|
-
```html
|
|
502
|
-
<x render=".item"></x> <!-- default ("main") view -->
|
|
503
|
-
<x render=".item" as="edit"></x> <!-- specific view -->
|
|
504
|
-
<x render-it></x> <!-- only inside @each / render-each -->
|
|
505
|
-
<x render=".byIndex[.currentIndex]"></x> <!-- list item access -->
|
|
506
|
-
<x render=".byKey[.currentKey]"></x> <!-- map item access -->
|
|
507
|
-
<x render=".item" show=".isOpen"></x> <!-- conditional wrap, see "Conditional Display" -->
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
The top-level `view` is registered under `"main"` (the default); extras
|
|
511
|
-
go under `views: { name: html\`...\` }`. `as="edit"` selects the `edit`
|
|
512
|
-
view of the rendered component, falling back to `main` if absent. `as`
|
|
513
|
-
only applies to the **direct** component — for whole-subtree control,
|
|
514
|
-
use `@push-view` (next section).
|
|
515
|
-
|
|
516
|
-
## Multiple Views & View Stack
|
|
517
|
-
|
|
518
|
-
```js
|
|
519
|
-
component({
|
|
520
|
-
view: html`<p @text=".title"></p>`, // "main"
|
|
521
|
-
views: { edit: html`<input :value=".title" @on.input=".setTitle value" />` },
|
|
522
|
-
});
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
```html
|
|
526
|
-
<!-- @push-view pushes a name onto the rendering stack;
|
|
527
|
-
descendants resolve to first matching view, falling back to "main" -->
|
|
528
|
-
<div @push-view=".view"><x render-each=".items"></x></div>
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
| Directive | Scope |
|
|
532
|
-
|--------------------|--------------------------------------------------------------------------|
|
|
533
|
-
| `as="edit"` | One `<x render>` element only. |
|
|
534
|
-
| `@push-view=".v"` | Every component rendered recursively under the host (children + descendants). Each picks the first stack entry it has a matching view for; falls back to `"main"`. Inner `@push-view`s nest, extending the outer ones. |
|
|
535
|
-
|
|
536
|
-
## Styles
|
|
537
|
-
|
|
538
|
-
```js
|
|
539
|
-
component({
|
|
540
|
-
style: css`.mine { color: red; }`, // scoped to main view
|
|
541
|
-
commonStyle: css`.shared { color: yellow; }`, // scoped to all views of this component
|
|
542
|
-
globalStyle: css`.app-thing { color: green; }`, // global, no scoping
|
|
543
|
-
views: {
|
|
544
|
-
two: { view: html`...`, style: css`.mine { color: orange; }` },
|
|
545
|
-
},
|
|
546
|
-
});
|
|
547
|
-
```
|
|
548
|
-
|
|
549
|
-
Tagged templates `html` and `css` are just `String.raw` (editor hinting
|
|
550
|
-
only). Plain strings work too.
|
|
551
|
-
|
|
552
|
-
## Triggers and Handlers
|
|
553
|
-
|
|
554
|
-
Tutuca has four orchestration channels. Each one pairs a trigger with
|
|
555
|
-
a same-shape handler block:
|
|
556
|
-
|
|
557
|
-
| Triggered by | Handler block |
|
|
558
|
-
| ------------------------------------------- | ------------------- |
|
|
559
|
-
| DOM event (`click`, `input`, …) | `input: { ... }` |
|
|
560
|
-
| `ctx.send(name)` — message to a target path | `receive: { ... }` |
|
|
561
|
-
| `ctx.request(name)` — async request | `response: { ... }` |
|
|
562
|
-
| `ctx.bubble(name)` — event up the tree | `bubble: { ... }` |
|
|
563
|
-
|
|
564
|
-
Every handler is called as `handler(...args, ctx)` and returns a
|
|
565
|
-
(possibly updated) instance of `this`; the framework swaps the
|
|
566
|
-
returned value into the dispatch path. The four sections below cover
|
|
567
|
-
each channel in turn.
|
|
568
|
-
|
|
569
|
-
## Bubble Events
|
|
570
|
-
|
|
571
|
-
```js
|
|
572
|
-
input: { onClick(ctx) { ctx.bubble("treeItemSelected", [this]); return this; } },
|
|
573
|
-
bubble: {
|
|
574
|
-
treeItemSelected(selected, ctx) { // ctx.stopPropagation() to halt
|
|
575
|
-
return this.insertInLogAt(0, `selected ${selected.label}`);
|
|
576
|
-
},
|
|
577
|
-
}
|
|
578
|
-
```
|
|
579
|
-
|
|
580
|
-
`ctx.bubble("name", args)` emits an event that walks the dispatch path
|
|
581
|
-
back toward the root. Each ancestor whose component defines
|
|
582
|
-
`bubble.<name>(...args, ctx)` runs it (others are skipped silently);
|
|
583
|
-
bubbling stops at the root or when a handler calls
|
|
584
|
-
`ctx.stopPropagation()`. Ancestors see the event *after* descendants
|
|
585
|
-
have transacted, so bubble handlers are the place for aggregate state
|
|
586
|
-
(logs, selections, totals).
|
|
587
|
-
|
|
588
|
-
## Send / Receive
|
|
589
|
-
|
|
590
|
-
`ctx.send(name, args)` delivers a message to a specific target
|
|
591
|
-
component (addressed by path; on its own `ctx.send` targets `this`).
|
|
592
|
-
The target's `receive.<name>(...args, ctx)` handler runs. There is
|
|
593
|
-
**no built-in lifecycle** — `receive.init` is just a convention; the
|
|
594
|
-
host must dispatch it (typically after `app.start()`) for it to run.
|
|
595
|
-
|
|
596
|
-
```js
|
|
597
|
-
receive: { init(ctx) { ctx.request("loadData"); return this.setIsLoading(true); } }
|
|
598
|
-
```
|
|
599
|
-
|
|
600
|
-
Dispatch from anywhere:
|
|
601
|
-
|
|
602
|
-
```js
|
|
603
|
-
app.sendAtRoot("init"); // host code, top-level
|
|
604
|
-
ctx.at.field("personalSite").send("init"); // child by field name
|
|
605
|
-
ctx.at.index("items", 3).send("init"); // list element at index 3
|
|
606
|
-
ctx.at.key("byKey", "k1").send("init"); // map entry by key
|
|
607
|
-
ctx.at.field("a").field("b").index("xs", 0).send("ping"); // chain freely
|
|
608
|
-
ctx.send("name"); // self
|
|
609
|
-
ctx.bubble("name", [arg]); // bubble up
|
|
610
|
-
```
|
|
611
|
-
|
|
612
|
-
`ctx.at` returns a `PathBuilder` with `.field(name)`, `.index(name, i)`,
|
|
613
|
-
and `.key(name, k)`. Each call appends a step to the path before
|
|
614
|
-
`.send(...)` / `.bubble(...)` fires; the handler runs inside the child
|
|
615
|
-
instance with `this` bound to it.
|
|
616
|
-
|
|
617
|
-
## Async Requests
|
|
618
|
-
|
|
619
|
-
`ctx.request("name", args)` triggers a host-registered async handler
|
|
620
|
-
and routes the result back to the issuing component's
|
|
621
|
-
`response.<name>(res, err, ctx)`. Use it for fetch / timer / IndexedDB
|
|
622
|
-
work that should land back in component state.
|
|
623
|
-
|
|
624
|
-
```js
|
|
625
|
-
export function getRequestHandlers() {
|
|
626
|
-
return {
|
|
627
|
-
async loadData() {
|
|
628
|
-
const r = await fetch("https://example.com/data.json");
|
|
629
|
-
return await r.json();
|
|
630
|
-
},
|
|
631
|
-
};
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
// register at the same scope where you registerComponents
|
|
635
|
-
const scope = app.registerComponents([Comp]);
|
|
636
|
-
scope.registerRequestHandlers(getRequestHandlers());
|
|
637
|
-
```
|
|
638
|
-
|
|
639
|
-
In a component:
|
|
640
|
-
|
|
641
|
-
```js
|
|
642
|
-
receive: { init(ctx) { ctx.request("loadData"); return this.setIsLoading(true); } },
|
|
643
|
-
response: { loadData(res, err, ctx) { return this.setIsLoading(false).setItems(res); } },
|
|
644
|
-
// override response handler names per-call:
|
|
645
|
-
// ctx.request("loadData", [], { onOkName: "loadDataOk", onErrorName: "loadDataErr" });
|
|
646
|
-
```
|
|
647
|
-
|
|
648
|
-
The `ctx` arg is the last argument of every `response` / `bubble` /
|
|
649
|
-
`receive` handler.
|
|
650
|
-
|
|
651
|
-
## Macros
|
|
652
|
-
|
|
653
|
-
Pure template expansion — no state, no methods. Calls inside a macro
|
|
654
|
-
resolve against the *host* component.
|
|
655
|
-
|
|
656
|
-
```js
|
|
657
|
-
import { macro, html } from "tutuca";
|
|
658
|
-
|
|
659
|
-
const badge = macro(
|
|
660
|
-
{ label: "'New'", kind: "'info'" }, // defaults are *expressions*
|
|
661
|
-
html`<span :class="badge badge-{^kind}" @text="^label"></span>`,
|
|
662
|
-
);
|
|
663
|
-
|
|
664
|
-
export function getMacros() {
|
|
665
|
-
return { badge };
|
|
666
|
-
}
|
|
667
|
-
```
|
|
668
|
-
|
|
669
|
-
```html
|
|
670
|
-
<x:badge></x:badge> <!-- defaults -->
|
|
671
|
-
<x:badge label="Sale"></x:badge> <!-- static string (no quotes needed) -->
|
|
672
|
-
<x:badge :label="'Sale'"></x:badge> <!-- dynamic literal -->
|
|
673
|
-
<x:badge :label=".status"></x:badge> <!-- field reference -->
|
|
674
|
-
```
|
|
675
|
-
|
|
676
|
-
Register macros at the same scope as components:
|
|
677
|
-
|
|
678
|
-
```js
|
|
679
|
-
const scope = app.registerComponents([Comp]);
|
|
680
|
-
scope.registerMacros(getMacros());
|
|
681
|
-
```
|
|
682
|
-
|
|
683
|
-
Registry keys are lowercased on insert because the HTML parser already
|
|
684
|
-
lowercases `<x:Tag>` to `<x:tag>`. `{ Card }` and `{ card }` both register
|
|
685
|
-
under `card`; registering two *different* macros under the same lowercased
|
|
686
|
-
name warns via `console.assert`.
|
|
687
|
-
|
|
688
|
-
### Slots
|
|
689
|
-
|
|
690
|
-
```js
|
|
691
|
-
const card = macro(
|
|
692
|
-
{ title: "'Card'" },
|
|
693
|
-
html`<div class="card">
|
|
694
|
-
<h2 @text="^title"></h2>
|
|
695
|
-
<x:slot></x:slot>
|
|
696
|
-
</div>`,
|
|
697
|
-
);
|
|
698
|
-
```
|
|
699
|
-
|
|
700
|
-
```html
|
|
701
|
-
<x:card title="Hi"><p>body</p></x:card> <!-- default slot -->
|
|
702
|
-
```
|
|
703
|
-
|
|
704
|
-
### Named Slots
|
|
705
|
-
|
|
706
|
-
```js
|
|
707
|
-
const panel = macro(
|
|
708
|
-
{},
|
|
709
|
-
html`<div>
|
|
710
|
-
<header><x:slot name="actions"></x:slot></header>
|
|
711
|
-
<main><x:slot></x:slot></main> <!-- default == name="_" -->
|
|
712
|
-
<footer><x:slot name="footer"></x:slot></footer>
|
|
713
|
-
</div>`,
|
|
714
|
-
);
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
```html
|
|
718
|
-
<x:panel>
|
|
719
|
-
<x slot="actions"><button @on.click=".inc">+</button></x>
|
|
720
|
-
<p>default slot content</p>
|
|
721
|
-
<x slot="footer">© 2026</x>
|
|
722
|
-
</x:panel>
|
|
723
|
-
```
|
|
724
|
-
|
|
725
|
-
## Raw HTML (escape hatch)
|
|
726
|
-
|
|
727
|
-
```html
|
|
728
|
-
<div @dangerouslysetinnerhtml=".trustedHtml"></div>
|
|
729
|
-
```
|
|
730
|
-
|
|
731
|
-
Bypasses all escaping; children of the element are ignored when active.
|
|
732
|
-
|
|
733
|
-
## Immutable Re-exports
|
|
734
|
-
|
|
735
|
-
`tutuca` re-exports **everything** from
|
|
736
|
-
[`immutable`](https://immutable-js.com/) (`List`, `OrderedMap`, `Record`,
|
|
737
|
-
`Seq`, `is`, `fromJS`, ...), plus short aliases to avoid clashes with
|
|
738
|
-
the host runtime's `Map` / `Set`:
|
|
739
|
-
|
|
740
|
-
```js
|
|
741
|
-
import {
|
|
742
|
-
IMap, OMap, ISet, // aliases for Map, OrderedMap, Set
|
|
743
|
-
isIMap, isOMap, // aliases for isMap, isOrderedMap
|
|
744
|
-
List, Record, Seq, fromJS, is, // ...everything else immutable exports
|
|
745
|
-
} from "tutuca";
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
## Conventional Module Exports
|
|
749
|
-
|
|
750
|
-
Examples and the storybook glue follow this shape so files compose freely
|
|
751
|
-
and the `tutuca` CLI can introspect any module without per-app glue:
|
|
752
|
-
|
|
753
|
-
```js
|
|
754
|
-
export function getComponents() { return [Comp, ...]; }
|
|
755
|
-
export function getMacros() { return { name: macro }; } // optional
|
|
756
|
-
export function getRequestHandlers() { return { name: async fn }; } // optional
|
|
757
|
-
export function getRoot() { return Root.make({...}); }
|
|
758
|
-
export function getExamples() {
|
|
759
|
-
// Return one section, or an array of sections.
|
|
760
|
-
return {
|
|
761
|
-
title: "...",
|
|
762
|
-
description: "...",
|
|
763
|
-
items: [{ title, description, value, view }], // value = Comp.make(...)
|
|
764
|
-
};
|
|
765
|
-
}
|
|
766
|
-
```
|