create-muten 0.0.16 → 0.0.17
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/README.md +1 -1
- package/index.js +8 -40
- package/package.json +1 -1
- package/template/.claude/AGENTS.md +10 -10
- package/template/.claude/skills/muten/SKILL.md +59 -77
- package/template/package.json +1 -1
- package/template/src/pages/home/home.muten +1 -1
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ whether a framework's island plugin is pre-wired:
|
|
|
89
89
|
| **muten + Svelte** | same, plus `@sveltejs/vite-plugin-svelte` + Svelte, for **Svelte islands** (a lighter runtime) |
|
|
90
90
|
|
|
91
91
|
An *island* is a real framework component used as a node - `use X from "react:./X.jsx"` →
|
|
92
|
-
`X(value:
|
|
92
|
+
`X(value: s, onChange: act) client:visible` (props ↓ + events ↑, lazy + code-split). Default to `.muten`;
|
|
93
93
|
reach for an island only for a widget muten can't express.
|
|
94
94
|
|
|
95
95
|
When **Tailwind or DaisyUI** is added, `theme.muten` is centralized to **match Tailwind's scale** (so
|
package/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// create-muten — scaffold a new Muten app, with modern interactive prompts (@clack/prompts).
|
|
3
3
|
//
|
|
4
4
|
// npm create muten@latest [name] (or: npx create-muten)
|
|
5
|
-
// create-muten [name] [--
|
|
5
|
+
// create-muten [name] [--css|--scss] [--tailwind] [--daisyui] [--vercel] [--tauri] [--pm npm|pnpm|yarn|bun] [--no-install]
|
|
6
6
|
//
|
|
7
7
|
// Stylesheet (CSS or SCSS) is the base; Tailwind is an optional add-on ON TOP of CSS (it's a styling
|
|
8
8
|
// library, not a stylesheet replacement). Interactive in a TTY; flags / non-TTY make it scriptable.
|
|
@@ -16,7 +16,6 @@ import color from 'picocolors';
|
|
|
16
16
|
const SELF = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
const TEMPLATE = join(SELF, 'template');
|
|
18
18
|
const TAURI_TEMPLATE = join(SELF, 'template-tauri'); // src-tauri/ overlay, copied only when --tauri
|
|
19
|
-
const TEMPLATES = ['muten', 'react', 'svelte']; // the "template" IS the flavor: pure muten, or muten + a framework for islands
|
|
20
19
|
const PKG = JSON.parse(readFileSync(join(SELF, 'package.json'), 'utf8'));
|
|
21
20
|
const PMS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
22
21
|
|
|
@@ -61,30 +60,13 @@ const WELCOME_CSS = `
|
|
|
61
60
|
.card-text { color: #71717a; font-size: 13px; line-height: 1.5; }
|
|
62
61
|
@media (max-width: 560px) { .stats, .cards { grid-template-columns: 1fr; } }
|
|
63
62
|
`;
|
|
64
|
-
// vite.config composed from the chosen options — muten always;
|
|
65
|
-
const viteConfig = ({ tailwind
|
|
63
|
+
// vite.config composed from the chosen options — muten always; tailwind last.
|
|
64
|
+
const viteConfig = ({ tailwind }) => {
|
|
66
65
|
const imports = [`import muten from '@muten/core/vite-plugin-muten.js';`];
|
|
67
66
|
const plugins = ['muten()'];
|
|
68
|
-
if (svelte) { imports.push(`import { svelte } from '@sveltejs/vite-plugin-svelte';`); plugins.push('svelte()'); }
|
|
69
|
-
if (react) { imports.push(`import react from '@vitejs/plugin-react';`); plugins.push('react()'); }
|
|
70
67
|
if (tailwind) { imports.push(`import tailwindcss from '@tailwindcss/vite';`); plugins.push('tailwindcss()'); }
|
|
71
68
|
return `${imports.join('\n')}\n\nexport default {\n plugins: [${plugins.join(', ')}],\n};\n`;
|
|
72
69
|
};
|
|
73
|
-
// Tell the AI the island plugin is wired so it can drop in a real Svelte/React component (incl. shadcn/Radix).
|
|
74
|
-
const ISLANDS_NOTE = ({ svelte, react }) => {
|
|
75
|
-
const techs = [svelte && 'Svelte', react && 'React'].filter(Boolean).join(' + ');
|
|
76
|
-
const ex = react
|
|
77
|
-
? `use Widget from "react:./Widget.jsx"` + ' → ' + `Widget(value: @sel, onChange: pick) client:visible`
|
|
78
|
-
: `use Widget from "svelte:./Widget.svelte"` + ' → ' + `Widget(value: @sel, onChange: pick) client:visible`;
|
|
79
|
-
return `
|
|
80
|
-
## Framework islands (${techs} — wired)
|
|
81
|
-
The ${techs} Vite plugin is installed. For a genuinely-interactive widget Muten can't express (date-picker,
|
|
82
|
-
combobox, command palette, rich editor — including React/Svelte libs like shadcn/Radix), write the component
|
|
83
|
-
in its own \`.${react ? 'jsx' : 'svelte'}\` file and mount it as a node: \`${ex}\`. props ↓ (\`@state\`) + events ↑
|
|
84
|
-
(an \`onX: action\` calls a Muten action), lazy + code-split. Default to \`.muten\` for the UI; reach for an
|
|
85
|
-
island only for the foreign piece. Full details: SKILL §14.
|
|
86
|
-
`;
|
|
87
|
-
};
|
|
88
70
|
// When Tailwind/DaisyUI is chosen, the Muten token scale is centralized to MATCH Tailwind's defaults, so
|
|
89
71
|
// style() tokens and Tailwind utilities share one scale (e.g. style(gap.md) == gap-4 == 1rem). Plain
|
|
90
72
|
// css/scss keeps the default theme.muten. (DaisyUI builds on Tailwind's scale; its colors come via @plugin.)
|
|
@@ -151,10 +133,9 @@ async function main() {
|
|
|
151
133
|
const has = (f) => argv.includes(f);
|
|
152
134
|
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
|
|
153
135
|
if (has('-v') || has('--version')) { console.log(PKG.version); return; }
|
|
154
|
-
if (has('-h') || has('--help')) { console.log('Usage: create-muten [name] [--
|
|
136
|
+
if (has('-h') || has('--help')) { console.log('Usage: create-muten [name] [--css|--scss] [--tailwind] [--daisyui] [--vercel] [--tauri] [--pm npm|pnpm|yarn|bun] [--no-install]'); return; }
|
|
155
137
|
|
|
156
|
-
let name = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--pm'
|
|
157
|
-
let template = val('--template') || (has('--react') ? 'react' : has('--svelte') ? 'svelte' : has('--muten') ? 'muten' : undefined); // flavor: muten | react | svelte
|
|
138
|
+
let name = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--pm')[0];
|
|
158
139
|
let style = has('--scss') ? 'scss' : has('--css') ? 'css' : undefined; // the base stylesheet
|
|
159
140
|
let tailwind = has('--tailwind') ? true : undefined; // optional add-on (CSS only)
|
|
160
141
|
let daisyui = has('--daisyui') ? true : undefined; // component classes on Tailwind
|
|
@@ -163,7 +144,6 @@ async function main() {
|
|
|
163
144
|
let pm = val('--pm');
|
|
164
145
|
let install = has('--no-install') ? false : undefined;
|
|
165
146
|
if (name && !validName(name)) { console.error(`Invalid name: "${name}" (letters, digits, . _ -)`); process.exit(1); }
|
|
166
|
-
if (template && !TEMPLATES.includes(template)) { console.error(`Unknown template: "${template}" (${TEMPLATES.join(', ')})`); process.exit(1); }
|
|
167
147
|
if (pm && !PMS.includes(pm)) { console.error(`Unknown package manager: "${pm}" (${PMS.join(', ')})`); process.exit(1); }
|
|
168
148
|
const dpm = detectPM();
|
|
169
149
|
|
|
@@ -172,11 +152,6 @@ async function main() {
|
|
|
172
152
|
logo();
|
|
173
153
|
intro(color.dim(`create-muten v${PKG.version}`));
|
|
174
154
|
if (!name) name = keep(await text({ message: 'Project name', placeholder: 'muten-app', defaultValue: 'muten-app', validate: (v) => (v && !validName(v)) ? 'Use letters, digits, . _ - (start alphanumeric).' : undefined }));
|
|
175
|
-
if (!template) template = keep(await select({ message: 'Template', options: [
|
|
176
|
-
{ value: 'muten', label: 'muten', hint: 'pure — the AI-first DSL, zero framework runtime' },
|
|
177
|
-
{ value: 'react', label: 'muten + React', hint: 'React islands: shadcn, Radix, any React lib' },
|
|
178
|
-
{ value: 'svelte', label: 'muten + Svelte', hint: 'Svelte islands: a lighter runtime' },
|
|
179
|
-
] }));
|
|
180
155
|
if (!style && tailwind === undefined) { // ONE explicit styling choice — each is opt-in, "CSS" = nothing extra
|
|
181
156
|
const styling = keep(await select({ message: 'Styling', options: [
|
|
182
157
|
{ value: 'css', label: 'CSS', hint: 'plain — no framework, zero deps' },
|
|
@@ -192,7 +167,6 @@ async function main() {
|
|
|
192
167
|
if (tauri === undefined) tauri = keep(await confirm({ message: 'Desktop app? (Tauri — native window, ships the OS webview, needs Rust)', initialValue: false }));
|
|
193
168
|
}
|
|
194
169
|
name = name || 'muten-app';
|
|
195
|
-
template = template || 'muten';
|
|
196
170
|
style = style || 'css';
|
|
197
171
|
if (daisyui) tailwind = true; // DaisyUI is a Tailwind plugin
|
|
198
172
|
if (tailwind === undefined) tailwind = false;
|
|
@@ -200,16 +174,13 @@ async function main() {
|
|
|
200
174
|
if (vercel === undefined) vercel = false;
|
|
201
175
|
if (tauri === undefined) tauri = false;
|
|
202
176
|
if (tailwind) style = 'css'; // Tailwind v4 is CSS-native (not SCSS)
|
|
203
|
-
const svelte = template === 'svelte'; // the flavor IS the islands choice
|
|
204
|
-
const react = template === 'react';
|
|
205
177
|
pm = pm || dpm;
|
|
206
178
|
if (install === undefined) install = false;
|
|
207
179
|
|
|
208
180
|
const target = resolve(name);
|
|
209
181
|
if (existsSync(target)) { (process.stdin.isTTY ? cancel : console.error)(`"${name}" already exists.`); process.exit(1); }
|
|
210
182
|
|
|
211
|
-
//
|
|
212
|
-
// island plugin + deps below, and tailwind/daisyui only swap the stylesheet.
|
|
183
|
+
// The base template is the pure "muten" app (the welcome page); tailwind/daisyui only swap the stylesheet.
|
|
213
184
|
cpSync(TEMPLATE, target, { recursive: true });
|
|
214
185
|
const ignore = join(target, '_gitignore');
|
|
215
186
|
if (existsSync(ignore)) renameSync(ignore, join(target, '.gitignore'));
|
|
@@ -232,9 +203,6 @@ async function main() {
|
|
|
232
203
|
writeFileSync(join(target, 'src', style === 'scss' ? 'styles.scss' : 'styles.css'), RESET + WELCOME_CSS);
|
|
233
204
|
}
|
|
234
205
|
if (style === 'scss') addDev({ sass: '^1.101.0' });
|
|
235
|
-
if (svelte) { addDep({ svelte: '^5.0.0' }); addDev({ '@sveltejs/vite-plugin-svelte': '^7.0.0' }); }
|
|
236
|
-
if (react) { addDep({ react: '^19.0.0', 'react-dom': '^19.0.0' }); addDev({ '@vitejs/plugin-react': '^6.0.0' }); }
|
|
237
|
-
if (svelte || react) appendAgents(ISLANDS_NOTE({ svelte, react }));
|
|
238
206
|
if (tauri) { // native desktop wrapper around the same web build (dist)
|
|
239
207
|
cpSync(join(TAURI_TEMPLATE, 'src-tauri'), join(target, 'src-tauri'), { recursive: true });
|
|
240
208
|
writeFileSync(join(target, 'src-tauri', '.gitignore'), '/target\n/gen/schemas\n'); // npm strips real .gitignore from packages
|
|
@@ -251,10 +219,10 @@ async function main() {
|
|
|
251
219
|
pkg.scripts = { ...pkg.scripts, tauri: 'tauri', 'tauri:dev': 'tauri dev', 'tauri:build': 'tauri build' };
|
|
252
220
|
appendAgents(TAURI_NOTE(pm));
|
|
253
221
|
}
|
|
254
|
-
writeFileSync(join(target, 'vite.config.mjs'), viteConfig({ tailwind
|
|
222
|
+
writeFileSync(join(target, 'vite.config.mjs'), viteConfig({ tailwind })); // composed: muten + chosen plugins
|
|
255
223
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
256
224
|
|
|
257
|
-
const desc =
|
|
225
|
+
const desc = `muten, ${style}${tailwind ? ' + Tailwind' : ''}${daisyui ? ' + DaisyUI' : ''}${vercel ? ' + Vercel' : ''}${tauri ? ' + Tauri' : ''}`;
|
|
258
226
|
if (!install) {
|
|
259
227
|
if (process.stdin.isTTY) { note(`cd ${name}\n${pm} install\n${pm} run dev`, 'Next steps'); outro(color.green(`Created ${name} (${desc})`)); }
|
|
260
228
|
else console.log(`\n Created ${name} (${desc}, ${pm})\n cd ${name} && ${pm} install && ${pm} run dev\n`);
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
This project uses **Muten**, an AI-first frontend framework. The UI is written in **`.muten` files**
|
|
4
4
|
(a small declarative DSL) — **not** React, JSX, Vue, Svelte or hand-written HTML/JS. No model is
|
|
5
5
|
trained on Muten yet, so **follow this guide instead of guessing**. Foreign code enters ONLY through explicit
|
|
6
|
-
escapes — `use` for JS functions,
|
|
6
|
+
escapes — `use` for JS functions, `Custom` for a vanilla-JS widget — never as the page UI itself; never add a JS bootstrap.
|
|
7
7
|
|
|
8
8
|
> Full language reference (every primitive, prop, token, pattern): the **`muten` skill** at
|
|
9
9
|
> [`skills/muten/SKILL.md`](skills/muten/SKILL.md).
|
|
@@ -14,7 +14,7 @@ escapes — `use` for JS functions, **islands** for a Svelte/React widget — ne
|
|
|
14
14
|
- Primitives are **PascalCase** (`Stack`, `Text`, `Button`); control flow is lowercase (`when`, `each`).
|
|
15
15
|
- **`style(...)`** = layout/typography tokens (Muten builds STRUCTURE). **`class("...")`** = your look
|
|
16
16
|
(your CSS / Tailwind). Muten ships no skin — appearance is yours.
|
|
17
|
-
-
|
|
17
|
+
- A state reference is a **bare name** (no sigil); interpolate in any string with `{expr}`: `Text "Hi, {user.name}"`.
|
|
18
18
|
|
|
19
19
|
## File map
|
|
20
20
|
```
|
|
@@ -40,8 +40,8 @@ Page style(padding.lg, gap.md) {
|
|
|
40
40
|
|
|
41
41
|
## Cheat-sheet
|
|
42
42
|
- **Layout:** `Stack` (vertical), `Page` (`<main>`), `Header`/`Nav`/`Sidebar`/`Footer` (landmarks). Horizontal = `style(row)`.
|
|
43
|
-
- **Content:** `Text`, `Title "x" h2`, `Span`, `Image "{src}" alt
|
|
44
|
-
- **Data:** `DataTable
|
|
43
|
+
- **Content:** `Text`, `Title "x" h2`, `Span`, `Image "{src}" alt("…")` (alt required), `Link "x" -> /route`, `Button "x" -> action(arg)`.
|
|
44
|
+
- **Data:** `DataTable list columns(a, b)`, `Form bind(draft) submit(create)`, `SearchField bind(q)`.
|
|
45
45
|
- **Control:** `when <expr> { … }`, `each <list> as item { … }`.
|
|
46
46
|
- **Interactivity:** reactive class `class(active when isOpen)`; events on any element `on(keydown: act, mouseenter: act)`; a `/404` route catches unmatched paths.
|
|
47
47
|
- **State:** `state { q = "" : text users = query listUsers : list<User> }` — query states expose `.loading/.error/.data`.
|
|
@@ -49,17 +49,17 @@ Page style(padding.lg, gap.md) {
|
|
|
49
49
|
- **Writes:** a source-backed list gets `create`/`update`/`delete` in an action (`orders.create(draft)` → POST/PUT/DELETE the resource, optimistic + updates the list). The action is async with reactive `name.pending`/`name.error` for UX. Local-only mutations stay `push`/`set`/`reset`/`remove`.
|
|
50
50
|
- **Refetch:** re-run a query with N params (search / paginate / filter): `products.refetch(q: term, page: n)` in an action → builds `?q=&page=` and reloads the list.
|
|
51
51
|
- **Escape hatch:** non-RESTful API? `post`/`put`/`delete` a `"client:/path"` (interpolated) with optional `body` in an action: `post "shop:/orders" body item`. Uses the client's base+headers; `mutates` is optional for pure commands.
|
|
52
|
-
- **JS
|
|
53
|
-
- **Actions:** `action add mutates users
|
|
52
|
+
- **JS escape (`use`):** call named JS functions behind a typed, synchronous border — `use fmt from "./lib.ts"` → `Text "{fmt(x)}"`. A visual widget Muten can't express → vanilla-JS `Custom` (no framework-component escape). Full details: SKILL §14.
|
|
53
|
+
- **Actions:** `action add(item: User) mutates users { users.push(item) }` — typed params in `(…)`; ops: `push/set/reset/remove/toggle/patch`; branch with `if/else`.
|
|
54
54
|
- **Tokens:** `gap.md padding.lg cols.3 text.lg row center between` — responsive prefix: `md:cols.2`.
|
|
55
55
|
|
|
56
56
|
## Dependencies & limits
|
|
57
57
|
- **CSS / Tailwind / SCSS: YES** — it's a Vite app; install them and use `class("…")` + your CSS.
|
|
58
|
-
- **React / Vue / Svelte
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
- **React / Vue / Svelte: NO — at all.** Muten ships ZERO framework runtime; pages are `.muten` (vanilla DOM).
|
|
59
|
+
A widget Muten can't express enters as a vanilla `Custom` component (SKILL §13); JS logic via `use` (§14) —
|
|
60
|
+
for the foreign piece, never the whole UI.
|
|
61
61
|
- Routing uses **real paths** (`/path`, History API; deploy serves `index.html` for any path); route params work (`/product/:id` → `param id`). SEO: `meta { title "…" description "…" }` per page → `<head>` tags (og auto-derived). Shell has no local state → use a
|
|
62
|
-
`.store`.
|
|
62
|
+
`.store`. Flip a bool with `x.toggle()`. `style()` is layout tokens only; visuals go in `class()`.
|
|
63
63
|
- The full reference (stores, routing, theme, every primitive) is in [`skills/muten/SKILL.md`](skills/muten/SKILL.md).
|
|
64
64
|
|
|
65
65
|
## Commands
|
|
@@ -6,16 +6,17 @@ description: Read and write Muten — the AI-first frontend DSL this app is buil
|
|
|
6
6
|
# Muten — complete language reference
|
|
7
7
|
|
|
8
8
|
Muten compiles `.muten` files to vanilla JS + fine-grained signals (no virtual DOM). The `@muten/core`
|
|
9
|
-
Vite plugin does the compiling. You write a small declarative DSL for the UI — **not** React/JSX/Vue/Svelte/HTML
|
|
10
|
-
foreign code comes in only through explicit escapes (`use` for JS
|
|
11
|
-
|
|
9
|
+
Vite plugin does the compiling. You write a small declarative DSL for the UI — **not** React/JSX/Vue/Svelte/HTML.
|
|
10
|
+
Muten ships ZERO framework runtime; foreign code comes in only through explicit escapes (`use` for JS logic
|
|
11
|
+
functions — §14, `Custom` for a vanilla-JS widget — §13). A page with no reactivity compiles to plain
|
|
12
|
+
zero-runtime HTML; a reactive one ships ~1KB of signals.
|
|
12
13
|
|
|
13
14
|
## Mental model & golden rules
|
|
14
15
|
- **UI** → `.muten` files (pages, parts, the app root, the theme). **App-global state** → `.store` files.
|
|
15
16
|
- **`src/app.muten` is the entry.** `index.html` loads it; the plugin boots it. **Never create `main.js`** or a `<script>` bootstrap.
|
|
16
17
|
- Primitives are **PascalCase** (`Stack`, `Text`); keywords/control flow are **lowercase** (`when`, `each`, `state`).
|
|
17
18
|
- `style(...)` = layout/typography **tokens** (Muten builds STRUCTURE). `class("...")` = **look** (your CSS / Tailwind); toggle reactively with `class(active when isOpen)`. Muten ships no skin.
|
|
18
|
-
-
|
|
19
|
+
- A reference is a **bare name** (no sigil) everywhere — `count`, `user.name`, `cart.total`. `{expr}` = interpolation inside a Text/label/path string: `Text "Hi, {user.name}"`. (NOT inside `class("…")` — for a dynamic class use `class(name when cond)`.)
|
|
19
20
|
- Each page has **one root node**. Reactivity is automatic: reading a state in interpolation / `when` / `each` re-renders just that spot.
|
|
20
21
|
|
|
21
22
|
## 1. What you CAN install / use
|
|
@@ -29,17 +30,13 @@ This is a normal **Vite** project, so the whole Vite/npm ecosystem for **styling
|
|
|
29
30
|
**`use` logic imports** (date libs, fetch wrappers, zod, etc.).
|
|
30
31
|
- **JS logic via `use … from "./lib.ts"` — YES.** Import named functions and call them in any expression
|
|
31
32
|
(`use fmt from "./lib.ts"` → `Text "{fmt(x)}"`). The `.ts` is a facade over any npm. See §14.
|
|
32
|
-
- **Svelte / React components via ISLANDS — YES.** A genuinely-interactive widget or a framework UI lib
|
|
33
|
-
Muten can't express → mount a real `.svelte`/`.jsx` with `use X from "svelte:…"`. See §14.
|
|
34
33
|
- **Host UI via the `Custom` primitive** — write vanilla JS in `src/components/<Name>.js` (charts,
|
|
35
34
|
maps, a third-party widget) and mount it with `Custom`. See §Custom.
|
|
36
35
|
|
|
37
36
|
## 2. What you CANNOT do
|
|
38
|
-
- **
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
not the whole UI. Default to `.muten`; reach for an island only when Muten genuinely can't express it.
|
|
42
|
-
- **No JSX / hooks / `className` inside `.muten`.** Those live in the island's own `.svelte`/`.jsx` file, never in a page.
|
|
37
|
+
- **No React / Vue / Svelte — at all.** Muten ships ZERO framework runtime. Pages are `.muten` → vanilla DOM;
|
|
38
|
+
you don't compose the app from MUI/Chakra/shadcn. For a widget Muten can't express, drop to a vanilla-JS
|
|
39
|
+
`Custom` (§13); for JS logic, `use` a function (§14). There is no JSX/hooks/`className` anywhere.
|
|
43
40
|
- **No arbitrary inline CSS via `style()`** — `style()` only takes the layout/typography tokens below.
|
|
44
41
|
Visual styling (colors, borders, shadows) goes through `class("…")` + your CSS.
|
|
45
42
|
|
|
@@ -49,7 +46,7 @@ This is a normal **Vite** project, so the whole Vite/npm ecosystem for **styling
|
|
|
49
46
|
static pages ship zero JS; reactive pages get their content (lists, `each`, interpolation from mock data)
|
|
50
47
|
baked into the HTML, then the runtime boots for interactivity. No special syntax needed.
|
|
51
48
|
- **Shell has no local state** — put shell/cross-page state in a `.store` (see the mobile-menu pattern).
|
|
52
|
-
- **
|
|
49
|
+
- **Flip a bool** with `x.toggle()` (or `x.set(not x)`).
|
|
53
50
|
- **Forms**: `Form` (auto-generated from an entity) and `SearchField` (single text input) are the
|
|
54
51
|
built-ins; richer custom inputs need a `Custom` component for now.
|
|
55
52
|
- **Pages are single-root** (one top node per page).
|
|
@@ -78,13 +75,13 @@ entity User { # data shape + validation (implicit `id uuid`)
|
|
|
78
75
|
|
|
79
76
|
state { # page-LOCAL reactive state
|
|
80
77
|
q = "" : text
|
|
81
|
-
users = query listUsers : list<User> # query → async; exposes
|
|
78
|
+
users = query listUsers : list<User> # query → async; exposes users.loading/.error/.data
|
|
82
79
|
}
|
|
83
80
|
|
|
84
81
|
const TAX = 0.21 # compile-time immutable scalar (inlined, never reactive)
|
|
85
82
|
|
|
86
|
-
action add mutates users
|
|
87
|
-
users.push(item) # ops: push | set | reset | remove
|
|
83
|
+
action add(item: User) mutates users { # mutation; typed params in (…); `mutates` lists what it may change (enforced)
|
|
84
|
+
users.push(item) # local ops: push | set | reset | remove | toggle | patch
|
|
88
85
|
users.push({ name: item.name, role: "admin" }) # inline object literal — build a record inline
|
|
89
86
|
if item.vip { rating.set(5) } else { rating.set(1) } # if/else = the only branching in actions
|
|
90
87
|
}
|
|
@@ -140,9 +137,9 @@ No `api` field → the client named `default`. The flat `api { base, headers }`
|
|
|
140
137
|
state { orders = query orders : list<Order> }
|
|
141
138
|
sources { orders: { api: "shop", url: "/orders", at: "data" } }
|
|
142
139
|
|
|
143
|
-
action buy mutates orders
|
|
144
|
-
action edit mutates orders
|
|
145
|
-
action drop mutates orders
|
|
140
|
+
action buy(item: Order) mutates orders { orders.create(item) } # POST /orders → append the result
|
|
141
|
+
action edit(item: Order) mutates orders { orders.update(item) } # PUT /orders/{id} → replace by id
|
|
142
|
+
action drop(item: Order) mutates orders { orders.delete(item) } # DELETE /orders/{id} → remove by id
|
|
146
143
|
|
|
147
144
|
Button "Buy" -> buy(product)
|
|
148
145
|
```
|
|
@@ -158,27 +155,27 @@ when buy.error { Text "Could not save: {buy.error}" }
|
|
|
158
155
|
state { q = "" : text page = 1 : number products = query products : list<Product> }
|
|
159
156
|
sources { products: { url: "/products", at: "data" } }
|
|
160
157
|
|
|
161
|
-
action search mutates products
|
|
158
|
+
action search(term: text) mutates products { products.refetch(q: term, page: 1) }
|
|
162
159
|
action next mutates products { page.set(page + 1) products.refetch(q: q, page: page) }
|
|
163
160
|
|
|
164
|
-
SearchField bind
|
|
161
|
+
SearchField bind(q)
|
|
165
162
|
Button "Search" -> search(q)
|
|
166
163
|
Button "Next" -> next
|
|
167
164
|
```
|
|
168
165
|
Pass as many params as you need (`q`, `page`, `sort`, `category`, …). The query's `.loading`/`.error` reflect the refetch.
|
|
169
166
|
|
|
170
|
-
**Live data — `
|
|
167
|
+
**Live data — `query x live` (WebSocket)** — append `live` to a query to subscribe to a **WebSocket** instead of fetching: the server PUSHES, muten reacts (event-driven, NOT polling). Each message replaces the data; the **keyed reconciliation** updates only the rows whose fields changed (focus/scroll survive) and writes **batch** into one render per frame:
|
|
171
168
|
```
|
|
172
|
-
state { prices = query prices
|
|
173
|
-
sources { prices: { url: "/prices", at: "data" } }
|
|
174
|
-
# each prices.data as p { Text "{p.symbol} {p.value}" } — only changed rows
|
|
169
|
+
state { prices = query prices live : list<Price> }
|
|
170
|
+
sources { prices: { url: "ws://feed.example.com/prices", at: "data" } }
|
|
171
|
+
# each prices.data as p { Text "{p.symbol} {p.value}" } — only changed rows touch the DOM
|
|
175
172
|
```
|
|
176
|
-
Client-side only (deploy via `vite build
|
|
173
|
+
Plain `WebSocket` under the hood, exposed as one keyword; the socket closes automatically when the page unmounts. Client-side only (deploy via `vite build`). Use `refetch` for user-driven refresh, `live` for server-pushed real-time. (Polling via a timer was intentionally NOT added — it isn't reactive. For *huge* live lists you still virtualize + send server-side deltas, as in any framework.)
|
|
177
174
|
|
|
178
175
|
**Escape hatch — explicit request** (when the API isn't RESTful): `post`/`put`/`delete` a `"client:/path"` (interpolated) with an optional `body`, in an action:
|
|
179
176
|
```
|
|
180
|
-
action buy
|
|
181
|
-
action cancel
|
|
177
|
+
action buy(item: Order) { post "shop:/orders" body item } # any method, any path
|
|
178
|
+
action cancel(o: Order) { delete "shop:/orders/{o.id}/cancel" } # custom path, interpolated
|
|
182
179
|
action ping { post "shop:/health" } # no body, no `mutates` needed
|
|
183
180
|
```
|
|
184
181
|
It uses the named client's base + headers; the action is async with `.pending`/`.error`. Prefer `create`/`update`/`delete` when the API is RESTful (those also update the list); reach for `post`/`put`/`delete` only when the convention doesn't fit.
|
|
@@ -194,21 +191,21 @@ A bare string is the node's main prop. `{ }` = children. Lay out with `style()`,
|
|
|
194
191
|
| `Text` | paragraph, interpolates | `Text "Hi, {user.name}"` |
|
|
195
192
|
| `Title` | heading; level keyword | `Title "Dashboard" h2` |
|
|
196
193
|
| `Span` | inline text | `Span "{cart.total}"` |
|
|
197
|
-
| `Image` | `<img>`, **alt required** | `Image "{p.image}" alt
|
|
194
|
+
| `Image` | `<img>`, **alt required** | `Image "{p.image}" alt("{p.title}")` |
|
|
198
195
|
| `Link` | client-side nav | `Link "Catalog" -> /catalog` |
|
|
199
196
|
| `Button` | runs an action | `Button "Save" -> save(draft)` |
|
|
200
|
-
| `SearchField` | text input bound to state | `SearchField bind
|
|
201
|
-
| `Form` | auto-form from an entity draft | `Form bind
|
|
202
|
-
| `DataTable` | reactive table over a list/query | `DataTable
|
|
197
|
+
| `SearchField` | text input bound to state | `SearchField bind(q) "Search…"` |
|
|
198
|
+
| `Form` | auto-form from an entity draft | `Form bind(draft) submit(create) "Save"` |
|
|
199
|
+
| `DataTable` | reactive table over a list/query | `DataTable users columns(name, email)` |
|
|
203
200
|
| `RowAction` | a button inside each table row | `RowAction "Delete" -> remove(row.id)` |
|
|
204
201
|
| `slot` | outlet inside `shell` | `slot` |
|
|
205
|
-
| `Custom` | host-JS escape hatch | `Custom Chart inputs(data:
|
|
202
|
+
| `Custom` | host-JS escape hatch | `Custom Chart inputs(data: sales) on(pick: select)` |
|
|
206
203
|
|
|
207
204
|
Horizontal layout = a region with `style(row)` (there is no `Row` primitive). Clickable card =
|
|
208
205
|
`Button { … }` or `Link "" -> /x { … }` with children instead of a label.
|
|
209
206
|
|
|
210
|
-
Modifiers (after a primitive): `style(tokens)` · `class("css")` · `bind
|
|
211
|
-
`where(clauses)` · `columns(a, b)` · `alt
|
|
207
|
+
Modifiers (after a primitive): `style(tokens)` · `class("css")` · `bind(state)` · `submit(action)` ·
|
|
208
|
+
`where(clauses)` · `columns(a, b)` · `alt("…")` · `inputs(k: v)` · `on(event: action)`.
|
|
212
209
|
`class()` also toggles reactively (`class(active when isOpen)`); `on(event: action)` works on **any** element
|
|
213
210
|
(keydown, mouseenter, change, blur, …) and calls the action — use `Button -> action(arg)` when you need an arg.
|
|
214
211
|
|
|
@@ -239,20 +236,22 @@ Responsive: prefix any token with a breakpoint → `md:cols.2`, `lg:cols.4` (`sm
|
|
|
239
236
|
|
|
240
237
|
## 8. State, actions & reactivity
|
|
241
238
|
- `state` cells are signals; reading them in interpolation / `when` / `each` auto-updates that spot.
|
|
242
|
-
- `query` state is async → render with `when
|
|
239
|
+
- `query` state is async → render with `when x.loading { … }`, then use `x.data`.
|
|
243
240
|
- Mutate **only** through `action`s, and only the state in `mutates` (the linter enforces it):
|
|
244
|
-
- `list.push(x)` (append; auto-fills uuid fields) · `s.set(v)` · `s.reset()` · `list.remove
|
|
241
|
+
- `list.push(x)` (append; auto-fills uuid fields) · `s.set(v)` · `s.reset()` · `s.toggle()` (flip a bool) · `list.remove where id == itemId`
|
|
245
242
|
- **Inline object literal** (build a record without leaving Muten): `posts.push({ title: draft.title, body: draft.body })`, `draft.set({ name: c.name })`. Keys must be real fields of the entity.
|
|
246
|
-
- **Edit / move / toggle an item in place**: `list.patch
|
|
243
|
+
- **Edit / move / toggle an item in place**: `list.patch where id == c.id with { done: not done }` — position-preserving, list ONLY the changed fields. This is the right tool for toggle/update/move (NOT remove+push, which reorders the item to the end).
|
|
244
|
+
- **Item fields are bare inside `where`/`with`** (item-implicit, like a `where`-filter). So a param must be named DIFFERENTLY from any field: `remove where id == id` is an error (both mean the field) — write `remove where id == itemId` with the param named `itemId`. The oracle flags the clash and tells you to rename.
|
|
247
245
|
- There is no `toggle`: `flag.set(not flag)`.
|
|
248
246
|
- Control flow in the tree: `when <expr> { … }` (mount/unmount), `each <list> as item { … }` (item is a scope var). Filter a list with `where`: `each posts as p where p.published { … }` renders only matching items.
|
|
249
247
|
- Expressions: `== != < > <= >=`, `and or not`, `contains` (case-insensitive substring / list membership),
|
|
250
248
|
`+ - * /`, ternary `c ? a : b`, parentheses, refs (`user.name`, `cart.total`, `$item.x`).
|
|
251
|
-
- **List aggregates**
|
|
252
|
-
- `lines.sum
|
|
253
|
-
- `.length` is the count-all; `count
|
|
254
|
-
- **
|
|
255
|
-
|
|
249
|
+
- **List aggregates** — `by` projects a value per item, `where` is a predicate; item fields are bare (item-implicit). For a cart total / KPI count / "N active", NO JS needed:
|
|
250
|
+
- `lines.sum by price * qty` · `todos.count where not done` · `reviews.avg by score` · `prices.min by amount` · `prices.max by amount`.
|
|
251
|
+
- `.length` is the count-all; `count where cond` is the filtered count. Works in interpolation, `when`, and a `get`.
|
|
252
|
+
- **Embedding in a bigger expression needs grouping `()`** (the `by`/`where` body runs to the end): `when (todos.count where not done) > 0 { … }`. Standalone (in a `get`) needs none: `get openCount = todos.count where not done`.
|
|
253
|
+
- **Sort a list** (`sort by` ascending / `sortDesc by` descending; returns a sorted COPY): `each contacts.sort by name as c { … }` ·
|
|
254
|
+
`each scores.sortDesc by points as s { … }`. Use in `each` or a `get`.
|
|
256
255
|
|
|
257
256
|
## 9. Stores — app-global state
|
|
258
257
|
A `.store` file = state shared across pages, **no prop drilling**. The file name is the domain.
|
|
@@ -260,13 +259,13 @@ A `.store` file = state shared across pages, **no prop drilling**. The file name
|
|
|
260
259
|
# src/ui.store → referenced everywhere as ui.<member>
|
|
261
260
|
state { menuOpen = false : bool }
|
|
262
261
|
get isOpen = menuOpen # derived/memoized value (read as ui.isOpen)
|
|
263
|
-
action toggleMenu mutates menuOpen
|
|
262
|
+
action toggleMenu mutates menuOpen { menuOpen.toggle() }
|
|
264
263
|
effect { /* runs whenever the store state it reads changes */ }
|
|
265
264
|
```
|
|
266
265
|
Use it from any page/shell by name: `when ui.menuOpen { … }`, `Button "☰" -> ui.toggleMenu`. The Vite
|
|
267
266
|
plugin auto-detects every `.store` file. `get` = memoized; `effect` = reactive side-effect (Angular-style).
|
|
268
|
-
**A page action can CALL a store action** (composition) — `action add
|
|
269
|
-
store work AND local work in one handler (e.g. add to the store, then clear the form). Wire it with `Form submit
|
|
267
|
+
**A page action can CALL a store action** (composition) — `action add(d: Item) mutates draft { cart.add(d) draft.reset() }` does
|
|
268
|
+
store work AND local work in one handler (e.g. add to the store, then clear the form). Wire it with `Form submit(add)`.
|
|
270
269
|
|
|
271
270
|
## 10. Routing — how it works
|
|
272
271
|
`src/app.muten` maps URLs to pages. It uses **real paths** (`/about`, History API — client-side nav, no
|
|
@@ -316,13 +315,13 @@ routes { / -> home }
|
|
|
316
315
|
```
|
|
317
316
|
|
|
318
317
|
## 11. Entities, forms & validation
|
|
319
|
-
`entity` defines a shape + constraints. `Form bind
|
|
318
|
+
`entity` defines a shape + constraints. `Form bind(draft) submit(create)` auto-renders one input per
|
|
320
319
|
field and validates on submit (per-field `.field-error`), blocking the action if invalid.
|
|
321
320
|
```
|
|
322
321
|
entity Task { title text required notes text done bool }
|
|
323
322
|
state { draft = {} : Task tasks = [] : list<Task> }
|
|
324
|
-
action create mutates tasks, draft
|
|
325
|
-
# in the page: Form bind
|
|
323
|
+
action create(t: Task) mutates tasks, draft { tasks.push(t) draft.reset() }
|
|
324
|
+
# in the page: Form bind(draft) submit(create) "Add task"
|
|
326
325
|
```
|
|
327
326
|
|
|
328
327
|
## 12. Parts — reusable composition
|
|
@@ -345,47 +344,30 @@ For anything Muten can't express (a chart, a 3rd-party widget), write vanilla JS
|
|
|
345
344
|
`src/components/<Name>.js` and mount it with `Custom`. It receives `inputs` (values/state) and wires
|
|
346
345
|
DOM events to your actions via `on`. This is the ONLY way to use non-Muten UI code.
|
|
347
346
|
```
|
|
348
|
-
Custom Chart inputs(data:
|
|
347
|
+
Custom Chart inputs(data: sales) on(pointSelect: select)
|
|
349
348
|
# → src/components/Chart.js exports a mount(el, { inputs, on }) that builds vanilla DOM.
|
|
350
349
|
```
|
|
351
350
|
|
|
352
|
-
## 14. `use` — JS logic functions
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
**Logic functions** — `use` named exports from a `.ts`/`.js` file and call them in any expression:
|
|
351
|
+
## 14. `use` — JS logic functions
|
|
352
|
+
One escape that pulls in real JS/npm behind a typed, **synchronous** border. `use` named exports from a
|
|
353
|
+
`.ts`/`.js` file and call them in any expression:
|
|
356
354
|
```
|
|
357
355
|
use fmt, slug from "./lib/format.ts" # named exports ONLY (the .ts is a facade over any npm)
|
|
358
356
|
Text "{fmt(order.total)}" # called like any expression
|
|
359
357
|
Link "{slug(post.title)}" -> /blog/{post.id}
|
|
360
358
|
```
|
|
361
359
|
Import zod/date-fns/nanoid/whatever *inside* `format.ts` and expose tidy named functions; Muten sees only the
|
|
362
|
-
names, so the oracle still checks your calls. Keep the border **synchronous** (no async functions).
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
can't express (a date-picker, rich editor, a React charting component):
|
|
366
|
-
```
|
|
367
|
-
use Counter from "svelte:./Counter.svelte" # `svelte:` / `react:` prefix = an ISLAND (not a logic fn)
|
|
368
|
-
use Likes from "react:./Likes.jsx"
|
|
369
|
-
Page {
|
|
370
|
-
Counter(start: @total, onChange: setTotal) # props ↓ (@state) + events ↑ (a muten action)
|
|
371
|
-
Likes(start: @total, onLike: setTotal) client:visible # lazy: hydrate when scrolled into view
|
|
372
|
-
}
|
|
373
|
-
```
|
|
374
|
-
- `prop: @state` sends a value **down** (snapshot; a React island re-renders when the signal changes). `onX: action`
|
|
375
|
-
sends a callback that fires a **muten action** — that's how the island writes **back** to muten state.
|
|
376
|
-
- `client:visible` / `client:idle` = **lazy** hydration (load the island's JS only when visible / idle). No
|
|
377
|
-
directive = on load. Every island is code-split, so it never bloats the main bundle.
|
|
378
|
-
- **Install the framework's Vite plugin** (`@sveltejs/vite-plugin-svelte` or `@vitejs/plugin-react`) next to
|
|
379
|
-
`muten()` in `vite.config.mjs`. The component file is normal Svelte/React — it owns its own tooling; Muten
|
|
380
|
-
only validates the node + its args. This is how a **React/Svelte component lib** comes in: wrap it in an island.
|
|
360
|
+
names, so the oracle still checks your calls. Keep the border **synchronous** (no async functions). For a
|
|
361
|
+
visual widget Muten can't express (a chart, a map, a date-picker), drop to a vanilla-JS `Custom` (§13) — there
|
|
362
|
+
is no framework-component escape; Muten owns the whole UI.
|
|
381
363
|
|
|
382
364
|
## 15. Gotchas
|
|
383
|
-
- It
|
|
365
|
+
- It is NOT JSX — PascalCase primitives + `{ }` children; no JSX/hooks/`className` anywhere.
|
|
384
366
|
- No `main.js`/`<script>` — `app.muten` is the entry.
|
|
385
367
|
- `style()` (layout tokens) ≠ `class()` (look). No colors/borders in `style()`.
|
|
386
|
-
- `Image` without `alt` fails validation (`alt
|
|
368
|
+
- `Image` without `alt` fails validation (`alt("")` for decorative).
|
|
387
369
|
- Actions may only touch their declared `mutates`.
|
|
388
|
-
- Want a library? CSS → `class()`. JS function → `use` (§14). A widget → `Custom
|
|
370
|
+
- Want a library? CSS → `class()`. JS function → `use` (§14). A widget → `Custom` (§13). There is no framework-component escape.
|
|
389
371
|
|
|
390
372
|
## 16. Minimal full app
|
|
391
373
|
```
|
|
@@ -395,11 +377,11 @@ routes { / -> home }
|
|
|
395
377
|
# src/pages/home/home.muten
|
|
396
378
|
screen home
|
|
397
379
|
state { name = "" : text }
|
|
398
|
-
action greet mutates name
|
|
380
|
+
action greet(v: text) mutates name { name.set(v) }
|
|
399
381
|
|
|
400
382
|
Page style(padding.lg, gap.md) {
|
|
401
383
|
Title "Hello"
|
|
402
|
-
SearchField bind
|
|
384
|
+
SearchField bind(name) "Your name"
|
|
403
385
|
when name { Text "Hi, {name}!" }
|
|
404
386
|
}
|
|
405
387
|
```
|
package/template/package.json
CHANGED
|
@@ -6,7 +6,7 @@ Page class("welcome") {
|
|
|
6
6
|
Stack class("wrap") {
|
|
7
7
|
|
|
8
8
|
Stack class("hero") {
|
|
9
|
-
Image "/muten.svg" alt
|
|
9
|
+
Image "/muten.svg" alt("muten logo") class("logo")
|
|
10
10
|
Title "muten" class("brand")
|
|
11
11
|
Text "An AI-first frontend framework." class("tagline")
|
|
12
12
|
Text "You write small .muten files. muten compiles them to vanilla JS plus fine-grained signals — no virtual DOM, no runtime to ship. The language stays small and analyzable, so an AI can locate and mutate your app cheaply." class("lead")
|