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 +2 -0
- package/dist/tutuca-cli.js +424 -53
- package/dist/tutuca-storybook.js +283 -0
- package/package.json +3 -1
- package/skill/tutuca/cli.md +56 -0
- package/skill/tutuca/core.md +17 -0
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
|
|
package/dist/tutuca-cli.js
CHANGED
|
@@ -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 =
|
|
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: () =>
|
|
15858
|
-
describe: () =>
|
|
16246
|
+
run: () => run5,
|
|
16247
|
+
describe: () => describe5
|
|
15859
16248
|
});
|
|
15860
|
-
var
|
|
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
|
|
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
|
|
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: ${
|
|
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
|
-
|
|
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
|
|
16499
|
+
import { resolve as resolve5 } from "node:path";
|
|
16130
16500
|
async function loadAndNormalize(modulePath) {
|
|
16131
|
-
const abs =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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.
|
|
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"
|
package/skill/tutuca/cli.md
CHANGED
|
@@ -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
|
package/skill/tutuca/core.md
CHANGED
|
@@ -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
|