create-muten 0.0.2 → 0.0.4
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 +22 -0
- package/index.js +120 -64
- package/overlays/full/src/app.muten +21 -0
- package/overlays/full/src/cart.store +9 -0
- package/overlays/full/src/pages/products/products.muten +26 -0
- package/overlays/routing/src/app.muten +19 -0
- package/overlays/routing/src/pages/about/about.muten +15 -0
- package/package.json +6 -2
- package/template/.claude/AGENTS.md +64 -0
- package/template/.claude/skills/muten/SKILL.md +352 -0
- package/template/package.json +1 -1
- package/template/theme.muten +1 -0
- package/template/src/styles.css +0 -9
package/README.md
CHANGED
|
@@ -60,10 +60,29 @@ In an interactive terminal it prompts for a few things (defaults in parentheses)
|
|
|
60
60
|
| Prompt | Options | Default |
|
|
61
61
|
|---|---|---|
|
|
62
62
|
| **Project name** | any valid folder name | `muten-app` |
|
|
63
|
+
| **Template** | `basic` / `routing` / `full` | `basic` |
|
|
63
64
|
| **Stylesheet** | `css` / `scss` | `css` |
|
|
65
|
+
| **Add Tailwind CSS?** | `Y` / `n` (CSS only) | `n` |
|
|
66
|
+
| **Add DaisyUI?** | `Y` / `n` (needs Tailwind) | `n` |
|
|
64
67
|
| **Package manager** | `npm` / `pnpm` / `yarn` / `bun` | the one that launched it |
|
|
65
68
|
| **Install deps and start dev now?** | `Y` / `n` | `Y` |
|
|
66
69
|
|
|
70
|
+
Tailwind is an optional add-on **on top of** CSS (the look layer; you still style via `class("…")`) — it
|
|
71
|
+
wires `@tailwindcss/vite` + an `@import "tailwindcss"` and notes the setup in the app's `.claude/` guide.
|
|
72
|
+
|
|
73
|
+
## Templates
|
|
74
|
+
|
|
75
|
+
| Template | What you get |
|
|
76
|
+
|---|---|
|
|
77
|
+
| **basic** | one page — the minimal starter |
|
|
78
|
+
| **routing** | a persistent shell (navbar + footer) + multiple real-path routes + a static `about` page |
|
|
79
|
+
| **full** | routing + a `.store` + an `api` block + a source-backed products page + Tailwind — a real data app |
|
|
80
|
+
|
|
81
|
+
When **Tailwind or DaisyUI** is selected, `theme.muten` is centralized to **match Tailwind's scale** (so
|
|
82
|
+
`style()` tokens and Tailwind utilities share one scale, e.g. `style(gap.md)` == `gap-4`); plain CSS/SCSS
|
|
83
|
+
keeps the default scale. **DaisyUI** adds component classes (`btn`, `card`, `modal`) usable in `class("…")` —
|
|
84
|
+
the closest fit to shadcn that works in Muten (pure classes, no React; behavior is Muten state + `on()`).
|
|
85
|
+
|
|
67
86
|
If you accept the last prompt it runs `<pm> install` followed by `<pm> run dev` — your app is live in a
|
|
68
87
|
single step. Choosing SCSS also adds `sass` and switches the stylesheet to `.scss` automatically.
|
|
69
88
|
|
|
@@ -80,7 +99,10 @@ create-muten my-app --css --no-install # just scaffold, decide later
|
|
|
80
99
|
| Flag | Effect |
|
|
81
100
|
|---|---|
|
|
82
101
|
| `<name>` | the project folder (positional argument) |
|
|
102
|
+
| `--template <basic\|routing\|full>` | which starter (default: `basic`; `full` implies Tailwind) |
|
|
83
103
|
| `--css` / `--scss` | pick the stylesheet (default: `css`) |
|
|
104
|
+
| `--tailwind` | add Tailwind CSS v4 on top of CSS (forces `--css`) |
|
|
105
|
+
| `--daisyui` | add DaisyUI component classes (implies `--tailwind`) |
|
|
84
106
|
| `--pm <npm\|pnpm\|yarn\|bun>` | package manager to use (default: detected) |
|
|
85
107
|
| `--no-install` | scaffold only — don't install or start the dev server |
|
|
86
108
|
| `--help` | print usage and exit |
|
package/index.js
CHANGED
|
@@ -1,111 +1,167 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// create-muten — scaffold a new Muten app
|
|
2
|
+
// create-muten — scaffold a new Muten app, with modern interactive prompts (@clack/prompts).
|
|
3
3
|
//
|
|
4
|
-
// npm create muten@latest [name] (
|
|
5
|
-
//
|
|
6
|
-
// create-muten [name] [--css|--scss] [--pm npm|pnpm|yarn|bun] [--no-install]
|
|
4
|
+
// npm create muten@latest [name] (or: npx create-muten)
|
|
5
|
+
// create-muten [name] [--template basic|routing|full] [--css|--scss] [--tailwind] [--daisyui] [--pm npm|pnpm|yarn|bun] [--no-install]
|
|
7
6
|
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// unless --no-install, runs `<pm> install` + `<pm> run dev`.
|
|
7
|
+
// Stylesheet (CSS or SCSS) is the base; Tailwind is an optional add-on ON TOP of CSS (it's a styling
|
|
8
|
+
// library, not a stylesheet replacement). Interactive in a TTY; flags / non-TTY make it scriptable.
|
|
11
9
|
import { cpSync, existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
12
10
|
import { join, dirname, resolve } from 'node:path';
|
|
13
11
|
import { fileURLToPath } from 'node:url';
|
|
14
|
-
import { createInterface } from 'node:readline/promises';
|
|
15
12
|
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { intro, outro, text, select, confirm, isCancel, cancel, note } from '@clack/prompts';
|
|
14
|
+
import color from 'picocolors';
|
|
16
15
|
|
|
17
16
|
const SELF = dirname(fileURLToPath(import.meta.url));
|
|
18
17
|
const TEMPLATE = join(SELF, 'template');
|
|
18
|
+
const OVERLAYS = join(SELF, 'overlays'); // additive layers per template variant (routing, full)
|
|
19
|
+
const TEMPLATES = ['basic', 'routing', 'full'];
|
|
19
20
|
const PKG = JSON.parse(readFileSync(join(SELF, 'package.json'), 'utf8'));
|
|
20
21
|
const PMS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
23
|
+
// the starter reset — written by the CLI so the template stays pure .muten (no default styles file).
|
|
24
|
+
const RESET = `/* Your look. Muten ships STRUCTURE + LAYOUT (style() tokens); the LOOK lives here, via class("…"). */
|
|
25
|
+
* { box-sizing: border-box; }
|
|
26
|
+
body { margin: 0; font: 15px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; color: #111; }
|
|
27
|
+
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
|
28
|
+
h1 { font-size: 32px; font-weight: 700; letter-spacing: -.02em; }
|
|
29
|
+
.stack { display: flex; flex-direction: column; }
|
|
30
|
+
img { max-width: 100%; display: block; }
|
|
31
|
+
a { color: inherit; text-decoration: none; }
|
|
32
|
+
`;
|
|
33
|
+
// CSS + Tailwind v4: one @import + the @tailwindcss/vite plugin. Preflight does the reset, so this stays
|
|
34
|
+
// minimal — only `.stack` (a Muten layout primitive Tailwind doesn't know). You style via class("…").
|
|
35
|
+
const tailwindStyles = (daisyui) => `@import "tailwindcss";${daisyui ? '\n@plugin "daisyui";' : ''}
|
|
36
|
+
|
|
37
|
+
/* Muten layout primitive Tailwind doesn't define */
|
|
38
|
+
.stack { display: flex; flex-direction: column; }
|
|
39
|
+
`;
|
|
40
|
+
const TAILWIND_VITE = `import muten from '@muten/core/vite-plugin-muten.js';
|
|
41
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
42
|
+
|
|
43
|
+
export default {
|
|
44
|
+
plugins: [muten(), tailwindcss()],
|
|
39
45
|
};
|
|
40
|
-
|
|
41
|
-
//
|
|
46
|
+
`;
|
|
47
|
+
// When Tailwind/DaisyUI is chosen, the Muten token scale is centralized to MATCH Tailwind's defaults, so
|
|
48
|
+
// style() tokens and Tailwind utilities share one scale (e.g. style(gap.md) == gap-4 == 1rem). Plain
|
|
49
|
+
// css/scss keeps the default theme.muten. (DaisyUI builds on Tailwind's scale; its colors come via @plugin.)
|
|
50
|
+
const TAILWIND_THEME = `# Token SCALE aligned with Tailwind's defaults — style() tokens + Tailwind utilities share one scale.
|
|
51
|
+
theme {
|
|
52
|
+
space { xs "0.25rem" sm "0.5rem" md "1rem" lg "1.5rem" xl "2rem" }
|
|
53
|
+
font { sm "0.875rem" md "1rem" lg "1.125rem" xl "1.25rem" }
|
|
54
|
+
weight { medium "500" semibold "600" bold "700" }
|
|
55
|
+
leading { tight "1.25" normal "1.5" loose "2" }
|
|
56
|
+
breakpoints { sm "640px" md "768px" lg "1024px" xl "1280px" }
|
|
57
|
+
}
|
|
58
|
+
`;
|
|
59
|
+
const TAILWIND_NOTE = `
|
|
60
|
+
## Styling: Tailwind CSS v4 (installed)
|
|
61
|
+
This app has Tailwind ON TOP of CSS. Write the LOOK with \`class("…")\` using Tailwind utilities, e.g.
|
|
62
|
+
\`class("flex gap-4 rounded-lg bg-zinc-900 text-white")\`. \`style()\` still owns Muten's layout/
|
|
63
|
+
typography tokens — don't put Tailwind classes in \`style()\`, and don't put layout in \`class()\`.
|
|
64
|
+
You can still add your own rules in \`src/styles.css\` below the \`@import "tailwindcss";\`.
|
|
65
|
+
`;
|
|
66
|
+
// DaisyUI = component CLASSES on top of Tailwind (no React). The "shadcn for Muten": pre-styled components
|
|
67
|
+
// you drop into class("…"); behavior (open/close) you build with Muten state + class(when) + on(…).
|
|
68
|
+
const DAISY_NOTE = `
|
|
69
|
+
## DaisyUI (installed)
|
|
70
|
+
DaisyUI adds **component classes** on top of Tailwind — use them in \`class("…")\`: \`class("btn btn-primary")\`,
|
|
71
|
+
\`class("card bg-base-100 shadow-xl")\`, \`class("badge")\`, \`class("alert")\`. Pure classes, no React.
|
|
72
|
+
\`@plugin "daisyui";\` is already in \`src/styles.css\`. Interactive behavior (toggle a modal/dropdown) you build
|
|
73
|
+
with Muten: \`state\` + \`class(active when isOpen)\` + \`on(click: …)\`.
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
// Which PM launched us? npm/pnpm/yarn/bun set npm_config_user_agent — the idiomatic default.
|
|
77
|
+
const detectPM = () => { const ua = process.env.npm_config_user_agent || ''; return PMS.find((p) => ua.startsWith(p + '/')) || 'npm'; };
|
|
42
78
|
const validName = (n) => /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(n);
|
|
43
|
-
const
|
|
79
|
+
const keep = (v) => { if (isCancel(v)) { cancel('Cancelled.'); process.exit(0); } return v; };
|
|
44
80
|
|
|
45
81
|
async function main() {
|
|
46
|
-
// args: one positional name + optional flags (so the CLI is also scriptable / CI-friendly)
|
|
47
82
|
const argv = process.argv.slice(2);
|
|
48
83
|
const has = (f) => argv.includes(f);
|
|
49
84
|
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
|
|
50
85
|
if (has('-v') || has('--version')) { console.log(PKG.version); return; }
|
|
51
|
-
console.log(
|
|
52
|
-
if (has('-h') || has('--help')) { console.log(' Usage: create-muten [name] [--css|--scss] [--pm npm|pnpm|yarn|bun] [--no-install]\n'); return; }
|
|
86
|
+
if (has('-h') || has('--help')) { console.log('Usage: create-muten [name] [--template basic|routing|full] [--css|--scss] [--tailwind] [--daisyui] [--pm npm|pnpm|yarn|bun] [--no-install]'); return; }
|
|
53
87
|
|
|
54
|
-
let name = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--pm')[0];
|
|
55
|
-
let
|
|
88
|
+
let name = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--pm' && argv[i - 1] !== '--template')[0];
|
|
89
|
+
let template = val('--template') || (has('--full') ? 'full' : has('--routing') ? 'routing' : has('--basic') ? 'basic' : undefined);
|
|
90
|
+
let style = has('--scss') ? 'scss' : has('--css') ? 'css' : undefined; // the base stylesheet
|
|
91
|
+
let tailwind = has('--tailwind') ? true : undefined; // optional add-on (CSS only)
|
|
92
|
+
let daisyui = has('--daisyui') ? true : undefined; // component classes on Tailwind
|
|
56
93
|
let pm = val('--pm');
|
|
57
94
|
let install = has('--no-install') ? false : undefined;
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
if (pm && !PMS.includes(pm))
|
|
61
|
-
|
|
95
|
+
if (name && !validName(name)) { console.error(`Invalid name: "${name}" (letters, digits, . _ -)`); process.exit(1); }
|
|
96
|
+
if (template && !TEMPLATES.includes(template)) { console.error(`Unknown template: "${template}" (${TEMPLATES.join(', ')})`); process.exit(1); }
|
|
97
|
+
if (pm && !PMS.includes(pm)) { console.error(`Unknown package manager: "${pm}" (${PMS.join(', ')})`); process.exit(1); }
|
|
62
98
|
const dpm = detectPM();
|
|
63
99
|
|
|
64
|
-
//
|
|
65
|
-
// flags + defaults instead of hanging on a prompt.
|
|
100
|
+
// Styled prompts only with a real TTY (piped/CI input would hang); otherwise use flags + defaults.
|
|
66
101
|
if (process.stdin.isTTY) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
if (
|
|
81
|
-
if (
|
|
82
|
-
if (install === undefined) install = (await ask('Install deps and start the dev server now? [Y/n] ', 'y')).toLowerCase() !== 'n';
|
|
83
|
-
rl.close();
|
|
102
|
+
intro(color.bgCyan(color.black(' create-muten ')) + color.dim(' the AI-first frontend framework'));
|
|
103
|
+
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 }));
|
|
104
|
+
if (!template) template = keep(await select({ message: 'Template', options: [
|
|
105
|
+
{ value: 'basic', label: 'Basic', hint: 'one page' },
|
|
106
|
+
{ value: 'routing', label: 'Routing', hint: 'shell + multiple pages' },
|
|
107
|
+
{ value: 'full', label: 'Routing + store + API + Tailwind', hint: 'a real data app' },
|
|
108
|
+
] }));
|
|
109
|
+
if (template === 'full') tailwind = true; // the full template implies Tailwind
|
|
110
|
+
// Tailwind is an add-on on top of CSS (Tailwind v4 is CSS-native; Sass isn't recommended).
|
|
111
|
+
if (!style && !tailwind) style = keep(await select({ message: 'Stylesheet', options: [
|
|
112
|
+
{ value: 'css', label: 'CSS', hint: 'plain, zero deps' },
|
|
113
|
+
{ value: 'scss', label: 'SCSS', hint: 'adds sass' },
|
|
114
|
+
] }));
|
|
115
|
+
if (tailwind === undefined) tailwind = style === 'css' ? keep(await confirm({ message: 'Add Tailwind CSS? (utility classes via class("…"))', initialValue: false })) : false;
|
|
116
|
+
if (tailwind && daisyui === undefined) daisyui = keep(await confirm({ message: 'Add DaisyUI? (component classes: btn, card, modal…)', initialValue: template === 'full' }));
|
|
84
117
|
}
|
|
85
118
|
name = name || 'muten-app';
|
|
119
|
+
template = template || 'basic';
|
|
120
|
+
if (template === 'full') tailwind = true; // full implies Tailwind
|
|
121
|
+
if (daisyui) tailwind = true; // DaisyUI is a Tailwind plugin
|
|
86
122
|
style = style || 'css';
|
|
123
|
+
if (tailwind === undefined) tailwind = false;
|
|
124
|
+
if (daisyui === undefined) daisyui = false;
|
|
125
|
+
if (tailwind) style = 'css'; // Tailwind implies a CSS base (not SCSS)
|
|
87
126
|
pm = pm || dpm;
|
|
88
127
|
if (install === undefined) install = false;
|
|
89
128
|
|
|
90
129
|
const target = resolve(name);
|
|
91
|
-
if (existsSync(target))
|
|
130
|
+
if (existsSync(target)) { (process.stdin.isTTY ? cancel : console.error)(`"${name}" already exists.`); process.exit(1); }
|
|
92
131
|
|
|
93
|
-
// scaffold from ./template
|
|
132
|
+
// scaffold from ./template (the basic base) + layer the chosen variant's overlay + the stylesheet/add-ons
|
|
94
133
|
cpSync(TEMPLATE, target, { recursive: true });
|
|
95
|
-
|
|
134
|
+
if (template !== 'basic') cpSync(join(OVERLAYS, template), target, { recursive: true }); // routing / full overlay
|
|
135
|
+
const ignore = join(target, '_gitignore');
|
|
96
136
|
if (existsSync(ignore)) renameSync(ignore, join(target, '.gitignore'));
|
|
97
|
-
if (style === 'scss') renameSync(join(target, 'src', 'styles.css'), join(target, 'src', 'styles.scss')); // plugin auto-detects
|
|
98
137
|
|
|
99
138
|
const pkgPath = join(target, 'package.json');
|
|
100
139
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
101
140
|
pkg.name = name;
|
|
102
|
-
|
|
141
|
+
const addDev = (deps) => { pkg.devDependencies = { ...(pkg.devDependencies || {}), ...deps }; };
|
|
142
|
+
|
|
143
|
+
if (tailwind) {
|
|
144
|
+
writeFileSync(join(target, 'src', 'styles.css'), tailwindStyles(daisyui));
|
|
145
|
+
writeFileSync(join(target, 'vite.config.mjs'), TAILWIND_VITE);
|
|
146
|
+
writeFileSync(join(target, 'theme.muten'), TAILWIND_THEME); // scale centralized to Tailwind's
|
|
147
|
+
addDev({ tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' });
|
|
148
|
+
if (daisyui) addDev({ daisyui: '^5.0.0' });
|
|
149
|
+
const agents = join(target, '.claude', 'AGENTS.md'); // tell the AI what styling is available
|
|
150
|
+
if (existsSync(agents)) writeFileSync(agents, readFileSync(agents, 'utf8') + TAILWIND_NOTE + (daisyui ? DAISY_NOTE : ''));
|
|
151
|
+
} else {
|
|
152
|
+
writeFileSync(join(target, 'src', style === 'scss' ? 'styles.scss' : 'styles.css'), RESET);
|
|
153
|
+
}
|
|
154
|
+
if (style === 'scss') addDev({ sass: '^1.101.0' });
|
|
103
155
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
104
156
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
157
|
+
const desc = `${template}, ${style}${tailwind ? ' + Tailwind' : ''}${daisyui ? ' + DaisyUI' : ''}`;
|
|
158
|
+
if (!install) {
|
|
159
|
+
if (process.stdin.isTTY) { note(`cd ${name}\n${pm} install\n${pm} run dev`, 'Next steps'); outro(color.green(`Created ${name} (${desc})`)); }
|
|
160
|
+
else console.log(`\n Created ${name} (${desc}, ${pm})\n cd ${name} && ${pm} install && ${pm} run dev\n`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
108
163
|
|
|
164
|
+
if (process.stdin.isTTY) outro(color.green(`Created ${name} (${desc}) — installing with ${pm}…`));
|
|
109
165
|
// PMs are .cmd shims on Windows → spawn needs shell:true to find them.
|
|
110
166
|
const run = (a) => spawnSync(pm, a, { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
111
167
|
if (run(['install']).status === 0) run(['run', 'dev']);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# The app ROOT. `api` is the backend config (one place: base URL + default headers) — every `sources`
|
|
2
|
+
# inherits it. The shell's navbar reads the cart store (global) and shows a live count.
|
|
3
|
+
api {
|
|
4
|
+
base: "https://fakestoreapi.com"
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
shell {
|
|
8
|
+
Header style(row, between, center) class("p-4 shadow bg-white") {
|
|
9
|
+
Link "Shop" -> / class("text-xl font-bold")
|
|
10
|
+
Nav "Main" style(row, gap.md, center) {
|
|
11
|
+
Link "Products" -> /products
|
|
12
|
+
Span "🛒 {cart.count}" class("font-medium")
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
slot
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
routes {
|
|
19
|
+
/ -> home
|
|
20
|
+
/products -> products
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# An app-global store (any `*.store` under src/). State is shared across pages + the shell, no imports.
|
|
2
|
+
# `get` is a derived value; `action`s are the only way to mutate.
|
|
3
|
+
store { items = [] : list<text> }
|
|
4
|
+
|
|
5
|
+
get count = items.length
|
|
6
|
+
|
|
7
|
+
action add mutates items <- id {
|
|
8
|
+
items.push(id)
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# A real data page: `query` state backed by a `sources` URL (relative → joined to the app's `api.base`).
|
|
2
|
+
# `muten build` fetches it at build and bakes the rows into the HTML (SSG); the runtime then takes over.
|
|
3
|
+
# Clicking "Add" calls the cart store action — the navbar count updates reactively.
|
|
4
|
+
screen products
|
|
5
|
+
|
|
6
|
+
meta { title "Products" }
|
|
7
|
+
|
|
8
|
+
entity Product { title text price text }
|
|
9
|
+
|
|
10
|
+
state { products = query products : list<Product> }
|
|
11
|
+
|
|
12
|
+
sources { products: "/products" }
|
|
13
|
+
|
|
14
|
+
Page style(padding.lg, gap.md) {
|
|
15
|
+
Title "Products" class("text-2xl font-bold")
|
|
16
|
+
when products.loading { Text "Loading…" class("opacity-60") }
|
|
17
|
+
each products as p {
|
|
18
|
+
Stack style(gap.sm) class("p-4 rounded-lg shadow bg-white") {
|
|
19
|
+
Text "{p.title}" class("font-semibold")
|
|
20
|
+
Stack style(row, between, center) {
|
|
21
|
+
Span "$ {p.price}" class("text-lg")
|
|
22
|
+
Button "Add to cart" -> cart.add(p.id) class("px-3 py-1 rounded bg-black text-white")
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# The app ROOT. A persistent `shell { … slot … }` wraps every page (navbar here); `slot` is where the
|
|
2
|
+
# active page mounts. `routes` maps a real-path URL to a page (the folder under src/pages/).
|
|
3
|
+
shell {
|
|
4
|
+
Header style(row, between, center) class("nav") {
|
|
5
|
+
Link "Home" -> /
|
|
6
|
+
Nav "Main" {
|
|
7
|
+
Link "About" -> /about
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
slot
|
|
11
|
+
Footer style(padding.md) {
|
|
12
|
+
Text "Built with Muten"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
routes {
|
|
17
|
+
/ -> home
|
|
18
|
+
/about -> about
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# A second page. `meta { … }` sets the <head> title/description (og:* auto-derived) — applied on
|
|
2
|
+
# navigation and baked into the static HTML at build. This page has no reactivity, so `muten build`
|
|
3
|
+
# pre-renders it to zero-JS HTML (crawlable).
|
|
4
|
+
screen about
|
|
5
|
+
|
|
6
|
+
meta {
|
|
7
|
+
title "About"
|
|
8
|
+
description "About this Muten app."
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
Page style(padding.lg, gap.md) {
|
|
12
|
+
Title "About"
|
|
13
|
+
Text "Static pages compile to zero-JS HTML at build — crawlable and instant."
|
|
14
|
+
Link "← Home" -> /
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-muten",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "Scaffold a new Muten app.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"bin": {
|
|
15
15
|
"create-muten": "index.js"
|
|
16
16
|
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@clack/prompts": "^0.7.0",
|
|
19
|
+
"picocolors": "^1.0.1"
|
|
20
|
+
},
|
|
17
21
|
"keywords": ["muten", "create-muten", "scaffold", "starter", "cli", "frontend"],
|
|
18
|
-
"files": ["index.js", "template"]
|
|
22
|
+
"files": ["index.js", "template", "overlays"]
|
|
19
23
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Working in a Muten app — guide for AI agents
|
|
2
|
+
|
|
3
|
+
This project uses **Muten**, an AI-first frontend framework. The UI is written in **`.muten` files**
|
|
4
|
+
(a small declarative DSL) — **not** React, JSX, Vue, Svelte or hand-written HTML/JS. No model is
|
|
5
|
+
trained on Muten yet, so **follow this guide instead of guessing**: never import React/Vue, never write
|
|
6
|
+
`.jsx`/`.vue`, never add a JS bootstrap.
|
|
7
|
+
|
|
8
|
+
> Full language reference (every primitive, prop, token, pattern): the **`muten` skill** at
|
|
9
|
+
> [`skills/muten/SKILL.md`](skills/muten/SKILL.md).
|
|
10
|
+
|
|
11
|
+
## Golden rules
|
|
12
|
+
- UI → `.muten` files. App-global state → `.store` files. Both compile via the `@muten/core` Vite plugin.
|
|
13
|
+
- **No `main.js`.** `src/app.muten` IS the entry (loaded by `index.html`). Never add a JS entry/bootstrap.
|
|
14
|
+
- Primitives are **PascalCase** (`Stack`, `Text`, `Button`); control flow is lowercase (`when`, `each`).
|
|
15
|
+
- **`style(...)`** = layout/typography tokens (Muten builds STRUCTURE). **`class("...")`** = your look
|
|
16
|
+
(your CSS / Tailwind). Muten ships no skin — appearance is yours.
|
|
17
|
+
- State references use `@name`; interpolate in any string with `{expr}`: `Text "Hi, {user.name}"`.
|
|
18
|
+
|
|
19
|
+
## File map
|
|
20
|
+
```
|
|
21
|
+
src/
|
|
22
|
+
app.muten ROOT — routes { /url -> page } (+ optional shell { … slot … })
|
|
23
|
+
pages/<route>/<route>.muten a page; the folder name IS the route
|
|
24
|
+
parts/<name>.muten reusable component (composition, inlined at build)
|
|
25
|
+
components/<Name>.js escape hatch (host JS) used via the `Custom` primitive
|
|
26
|
+
theme.muten design tokens: space, font, weight, breakpoints
|
|
27
|
+
src/styles.css your look (.scss if you picked SCSS)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## A page looks like this
|
|
31
|
+
```
|
|
32
|
+
screen home
|
|
33
|
+
|
|
34
|
+
Page style(padding.lg, gap.md) {
|
|
35
|
+
Title "Hello"
|
|
36
|
+
Text "Body copy with reactive state: {user.name}"
|
|
37
|
+
Button "Save" -> save
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Cheat-sheet
|
|
42
|
+
- **Layout:** `Stack` (vertical), `Page` (`<main>`), `Header`/`Nav`/`Sidebar`/`Footer` (landmarks). Horizontal = `style(row)`.
|
|
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
|
+
- **Control:** `when <expr> { … }`, `each <list> as item { … }`.
|
|
46
|
+
- **Interactivity:** reactive class `class(active when isOpen)`; events on any element `on(keydown: act, mouseenter: act)`; a `/404` route catches unmatched paths.
|
|
47
|
+
- **State:** `state { q = "" : text users = query listUsers : list<User> }` — query states expose `.loading/.error/.data`.
|
|
48
|
+
- **Backend:** `sources { x: { url, method?, headers?, body?, at? } }` feeds a `query`. Shared base+auth go in `api { base, headers }` (app.muten, named clients via `{ api: "shop" }`) — relative source urls join to `base`. GET sources pre-render at build (SSG).
|
|
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
|
+
- **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
|
+
- **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
|
+
- **Actions:** `action add mutates users <- item { users.push(item) }` — ops: `push/set/reset/remove`; branch with `if/else`.
|
|
53
|
+
- **Tokens:** `gap.md padding.lg cols.3 text.lg row center between` — responsive prefix: `md:cols.2`.
|
|
54
|
+
|
|
55
|
+
## Dependencies & limits
|
|
56
|
+
- **CSS / Tailwind / SCSS: YES** — it's a Vite app; install them and use `class("…")` + your CSS.
|
|
57
|
+
- **React / Vue / Svelte UI libraries: NO** — the UI is `.muten` (vanilla DOM, no JS framework runtime).
|
|
58
|
+
Need a JS widget? Wrap it in a `Custom` host component (`src/components/<Name>.js`).
|
|
59
|
+
- 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
|
|
60
|
+
`.store`. No `toggle` op → `set(not x)`. `style()` is layout tokens only; visuals go in `class()`.
|
|
61
|
+
- The full reference (stores, routing, theme, every primitive) is in [`skills/muten/SKILL.md`](skills/muten/SKILL.md).
|
|
62
|
+
|
|
63
|
+
## Commands
|
|
64
|
+
`npm run dev` (dev server + HMR) · `npm run build` (production) · `npm run lint` (validate `.muten`).
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: muten
|
|
3
|
+
description: Read and write Muten — the AI-first frontend DSL this app is built in (.muten and .store files), NOT React/Vue/HTML. Use whenever creating or editing any .muten or .store file, app.muten routes, theme.muten, parts, components, stores, or deciding what can be installed. No model is trained on Muten, so consult this before writing any Muten code or adding a dependency. Assume the human will NOT read the code — you do everything.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Muten — complete language reference
|
|
7
|
+
|
|
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 — **never** React/JSX/Vue/Svelte/HTML.
|
|
10
|
+
A page with no reactivity compiles to plain zero-runtime HTML; a reactive one ships ~1KB of signals.
|
|
11
|
+
|
|
12
|
+
## Mental model & golden rules
|
|
13
|
+
- **UI** → `.muten` files (pages, parts, the app root, the theme). **App-global state** → `.store` files.
|
|
14
|
+
- **`src/app.muten` is the entry.** `index.html` loads it; the plugin boots it. **Never create `main.js`** or a `<script>` bootstrap.
|
|
15
|
+
- Primitives are **PascalCase** (`Stack`, `Text`); keywords/control flow are **lowercase** (`when`, `each`, `state`).
|
|
16
|
+
- `style(...)` = layout/typography **tokens** (Muten builds STRUCTURE). `class("...")` = **look** (your CSS / Tailwind); toggle reactively with `class(active when isOpen)`. Muten ships no skin.
|
|
17
|
+
- `@name` = a state reference. `{expr}` = interpolation inside any string: `Text "Hi, {user.name}"`.
|
|
18
|
+
- Each page has **one root node**. Reactivity is automatic: reading a state in interpolation / `when` / `each` re-renders just that spot.
|
|
19
|
+
|
|
20
|
+
## 1. What you CAN install / use
|
|
21
|
+
This is a normal **Vite** project, so the whole Vite/npm ecosystem for **styling, build, and data** works:
|
|
22
|
+
- **Tailwind CSS — YES.** Install it (`tailwindcss`, `postcss`, `autoprefixer`), add the config + the
|
|
23
|
+
`@tailwind` directives to `src/styles.css`, and use utilities via `class("flex gap-4 rounded")`.
|
|
24
|
+
`class()` emits raw class names, so any CSS framework (Tailwind, UnoCSS, Bootstrap CSS, your own CSS) works.
|
|
25
|
+
- **Sass/SCSS** — supported out of the box if you scaffolded with SCSS (or add `sass`); use `src/styles.scss`.
|
|
26
|
+
- **Any Vite plugin / PostCSS plugin** — add it to `vite.config.mjs` alongside `muten()`.
|
|
27
|
+
- **Data / utility npm packages** — usable inside `.store` logic and inside `Custom` host components
|
|
28
|
+
(date libs, fetch wrappers, zod, etc.).
|
|
29
|
+
- **Host UI via the `Custom` primitive** — write vanilla JS in `src/components/<Name>.js` (charts,
|
|
30
|
+
maps, a third-party widget) and mount it with `Custom`. See §Custom.
|
|
31
|
+
|
|
32
|
+
## 2. What you CANNOT do
|
|
33
|
+
- **No React / Vue / Svelte component libraries as UI.** There is no React/Vue runtime — the UI is
|
|
34
|
+
`.muten` compiled to vanilla DOM. You cannot drop in MUI, Chakra, Ant, shadcn, a React table, etc.
|
|
35
|
+
If you truly need a JS widget, wrap it yourself in a `Custom` component (vanilla JS).
|
|
36
|
+
- **No JSX / `.jsx` / `.vue` / hand-written DOM in pages.** No `className`, no hooks, no lifecycle.
|
|
37
|
+
- **No arbitrary inline CSS via `style()`** — `style()` only takes the layout/typography tokens below.
|
|
38
|
+
Visual styling (colors, borders, shadows) goes through `class("…")` + your CSS.
|
|
39
|
+
|
|
40
|
+
## 3. Limitations (current)
|
|
41
|
+
- **Routing uses real paths** (`/path`, History API). Route params ARE supported: `/product/:id` → declare `param id`
|
|
42
|
+
in the page (see §10). `muten build` pre-renders pages to real HTML (SSG) so content is crawlable —
|
|
43
|
+
static pages ship zero JS; reactive pages get their content (lists, `each`, interpolation from mock data)
|
|
44
|
+
baked into the HTML, then the runtime boots for interactivity. No special syntax needed.
|
|
45
|
+
- **Shell has no local state** — put shell/cross-page state in a `.store` (see the mobile-menu pattern).
|
|
46
|
+
- **No `toggle` op** — flip a bool with `set(not x)`.
|
|
47
|
+
- **Forms**: `Form` (auto-generated from an entity) and `SearchField` (single text input) are the
|
|
48
|
+
built-ins; richer custom inputs need a `Custom` component for now.
|
|
49
|
+
- **Pages are single-root** (one top node per page).
|
|
50
|
+
|
|
51
|
+
## 4. Files
|
|
52
|
+
```
|
|
53
|
+
src/app.muten routes (+ optional shell) — the ROOT; read it first
|
|
54
|
+
src/pages/<route>/<route>.muten one page; the folder name IS the route
|
|
55
|
+
src/parts/<name>.muten reusable component (inlined at build time)
|
|
56
|
+
src/components/<Name>.js host-JS escape hatch, mounted via Custom
|
|
57
|
+
src/<domain>.store app-global state slice (domain = file name)
|
|
58
|
+
theme.muten token scale (space/font/weight/leading/breakpoints)
|
|
59
|
+
src/styles.css reset + look (or styles.scss)
|
|
60
|
+
index.html / vite.config.mjs wired to @muten/core; don't hand-edit the boot
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 5. Declarations
|
|
64
|
+
```
|
|
65
|
+
screen <name> # page identity (first line of a page)
|
|
66
|
+
|
|
67
|
+
entity User { # data shape + validation (implicit `id uuid`)
|
|
68
|
+
name text required # constraints: required | min:N | max:N
|
|
69
|
+
email email required
|
|
70
|
+
role admin | member # `a | b | c` = enum
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
state { # page-LOCAL reactive state
|
|
74
|
+
q = "" : text
|
|
75
|
+
users = query listUsers : list<User> # query → async; exposes @users.loading/.error/.data
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const TAX = 0.21 # compile-time immutable scalar (inlined, never reactive)
|
|
79
|
+
|
|
80
|
+
action add mutates users <- item { # mutation; `mutates` lists what it may change (enforced)
|
|
81
|
+
users.push(item) # ops: push | set | reset | remove
|
|
82
|
+
if item.vip { rating.set(5) } else { rating.set(1) } # if/else = the only branching in actions
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
mock { listUsers: [ { name: "Ana", role: admin } ] } # mock data for a query
|
|
86
|
+
sources { listUsers: { url: "https://api…", at: "results" } } # real data source for a query
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
A `sources` entry is a complete HTTP request — a bare URL, or `{ url, method?, headers?, body?, at? }`:
|
|
90
|
+
```
|
|
91
|
+
sources {
|
|
92
|
+
products: "https://api.shop.com/products" # GET, response is the array
|
|
93
|
+
orders: { url: "https://api…/orders", headers: { Authorization: "Bearer KEY" }, at: "data" }
|
|
94
|
+
search: { url: "https://api…/graphql", method: "POST", body: { query: "…" }, at: "data" }
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
- `at` reads the array out of `json[at]` — dotted for nested envelopes (`"data.posts"`). Else the response IS the array. `body` is JSON-encoded (sets `content-type`).
|
|
98
|
+
- At build (`muten build`), **GET** sources are fetched and baked into the HTML (SSG); non-GET run only client-side (no build side-effects).
|
|
99
|
+
- **Headers ship to the client** like any browser fetch — use public keys or a per-user token, never a server secret.
|
|
100
|
+
|
|
101
|
+
**Don't repeat the backend — `api { }` in `src/app.muten`** sets the base URL + default headers for ALL sources:
|
|
102
|
+
```
|
|
103
|
+
# src/app.muten
|
|
104
|
+
api { base: "https://api.shop.com/v1" headers: { Authorization: "Bearer KEY" } }
|
|
105
|
+
```
|
|
106
|
+
```
|
|
107
|
+
# any page — only what differs
|
|
108
|
+
sources {
|
|
109
|
+
products: { url: "/products", at: "data" } # → https://api.shop.com/v1/products, with the Authorization header
|
|
110
|
+
orders: { url: "/orders", at: "data" }
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
A **relative** source url is joined to `base`; an **absolute** one (`https://…`) ignores it (other host). Source headers override the api defaults. Define the backend once.
|
|
114
|
+
|
|
115
|
+
**Multiple backends** — name the clients, pick one per source with `{ api: "name" }`:
|
|
116
|
+
```
|
|
117
|
+
# src/app.muten
|
|
118
|
+
api {
|
|
119
|
+
shop: { base: "https://api.shop.com/v1", headers: { Authorization: "Bearer KEY" } }
|
|
120
|
+
cms: { base: "https://cms.io/api" }
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
```
|
|
124
|
+
sources {
|
|
125
|
+
products: { api: "shop", url: "/products", at: "data" }
|
|
126
|
+
posts: { api: "cms", url: "/posts", at: "data.posts" }
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
No `api` field → the client named `default`. The flat `api { base, headers }` form is just a single default client.
|
|
130
|
+
|
|
131
|
+
**Writing to the backend (POST/PUT/DELETE)** — a source-backed list gets `create`/`update`/`delete` in an action, fired by an event; each hits the resource endpoint (reusing the source's `api` base + headers) and updates the list reactively:
|
|
132
|
+
```
|
|
133
|
+
state { orders = query orders : list<Order> }
|
|
134
|
+
sources { orders: { api: "shop", url: "/orders", at: "data" } }
|
|
135
|
+
|
|
136
|
+
action buy mutates orders <- item { orders.create(item) } # POST /orders → append the result
|
|
137
|
+
action edit mutates orders <- item { orders.update(item) } # PUT /orders/{id} → replace by id
|
|
138
|
+
action drop mutates orders <- item { orders.delete(item) } # DELETE /orders/{id} → remove by id
|
|
139
|
+
|
|
140
|
+
Button "Buy" -> buy(product)
|
|
141
|
+
```
|
|
142
|
+
The write action is **async** and exposes reactive **`buy.pending`** (true while in flight) and **`buy.error`** — use them for UX:
|
|
143
|
+
```
|
|
144
|
+
when buy.pending { Text "Saving…" }
|
|
145
|
+
when buy.error { Text "Could not save: {buy.error}" }
|
|
146
|
+
```
|
|
147
|
+
`create`/`update`/`delete` are **optimistic** — the list changes instantly, reconciles with the server response, and **reverts** if the request fails (with `.error` set). REST convention: create = POST to the collection, update/delete target `/{item.id}`. Local-only mutations stay `push`/`set`/`reset`/`remove`; `create/update/delete` talk to the server.
|
|
148
|
+
|
|
149
|
+
**Re-running a query — `refetch` (search / pagination / filters)** — call it in an action with **N named params**; they become the query string (`?q=…&page=…`, url-encoded) and the list reloads. Works for any web-app, not just lists:
|
|
150
|
+
```
|
|
151
|
+
state { q = "" : text page = 1 : number products = query products : list<Product> }
|
|
152
|
+
sources { products: { url: "/products", at: "data" } }
|
|
153
|
+
|
|
154
|
+
action search mutates products <- term { products.refetch(q: term, page: 1) }
|
|
155
|
+
action next mutates products { page.set(page + 1) products.refetch(q: q, page: page) }
|
|
156
|
+
|
|
157
|
+
SearchField bind q
|
|
158
|
+
Button "Search" -> search(q)
|
|
159
|
+
Button "Next" -> next
|
|
160
|
+
```
|
|
161
|
+
Pass as many params as you need (`q`, `page`, `sort`, `category`, …). The query's `.loading`/`.error` reflect the refetch.
|
|
162
|
+
|
|
163
|
+
**Escape hatch — explicit request** (when the API isn't RESTful): `post`/`put`/`delete` a `"client:/path"` (interpolated) with an optional `body`, in an action:
|
|
164
|
+
```
|
|
165
|
+
action buy <- item { post "shop:/orders" body item } # any method, any path
|
|
166
|
+
action cancel <- o { delete "shop:/orders/{o.id}/cancel" } # custom path, interpolated
|
|
167
|
+
action ping { post "shop:/health" } # no body, no `mutates` needed
|
|
168
|
+
```
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
## 6. Primitives
|
|
172
|
+
A bare string is the node's main prop. `{ }` = children. Lay out with `style()`, skin with `class()`.
|
|
173
|
+
|
|
174
|
+
| Primitive | Use | Example |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `Stack` | vertical stack (flex column) | `Stack style(gap.md) { … }` |
|
|
177
|
+
| `Page` | page root `<main>` (one per route) | `Page style(padding.lg) { … }` |
|
|
178
|
+
| `Header`/`Nav`/`Sidebar`/`Footer` | landmarks | `Header style(row, between, center) { … }` |
|
|
179
|
+
| `Text` | paragraph, interpolates | `Text "Hi, {user.name}"` |
|
|
180
|
+
| `Title` | heading; level keyword | `Title "Dashboard" h2` |
|
|
181
|
+
| `Span` | inline text | `Span "{cart.total}"` |
|
|
182
|
+
| `Image` | `<img>`, **alt required** | `Image "{p.image}" alt "{p.title}"` |
|
|
183
|
+
| `Link` | client-side nav | `Link "Catalog" -> /catalog` |
|
|
184
|
+
| `Button` | runs an action | `Button "Save" -> save(draft)` |
|
|
185
|
+
| `SearchField` | text input bound to state | `SearchField bind @q "Search…"` |
|
|
186
|
+
| `Form` | auto-form from an entity draft | `Form bind @draft submit create "Save"` |
|
|
187
|
+
| `DataTable` | reactive table over a list/query | `DataTable @users columns(name, email)` |
|
|
188
|
+
| `RowAction` | a button inside each table row | `RowAction "Delete" -> remove(row.id)` |
|
|
189
|
+
| `slot` | outlet inside `shell` | `slot` |
|
|
190
|
+
| `Custom` | host-JS escape hatch | `Custom Chart inputs(data: @sales) on(pick: select)` |
|
|
191
|
+
|
|
192
|
+
Horizontal layout = a region with `style(row)` (there is no `Row` primitive). Clickable card =
|
|
193
|
+
`Button { … }` or `Link "" -> /x { … }` with children instead of a label.
|
|
194
|
+
|
|
195
|
+
Modifiers (after a primitive): `style(tokens)` · `class("css")` · `bind @state` · `submit action` ·
|
|
196
|
+
`where(clauses)` · `columns(a, b)` · `alt "…"` · `inputs(k: v)` · `on(event: action)`.
|
|
197
|
+
`class()` also toggles reactively (`class(active when isOpen)`); `on(event: action)` works on **any** element
|
|
198
|
+
(keydown, mouseenter, change, blur, …) and calls the action — use `Button -> action(arg)` when you need an arg.
|
|
199
|
+
|
|
200
|
+
## 7. Theme — how it works
|
|
201
|
+
`theme.muten` supplies the **scale** (values); the engine owns only the **vocabulary** (token names).
|
|
202
|
+
```
|
|
203
|
+
theme {
|
|
204
|
+
space { xs "4px" sm "8px" md "16px" lg "24px" xl "32px" }
|
|
205
|
+
font { sm "13px" md "15px" lg "20px" xl "28px" }
|
|
206
|
+
weight { medium "500" bold "700" }
|
|
207
|
+
leading { tight "1.2" normal "1.5" }
|
|
208
|
+
breakpoints { sm "640px" md "768px" lg "1024px" }
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
A token like `gap.md` resolves to `gap: 16px` via `space.md`; `text.lg` → `font.lg`; `md:cols.2` uses
|
|
212
|
+
`breakpoints.md`. **No CSS/reset goes in `theme.muten`** — the reset and the look live in `src/styles.css`.
|
|
213
|
+
|
|
214
|
+
### Style tokens (`style(...)`)
|
|
215
|
+
```
|
|
216
|
+
row column wrap grid grow center between
|
|
217
|
+
gap.sm|md|lg padding.md|lg padding.x.md padding.y.md margin.md
|
|
218
|
+
cols.2|3|auto rows.2
|
|
219
|
+
text.sm|md|lg|xl weight.medium|bold leading.normal italic bold
|
|
220
|
+
align.left|center|right justify.center|between items.center|start
|
|
221
|
+
width.full height.full
|
|
222
|
+
```
|
|
223
|
+
Responsive: prefix any token with a breakpoint → `md:cols.2`, `lg:cols.4` (`sm/md/lg/xl`).
|
|
224
|
+
|
|
225
|
+
## 8. State, actions & reactivity
|
|
226
|
+
- `state` cells are signals; reading them in interpolation / `when` / `each` auto-updates that spot.
|
|
227
|
+
- `query` state is async → render with `when @x.loading { … }`, then use `@x.data`.
|
|
228
|
+
- Mutate **only** through `action`s, and only the state in `mutates` (the linter enforces it):
|
|
229
|
+
- `list.push(x)` (append; auto-fills uuid fields) · `s.set(v)` · `s.reset()` · `list.remove(x => x.id == id)`
|
|
230
|
+
- There is no `toggle`: `flag.set(not flag)`.
|
|
231
|
+
- Control flow in the tree: `when <expr> { … }` (mount/unmount), `each <list> as item { … }` (item is a scope var).
|
|
232
|
+
- Expressions: `== != < > <= >=`, `and or not`, `contains` (case-insensitive substring / list membership),
|
|
233
|
+
`+ - * /`, ternary `c ? a : b`, parentheses, refs (`user.name`, `cart.total`, `$item.x`).
|
|
234
|
+
|
|
235
|
+
## 9. Stores — app-global state
|
|
236
|
+
A `.store` file = state shared across pages, **no prop drilling**. The file name is the domain.
|
|
237
|
+
```
|
|
238
|
+
# src/ui.store → referenced everywhere as ui.<member>
|
|
239
|
+
state { menuOpen = false : bool }
|
|
240
|
+
get isOpen = menuOpen # derived/memoized value (read as ui.isOpen)
|
|
241
|
+
action toggleMenu mutates menuOpen <- x { menuOpen.set(not menuOpen) }
|
|
242
|
+
effect { /* runs whenever the store state it reads changes */ }
|
|
243
|
+
```
|
|
244
|
+
Use it from any page/shell by name: `when ui.menuOpen { … }`, `Button "☰" -> ui.toggleMenu`. The Vite
|
|
245
|
+
plugin auto-detects every `.store` file. `get` = memoized; `effect` = reactive side-effect (Angular-style).
|
|
246
|
+
|
|
247
|
+
## 10. Routing — how it works
|
|
248
|
+
`src/app.muten` maps URLs to pages. It uses **real paths** (`/about`, History API — client-side nav, no
|
|
249
|
+
reload); the **first route is the default**. The folder under `src/pages/` must match the page name.
|
|
250
|
+
*(Deploy: the host must serve `index.html` for any path — standard SPA fallback.)*
|
|
251
|
+
```
|
|
252
|
+
routes {
|
|
253
|
+
/ -> home # src/pages/home/home.muten
|
|
254
|
+
/about -> about # static page → compiles to zero-runtime HTML
|
|
255
|
+
/cart -> cart guard auth.loggedIn else /login # guard: a store boolean; redirect if false
|
|
256
|
+
/login -> login guard not auth.loggedIn else / # guest-only page
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
Guards read a **store boolean**; when it flips (login/logout) the active route re-renders automatically.
|
|
260
|
+
A route named `/404` catches any unmatched path (otherwise the first route is shown).
|
|
261
|
+
Navigate with `Link "x" -> /path` (client-side, no reload).
|
|
262
|
+
|
|
263
|
+
**Route params:** a `:seg` in the route captures a URL value. The page declares it with `param <name>`,
|
|
264
|
+
then uses it as a read-only string in interpolation / `when` / expressions (it can't be mutated):
|
|
265
|
+
```
|
|
266
|
+
# app.muten
|
|
267
|
+
routes { /product/:id -> product }
|
|
268
|
+
# src/pages/product/product.muten
|
|
269
|
+
screen product
|
|
270
|
+
param id
|
|
271
|
+
Page { Title "Product {id}" }
|
|
272
|
+
```
|
|
273
|
+
Navigating `/product/1` → `/product/2` re-mounts the page with the new `id` (re-fetch the new item).
|
|
274
|
+
|
|
275
|
+
**`<head>` meta (SEO):** a page declares `meta { title "…" description "…" }` → `<title>` + `<meta>` tags
|
|
276
|
+
(`og:title`/`og:description` auto-derived). Applied on navigation and baked into the SSG HTML at build.
|
|
277
|
+
|
|
278
|
+
### Shell (persistent chrome)
|
|
279
|
+
Wrap routes in a `shell { … slot … }` for a nav/footer around every page. `slot` is where the active
|
|
280
|
+
page mounts. The shell has **no local state** → use a store for things like a mobile menu:
|
|
281
|
+
```
|
|
282
|
+
shell {
|
|
283
|
+
Header style(row, between, center) class("nav") {
|
|
284
|
+
Link "Home" -> /
|
|
285
|
+
Button "☰" -> ui.toggleMenu class("burger")
|
|
286
|
+
}
|
|
287
|
+
when ui.menuOpen { Stack class("mobile-menu") { Link "About" -> /about } }
|
|
288
|
+
slot
|
|
289
|
+
Footer { Span "© 2026" }
|
|
290
|
+
}
|
|
291
|
+
routes { / -> home }
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
## 11. Entities, forms & validation
|
|
295
|
+
`entity` defines a shape + constraints. `Form bind @draft submit create` auto-renders one input per
|
|
296
|
+
field and validates on submit (per-field `.field-error`), blocking the action if invalid.
|
|
297
|
+
```
|
|
298
|
+
entity Task { title text required notes text done bool }
|
|
299
|
+
state { draft = {} : Task tasks = [] : list<Task> }
|
|
300
|
+
action create mutates tasks, draft <- t { tasks.push(draft) draft.reset() }
|
|
301
|
+
# in the page: Form bind @draft submit create "Add task"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## 12. Parts — reusable composition
|
|
305
|
+
`part` = a reusable fragment, **inlined at build** (not a runtime component). Pass OBJECTS (`$x.field`)
|
|
306
|
+
and ACTION callbacks (`-> $onPick(...)`).
|
|
307
|
+
```
|
|
308
|
+
# src/parts/feature.muten
|
|
309
|
+
part Feature(item: Feature, onPick: action) {
|
|
310
|
+
Stack style(column, gap.sm) class("card") {
|
|
311
|
+
Title "{$item.title}" h3
|
|
312
|
+
Text "{$item.body}"
|
|
313
|
+
Button "Choose" -> $onPick($item.id)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
# use it: Feature(item: f, onPick: select)
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## 13. Custom — the host-JS escape hatch
|
|
320
|
+
For anything Muten can't express (a chart, a 3rd-party widget), write vanilla JS in
|
|
321
|
+
`src/components/<Name>.js` and mount it with `Custom`. It receives `inputs` (values/state) and wires
|
|
322
|
+
DOM events to your actions via `on`. This is the ONLY way to use non-Muten UI code.
|
|
323
|
+
```
|
|
324
|
+
Custom Chart inputs(data: @sales) on(pointSelect: select)
|
|
325
|
+
# → src/components/Chart.js exports a mount(el, { inputs, on }) that builds vanilla DOM.
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
## 14. Gotchas
|
|
329
|
+
- It's NOT React: PascalCase primitives + `{ }` children; no JSX/hooks/`className`.
|
|
330
|
+
- No `main.js`/`<script>` — `app.muten` is the entry.
|
|
331
|
+
- `style()` (layout tokens) ≠ `class()` (look). No colors/borders in `style()`.
|
|
332
|
+
- `Image` without `alt` fails validation (`alt ""` for decorative).
|
|
333
|
+
- Actions may only touch their declared `mutates`.
|
|
334
|
+
- Want a library? If it's CSS → `class()`. If it's a JS widget → `Custom`. If it's React/Vue UI → not possible.
|
|
335
|
+
|
|
336
|
+
## 15. Minimal full app
|
|
337
|
+
```
|
|
338
|
+
# src/app.muten
|
|
339
|
+
routes { / -> home }
|
|
340
|
+
|
|
341
|
+
# src/pages/home/home.muten
|
|
342
|
+
screen home
|
|
343
|
+
state { name = "" : text }
|
|
344
|
+
action greet mutates name <- v { name.set(v) }
|
|
345
|
+
|
|
346
|
+
Page style(padding.lg, gap.md) {
|
|
347
|
+
Title "Hello"
|
|
348
|
+
SearchField bind @name "Your name"
|
|
349
|
+
when name { Text "Hi, {name}!" }
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
Validate anytime: `npm run lint`.
|
package/template/package.json
CHANGED
package/template/theme.muten
CHANGED
package/template/src/styles.css
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/* Your look. Muten ships STRUCTURE (semantic tags) + LAYOUT (style tokens); the LOOK lives here,
|
|
2
|
-
referenced from .muten via class("…"). This base reset is all a fresh app needs to start. */
|
|
3
|
-
* { box-sizing: border-box; }
|
|
4
|
-
body { margin: 0; font: 15px/1.55 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; color: #111; }
|
|
5
|
-
h1, h2, h3, h4, h5, h6, p { margin: 0; }
|
|
6
|
-
h1 { font-size: 32px; font-weight: 700; letter-spacing: -.02em; }
|
|
7
|
-
.stack { display: flex; flex-direction: column; } /* the class Stack and column layouts emit */
|
|
8
|
-
img { max-width: 100%; display: block; }
|
|
9
|
-
a { color: inherit; text-decoration: none; }
|