tutuca 0.9.83 → 0.9.84

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
 
@@ -15854,10 +16204,10 @@ init_feedback();
15854
16204
  init_chai_jest();
15855
16205
  var exports_help = {};
15856
16206
  __export(exports_help, {
15857
- run: () => run4,
15858
- describe: () => describe4
16207
+ run: () => run5,
16208
+ describe: () => describe5
15859
16209
  });
15860
- var describe4 = "Show usage. `help <command>` for per-command detail.";
16210
+ var describe5 = "Show usage. `help <command>` for per-command detail.";
15861
16211
  var OVERVIEW = `tutuca — CLI for inspecting, documenting, linting and rendering tutuca
15862
16212
  components defined in an ES module.
15863
16213
 
@@ -15874,8 +16224,8 @@ INVOCATION SHAPE
15874
16224
  components; pass it to operate on exactly one (e.g. \`show Button\`).
15875
16225
  - Per-command flags follow the module path; global flags can appear
15876
16226
  anywhere. Unknown flags are rejected by the subcommand's parser.
15877
- - \`help\`, \`feedback\`, \`install-skill\`, \`agent-context\` do NOT take a
15878
- module path.
16227
+ - \`help\`, \`feedback\`, \`install-skill\`, \`storybook\`, \`agent-context\` do NOT
16228
+ take a module path.
15879
16229
 
15880
16230
  MODULE CONVENTION
15881
16231
  A module passed to tutuca must export one or more of:
@@ -15962,6 +16312,20 @@ COMMANDS (no module required)
15962
16312
  or suggestions about the CLI, skills, docs, or the library.
15963
16313
  Message comes from the positional arg or piped stdin.
15964
16314
 
16315
+ storybook [dir] [--port <n>] [--out <dir>] [--no-margaui] [--no-check] [--no-tests]
16316
+ Serve a live storybook for the project. Recursively auto-discovers
16317
+ co-located *.dev.js modules (dev-only modules holding stories + tests
16318
+ + helpers, never shipped) and mounts them via the tutuca/storybook
16319
+ library — zero setup. By default also runs their getTests() in the
16320
+ terminal and wires margaui styling + an in-browser check(app).
16321
+ [dir] project root to scan/serve (default: cwd)
16322
+ --port <n> preferred port (default 4321; else a free port)
16323
+ --out <dir> write a static index.html + bootstrap (CDN import map)
16324
+ instead of serving; host from the project root
16325
+ --no-margaui render unstyled (skip margaui)
16326
+ --no-check skip the in-browser check(app)
16327
+ --no-tests skip the pre-serve getTests() run
16328
+
15965
16329
  agent-context
15966
16330
  Print a machine-readable schema of every command, flag, exit code,
15967
16331
  and error code as JSON on stdout. Use this once to teach an agent the
@@ -16027,21 +16391,22 @@ EXAMPLES
16027
16391
  tutuca lint ./src/components.js
16028
16392
  tutuca render ./src/components.js --title "Disabled state"
16029
16393
  `;
16030
- async function run4(argv, opts = {}) {
16394
+ async function run5(argv, opts = {}) {
16031
16395
  const target = argv?.[0];
16032
16396
  if (!target) {
16033
16397
  process.stdout.write(OVERVIEW);
16034
16398
  return;
16035
16399
  }
16036
16400
  if (target === "help") {
16037
- process.stdout.write(`help: ${describe4}
16401
+ process.stdout.write(`help: ${describe5}
16038
16402
  `);
16039
16403
  return;
16040
16404
  }
16041
16405
  const { COMMANDS: COMMANDS2 } = await Promise.resolve().then(() => (init__registry(), exports__registry));
16042
16406
  const noModule = {
16043
16407
  feedback: await Promise.resolve().then(() => (init_feedback(), exports_feedback)),
16044
- "install-skill": await Promise.resolve().then(() => (init_install_skill(), exports_install_skill))
16408
+ "install-skill": await Promise.resolve().then(() => (init_install_skill(), exports_install_skill)),
16409
+ storybook: await Promise.resolve().then(() => (init_storybook(), exports_storybook))
16045
16410
  };
16046
16411
  const cmd = COMMANDS2[target] ?? noModule[target];
16047
16412
  if (!cmd) {
@@ -16084,58 +16449,24 @@ ${group}
16084
16449
 
16085
16450
  // tools/tutuca.js
16086
16451
  init_install_skill();
16452
+ init_storybook();
16087
16453
  init_errors();
16088
16454
 
16089
16455
  // 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
- }
16456
+ init_env2();
16457
+ import { parseArgs as parseArgs4 } from "node:util";
16127
16458
 
16128
16459
  // tools/cli/load.js
16129
- import { resolve as resolve4 } from "node:path";
16460
+ import { resolve as resolve5 } from "node:path";
16130
16461
  async function loadAndNormalize(modulePath) {
16131
- const abs = resolve4(modulePath);
16462
+ const abs = resolve5(modulePath);
16132
16463
  const mod = await import(abs);
16133
16464
  const { normalized } = normalizeModule(mod, { path: abs });
16134
16465
  return normalized;
16135
16466
  }
16136
16467
 
16137
16468
  // tools/cli/output.js
16138
- import { writeFileSync } from "node:fs";
16469
+ import { writeFileSync as writeFileSync2 } from "node:fs";
16139
16470
 
16140
16471
  // tools/format/cli.js
16141
16472
  var exports_cli = {};
@@ -16621,7 +16952,7 @@ async function formatResult(formatName, result, options = {}) {
16621
16952
  async function emit(result, { format: format5, pretty, output }) {
16622
16953
  const text = await formatResult(format5, result, { pretty });
16623
16954
  if (output) {
16624
- writeFileSync(output, text);
16955
+ writeFileSync2(output, text);
16625
16956
  } else {
16626
16957
  process.stdout.write(text);
16627
16958
  if (!text.endsWith(`
@@ -16633,7 +16964,7 @@ async function emit(result, { format: format5, pretty, output }) {
16633
16964
 
16634
16965
  // tools/cli/with-module.js
16635
16966
  async function runCommand(cmd, argv, globalOpts) {
16636
- const parsed = parseArgs3({
16967
+ const parsed = parseArgs4({
16637
16968
  args: argv,
16638
16969
  options: cmd.parseOptions ?? {},
16639
16970
  allowPositionals: true
@@ -16658,6 +16989,7 @@ var NO_MODULE_COMMANDS = {
16658
16989
  help: exports_help,
16659
16990
  feedback: exports_feedback,
16660
16991
  "install-skill": exports_install_skill,
16992
+ storybook: exports_storybook,
16661
16993
  "agent-context": exports_agent_context
16662
16994
  };
16663
16995
  var VALID_FORMATS = ["cli", "md", "json", "html"];
@@ -16711,7 +17043,7 @@ function dispatchKnownCommands() {
16711
17043
  async function main() {
16712
17044
  const { opts, rest } = extractGlobals(process.argv.slice(2));
16713
17045
  if (rest.length === 0) {
16714
- await run4([], opts);
17046
+ await run5([], opts);
16715
17047
  return;
16716
17048
  }
16717
17049
  const command = rest[0];
@@ -16722,7 +17054,7 @@ async function main() {
16722
17054
  return;
16723
17055
  }
16724
17056
  if (opts.help) {
16725
- await run4([command], opts);
17057
+ await run5([command], opts);
16726
17058
  return;
16727
17059
  }
16728
17060
  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.84",
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"
@@ -977,6 +977,17 @@ 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
+
980
991
  ## See also
981
992
 
982
993
  - [component-design.md](./component-design.md) — design judgment for shaping a