tutuca 0.9.85 → 0.9.87
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tutuca-cli.js +109 -2
- package/dist/tutuca-storybook.js +88 -14
- package/package.json +6 -4
- package/skill/tutuca/cli.md +3 -1
- package/skill/tutuca/component-design.md +17 -0
- package/skill/tutuca/core.md +34 -0
- package/skill/tutuca/request-response.md +5 -1
package/dist/tutuca-cli.js
CHANGED
|
@@ -15722,6 +15722,13 @@ function resolveTutucaBase(projectDir, self, forCdn) {
|
|
|
15722
15722
|
return { base: DIST_PREFIX.replace(/\/$/, ""), serveDist: self.distRoot };
|
|
15723
15723
|
return { base: `https://cdn.jsdelivr.net/npm/tutuca@${self.version}/dist`, serveDist: null };
|
|
15724
15724
|
}
|
|
15725
|
+
function tutucaSource(base) {
|
|
15726
|
+
if (base.startsWith("http"))
|
|
15727
|
+
return "CDN";
|
|
15728
|
+
if (base.startsWith("/node_modules"))
|
|
15729
|
+
return "node_modules";
|
|
15730
|
+
return "local dist";
|
|
15731
|
+
}
|
|
15725
15732
|
function buildImports(base, { margaui }) {
|
|
15726
15733
|
const dev = `${base}/tutuca-dev.js`;
|
|
15727
15734
|
const imports = {
|
|
@@ -15809,6 +15816,40 @@ async function runDevTests(projectDir, devModuleUrls) {
|
|
|
15809
15816
|
}
|
|
15810
15817
|
return { totalTests, failedTests, withTests, failures, importErrors };
|
|
15811
15818
|
}
|
|
15819
|
+
async function discoverModules(projectDir, devModuleUrls) {
|
|
15820
|
+
await createNodeEnv();
|
|
15821
|
+
const modules = [];
|
|
15822
|
+
for (const url of devModuleUrls) {
|
|
15823
|
+
const abs = resolve4(projectDir, url.slice(1));
|
|
15824
|
+
try {
|
|
15825
|
+
const mod = await import(abs);
|
|
15826
|
+
const { normalized, present } = normalizeModule(mod, { path: abs });
|
|
15827
|
+
modules.push({
|
|
15828
|
+
url,
|
|
15829
|
+
present: [...present],
|
|
15830
|
+
components: normalized.components.map((c) => c.name),
|
|
15831
|
+
sections: normalized.sections.map((s) => ({
|
|
15832
|
+
title: s.title,
|
|
15833
|
+
description: s.description,
|
|
15834
|
+
items: s.items.map((it) => ({
|
|
15835
|
+
title: it.title,
|
|
15836
|
+
view: it.view,
|
|
15837
|
+
componentName: it.componentName
|
|
15838
|
+
}))
|
|
15839
|
+
})),
|
|
15840
|
+
macros: normalized.macros ? Object.keys(normalized.macros) : [],
|
|
15841
|
+
requestHandlers: normalized.requestHandlers ? Object.keys(normalized.requestHandlers) : [],
|
|
15842
|
+
error: null
|
|
15843
|
+
});
|
|
15844
|
+
} catch (e) {
|
|
15845
|
+
modules.push({
|
|
15846
|
+
url,
|
|
15847
|
+
error: { code: e.code ?? null, message: e.message, where: e.where ?? null }
|
|
15848
|
+
});
|
|
15849
|
+
}
|
|
15850
|
+
}
|
|
15851
|
+
return modules;
|
|
15852
|
+
}
|
|
15812
15853
|
function collectFailures(node, acc) {
|
|
15813
15854
|
if (node.children) {
|
|
15814
15855
|
for (const child of node.children)
|
|
@@ -15844,12 +15885,13 @@ async function run4(argv, opts = {}) {
|
|
|
15844
15885
|
"no-margaui": { type: "boolean", default: false },
|
|
15845
15886
|
"no-check": { type: "boolean", default: false },
|
|
15846
15887
|
"no-tests": { type: "boolean", default: false },
|
|
15888
|
+
"dry-run": { type: "boolean", default: false },
|
|
15847
15889
|
help: { type: "boolean", short: "h", default: false }
|
|
15848
15890
|
},
|
|
15849
15891
|
allowPositionals: true
|
|
15850
15892
|
});
|
|
15851
15893
|
if (parsed.values.help) {
|
|
15852
|
-
process.stdout.write(`tutuca storybook [dir] [--port <n>] [--out <dir>]
|
|
15894
|
+
process.stdout.write(`tutuca storybook [dir] [--port <n>] [--out <dir>] [--dry-run]
|
|
15853
15895
|
[--no-margaui] [--no-check] [--no-tests]
|
|
15854
15896
|
|
|
15855
15897
|
Auto-discovers co-located *.dev.js modules (recursively, skipping
|
|
@@ -15860,6 +15902,9 @@ async function run4(argv, opts = {}) {
|
|
|
15860
15902
|
--port <n> preferred port (default 4321; falls back to a free port)
|
|
15861
15903
|
--out <dir> write a static index.html + bootstrap (CDN import map)
|
|
15862
15904
|
instead of serving; host it from the project root
|
|
15905
|
+
--dry-run do all the prep (discover, import and normalize modules,
|
|
15906
|
+
resolve the runtime, run tests) and print what would be
|
|
15907
|
+
shown instead of serving; pass --json for structured output
|
|
15863
15908
|
--no-margaui skip margaui styling (renders functional but unstyled)
|
|
15864
15909
|
--no-check skip the in-browser check(app) dev validation
|
|
15865
15910
|
--no-tests skip running the modules' getTests() before serving
|
|
@@ -15899,6 +15944,63 @@ async function run4(argv, opts = {}) {
|
|
|
15899
15944
|
`);
|
|
15900
15945
|
return;
|
|
15901
15946
|
}
|
|
15947
|
+
if (parsed.values["dry-run"]) {
|
|
15948
|
+
const { base: base2 } = resolveTutucaBase(projectDir, self, false);
|
|
15949
|
+
const imports2 = buildImports(base2, { margaui });
|
|
15950
|
+
const modules = await discoverModules(projectDir, devModuleUrls);
|
|
15951
|
+
const tests = parsed.values["no-tests"] ? null : await runDevTests(projectDir, devModuleUrls);
|
|
15952
|
+
const source = tutucaSource(base2);
|
|
15953
|
+
const result = {
|
|
15954
|
+
projectDir,
|
|
15955
|
+
tutuca: { source, base: base2, version: self.version },
|
|
15956
|
+
options: { margaui, check, runTests: !parsed.values["no-tests"] },
|
|
15957
|
+
imports: imports2,
|
|
15958
|
+
modules,
|
|
15959
|
+
tests
|
|
15960
|
+
};
|
|
15961
|
+
if (opts.format === "json") {
|
|
15962
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
15963
|
+
`);
|
|
15964
|
+
return;
|
|
15965
|
+
}
|
|
15966
|
+
process.stdout.write(`tutuca storybook dry run (no server started)
|
|
15967
|
+
project: ${projectDir}
|
|
15968
|
+
tutuca runtime: ${source} (${base2}, version ${self.version})
|
|
15969
|
+
margaui: ${margaui ? "on" : "off"}, in-browser check: ${check ? "on" : "off"}
|
|
15970
|
+
${modules.length} dev module(s):
|
|
15971
|
+
`);
|
|
15972
|
+
for (const m of modules) {
|
|
15973
|
+
if (m.error) {
|
|
15974
|
+
process.stdout.write(` error ${m.url} — ${m.error.message}
|
|
15975
|
+
`);
|
|
15976
|
+
continue;
|
|
15977
|
+
}
|
|
15978
|
+
const sectionItems = m.sections.reduce((n, s) => n + s.items.length, 0);
|
|
15979
|
+
process.stdout.write(` ok ${m.url} — ${m.components.length} component(s), ${m.sections.length} section(s), ${sectionItems} example(s)
|
|
15980
|
+
`);
|
|
15981
|
+
}
|
|
15982
|
+
if (tests) {
|
|
15983
|
+
for (const ie of tests.importErrors) {
|
|
15984
|
+
process.stdout.write(` ! skipped tests for ${ie.url}: ${ie.message}
|
|
15985
|
+
`);
|
|
15986
|
+
}
|
|
15987
|
+
if (tests.withTests === 0) {
|
|
15988
|
+
process.stdout.write(` tests: no getTests() in any dev module
|
|
15989
|
+
`);
|
|
15990
|
+
} else {
|
|
15991
|
+
process.stdout.write(` tests: ${tests.totalTests - tests.failedTests}/${tests.totalTests} passed across ${tests.withTests} module(s)
|
|
15992
|
+
`);
|
|
15993
|
+
for (const f of tests.failures) {
|
|
15994
|
+
process.stdout.write(` failed ${f.fullPath}: ${f.error?.message ?? "failed"}
|
|
15995
|
+
`);
|
|
15996
|
+
}
|
|
15997
|
+
}
|
|
15998
|
+
} else {
|
|
15999
|
+
process.stdout.write(` tests: skipped (--no-tests)
|
|
16000
|
+
`);
|
|
16001
|
+
}
|
|
16002
|
+
return;
|
|
16003
|
+
}
|
|
15902
16004
|
if (!parsed.values["no-tests"]) {
|
|
15903
16005
|
const r = await runDevTests(projectDir, devModuleUrls);
|
|
15904
16006
|
for (const ie of r.importErrors) {
|
|
@@ -15949,7 +16051,7 @@ async function run4(argv, opts = {}) {
|
|
|
15949
16051
|
});
|
|
15950
16052
|
server.on("listening", () => {
|
|
15951
16053
|
const actual = server.address().port;
|
|
15952
|
-
const where =
|
|
16054
|
+
const where = tutucaSource(base);
|
|
15953
16055
|
process.stdout.write(`tutuca storybook: http://localhost:${actual}/ (${devModuleUrls.length} dev modules, tutuca from ${where})
|
|
15954
16056
|
`);
|
|
15955
16057
|
});
|
|
@@ -16155,6 +16257,11 @@ var NO_MODULE_COMMANDS_META = {
|
|
|
16155
16257
|
type: "boolean",
|
|
16156
16258
|
description: "Skip running the modules' getTests() before serving."
|
|
16157
16259
|
},
|
|
16260
|
+
{
|
|
16261
|
+
name: "dry-run",
|
|
16262
|
+
type: "boolean",
|
|
16263
|
+
description: "Do all prep (discover + import + normalize modules, resolve runtime, run tests) and print what would be shown instead of serving. Pass --json for structured output."
|
|
16264
|
+
},
|
|
16158
16265
|
{ name: "help", short: "h", type: "boolean" }
|
|
16159
16266
|
],
|
|
16160
16267
|
positionals: [
|
package/dist/tutuca-storybook.js
CHANGED
|
@@ -34,20 +34,36 @@ var Storybook = component({
|
|
|
34
34
|
return this;
|
|
35
35
|
}
|
|
36
36
|
return this.setSectionId(sectionId).setExampleId(exampleId).setFocusExample(example.value);
|
|
37
|
+
},
|
|
38
|
+
setSelectedSectionFilter(value) {
|
|
39
|
+
if (this.sections.size === 0)
|
|
40
|
+
return this;
|
|
41
|
+
const i = this.selectedSectionIndex;
|
|
42
|
+
const sections = this.sections.map((s, idx) => idx === i ? s.setFilter(value ?? "") : s);
|
|
43
|
+
return this.setSections(sections);
|
|
44
|
+
},
|
|
45
|
+
toUrlState(overrides = {}) {
|
|
46
|
+
const section = this.sections.get(this.selectedSectionIndex);
|
|
47
|
+
return {
|
|
48
|
+
section: section?.id ?? "",
|
|
49
|
+
example: this.exampleId ?? "",
|
|
50
|
+
sectionFilter: this.filter ?? "",
|
|
51
|
+
exampleFilter: section?.filter ?? "",
|
|
52
|
+
...overrides
|
|
53
|
+
};
|
|
37
54
|
}
|
|
38
55
|
},
|
|
39
56
|
input: {
|
|
40
57
|
onApplyFilter(value, ctx) {
|
|
41
|
-
ctx.request("persistState", [{
|
|
58
|
+
ctx.request("persistState", [this.toUrlState({ sectionFilter: value }), this, false]);
|
|
42
59
|
return this.setFilter(value);
|
|
43
60
|
},
|
|
44
61
|
onClearFilter(ctx) {
|
|
45
|
-
ctx.request("persistState", [{
|
|
62
|
+
ctx.request("persistState", [this.toUrlState({ sectionFilter: "" }), this, false]);
|
|
46
63
|
return this.resetFilter();
|
|
47
64
|
},
|
|
48
65
|
onFocusClose(ctx) {
|
|
49
|
-
ctx.request("persistState", [{
|
|
50
|
-
ctx.request("persistState", [{ key: "exampleId", value: "" }]);
|
|
66
|
+
ctx.request("persistState", [this.toUrlState({ example: "" }), this, true]);
|
|
51
67
|
return this.setSectionId(null).setExampleId(null).setFocusExample(null);
|
|
52
68
|
}
|
|
53
69
|
},
|
|
@@ -59,16 +75,38 @@ var Storybook = component({
|
|
|
59
75
|
bubble: {
|
|
60
76
|
sectionSelected(section, ctx) {
|
|
61
77
|
ctx.stopPropagation();
|
|
62
|
-
ctx.request("persistState", [
|
|
78
|
+
ctx.request("persistState", [
|
|
79
|
+
this.toUrlState({ section: section.id, exampleFilter: section.filter }),
|
|
80
|
+
this,
|
|
81
|
+
true
|
|
82
|
+
]);
|
|
63
83
|
return this.selectSectionAtIndex(this.sections.indexOf(section));
|
|
64
84
|
},
|
|
65
85
|
exampleFocusRequested(example, ctx) {
|
|
66
86
|
ctx.stopPropagation();
|
|
67
87
|
const section = this.sections.get(this.selectedSectionIndex);
|
|
68
88
|
const sectionId = section?.id ?? null;
|
|
69
|
-
ctx.request("persistState", [{
|
|
70
|
-
ctx.request("persistState", [{ key: "exampleId", value: example.id }]);
|
|
89
|
+
ctx.request("persistState", [this.toUrlState({ example: example.id }), this, true]);
|
|
71
90
|
return this.setSectionId(sectionId).setExampleId(example.id).setFocusExample(example.value);
|
|
91
|
+
},
|
|
92
|
+
exampleFilterChanged(value, ctx) {
|
|
93
|
+
ctx.stopPropagation();
|
|
94
|
+
ctx.request("persistState", [this.toUrlState({ exampleFilter: value }), this, false]);
|
|
95
|
+
return this;
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
receive: {
|
|
99
|
+
init(ctx) {
|
|
100
|
+
ctx.request("loadState", []);
|
|
101
|
+
return this;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
response: {
|
|
105
|
+
loadState(state, err) {
|
|
106
|
+
if (err || !state)
|
|
107
|
+
return this;
|
|
108
|
+
const next = this.selectSectionWithId(state.section).setFilter(state.sectionFilter ?? "").setSelectedSectionFilter(state.exampleFilter ?? "");
|
|
109
|
+
return state.example ? next.focusExampleByIds(state.section, state.example) : next.setSectionId(null).setExampleId(null).setFocusExample(null);
|
|
72
110
|
}
|
|
73
111
|
},
|
|
74
112
|
view: html`<div>
|
|
@@ -115,10 +153,13 @@ var Section = component({
|
|
|
115
153
|
selected: false
|
|
116
154
|
},
|
|
117
155
|
statics: {
|
|
118
|
-
fromData(
|
|
119
|
-
|
|
156
|
+
fromData(raw) {
|
|
157
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw) || raw.title == null) {
|
|
158
|
+
throw new Error(`Section.fromData: expected a section object { title, items }, got ${JSON.stringify(raw)}. ` + `getExamples() must return a section object or an array of section objects.`);
|
|
159
|
+
}
|
|
160
|
+
const { id, title, description = "", items = [] } = raw;
|
|
120
161
|
return this.make({
|
|
121
|
-
id,
|
|
162
|
+
id: id ?? slugify(title),
|
|
122
163
|
title,
|
|
123
164
|
description,
|
|
124
165
|
items: items.map((v) => Example.Class.fromData(v))
|
|
@@ -132,11 +173,11 @@ var Section = component({
|
|
|
132
173
|
},
|
|
133
174
|
input: {
|
|
134
175
|
onApplyFilter(value, ctx) {
|
|
135
|
-
ctx.
|
|
176
|
+
ctx.bubble("exampleFilterChanged", [value]);
|
|
136
177
|
return this.setFilter(value);
|
|
137
178
|
},
|
|
138
179
|
onClearFilter(ctx) {
|
|
139
|
-
ctx.
|
|
180
|
+
ctx.bubble("exampleFilterChanged", [""]);
|
|
140
181
|
return this.resetFilter();
|
|
141
182
|
},
|
|
142
183
|
onListItemClick(ctx) {
|
|
@@ -235,7 +276,12 @@ function slugify(str) {
|
|
|
235
276
|
return String(str).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
236
277
|
}
|
|
237
278
|
function buildStorybook(modules) {
|
|
238
|
-
const sections = modules.
|
|
279
|
+
const sections = modules.flatMap((m) => {
|
|
280
|
+
const raw = m.getExamples?.();
|
|
281
|
+
if (raw == null)
|
|
282
|
+
return [];
|
|
283
|
+
return Array.isArray(raw) ? raw : [raw];
|
|
284
|
+
}).map((s) => Section.Class.fromData(s)).sort((a, b) => a.title.localeCompare(b.title));
|
|
239
285
|
const components = new Set([Storybook, Section, Example]);
|
|
240
286
|
const macros = {};
|
|
241
287
|
const requestHandlers = {};
|
|
@@ -255,18 +301,46 @@ function buildStorybook(modules) {
|
|
|
255
301
|
requestHandlers
|
|
256
302
|
};
|
|
257
303
|
}
|
|
258
|
-
async function mountStorybook(selector, modules, { compileCss, root } = {}) {
|
|
304
|
+
async function mountStorybook(selector, modules, { compileCss, root, persistUrl = true } = {}) {
|
|
259
305
|
const app = tutuca(selector);
|
|
260
306
|
const built = buildStorybook(modules);
|
|
261
307
|
app.state.set(root ?? built.root);
|
|
262
308
|
const scope = app.registerComponents(built.components);
|
|
263
309
|
scope.registerMacros(built.macros);
|
|
264
310
|
scope.registerRequestHandlers(built.requestHandlers);
|
|
311
|
+
if (persistUrl) {
|
|
312
|
+
scope.registerRequestHandlers({ persistState, loadState });
|
|
313
|
+
}
|
|
265
314
|
if (compileCss) {
|
|
266
315
|
injectCss("tutuca-storybook", await compileCss(app));
|
|
267
316
|
}
|
|
268
317
|
app.start();
|
|
318
|
+
if (persistUrl) {
|
|
319
|
+
app.sendAtRoot("init", []);
|
|
320
|
+
window.addEventListener("popstate", () => app.sendAtRoot("init", []));
|
|
321
|
+
}
|
|
269
322
|
return app;
|
|
323
|
+
function persistState(state, instance, push) {
|
|
324
|
+
if (instance !== app.state.val)
|
|
325
|
+
return;
|
|
326
|
+
const url = new URL(window.location.href);
|
|
327
|
+
for (const [k, v] of Object.entries(state)) {
|
|
328
|
+
if (v === "" || v == null)
|
|
329
|
+
url.searchParams.delete(k);
|
|
330
|
+
else
|
|
331
|
+
url.searchParams.set(k, String(v));
|
|
332
|
+
}
|
|
333
|
+
window.history[push ? "pushState" : "replaceState"](null, "", url);
|
|
334
|
+
}
|
|
335
|
+
function loadState() {
|
|
336
|
+
const p = new URLSearchParams(window.location.search);
|
|
337
|
+
return {
|
|
338
|
+
section: p.get("section"),
|
|
339
|
+
example: p.get("example"),
|
|
340
|
+
sectionFilter: p.get("sectionFilter") ?? "",
|
|
341
|
+
exampleFilter: p.get("exampleFilter") ?? ""
|
|
342
|
+
};
|
|
343
|
+
}
|
|
270
344
|
}
|
|
271
345
|
function getComponents() {
|
|
272
346
|
return [Storybook, Section, Example];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tutuca",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.87",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Zero-dependency SPA framework with immutable state and virtual DOM",
|
|
6
6
|
"main": "./dist/tutuca.js",
|
|
@@ -22,15 +22,16 @@
|
|
|
22
22
|
"tutuca": "./dist/tutuca-cli.js"
|
|
23
23
|
},
|
|
24
24
|
"scripts": {
|
|
25
|
-
"clean": "rm -rf dist",
|
|
25
|
+
"clean": "rm -rf dist skill",
|
|
26
26
|
"dist": "bun scripts/dist.js",
|
|
27
27
|
"dist-ext": "bun scripts/dist-ext.js",
|
|
28
28
|
"dist-immutable": "bun scripts/dist-immutable.js",
|
|
29
29
|
"dist-all": "bun run dist-immutable && bun run dist && bun run dist-ext && bun run smoke",
|
|
30
30
|
"smoke": "node scripts/smoke.js",
|
|
31
|
-
"release": "bun run dist-all && bun run build-skill && npm publish --access public",
|
|
32
|
-
"release-dry": "bun run dist-all && bun run build-skill && npm publish --dry-run",
|
|
31
|
+
"release": "bun run dist-all && bun run build-skill && (npm publish --access public; bun run clean-skill)",
|
|
32
|
+
"release-dry": "bun run dist-all && bun run build-skill && (npm publish --dry-run; bun run clean-skill)",
|
|
33
33
|
"build-skill": "bun scripts/build-skill.js",
|
|
34
|
+
"clean-skill": "rm -rf skill",
|
|
34
35
|
"test": "bun test test/*.test.js",
|
|
35
36
|
"test-watch": "bun test --watch test/*.test.js",
|
|
36
37
|
"format": "bunx @biomejs/biome format --write",
|
|
@@ -38,6 +39,7 @@
|
|
|
38
39
|
"lint-fix": "bunx @biomejs/biome lint --write",
|
|
39
40
|
"fix": "bunx @biomejs/biome check --write",
|
|
40
41
|
"tutuca": "bun tools/tutuca.js",
|
|
42
|
+
"storybook:examples": "bun scripts/storybook-examples.js",
|
|
41
43
|
"stresstest": "bun scripts/stresstest.js",
|
|
42
44
|
"smoke-test": "bun tools/tutuca.js lint ./test/todo.js && bun tools/tutuca.js render ./test/todo.js && bun tools/tutuca.js test ./test/todo.js && bun tools/tutuca.js lint ./test/json.js && bun tools/tutuca.js render ./test/json.js"
|
|
43
45
|
},
|
package/skill/tutuca/cli.md
CHANGED
|
@@ -40,7 +40,7 @@ Use `--module=<path>` if the path conflicts with positional parsing.
|
|
|
40
40
|
| `lint <module> [name]` | Run the linter; exits **2** on any error-level finding |
|
|
41
41
|
| `render <module> [name]` | Render examples to HTML in a headless DOM. Filter by component name or `--title`/`--view`. Exits **3** on render crash |
|
|
42
42
|
| `test <module> [name]` | Run tests defined by `getTests({ describe, test, expect })`. Filter by component name, `--grep <pattern>`, or `--bail`. Exits **4** on any failure |
|
|
43
|
-
| `storybook [dir]` | Serve a live storybook for the project, auto-discovering co-located `*.dev.js` modules. Flags: `--port`, `--out`, `--no-margaui`, `--no-check`, `--no-tests`. No module path needed |
|
|
43
|
+
| `storybook [dir]` | Serve a live storybook for the project, auto-discovering co-located `*.dev.js` modules. Flags: `--port`, `--out`, `--dry-run` (prep + print, don't serve), `--no-margaui`, `--no-check`, `--no-tests`. No module path needed |
|
|
44
44
|
| `help [cmd]` | Show usage. No module path needed |
|
|
45
45
|
| `feedback [message]` | Append a feedback note (positional or stdin) to `~/.tutuca/feedback.jsonl`. No module path needed |
|
|
46
46
|
| `install-skill [name]` | Copy a bundled skill (`tutuca`, `margaui`, `immutable-js`, or `--all`) into `.claude/skills/`. No module path needed |
|
|
@@ -200,6 +200,8 @@ tutuca storybook # scan + serve the current directory
|
|
|
200
200
|
tutuca storybook ./packages/ui # scan + serve another directory
|
|
201
201
|
tutuca storybook --port 4321 # preferred port (falls back to a free one if taken)
|
|
202
202
|
tutuca storybook --out ./_site # write a static index.html + bootstrap instead of serving
|
|
203
|
+
tutuca storybook --dry-run # do all the prep + print what would be shown, don't serve (smoke test)
|
|
204
|
+
tutuca storybook --dry-run --json # same, machine-readable for agents
|
|
203
205
|
tutuca storybook --no-tests # skip the pre-serve getTests() run
|
|
204
206
|
tutuca storybook --no-margaui # render unstyled (skip margaui)
|
|
205
207
|
tutuca storybook --no-check # skip the in-browser check(app)
|
|
@@ -28,6 +28,13 @@ Walk these top-down whenever you add or reshape a component:
|
|
|
28
28
|
|
|
29
29
|
## Communication decision ladder
|
|
30
30
|
|
|
31
|
+
This ladder is about *acting across* a component boundary — reaching up,
|
|
32
|
+
messaging a target, doing async work, or mutating state someone else owns. It is
|
|
33
|
+
**not** about merely *reading* a child's state: an ancestor already holds its
|
|
34
|
+
children as immutable fields and can read them directly in any handler or method
|
|
35
|
+
(`this.items.get(i).done`) — no channel needed. See [core.md](./core.md) "The
|
|
36
|
+
value tree".
|
|
37
|
+
|
|
31
38
|
Reach for the *narrowest* channel that does the job, and only move further down
|
|
32
39
|
the ladder when the one above can't express it:
|
|
33
40
|
|
|
@@ -72,6 +79,16 @@ A compact worked version of the first four (`method`, `bubble`, `send`/`receive`
|
|
|
72
79
|
between** (the React "prop drilling" reflex) — let a child render the field it
|
|
73
80
|
needs from its owner. → [patterns/share-state-across-the-tree.md](./patterns/share-state-across-the-tree.md)
|
|
74
81
|
|
|
82
|
+
- **Do read a child's state directly when an ancestor needs it for an aggregate
|
|
83
|
+
decision.** A parent holds its children as immutable fields, so a handler or
|
|
84
|
+
method can read `this.items.get(i).done` straight off — children don't have to
|
|
85
|
+
`bubble` their state up just to be *read*. **Don't reach for a channel to read
|
|
86
|
+
downward**; `bubble` / `send` are for reaching *up*, messaging a target, or
|
|
87
|
+
mutating — not for inspecting state you already own. (And don't reach in to
|
|
88
|
+
mutate a child around the model — that still goes through the owner returning a
|
|
89
|
+
new self or `ctx.send`.) → [core.md](./core.md) "The value tree" and
|
|
90
|
+
[request-response.md](./request-response.md) "When to bubble"
|
|
91
|
+
|
|
75
92
|
- **Do reach for `provide` / `lookup` (`*name`) last** — only when a deep
|
|
76
93
|
descendant needs a value owned far away and nothing in between should know about
|
|
77
94
|
it. Dynamic bindings couple a consumer to a producer that may not be in scope.
|
package/skill/tutuca/core.md
CHANGED
|
@@ -69,6 +69,23 @@ edit done:
|
|
|
69
69
|
emitted HTML to verify structure (attributes, nesting, text); omit it
|
|
70
70
|
when you only care that the render didn't crash.
|
|
71
71
|
|
|
72
|
+
4. **Smoke-test the whole project** — when you've touched several
|
|
73
|
+
`*.dev.js` modules, or are about to launch the storybook, do a
|
|
74
|
+
project-wide dry run instead of opening a browser:
|
|
75
|
+
|
|
76
|
+
tutuca storybook --dry-run
|
|
77
|
+
tutuca storybook --dry-run --json # machine-readable for agents
|
|
78
|
+
|
|
79
|
+
It does everything the server would do up front — discovers every
|
|
80
|
+
co-located `*.dev.js`, imports and normalizes each (catching a missing
|
|
81
|
+
`getComponents()` or a malformed `getExamples()` shape), runs their
|
|
82
|
+
`getTests()`, and resolves the runtime import map — then prints what
|
|
83
|
+
it *would* show instead of serving. A broken module is reported in
|
|
84
|
+
place (an `error` line, or `modules[].error` in `--json`) while the
|
|
85
|
+
others still report, so one bad module never hides the rest. This is
|
|
86
|
+
the fast "is the whole catalog wired up correctly?" check; steps 1–3
|
|
87
|
+
stay the per-module loop.
|
|
88
|
+
|
|
72
89
|
Full reference: [cli.md](./cli.md).
|
|
73
90
|
|
|
74
91
|
The Tutuca CLI only catches Tutuca-specific issues. For generic JS
|
|
@@ -152,6 +169,23 @@ keys its cache on `===` identity, so unchanged subtrees skip work.
|
|
|
152
169
|
Every value carries a hidden tag back to its component class, so the
|
|
153
170
|
runtime never needs `instanceof` — it asks the value what it is.
|
|
154
171
|
|
|
172
|
+
Because children are just immutable Records held in fields, **handlers
|
|
173
|
+
and methods are ordinary JS with full read access to nested child
|
|
174
|
+
state** — `this.child.count`, `this.items.get(i).done`,
|
|
175
|
+
`this.byKey.get(k).label`. Reading *down* the tree is direct and needs
|
|
176
|
+
no channel: an ancestor that owns a list already holds every child's
|
|
177
|
+
state and can read it for an aggregate decision. The single-level
|
|
178
|
+
`.field` restriction (no `.foo.bar`) is a **view-template** rule, not a
|
|
179
|
+
JS one — it's why a derivation like `userName() { return this.user.name; }`
|
|
180
|
+
is written as a method (see *Methods as Predicates & Computed Values*).
|
|
181
|
+
Reading is free; **mutating** a child still flows through the model —
|
|
182
|
+
the owner returns a new self (`setInItemsAt`, …) or messages the child
|
|
183
|
+
with `ctx.send`. Don't reach in to mutate around the handler discipline,
|
|
184
|
+
and prefer letting a child own and render its own state — reach down to
|
|
185
|
+
read only when the ancestor genuinely needs it. See
|
|
186
|
+
[component-design.md](./component-design.md) and "When to bubble" in
|
|
187
|
+
[request-response.md](./request-response.md).
|
|
188
|
+
|
|
155
189
|
**Stack: frames vs scopes.** As the renderer walks the AST it pushes
|
|
156
190
|
`BindFrame`s. A *frame* is a barrier: name lookups (`@x`) stop at it,
|
|
157
191
|
so a child component view sees a clean namespace. A *scope* is
|
|
@@ -56,7 +56,11 @@ the state needed to respond. Bubble when the action belongs to an
|
|
|
56
56
|
ancestor (a list item's "remove" must reach the list that owns the
|
|
57
57
|
items), or when an ancestor may want to react to or record something
|
|
58
58
|
that happened (selection, logging, analytics). Don't bubble events
|
|
59
|
-
with no consumer
|
|
59
|
+
with no consumer — and don't bubble merely so an ancestor can *read* a
|
|
60
|
+
child's state: the ancestor already holds the child as a field and can
|
|
61
|
+
read it directly in its own handler (`this.items.get(i).done`). Bubble
|
|
62
|
+
when the ancestor must **act on** or **record** the event, not to learn
|
|
63
|
+
state it already owns.
|
|
60
64
|
|
|
61
65
|
## Send / Receive
|
|
62
66
|
|