create-muten 0.0.3 → 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 +68 -25
- 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 +2 -2
- package/template/.claude/AGENTS.md +6 -1
- package/template/.claude/skills/muten/SKILL.md +109 -6
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
|
@@ -2,10 +2,10 @@
|
|
|
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] [--css|--scss
|
|
5
|
+
// create-muten [name] [--template basic|routing|full] [--css|--scss] [--tailwind] [--daisyui] [--pm npm|pnpm|yarn|bun] [--no-install]
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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.
|
|
9
9
|
import { cpSync, existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
10
10
|
import { join, dirname, resolve } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
@@ -15,9 +15,10 @@ import color from 'picocolors';
|
|
|
15
15
|
|
|
16
16
|
const SELF = dirname(fileURLToPath(import.meta.url));
|
|
17
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'];
|
|
18
20
|
const PKG = JSON.parse(readFileSync(join(SELF, 'package.json'), 'utf8'));
|
|
19
21
|
const PMS = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
20
|
-
const STYLES = ['css', 'scss', 'tailwind'];
|
|
21
22
|
|
|
22
23
|
// the starter reset — written by the CLI so the template stays pure .muten (no default styles file).
|
|
23
24
|
const RESET = `/* Your look. Muten ships STRUCTURE + LAYOUT (style() tokens); the LOOK lives here, via class("…"). */
|
|
@@ -29,9 +30,9 @@ h1 { font-size: 32px; font-weight: 700; letter-spacing: -.02em; }
|
|
|
29
30
|
img { max-width: 100%; display: block; }
|
|
30
31
|
a { color: inherit; text-decoration: none; }
|
|
31
32
|
`;
|
|
32
|
-
// Tailwind v4: one @import + the @tailwindcss/vite plugin
|
|
33
|
-
//
|
|
34
|
-
const
|
|
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";' : ''}
|
|
35
36
|
|
|
36
37
|
/* Muten layout primitive Tailwind doesn't define */
|
|
37
38
|
.stack { display: flex; flex-direction: column; }
|
|
@@ -43,11 +44,33 @@ export default {
|
|
|
43
44
|
plugins: [muten(), tailwindcss()],
|
|
44
45
|
};
|
|
45
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
|
+
`;
|
|
46
59
|
const TAILWIND_NOTE = `
|
|
47
60
|
## Styling: Tailwind CSS v4 (installed)
|
|
48
|
-
This app has Tailwind. Write the LOOK with \`class("…")\` using Tailwind utilities, e.g.
|
|
61
|
+
This app has Tailwind ON TOP of CSS. Write the LOOK with \`class("…")\` using Tailwind utilities, e.g.
|
|
49
62
|
\`class("flex gap-4 rounded-lg bg-zinc-900 text-white")\`. \`style()\` still owns Muten's layout/
|
|
50
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: …)\`.
|
|
51
74
|
`;
|
|
52
75
|
|
|
53
76
|
// Which PM launched us? npm/pnpm/yarn/bun set npm_config_user_agent — the idiomatic default.
|
|
@@ -60,13 +83,17 @@ async function main() {
|
|
|
60
83
|
const has = (f) => argv.includes(f);
|
|
61
84
|
const val = (f) => { const i = argv.indexOf(f); return i >= 0 ? argv[i + 1] : undefined; };
|
|
62
85
|
if (has('-v') || has('--version')) { console.log(PKG.version); return; }
|
|
63
|
-
if (has('-h') || has('--help')) { console.log('Usage: create-muten [name] [--css|--scss
|
|
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; }
|
|
64
87
|
|
|
65
|
-
let name = argv.filter((a, i) => !a.startsWith('-') && argv[i - 1] !== '--pm')[0];
|
|
66
|
-
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
|
|
67
93
|
let pm = val('--pm');
|
|
68
94
|
let install = has('--no-install') ? false : undefined;
|
|
69
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); }
|
|
70
97
|
if (pm && !PMS.includes(pm)) { console.error(`Unknown package manager: "${pm}" (${PMS.join(', ')})`); process.exit(1); }
|
|
71
98
|
const dpm = detectPM();
|
|
72
99
|
|
|
@@ -74,24 +101,37 @@ async function main() {
|
|
|
74
101
|
if (process.stdin.isTTY) {
|
|
75
102
|
intro(color.bgCyan(color.black(' create-muten ')) + color.dim(' the AI-first frontend framework'));
|
|
76
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 }));
|
|
77
|
-
if (!
|
|
78
|
-
{ value: '
|
|
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' },
|
|
79
113
|
{ value: 'scss', label: 'SCSS', hint: 'adds sass' },
|
|
80
|
-
{ value: 'tailwind', label: 'Tailwind CSS', hint: 'utility classes via class("…") — fastest to style' },
|
|
81
114
|
] }));
|
|
82
|
-
if (
|
|
83
|
-
if (
|
|
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
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 });
|
|
134
|
+
if (template !== 'basic') cpSync(join(OVERLAYS, template), target, { recursive: true }); // routing / full overlay
|
|
95
135
|
const ignore = join(target, '_gitignore');
|
|
96
136
|
if (existsSync(ignore)) renameSync(ignore, join(target, '.gitignore'));
|
|
97
137
|
|
|
@@ -100,25 +140,28 @@ async function main() {
|
|
|
100
140
|
pkg.name = name;
|
|
101
141
|
const addDev = (deps) => { pkg.devDependencies = { ...(pkg.devDependencies || {}), ...deps }; };
|
|
102
142
|
|
|
103
|
-
if (
|
|
104
|
-
writeFileSync(join(target, 'src', 'styles.css'),
|
|
143
|
+
if (tailwind) {
|
|
144
|
+
writeFileSync(join(target, 'src', 'styles.css'), tailwindStyles(daisyui));
|
|
105
145
|
writeFileSync(join(target, 'vite.config.mjs'), TAILWIND_VITE);
|
|
146
|
+
writeFileSync(join(target, 'theme.muten'), TAILWIND_THEME); // scale centralized to Tailwind's
|
|
106
147
|
addDev({ tailwindcss: '^4.0.0', '@tailwindcss/vite': '^4.0.0' });
|
|
107
|
-
|
|
108
|
-
|
|
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 : ''));
|
|
109
151
|
} else {
|
|
110
152
|
writeFileSync(join(target, 'src', style === 'scss' ? 'styles.scss' : 'styles.css'), RESET);
|
|
111
|
-
if (style === 'scss') addDev({ sass: '^1.101.0' });
|
|
112
153
|
}
|
|
154
|
+
if (style === 'scss') addDev({ sass: '^1.101.0' });
|
|
113
155
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
114
156
|
|
|
157
|
+
const desc = `${template}, ${style}${tailwind ? ' + Tailwind' : ''}${daisyui ? ' + DaisyUI' : ''}`;
|
|
115
158
|
if (!install) {
|
|
116
|
-
if (process.stdin.isTTY) { note(`cd ${name}\n${pm} install\n${pm} run dev`, 'Next steps'); outro(color.green(`Created ${name} (${
|
|
117
|
-
else console.log(`\n Created ${name} (${
|
|
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`);
|
|
118
161
|
return;
|
|
119
162
|
}
|
|
120
163
|
|
|
121
|
-
if (process.stdin.isTTY) outro(color.green(`Created ${name} (${
|
|
164
|
+
if (process.stdin.isTTY) outro(color.green(`Created ${name} (${desc}) — installing with ${pm}…`));
|
|
122
165
|
// PMs are .cmd shims on Windows → spawn needs shell:true to find them.
|
|
123
166
|
const run = (a) => spawnSync(pm, a, { cwd: target, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
124
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",
|
|
@@ -19,5 +19,5 @@
|
|
|
19
19
|
"picocolors": "^1.0.1"
|
|
20
20
|
},
|
|
21
21
|
"keywords": ["muten", "create-muten", "scaffold", "starter", "cli", "frontend"],
|
|
22
|
-
"files": ["index.js", "template"]
|
|
22
|
+
"files": ["index.js", "template", "overlays"]
|
|
23
23
|
}
|
|
@@ -43,7 +43,12 @@ Page style(padding.lg, gap.md) {
|
|
|
43
43
|
- **Content:** `Text`, `Title "x" h2`, `Span`, `Image "{src}" alt "…"` (alt required), `Link "x" -> /route`, `Button "x" -> action(arg)`.
|
|
44
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
|
+
- **Interactivity:** reactive class `class(active when isOpen)`; events on any element `on(keydown: act, mouseenter: act)`; a `/404` route catches unmatched paths.
|
|
46
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.
|
|
47
52
|
- **Actions:** `action add mutates users <- item { users.push(item) }` — ops: `push/set/reset/remove`; branch with `if/else`.
|
|
48
53
|
- **Tokens:** `gap.md padding.lg cols.3 text.lg row center between` — responsive prefix: `md:cols.2`.
|
|
49
54
|
|
|
@@ -51,7 +56,7 @@ Page style(padding.lg, gap.md) {
|
|
|
51
56
|
- **CSS / Tailwind / SCSS: YES** — it's a Vite app; install them and use `class("…")` + your CSS.
|
|
52
57
|
- **React / Vue / Svelte UI libraries: NO** — the UI is `.muten` (vanilla DOM, no JS framework runtime).
|
|
53
58
|
Need a JS widget? Wrap it in a `Custom` host component (`src/components/<Name>.js`).
|
|
54
|
-
- Routing
|
|
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
|
|
55
60
|
`.store`. No `toggle` op → `set(not x)`. `style()` is layout tokens only; visuals go in `class()`.
|
|
56
61
|
- The full reference (stores, routing, theme, every primitive) is in [`skills/muten/SKILL.md`](skills/muten/SKILL.md).
|
|
57
62
|
|
|
@@ -13,7 +13,7 @@ A page with no reactivity compiles to plain zero-runtime HTML; a reactive one sh
|
|
|
13
13
|
- **UI** → `.muten` files (pages, parts, the app root, the theme). **App-global state** → `.store` files.
|
|
14
14
|
- **`src/app.muten` is the entry.** `index.html` loads it; the plugin boots it. **Never create `main.js`** or a `<script>` bootstrap.
|
|
15
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)
|
|
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
17
|
- `@name` = a state reference. `{expr}` = interpolation inside any string: `Text "Hi, {user.name}"`.
|
|
18
18
|
- Each page has **one root node**. Reactivity is automatic: reading a state in interpolation / `when` / `each` re-renders just that spot.
|
|
19
19
|
|
|
@@ -38,8 +38,10 @@ This is a normal **Vite** project, so the whole Vite/npm ecosystem for **styling
|
|
|
38
38
|
Visual styling (colors, borders, shadows) goes through `class("…")` + your CSS.
|
|
39
39
|
|
|
40
40
|
## 3. Limitations (current)
|
|
41
|
-
- **Routing
|
|
42
|
-
(
|
|
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.
|
|
43
45
|
- **Shell has no local state** — put shell/cross-page state in a `.store` (see the mobile-menu pattern).
|
|
44
46
|
- **No `toggle` op** — flip a bool with `set(not x)`.
|
|
45
47
|
- **Forms**: `Form` (auto-generated from an entity) and `SearchField` (single text input) are the
|
|
@@ -84,6 +86,88 @@ mock { listUsers: [ { name: "Ana", role: admin } ] } # mock data for
|
|
|
84
86
|
sources { listUsers: { url: "https://api…", at: "results" } } # real data source for a query
|
|
85
87
|
```
|
|
86
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
|
+
|
|
87
171
|
## 6. Primitives
|
|
88
172
|
A bare string is the node's main prop. `{ }` = children. Lay out with `style()`, skin with `class()`.
|
|
89
173
|
|
|
@@ -110,6 +194,8 @@ Horizontal layout = a region with `style(row)` (there is no `Row` primitive). Cl
|
|
|
110
194
|
|
|
111
195
|
Modifiers (after a primitive): `style(tokens)` · `class("css")` · `bind @state` · `submit action` ·
|
|
112
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.
|
|
113
199
|
|
|
114
200
|
## 7. Theme — how it works
|
|
115
201
|
`theme.muten` supplies the **scale** (values); the engine owns only the **vocabulary** (token names).
|
|
@@ -159,8 +245,9 @@ Use it from any page/shell by name: `when ui.menuOpen { … }`, `Button "☰" ->
|
|
|
159
245
|
plugin auto-detects every `.store` file. `get` = memoized; `effect` = reactive side-effect (Angular-style).
|
|
160
246
|
|
|
161
247
|
## 10. Routing — how it works
|
|
162
|
-
`src/app.muten` maps URLs to pages. It
|
|
163
|
-
route is the default**. The folder under `src/pages/` must match the page name.
|
|
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.)*
|
|
164
251
|
```
|
|
165
252
|
routes {
|
|
166
253
|
/ -> home # src/pages/home/home.muten
|
|
@@ -170,7 +257,23 @@ routes {
|
|
|
170
257
|
}
|
|
171
258
|
```
|
|
172
259
|
Guards read a **store boolean**; when it flips (login/logout) the active route re-renders automatically.
|
|
173
|
-
|
|
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.
|
|
174
277
|
|
|
175
278
|
### Shell (persistent chrome)
|
|
176
279
|
Wrap routes in a `shell { … slot … }` for a nav/footer around every page. `slot` is where the active
|