tutuca 0.9.83 → 0.9.85

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 CHANGED
@@ -68,6 +68,8 @@ Tutuca ships a single-file CLI (`dist/tutuca-cli.js`) for inspecting, linting,
68
68
  documenting, and rendering components defined in an ES module. The module just
69
69
  needs to export `getComponents()` and, for render-time commands, `getExamples()`
70
70
  in the storybook shape `{ title, description?, items: [{ title, description?, value, view? }] }` (a single section, or an array of sections).
71
+ Expose **all** of your app's components through `getComponents()` — components
72
+ left out are invisible to `lint`/`render`/`test` and silently lose coverage.
71
73
 
72
74
  ### Setup
73
75
 
@@ -15629,6 +15629,356 @@ var init_install_skill = __esm(() => {
15629
15629
  ];
15630
15630
  });
15631
15631
 
15632
+ // tools/cli/env.js
15633
+ import { JSDOM, VirtualConsole } from "jsdom";
15634
+ async function createNodeEnv() {
15635
+ const virtualConsole = new VirtualConsole;
15636
+ virtualConsole.forwardTo(console, { jsdomErrors: "none" });
15637
+ virtualConsole.on("jsdomError", (err) => {
15638
+ if (err?.message?.includes("Could not parse CSS stylesheet"))
15639
+ return;
15640
+ console.error(err.message);
15641
+ });
15642
+ const dom = new JSDOM("<!DOCTYPE html><html><head></head><body></body></html>", {
15643
+ virtualConsole
15644
+ });
15645
+ const { document: document2, Text, Comment } = dom.window;
15646
+ globalThis.document = document2;
15647
+
15648
+ class HeadlessParseContext extends ParseContext {
15649
+ constructor() {
15650
+ super(document2, Text, Comment);
15651
+ }
15652
+ }
15653
+
15654
+ class HeadlessLintParseContext extends LintParseContext {
15655
+ constructor() {
15656
+ super(document2, Text, Comment);
15657
+ }
15658
+ }
15659
+ return {
15660
+ document: dom.window.document,
15661
+ ParseContext: HeadlessParseContext,
15662
+ LintParseContext: HeadlessLintParseContext
15663
+ };
15664
+ }
15665
+ var init_env2 = __esm(() => {
15666
+ init_anode();
15667
+ init_lint_check();
15668
+ });
15669
+
15670
+ // tools/cli/commands/storybook.js
15671
+ var exports_storybook = {};
15672
+ __export(exports_storybook, {
15673
+ run: () => run4,
15674
+ describe: () => describe4
15675
+ });
15676
+ import {
15677
+ createReadStream,
15678
+ existsSync as existsSync4,
15679
+ mkdirSync as mkdirSync3,
15680
+ readdirSync as readdirSync2,
15681
+ readFileSync as readFileSync3,
15682
+ statSync,
15683
+ writeFileSync
15684
+ } from "node:fs";
15685
+ import { createServer } from "node:http";
15686
+ import { dirname as dirname4, join, normalize, relative as relative2, resolve as resolve4, sep } from "node:path";
15687
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
15688
+ import { parseArgs as parseArgs3 } from "node:util";
15689
+ function findDevModules(root, dir, acc) {
15690
+ for (const e of readdirSync2(dir, { withFileTypes: true })) {
15691
+ if (e.name.startsWith(".") || e.name === "node_modules")
15692
+ continue;
15693
+ const full = resolve4(dir, e.name);
15694
+ if (e.isDirectory()) {
15695
+ findDevModules(root, full, acc);
15696
+ } else if (e.isFile() && e.name.endsWith(".dev.js")) {
15697
+ acc.push(`/${relative2(root, full).split(sep).join("/")}`);
15698
+ }
15699
+ }
15700
+ return acc;
15701
+ }
15702
+ function findSelf() {
15703
+ const here = dirname4(fileURLToPath4(import.meta.url));
15704
+ const pkgCandidates = [
15705
+ resolve4(here, "..", "..", "..", "package.json"),
15706
+ resolve4(here, "..", "package.json")
15707
+ ];
15708
+ const pkgPath = pkgCandidates.find(existsSync4);
15709
+ const version = pkgPath ? JSON.parse(readFileSync3(pkgPath, "utf8")).version : "latest";
15710
+ const distCandidates = [resolve4(here, "..", "..", "..", "dist"), resolve4(here, ".")];
15711
+ const distRoot = distCandidates.find((d) => existsSync4(resolve4(d, "tutuca-storybook.js"))) ?? null;
15712
+ return { version, distRoot };
15713
+ }
15714
+ function resolveTutucaBase(projectDir, self, forCdn) {
15715
+ if (forCdn)
15716
+ return { base: `https://cdn.jsdelivr.net/npm/tutuca@${self.version}/dist`, serveDist: null };
15717
+ const nm = resolve4(projectDir, "node_modules", "tutuca", "dist");
15718
+ if (existsSync4(resolve4(nm, "tutuca-dev.js"))) {
15719
+ return { base: "/node_modules/tutuca/dist", serveDist: null };
15720
+ }
15721
+ if (self.distRoot)
15722
+ return { base: DIST_PREFIX.replace(/\/$/, ""), serveDist: self.distRoot };
15723
+ return { base: `https://cdn.jsdelivr.net/npm/tutuca@${self.version}/dist`, serveDist: null };
15724
+ }
15725
+ function buildImports(base, { margaui }) {
15726
+ const dev = `${base}/tutuca-dev.js`;
15727
+ const imports = {
15728
+ tutuca: dev,
15729
+ "tutuca/extra": dev,
15730
+ "tutuca/dev": dev,
15731
+ "tutuca/storybook": `${base}/tutuca-storybook.js`
15732
+ };
15733
+ if (margaui)
15734
+ imports.margaui = MARGAUI_CDN;
15735
+ return imports;
15736
+ }
15737
+ function renderIndexHtml(imports, { margaui, bootstrapUrl }) {
15738
+ const theme = margaui ? `
15739
+ <link rel="stylesheet" href="${MARGAUI_THEME}" />` : "";
15740
+ return `<!doctype html>
15741
+ <html lang="en">
15742
+ <head>
15743
+ <meta charset="UTF-8" />
15744
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
15745
+ <title>Storybook</title>${theme}
15746
+ <script type="importmap">
15747
+ ${JSON.stringify({ imports }, null, 6)}
15748
+ </script>
15749
+ </head>
15750
+ <body>
15751
+ <div id="app"></div>
15752
+ <script type="module" src="${bootstrapUrl}"></script>
15753
+ </body>
15754
+ </html>
15755
+ `;
15756
+ }
15757
+ function renderBootstrap(devModuleUrls, { margaui, check }) {
15758
+ const lines = ['import { mountStorybook } from "tutuca/storybook";'];
15759
+ if (margaui) {
15760
+ lines.push('import { compileClassesToStyleText } from "tutuca/extra";');
15761
+ lines.push('import { compile } from "margaui";');
15762
+ }
15763
+ if (check)
15764
+ lines.push('import { check } from "tutuca/dev";');
15765
+ devModuleUrls.forEach((url, i) => {
15766
+ lines.push(`import * as m${i} from ${JSON.stringify(url)};`);
15767
+ });
15768
+ const modules = devModuleUrls.map((_, i) => `m${i}`).join(", ");
15769
+ const opts = margaui ? "{ compileCss: (app) => compileClassesToStyleText(app, compile) }" : "{}";
15770
+ lines.push("");
15771
+ lines.push(`const app = await mountStorybook("#app", [${modules}], ${opts});`);
15772
+ if (check)
15773
+ lines.push("check(app);");
15774
+ lines.push("");
15775
+ return lines.join(`
15776
+ `);
15777
+ }
15778
+ async function runDevTests(projectDir, devModuleUrls) {
15779
+ await createNodeEnv();
15780
+ let totalTests = 0;
15781
+ let failedTests = 0;
15782
+ let withTests = 0;
15783
+ const failures = [];
15784
+ const importErrors = [];
15785
+ for (const url of devModuleUrls) {
15786
+ const abs = resolve4(projectDir, url.slice(1));
15787
+ let mod;
15788
+ try {
15789
+ mod = await import(abs);
15790
+ } catch (e) {
15791
+ importErrors.push({ url, message: e.message });
15792
+ continue;
15793
+ }
15794
+ if (typeof mod.getTests !== "function")
15795
+ continue;
15796
+ withTests++;
15797
+ const { normalized } = normalizeModule(mod, { path: abs });
15798
+ const report = await runTests({
15799
+ getTests: mod.getTests,
15800
+ components: normalized.components,
15801
+ path: abs,
15802
+ expect
15803
+ });
15804
+ const m = report.modules[0];
15805
+ totalTests += m.counts.total;
15806
+ failedTests += m.counts.fail;
15807
+ for (const suite of m.suites)
15808
+ collectFailures(suite, failures);
15809
+ }
15810
+ return { totalTests, failedTests, withTests, failures, importErrors };
15811
+ }
15812
+ function collectFailures(node, acc) {
15813
+ if (node.children) {
15814
+ for (const child of node.children)
15815
+ collectFailures(child, acc);
15816
+ } else if (node.status === "fail") {
15817
+ acc.push(node);
15818
+ }
15819
+ }
15820
+ function safeJoin(rootDir, urlPath) {
15821
+ const decoded = decodeURIComponent(urlPath.split("?")[0]);
15822
+ const p = normalize(join(rootDir, decoded));
15823
+ if (p !== rootDir && !p.startsWith(rootDir + sep))
15824
+ return null;
15825
+ return p;
15826
+ }
15827
+ function serveFile(res, filePath) {
15828
+ if (!filePath || !existsSync4(filePath) || statSync(filePath).isDirectory()) {
15829
+ res.writeHead(404);
15830
+ res.end("Not found");
15831
+ return;
15832
+ }
15833
+ const dot = filePath.lastIndexOf(".");
15834
+ const ext = dot >= 0 ? filePath.slice(dot) : "";
15835
+ res.setHeader("Content-Type", MIME[ext] ?? "application/octet-stream");
15836
+ createReadStream(filePath).pipe(res);
15837
+ }
15838
+ async function run4(argv, opts = {}) {
15839
+ const parsed = parseArgs3({
15840
+ args: argv,
15841
+ options: {
15842
+ port: { type: "string" },
15843
+ out: { type: "string" },
15844
+ "no-margaui": { type: "boolean", default: false },
15845
+ "no-check": { type: "boolean", default: false },
15846
+ "no-tests": { type: "boolean", default: false },
15847
+ help: { type: "boolean", short: "h", default: false }
15848
+ },
15849
+ allowPositionals: true
15850
+ });
15851
+ if (parsed.values.help) {
15852
+ process.stdout.write(`tutuca storybook [dir] [--port <n>] [--out <dir>]
15853
+ [--no-margaui] [--no-check] [--no-tests]
15854
+
15855
+ Auto-discovers co-located *.dev.js modules (recursively, skipping
15856
+ node_modules/dotdirs) and serves a live storybook that mounts them via
15857
+ the tutuca/storybook library. Zero setup.
15858
+
15859
+ [dir] project root to scan and serve (default: cwd)
15860
+ --port <n> preferred port (default 4321; falls back to a free port)
15861
+ --out <dir> write a static index.html + bootstrap (CDN import map)
15862
+ instead of serving; host it from the project root
15863
+ --no-margaui skip margaui styling (renders functional but unstyled)
15864
+ --no-check skip the in-browser check(app) dev validation
15865
+ --no-tests skip running the modules' getTests() before serving
15866
+ `);
15867
+ return;
15868
+ }
15869
+ const projectDir = resolve4(parsed.positionals[0] ?? process.cwd());
15870
+ if (!existsSync4(projectDir) || !statSync(projectDir).isDirectory()) {
15871
+ emitError(opts, {
15872
+ code: CODES.USAGE_MISSING_ARGUMENT,
15873
+ message: `not a directory: ${projectDir}`,
15874
+ hint: "Pass a project directory to scan, or omit it to use the current directory."
15875
+ });
15876
+ }
15877
+ const devModuleUrls = findDevModules(projectDir, projectDir, []);
15878
+ if (devModuleUrls.length === 0) {
15879
+ emitError(opts, {
15880
+ code: CODES.USAGE_MISSING_ARGUMENT,
15881
+ message: `no *.dev.js modules found under ${projectDir}`,
15882
+ hint: "Create a co-located <name>.dev.js exporting getComponents() and getExamples()."
15883
+ });
15884
+ }
15885
+ const margaui = !parsed.values["no-margaui"];
15886
+ const check = !parsed.values["no-check"];
15887
+ const self = findSelf();
15888
+ if (parsed.values.out) {
15889
+ const outDir = resolve4(parsed.values.out);
15890
+ mkdirSync3(outDir, { recursive: true });
15891
+ const { base: base2 } = resolveTutucaBase(projectDir, self, true);
15892
+ const imports2 = buildImports(base2, { margaui });
15893
+ const bootstrapName = "tutuca-storybook.bootstrap.js";
15894
+ writeFileSync(resolve4(outDir, "index.html"), renderIndexHtml(imports2, { margaui, bootstrapUrl: `./${bootstrapName}` }));
15895
+ writeFileSync(resolve4(outDir, bootstrapName), renderBootstrap(devModuleUrls, { margaui, check }));
15896
+ process.stdout.write(`wrote static storybook → ${relative2(process.cwd(), outDir) || "."}/
15897
+ index.html + ${bootstrapName} (${devModuleUrls.length} dev modules, CDN import map)
15898
+ Host it from the project root so /*.dev.js paths resolve.
15899
+ `);
15900
+ return;
15901
+ }
15902
+ if (!parsed.values["no-tests"]) {
15903
+ const r = await runDevTests(projectDir, devModuleUrls);
15904
+ for (const ie of r.importErrors) {
15905
+ process.stdout.write(` ! skipped tests for ${ie.url}: ${ie.message}
15906
+ `);
15907
+ }
15908
+ if (r.withTests === 0) {
15909
+ process.stdout.write(`tests: no getTests() in any dev module
15910
+ `);
15911
+ } else {
15912
+ process.stdout.write(`tests: ${r.totalTests - r.failedTests}/${r.totalTests} passed across ${r.withTests} module(s)
15913
+ `);
15914
+ for (const f of r.failures) {
15915
+ process.stdout.write(` ✗ ${f.fullPath}: ${f.error?.message ?? "failed"}
15916
+ `);
15917
+ }
15918
+ }
15919
+ }
15920
+ const { base, serveDist } = resolveTutucaBase(projectDir, self, false);
15921
+ const imports = buildImports(base, { margaui });
15922
+ const indexHtml = renderIndexHtml(imports, { margaui, bootstrapUrl: BOOTSTRAP_URL });
15923
+ const bootstrapJs = renderBootstrap(devModuleUrls, { margaui, check });
15924
+ const server = createServer((req, res) => {
15925
+ const path = req.url.split("?")[0];
15926
+ if (path === "/" || path === "/index.html") {
15927
+ res.setHeader("Content-Type", MIME[".html"]);
15928
+ res.end(indexHtml);
15929
+ return;
15930
+ }
15931
+ if (path === BOOTSTRAP_URL) {
15932
+ res.setHeader("Content-Type", MIME[".js"]);
15933
+ res.end(bootstrapJs);
15934
+ return;
15935
+ }
15936
+ if (serveDist && path.startsWith(DIST_PREFIX)) {
15937
+ serveFile(res, safeJoin(serveDist, `/${path.slice(DIST_PREFIX.length)}`));
15938
+ return;
15939
+ }
15940
+ serveFile(res, safeJoin(projectDir, path));
15941
+ });
15942
+ const preferred = Number.parseInt(parsed.values.port ?? "4321", 10);
15943
+ server.on("error", (e) => {
15944
+ if (e.code === "EADDRINUSE") {
15945
+ server.listen(0);
15946
+ } else {
15947
+ throw e;
15948
+ }
15949
+ });
15950
+ server.on("listening", () => {
15951
+ const actual = server.address().port;
15952
+ const where = base.startsWith("http") ? "CDN" : base.startsWith("/node_modules") ? "node_modules" : "local dist";
15953
+ process.stdout.write(`tutuca storybook: http://localhost:${actual}/ (${devModuleUrls.length} dev modules, tutuca from ${where})
15954
+ `);
15955
+ });
15956
+ server.listen(preferred);
15957
+ }
15958
+ var describe4 = "Serve a storybook for the project's co-located *.dev.js modules (auto-discovered).", BOOTSTRAP_URL = "/__tutuca_storybook__.js", DIST_PREFIX = "/__tutuca__/", MIME, MARGAUI_CDN = "https://cdn.jsdelivr.net/npm/margaui/+esm", MARGAUI_THEME = "https://marianoguerra.github.io/margaui/themes/theme.css";
15959
+ var init_storybook = __esm(() => {
15960
+ init_chai2();
15961
+ init_test();
15962
+ init_env2();
15963
+ init_errors();
15964
+ MIME = {
15965
+ ".js": "text/javascript",
15966
+ ".mjs": "text/javascript",
15967
+ ".css": "text/css",
15968
+ ".html": "text/html; charset=utf-8",
15969
+ ".json": "application/json",
15970
+ ".wasm": "application/wasm",
15971
+ ".svg": "image/svg+xml",
15972
+ ".png": "image/png",
15973
+ ".jpg": "image/jpeg",
15974
+ ".jpeg": "image/jpeg",
15975
+ ".webp": "image/webp",
15976
+ ".gif": "image/gif",
15977
+ ".ico": "image/x-icon",
15978
+ ".map": "application/json"
15979
+ };
15980
+ });
15981
+
15632
15982
  // tools/tutuca.js
15633
15983
  init__registry();
15634
15984
 
@@ -15644,7 +15994,7 @@ import { existsSync, readFileSync } from "node:fs";
15644
15994
  import { dirname, resolve } from "node:path";
15645
15995
  import { fileURLToPath } from "node:url";
15646
15996
  var describe = "Print a machine-readable schema (commands, flags, exit codes) as JSON.";
15647
- var SCHEMA_VERSION = 2;
15997
+ var SCHEMA_VERSION = 3;
15648
15998
  var GLOBAL_FLAGS = [
15649
15999
  {
15650
16000
  name: "json",
@@ -15776,6 +16126,45 @@ var NO_MODULE_COMMANDS_META = {
15776
16126
  ],
15777
16127
  positionals: []
15778
16128
  },
16129
+ storybook: {
16130
+ describe: "Serve a live storybook for the project, auto-discovering co-located *.dev.js modules.",
16131
+ needsModule: false,
16132
+ flags: [
16133
+ {
16134
+ name: "port",
16135
+ type: "string",
16136
+ description: "Preferred port (default 4321; falls back to a free port)."
16137
+ },
16138
+ {
16139
+ name: "out",
16140
+ type: "string",
16141
+ description: "Write a static index.html + bootstrap (CDN import map) instead of serving."
16142
+ },
16143
+ {
16144
+ name: "no-margaui",
16145
+ type: "boolean",
16146
+ description: "Render unstyled (skip margaui)."
16147
+ },
16148
+ {
16149
+ name: "no-check",
16150
+ type: "boolean",
16151
+ description: "Skip the in-browser check(app)."
16152
+ },
16153
+ {
16154
+ name: "no-tests",
16155
+ type: "boolean",
16156
+ description: "Skip running the modules' getTests() before serving."
16157
+ },
16158
+ { name: "help", short: "h", type: "boolean" }
16159
+ ],
16160
+ positionals: [
16161
+ {
16162
+ name: "dir",
16163
+ required: false,
16164
+ description: "Project root to scan and serve (default: cwd)."
16165
+ }
16166
+ ]
16167
+ },
15779
16168
  "agent-context": {
15780
16169
  describe: "Print this schema as JSON.",
15781
16170
  needsModule: false,
@@ -15854,10 +16243,10 @@ init_feedback();
15854
16243
  init_chai_jest();
15855
16244
  var exports_help = {};
15856
16245
  __export(exports_help, {
15857
- run: () => run4,
15858
- describe: () => describe4
16246
+ run: () => run5,
16247
+ describe: () => describe5
15859
16248
  });
15860
- var describe4 = "Show usage. `help <command>` for per-command detail.";
16249
+ var describe5 = "Show usage. `help <command>` for per-command detail.";
15861
16250
  var OVERVIEW = `tutuca — CLI for inspecting, documenting, linting and rendering tutuca
15862
16251
  components defined in an ES module.
15863
16252
 
@@ -15874,8 +16263,8 @@ INVOCATION SHAPE
15874
16263
  components; pass it to operate on exactly one (e.g. \`show Button\`).
15875
16264
  - Per-command flags follow the module path; global flags can appear
15876
16265
  anywhere. Unknown flags are rejected by the subcommand's parser.
15877
- - \`help\`, \`feedback\`, \`install-skill\`, \`agent-context\` do NOT take a
15878
- module path.
16266
+ - \`help\`, \`feedback\`, \`install-skill\`, \`storybook\`, \`agent-context\` do NOT
16267
+ take a module path.
15879
16268
 
15880
16269
  MODULE CONVENTION
15881
16270
  A module passed to tutuca must export one or more of:
@@ -15962,6 +16351,20 @@ COMMANDS (no module required)
15962
16351
  or suggestions about the CLI, skills, docs, or the library.
15963
16352
  Message comes from the positional arg or piped stdin.
15964
16353
 
16354
+ storybook [dir] [--port <n>] [--out <dir>] [--no-margaui] [--no-check] [--no-tests]
16355
+ Serve a live storybook for the project. Recursively auto-discovers
16356
+ co-located *.dev.js modules (dev-only modules holding stories + tests
16357
+ + helpers, never shipped) and mounts them via the tutuca/storybook
16358
+ library — zero setup. By default also runs their getTests() in the
16359
+ terminal and wires margaui styling + an in-browser check(app).
16360
+ [dir] project root to scan/serve (default: cwd)
16361
+ --port <n> preferred port (default 4321; else a free port)
16362
+ --out <dir> write a static index.html + bootstrap (CDN import map)
16363
+ instead of serving; host from the project root
16364
+ --no-margaui render unstyled (skip margaui)
16365
+ --no-check skip the in-browser check(app)
16366
+ --no-tests skip the pre-serve getTests() run
16367
+
15965
16368
  agent-context
15966
16369
  Print a machine-readable schema of every command, flag, exit code,
15967
16370
  and error code as JSON on stdout. Use this once to teach an agent the
@@ -16027,21 +16430,22 @@ EXAMPLES
16027
16430
  tutuca lint ./src/components.js
16028
16431
  tutuca render ./src/components.js --title "Disabled state"
16029
16432
  `;
16030
- async function run4(argv, opts = {}) {
16433
+ async function run5(argv, opts = {}) {
16031
16434
  const target = argv?.[0];
16032
16435
  if (!target) {
16033
16436
  process.stdout.write(OVERVIEW);
16034
16437
  return;
16035
16438
  }
16036
16439
  if (target === "help") {
16037
- process.stdout.write(`help: ${describe4}
16440
+ process.stdout.write(`help: ${describe5}
16038
16441
  `);
16039
16442
  return;
16040
16443
  }
16041
16444
  const { COMMANDS: COMMANDS2 } = await Promise.resolve().then(() => (init__registry(), exports__registry));
16042
16445
  const noModule = {
16043
16446
  feedback: await Promise.resolve().then(() => (init_feedback(), exports_feedback)),
16044
- "install-skill": await Promise.resolve().then(() => (init_install_skill(), exports_install_skill))
16447
+ "install-skill": await Promise.resolve().then(() => (init_install_skill(), exports_install_skill)),
16448
+ storybook: await Promise.resolve().then(() => (init_storybook(), exports_storybook))
16045
16449
  };
16046
16450
  const cmd = COMMANDS2[target] ?? noModule[target];
16047
16451
  if (!cmd) {
@@ -16084,58 +16488,24 @@ ${group}
16084
16488
 
16085
16489
  // tools/tutuca.js
16086
16490
  init_install_skill();
16491
+ init_storybook();
16087
16492
  init_errors();
16088
16493
 
16089
16494
  // tools/cli/with-module.js
16090
- import { parseArgs as parseArgs3 } from "node:util";
16091
-
16092
- // tools/cli/env.js
16093
- init_anode();
16094
- init_lint_check();
16095
- import { JSDOM, VirtualConsole } from "jsdom";
16096
- async function createNodeEnv() {
16097
- const virtualConsole = new VirtualConsole;
16098
- virtualConsole.forwardTo(console, { jsdomErrors: "none" });
16099
- virtualConsole.on("jsdomError", (err) => {
16100
- if (err?.message?.includes("Could not parse CSS stylesheet"))
16101
- return;
16102
- console.error(err.message);
16103
- });
16104
- const dom = new JSDOM("<!DOCTYPE html><html><head></head><body></body></html>", {
16105
- virtualConsole
16106
- });
16107
- const { document: document2, Text, Comment } = dom.window;
16108
- globalThis.document = document2;
16109
-
16110
- class HeadlessParseContext extends ParseContext {
16111
- constructor() {
16112
- super(document2, Text, Comment);
16113
- }
16114
- }
16115
-
16116
- class HeadlessLintParseContext extends LintParseContext {
16117
- constructor() {
16118
- super(document2, Text, Comment);
16119
- }
16120
- }
16121
- return {
16122
- document: dom.window.document,
16123
- ParseContext: HeadlessParseContext,
16124
- LintParseContext: HeadlessLintParseContext
16125
- };
16126
- }
16495
+ init_env2();
16496
+ import { parseArgs as parseArgs4 } from "node:util";
16127
16497
 
16128
16498
  // tools/cli/load.js
16129
- import { resolve as resolve4 } from "node:path";
16499
+ import { resolve as resolve5 } from "node:path";
16130
16500
  async function loadAndNormalize(modulePath) {
16131
- const abs = resolve4(modulePath);
16501
+ const abs = resolve5(modulePath);
16132
16502
  const mod = await import(abs);
16133
16503
  const { normalized } = normalizeModule(mod, { path: abs });
16134
16504
  return normalized;
16135
16505
  }
16136
16506
 
16137
16507
  // tools/cli/output.js
16138
- import { writeFileSync } from "node:fs";
16508
+ import { writeFileSync as writeFileSync2 } from "node:fs";
16139
16509
 
16140
16510
  // tools/format/cli.js
16141
16511
  var exports_cli = {};
@@ -16621,7 +16991,7 @@ async function formatResult(formatName, result, options = {}) {
16621
16991
  async function emit(result, { format: format5, pretty, output }) {
16622
16992
  const text = await formatResult(format5, result, { pretty });
16623
16993
  if (output) {
16624
- writeFileSync(output, text);
16994
+ writeFileSync2(output, text);
16625
16995
  } else {
16626
16996
  process.stdout.write(text);
16627
16997
  if (!text.endsWith(`
@@ -16633,7 +17003,7 @@ async function emit(result, { format: format5, pretty, output }) {
16633
17003
 
16634
17004
  // tools/cli/with-module.js
16635
17005
  async function runCommand(cmd, argv, globalOpts) {
16636
- const parsed = parseArgs3({
17006
+ const parsed = parseArgs4({
16637
17007
  args: argv,
16638
17008
  options: cmd.parseOptions ?? {},
16639
17009
  allowPositionals: true
@@ -16658,6 +17028,7 @@ var NO_MODULE_COMMANDS = {
16658
17028
  help: exports_help,
16659
17029
  feedback: exports_feedback,
16660
17030
  "install-skill": exports_install_skill,
17031
+ storybook: exports_storybook,
16661
17032
  "agent-context": exports_agent_context
16662
17033
  };
16663
17034
  var VALID_FORMATS = ["cli", "md", "json", "html"];
@@ -16711,7 +17082,7 @@ function dispatchKnownCommands() {
16711
17082
  async function main() {
16712
17083
  const { opts, rest } = extractGlobals(process.argv.slice(2));
16713
17084
  if (rest.length === 0) {
16714
- await run4([], opts);
17085
+ await run5([], opts);
16715
17086
  return;
16716
17087
  }
16717
17088
  const command = rest[0];
@@ -16722,7 +17093,7 @@ async function main() {
16722
17093
  return;
16723
17094
  }
16724
17095
  if (opts.help) {
16725
- await run4([command], opts);
17096
+ await run5([command], opts);
16726
17097
  return;
16727
17098
  }
16728
17099
  const cmd = COMMANDS[command];
@@ -0,0 +1,283 @@
1
+ // src/storybook.js
2
+ import { component, html, injectCss, tutuca } from "tutuca";
3
+ var Storybook = component({
4
+ name: "Storybook",
5
+ fields: {
6
+ selectedSectionIndex: 0,
7
+ sections: [],
8
+ filter: "",
9
+ sectionId: null,
10
+ exampleId: null,
11
+ focusExample: null
12
+ },
13
+ methods: {
14
+ selectSectionAtIndex(index) {
15
+ if (this.sections.size === 0)
16
+ return this;
17
+ const safeIndex = index >= 0 && index < this.sections.size ? index : 0;
18
+ const sections = this.sections.map((s, i) => s.setSelected(i === safeIndex));
19
+ return this.setSelectedSectionIndex(safeIndex).setSections(sections);
20
+ },
21
+ selectSectionWithId(id) {
22
+ if (!id)
23
+ return this.selectSectionAtIndex(this.selectedSectionIndex);
24
+ const index = this.sections.findIndex((s) => s.id === id);
25
+ return this.selectSectionAtIndex(index);
26
+ },
27
+ focusExampleByIds(sectionId, exampleId) {
28
+ if (!sectionId || !exampleId) {
29
+ return this;
30
+ }
31
+ const section = this.sections.find((s) => s.id === sectionId);
32
+ const example = section?.items.find((e) => e.id === exampleId);
33
+ if (!example) {
34
+ return this;
35
+ }
36
+ return this.setSectionId(sectionId).setExampleId(exampleId).setFocusExample(example.value);
37
+ }
38
+ },
39
+ input: {
40
+ onApplyFilter(value, ctx) {
41
+ ctx.request("persistState", [{ key: "sectionFilter", value }]);
42
+ return this.setFilter(value);
43
+ },
44
+ onClearFilter(ctx) {
45
+ ctx.request("persistState", [{ key: "sectionFilter", value: "" }]);
46
+ return this.resetFilter();
47
+ },
48
+ onFocusClose(ctx) {
49
+ ctx.request("persistState", [{ key: "sectionId", value: "" }]);
50
+ ctx.request("persistState", [{ key: "exampleId", value: "" }]);
51
+ return this.setSectionId(null).setExampleId(null).setFocusExample(null);
52
+ }
53
+ },
54
+ alter: {
55
+ filterSection(_key, section) {
56
+ return this.filter === "" || fuzzyMatch(this.filter, `${section.title} ${section.description}`);
57
+ }
58
+ },
59
+ bubble: {
60
+ sectionSelected(section, ctx) {
61
+ ctx.stopPropagation();
62
+ ctx.request("persistState", [{ key: "section", value: section.id }]);
63
+ return this.selectSectionAtIndex(this.sections.indexOf(section));
64
+ },
65
+ exampleFocusRequested(example, ctx) {
66
+ ctx.stopPropagation();
67
+ const section = this.sections.get(this.selectedSectionIndex);
68
+ const sectionId = section?.id ?? null;
69
+ ctx.request("persistState", [{ key: "sectionId", value: sectionId }]);
70
+ ctx.request("persistState", [{ key: "exampleId", value: example.id }]);
71
+ return this.setSectionId(sectionId).setExampleId(example.id).setFocusExample(example.value);
72
+ }
73
+ },
74
+ view: html`<div>
75
+ <div class="flex flex-col gap-3 p-3 h-screen" @show="truthy? .focusExample">
76
+ <div class="flex justify-end">
77
+ <button class="btn btn-ghost btn-sm" @on.click="onFocusClose">
78
+ close
79
+ </button>
80
+ </div>
81
+ <div class="flex-1 overflow-y-auto">
82
+ <x render=".focusExample"></x>
83
+ </div>
84
+ </div>
85
+ <div class="flex gap-3 p-3 h-screen" @hide="truthy? .focusExample">
86
+ <div
87
+ class="w-1/4 flex flex-col gap-3 bg-base-100 shadow-md h-full overflow-hidden"
88
+ >
89
+ <input
90
+ class="input w-full outline-0 focus:bg-base-200"
91
+ type="search"
92
+ placeholder="Filter sections"
93
+ :value=".filter"
94
+ @on.input="onApplyFilter value"
95
+ @on.keydown.cancel="onClearFilter"
96
+ />
97
+ <div class="list h-full flex-1 overflow-y-auto">
98
+ <x render-each=".sections" as="listEntry" when="filterSection"></x>
99
+ </div>
100
+ </div>
101
+ <div class="w-full h-full overflow-y-auto">
102
+ <x render=".sections[.selectedSectionIndex]"></x>
103
+ </div>
104
+ </div>
105
+ </div>`
106
+ });
107
+ var Section = component({
108
+ name: "Section",
109
+ fields: {
110
+ id: "?",
111
+ title: "No Title Section",
112
+ description: "",
113
+ items: [],
114
+ filter: "",
115
+ selected: false
116
+ },
117
+ statics: {
118
+ fromData({ id, title = "???", description = "", items = [] }) {
119
+ id ??= slugify(title);
120
+ return this.make({
121
+ id,
122
+ title,
123
+ description,
124
+ items: items.map((v) => Example.Class.fromData(v))
125
+ });
126
+ }
127
+ },
128
+ alter: {
129
+ filterItem(_key, item) {
130
+ return this.filter === "" || fuzzyMatch(this.filter, `${item.title} ${item.description}`);
131
+ }
132
+ },
133
+ input: {
134
+ onApplyFilter(value, ctx) {
135
+ ctx.request("persistState", [{ key: "exampleFilter", value }]);
136
+ return this.setFilter(value);
137
+ },
138
+ onClearFilter(ctx) {
139
+ ctx.request("persistState", [{ key: "exampleFilter", value: "" }]);
140
+ return this.resetFilter();
141
+ },
142
+ onListItemClick(ctx) {
143
+ ctx.bubble("sectionSelected", [this]);
144
+ return this;
145
+ }
146
+ },
147
+ view: html`<section class="flex flex-col gap-3">
148
+ <div class="sticky top-0 z-10 bg-base-100 pt-1 pb-2 shadow-sm">
149
+ <h2 class="text-lg font-bold" @text=".title"></h2>
150
+ <p class="text-md italic opacity-60" @text=".description"></p>
151
+ </div>
152
+ <input
153
+ class="input w-full outline-0 focus:bg-base-200"
154
+ type="search"
155
+ placeholder="Filter examples"
156
+ :value=".filter"
157
+ @on.input="onApplyFilter value"
158
+ @on.keydown.cancel="onClearFilter"
159
+ />
160
+ <div class="flex flex-col gap-3">
161
+ <x render-each=".items" when="filterItem"></x>
162
+ </div>
163
+ </section>`,
164
+ views: {
165
+ listEntry: html`<div
166
+ @if.class=".selected"
167
+ @then="'list-row cursor-pointer text-blue-400 hover:text-blue-500 font-semibold'"
168
+ @else="'list-row cursor-pointer hover:bg-base-200'"
169
+ :title=".description"
170
+ @on.click="onListItemClick"
171
+ >
172
+ <div @text=".title" class="list-col-grow"></div>
173
+ <p
174
+ class="text-xs opacity-60 list-col-wrap truncate"
175
+ @text=".description"
176
+ ></p>
177
+ </div> `
178
+ }
179
+ });
180
+ var Example = component({
181
+ name: "Example",
182
+ fields: { id: "?", title: "?", description: "", value: null, view: "main" },
183
+ statics: {
184
+ fromData({ id, title = "No Title Example", description = "", value = null, view = "main" }) {
185
+ id ??= slugify(title);
186
+ return this.make({
187
+ id,
188
+ title,
189
+ description,
190
+ value,
191
+ view
192
+ });
193
+ }
194
+ },
195
+ input: {
196
+ onLogSelected() {
197
+ console.log(this.value);
198
+ return this;
199
+ },
200
+ onFocusSelected(ctx) {
201
+ ctx.bubble("exampleFocusRequested", [this]);
202
+ return this;
203
+ }
204
+ },
205
+ view: html`<div class="card card-border bg-base-100 shadow-md">
206
+ <div class="card-body">
207
+ <h2 class="card-title flex justify-between">
208
+ <a :href="$'#example-{.id}'" :id="$'example-{.id}'" @text=".title"></a>
209
+ <div class="flex gap-2">
210
+ <button class="btn btn-ghost btn-sm" @on.click="onFocusSelected">
211
+ focus
212
+ </button>
213
+ <button class="btn btn-ghost btn-sm" @on.click="onLogSelected">
214
+ log
215
+ </button>
216
+ </div>
217
+ </h2>
218
+ <p class="text-md italic opacity-60" @text=".description"></p>
219
+ <div class="bg-base-100 p-3" @push-view=".view">
220
+ <x render=".value"></x>
221
+ </div>
222
+ </div>
223
+ </div>`
224
+ });
225
+ function fuzzyMatch(query, target) {
226
+ const q = query.toLowerCase(), t = target.toLowerCase();
227
+ let qi = 0;
228
+ for (let ti = 0;ti < t.length && qi < q.length; ti++) {
229
+ if (t[ti] === q[qi])
230
+ qi++;
231
+ }
232
+ return qi === q.length;
233
+ }
234
+ function slugify(str) {
235
+ return String(str).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
236
+ }
237
+ function buildStorybook(modules) {
238
+ const sections = modules.map((m) => Section.Class.fromData(m.getExamples())).sort((a, b) => a.title.localeCompare(b.title));
239
+ const components = new Set([Storybook, Section, Example]);
240
+ const macros = {};
241
+ const requestHandlers = {};
242
+ for (const m of modules) {
243
+ for (const c of m.getComponents?.() ?? []) {
244
+ components.add(c);
245
+ }
246
+ if (m.getMacros)
247
+ Object.assign(macros, m.getMacros());
248
+ if (m.getRequestHandlers)
249
+ Object.assign(requestHandlers, m.getRequestHandlers());
250
+ }
251
+ return {
252
+ root: Storybook.make({ sections }),
253
+ components: [...components],
254
+ macros,
255
+ requestHandlers
256
+ };
257
+ }
258
+ async function mountStorybook(selector, modules, { compileCss, root } = {}) {
259
+ const app = tutuca(selector);
260
+ const built = buildStorybook(modules);
261
+ app.state.set(root ?? built.root);
262
+ const scope = app.registerComponents(built.components);
263
+ scope.registerMacros(built.macros);
264
+ scope.registerRequestHandlers(built.requestHandlers);
265
+ if (compileCss) {
266
+ injectCss("tutuca-storybook", await compileCss(app));
267
+ }
268
+ app.start();
269
+ return app;
270
+ }
271
+ function getComponents() {
272
+ return [Storybook, Section, Example];
273
+ }
274
+ export {
275
+ slugify,
276
+ mountStorybook,
277
+ getComponents,
278
+ fuzzyMatch,
279
+ buildStorybook,
280
+ Storybook,
281
+ Section,
282
+ Example
283
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tutuca",
3
- "version": "0.9.83",
3
+ "version": "0.9.85",
4
4
  "type": "module",
5
5
  "description": "Zero-dependency SPA framework with immutable state and virtual DOM",
6
6
  "main": "./dist/tutuca.js",
@@ -13,6 +13,7 @@
13
13
  "./ext": "./dist/tutuca.ext.js",
14
14
  "./extra-ext": "./dist/tutuca-extra.ext.js",
15
15
  "./dev-ext": "./dist/tutuca-dev.ext.js",
16
+ "./storybook": "./dist/tutuca-storybook.js",
16
17
  "./immutable": "./dist/immutable.js",
17
18
  "./chai": "./dist/chai.js",
18
19
  "./package.json": "./package.json"
@@ -54,6 +55,7 @@
54
55
  "dist/tutuca.ext.js",
55
56
  "dist/tutuca-extra.ext.js",
56
57
  "dist/tutuca-dev.ext.js",
58
+ "dist/tutuca-storybook.js",
57
59
  "dist/immutable.js",
58
60
  "dist/chai.js",
59
61
  "skill"
@@ -40,6 +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
44
  | `help [cmd]` | Show usage. No module path needed |
44
45
  | `feedback [message]` | Append a feedback note (positional or stdin) to `~/.tutuca/feedback.jsonl`. No module path needed |
45
46
  | `install-skill [name]` | Copy a bundled skill (`tutuca`, `margaui`, `immutable-js`, or `--all`) into `.claude/skills/`. No module path needed |
@@ -187,6 +188,61 @@ export function getTests({ describe, test, expect }) {
187
188
  `tutuca test <module> Counter` picks it up. Untagged `test(...)` at the
188
189
  top of a tagged `describe` inherits the tag.
189
190
 
191
+ ## storybook — live component catalog
192
+
193
+ `tutuca storybook [dir]` serves a browser storybook for a project with no
194
+ setup. It recursively discovers co-located `*.dev.js` modules (see the
195
+ `.dev.js` convention below), mounts them via the shipped `tutuca/storybook`
196
+ library, and serves an ephemeral page — no config, no HTML to write.
197
+
198
+ ```sh
199
+ tutuca storybook # scan + serve the current directory
200
+ tutuca storybook ./packages/ui # scan + serve another directory
201
+ tutuca storybook --port 4321 # preferred port (falls back to a free one if taken)
202
+ tutuca storybook --out ./_site # write a static index.html + bootstrap instead of serving
203
+ tutuca storybook --no-tests # skip the pre-serve getTests() run
204
+ tutuca storybook --no-margaui # render unstyled (skip margaui)
205
+ tutuca storybook --no-check # skip the in-browser check(app)
206
+ ```
207
+
208
+ It is **batteries-included by default**: before serving it runs each module's
209
+ `getTests()` in the terminal, the page wires margaui styling, and the browser
210
+ runs `check(app)`. Each is individually disablable with the `--no-*` flags.
211
+
212
+ How tutuca itself is resolved (convention over configuration): a local
213
+ `node_modules/tutuca` install if present, else the CLI's own `dist`, else the
214
+ version-pinned CDN. All tutuca specifiers resolve to a single runtime, which
215
+ component scope/identity requires. `--out` always pins the CDN so the static
216
+ artifact is portable (host it from the project root so `/*.dev.js` paths resolve).
217
+
218
+ ### The `.dev.js` convention
219
+
220
+ A `*.dev.js` file is a **dev-only module**: it holds stories
221
+ (`getComponents()` + `getExamples()`), tests (`getTests()`), and any other
222
+ development-time helpers for nearby components, and is **never shipped to
223
+ production or the UI**. The `.dev.js` suffix is the contract — your app imports
224
+ its real components directly and never a `.dev.js`, and a production build glob
225
+ can exclude `**/*.dev.js`. Because they follow the full module convention, the
226
+ same files are valid targets for `tutuca test`/`lint`/`render` too.
227
+
228
+ ```js
229
+ // counter.dev.js — lives next to counter.js
230
+ import { component, html } from "tutuca";
231
+ import { Counter } from "./counter.js";
232
+
233
+ export function getComponents() {
234
+ return [Counter];
235
+ }
236
+ export function getExamples() {
237
+ return { title: "Counter", items: [{ title: "Basic", value: Counter.make({}) }] };
238
+ }
239
+ export function getTests({ describe, test, expect }) {
240
+ describe(Counter, () => {
241
+ test("starts at zero", () => expect(Counter.make({}).count).toBe(0));
242
+ });
243
+ }
244
+ ```
245
+
190
246
  ## Install skill assets
191
247
 
192
248
  `tutuca install-skill` copies bundled Claude Code skill files into
@@ -977,6 +977,23 @@ export function getExamples() {
977
977
  export function getTests({ describe, test, expect }) { /*...*/ } // optional — see cli.md
978
978
  ```
979
979
 
980
+ Best practice: have `getComponents()` return **every** component the module
981
+ defines — child and helper components included — and give each one at least
982
+ one item in `getExamples()`. A component left out of `getComponents()` is
983
+ invisible to `tutuca lint`/`render`/`test`, so it silently loses linting and
984
+ render coverage. If your components already live behind a differently named
985
+ export, alias it instead of teaching tools a new name:
986
+
987
+ ```js
988
+ export { allMyComponents as getComponents } from "./app.js";
989
+ ```
990
+
991
+ Put these exports in a co-located **`*.dev.js`** file (a dev-only module
992
+ holding stories + tests, never shipped) and `tutuca storybook` auto-discovers
993
+ and renders them with no setup — see [cli.md](./cli.md). The same shape is
994
+ consumed by the shipped `tutuca/storybook` library (`mountStorybook`,
995
+ `buildStorybook`) if you want to embed a storybook in your own page.
996
+
980
997
  ## See also
981
998
 
982
999
  - [component-design.md](./component-design.md) — design judgment for shaping a