flowbook 0.1.0

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.
@@ -0,0 +1,104 @@
1
+ import { useEffect, useRef, useState, useId } from "react";
2
+ import mermaid from "mermaid";
3
+
4
+ let initialized = false;
5
+
6
+ function ensureInit() {
7
+ if (initialized) return;
8
+ mermaid.initialize({
9
+ startOnLoad: false,
10
+ theme: "dark",
11
+ securityLevel: "loose",
12
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, monospace",
13
+ themeVariables: {
14
+ darkMode: true,
15
+ background: "#18181b",
16
+ mainBkg: "#27272a",
17
+ primaryColor: "#7c3aed",
18
+ primaryTextColor: "#f4f4f5",
19
+ primaryBorderColor: "#6d28d9",
20
+ secondaryColor: "#3f3f46",
21
+ secondaryTextColor: "#d4d4d8",
22
+ secondaryBorderColor: "#52525b",
23
+ tertiaryColor: "#27272a",
24
+ tertiaryTextColor: "#a1a1aa",
25
+ tertiaryBorderColor: "#3f3f46",
26
+ lineColor: "#71717a",
27
+ textColor: "#e4e4e7",
28
+ nodeTextColor: "#f4f4f5",
29
+ },
30
+ });
31
+ initialized = true;
32
+ }
33
+
34
+ interface Props {
35
+ code: string;
36
+ className?: string;
37
+ }
38
+
39
+ export function MermaidRenderer({ code, className }: Props) {
40
+ const [svg, setSvg] = useState("");
41
+ const [error, setError] = useState("");
42
+ const containerRef = useRef<HTMLDivElement>(null);
43
+ const rawId = useId();
44
+ const safeId = "mermaid" + rawId.replace(/:/g, "-");
45
+
46
+ useEffect(() => {
47
+ ensureInit();
48
+
49
+ let cancelled = false;
50
+
51
+ mermaid
52
+ .render(safeId, code)
53
+ .then(({ svg: rendered }) => {
54
+ if (!cancelled) {
55
+ setSvg(rendered);
56
+ setError("");
57
+ }
58
+ })
59
+ .catch((err: Error) => {
60
+ if (!cancelled) {
61
+ setError(err.message ?? "Failed to render diagram");
62
+ setSvg("");
63
+ }
64
+ });
65
+
66
+ return () => {
67
+ cancelled = true;
68
+ };
69
+ }, [code, safeId]);
70
+
71
+ if (error) {
72
+ return (
73
+ <div
74
+ className={`bg-red-500/10 border border-red-500/20 rounded-lg p-4 ${className ?? ""}`}
75
+ >
76
+ <p className="text-red-400 text-sm font-medium mb-2">
77
+ Render error
78
+ </p>
79
+ <p className="text-red-300/70 text-xs font-mono mb-3">{error}</p>
80
+ <pre className="text-xs text-zinc-500 overflow-x-auto whitespace-pre-wrap">
81
+ {code}
82
+ </pre>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ if (!svg) {
88
+ return (
89
+ <div
90
+ className={`flex items-center justify-center py-12 ${className ?? ""}`}
91
+ >
92
+ <div className="w-5 h-5 border-2 border-violet-500/30 border-t-violet-500 rounded-full animate-spin" />
93
+ </div>
94
+ );
95
+ }
96
+
97
+ return (
98
+ <div
99
+ ref={containerRef}
100
+ className={`flowbook-mermaid overflow-x-auto ${className ?? ""}`}
101
+ dangerouslySetInnerHTML={{ __html: svg }}
102
+ />
103
+ );
104
+ }
@@ -0,0 +1,88 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { FlowEntry } from "../../types";
3
+
4
+ interface SidebarProps {
5
+ flows: FlowEntry[];
6
+ selectedId: string | null;
7
+ onSelect: (id: string) => void;
8
+ }
9
+
10
+ export function Sidebar({ flows, selectedId, onSelect }: SidebarProps) {
11
+ const [collapsedCategories, setCollapsedCategories] = useState<Set<string>>(
12
+ new Set(),
13
+ );
14
+
15
+ const categories = useMemo(() => {
16
+ const map = new Map<string, FlowEntry[]>();
17
+ for (const flow of flows) {
18
+ const cat = flow.category;
19
+ if (!map.has(cat)) map.set(cat, []);
20
+ map.get(cat)!.push(flow);
21
+ }
22
+ return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
23
+ }, [flows]);
24
+
25
+ function toggleCategory(cat: string) {
26
+ setCollapsedCategories((prev) => {
27
+ const next = new Set(prev);
28
+ if (next.has(cat)) next.delete(cat);
29
+ else next.add(cat);
30
+ return next;
31
+ });
32
+ }
33
+
34
+ return (
35
+ <aside className="w-64 border-r border-zinc-800 overflow-y-auto shrink-0 bg-zinc-950/50">
36
+ <nav className="p-3 space-y-1">
37
+ {categories.map(([category, items]) => {
38
+ const isCollapsed = collapsedCategories.has(category);
39
+
40
+ return (
41
+ <div key={category}>
42
+ <button
43
+ onClick={() => toggleCategory(category)}
44
+ className="w-full flex items-center gap-1.5 px-2 py-1.5 text-xs font-semibold text-zinc-400 uppercase tracking-wider hover:text-zinc-300 transition-colors"
45
+ >
46
+ <svg
47
+ className={`w-3 h-3 transition-transform ${isCollapsed ? "" : "rotate-90"}`}
48
+ viewBox="0 0 24 24"
49
+ fill="currentColor"
50
+ >
51
+ <path d="M8 5l8 7-8 7z" />
52
+ </svg>
53
+ {category}
54
+ <span className="ml-auto text-zinc-600 font-normal normal-case">
55
+ {items.length}
56
+ </span>
57
+ </button>
58
+ {!isCollapsed && (
59
+ <ul className="ml-2 space-y-0.5">
60
+ {items.map((flow) => (
61
+ <li key={flow.id}>
62
+ <button
63
+ onClick={() => onSelect(flow.id)}
64
+ className={`w-full text-left px-3 py-1.5 rounded-md text-sm transition-colors truncate ${
65
+ selectedId === flow.id
66
+ ? "bg-violet-500/15 text-violet-300 font-medium"
67
+ : "text-zinc-400 hover:bg-zinc-800/60 hover:text-zinc-200"
68
+ }`}
69
+ title={flow.title}
70
+ >
71
+ {flow.title}
72
+ </button>
73
+ </li>
74
+ ))}
75
+ </ul>
76
+ )}
77
+ </div>
78
+ );
79
+ })}
80
+ {categories.length === 0 && (
81
+ <p className="text-sm text-zinc-600 px-2 py-4 text-center">
82
+ No flows match your search
83
+ </p>
84
+ )}
85
+ </nav>
86
+ </aside>
87
+ );
88
+ }
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📊</text></svg>" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Flowbook</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="./main.tsx"></script>
12
+ </body>
13
+ </html>
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./App";
4
+ import "./styles/globals.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,39 @@
1
+ @import "tailwindcss";
2
+
3
+ :root {
4
+ color-scheme: dark;
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ font-family:
10
+ -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
11
+ Arial, sans-serif;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
15
+
16
+ /* Mermaid SVG sizing */
17
+ .flowbook-mermaid svg {
18
+ max-width: 100%;
19
+ height: auto;
20
+ }
21
+
22
+ /* Scrollbar */
23
+ ::-webkit-scrollbar {
24
+ width: 6px;
25
+ height: 6px;
26
+ }
27
+
28
+ ::-webkit-scrollbar-track {
29
+ background: transparent;
30
+ }
31
+
32
+ ::-webkit-scrollbar-thumb {
33
+ background: #3f3f46;
34
+ border-radius: 3px;
35
+ }
36
+
37
+ ::-webkit-scrollbar-thumb:hover {
38
+ background: #52525b;
39
+ }
@@ -0,0 +1,21 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ declare module "virtual:flowbook-data" {
4
+ interface FlowEntry {
5
+ id: string;
6
+ title: string;
7
+ category: string;
8
+ tags: string[];
9
+ description: string;
10
+ order: number;
11
+ filePath: string;
12
+ mermaidBlocks: string[];
13
+ }
14
+
15
+ interface FlowbookData {
16
+ flows: FlowEntry[];
17
+ }
18
+
19
+ const data: FlowbookData;
20
+ export default data;
21
+ }
@@ -0,0 +1,59 @@
1
+ import { initFlowbook } from "./init";
2
+ import { startDevServer, buildStatic } from "./server";
3
+
4
+ async function main() {
5
+ const args = process.argv.slice(2);
6
+ const command = args[0];
7
+
8
+ switch (command) {
9
+ case "init":
10
+ await initFlowbook();
11
+ break;
12
+
13
+ case "dev": {
14
+ const port = Number(getFlag(args, "--port", "6200"));
15
+ await startDevServer({ port });
16
+ break;
17
+ }
18
+
19
+ case "build": {
20
+ const outDir = String(getFlag(args, "--out-dir", "flowbook-static"));
21
+ await buildStatic({ outDir });
22
+ break;
23
+ }
24
+
25
+ default:
26
+ printUsage();
27
+ break;
28
+ }
29
+ }
30
+
31
+ function getFlag(
32
+ args: string[],
33
+ flag: string,
34
+ fallback: string,
35
+ ): string {
36
+ const idx = args.indexOf(flag);
37
+ if (idx === -1 || idx + 1 >= args.length) return fallback;
38
+ return args[idx + 1];
39
+ }
40
+
41
+ function printUsage() {
42
+ console.log(`
43
+ flowbook — Storybook for flowcharts
44
+
45
+ Usage:
46
+ flowbook init Set up Flowbook in your project
47
+ flowbook dev [--port 6200] Start the dev server
48
+ flowbook build [--out-dir d] Build a static site
49
+
50
+ Options:
51
+ --port <number> Dev server port (default: 6200)
52
+ --out-dir <path> Build output directory (default: flowbook-static)
53
+ `);
54
+ }
55
+
56
+ main().catch((err) => {
57
+ console.error(err);
58
+ process.exit(1);
59
+ });
@@ -0,0 +1,16 @@
1
+ import fg from "fast-glob";
2
+ import type { FlowbookPluginOptions } from "../types";
3
+
4
+ export async function discoverFlowFiles(
5
+ options: FlowbookPluginOptions,
6
+ ): Promise<string[]> {
7
+ const patterns = options.include ?? ["**/*.flow.md", "**/*.flowchart.md"];
8
+ const ignore = options.ignore ?? [
9
+ "node_modules/**",
10
+ ".git/**",
11
+ "dist/**",
12
+ ];
13
+ const cwd = options.cwd ?? process.cwd();
14
+
15
+ return fg(patterns, { cwd, ignore, absolute: true });
16
+ }
@@ -0,0 +1,71 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ const EXAMPLE_FLOW = `---
5
+ title: Example Flow
6
+ category: Getting Started
7
+ tags: [example]
8
+ order: 1
9
+ description: An example flowchart to get you started
10
+ ---
11
+
12
+ \`\`\`mermaid
13
+ flowchart TD
14
+ A[Start] --> B{Decision}
15
+ B -->|Yes| C[Action A]
16
+ B -->|No| D[Action B]
17
+ C --> E[End]
18
+ D --> E
19
+ \`\`\`
20
+ `;
21
+
22
+ export async function initFlowbook() {
23
+ const cwd = process.cwd();
24
+ const pkgPath = resolve(cwd, "package.json");
25
+
26
+ if (!existsSync(pkgPath)) {
27
+ console.error(" No package.json found. Run 'npm init' first.");
28
+ process.exit(1);
29
+ }
30
+
31
+ // 1. Add scripts to package.json
32
+ const raw = readFileSync(pkgPath, "utf-8");
33
+ const pkg = JSON.parse(raw);
34
+ pkg.scripts = pkg.scripts ?? {};
35
+
36
+ let scriptsAdded = false;
37
+
38
+ if (!pkg.scripts.flowbook) {
39
+ pkg.scripts.flowbook = "flowbook dev";
40
+ scriptsAdded = true;
41
+ }
42
+ if (!pkg.scripts["build-flowbook"]) {
43
+ pkg.scripts["build-flowbook"] = "flowbook build";
44
+ scriptsAdded = true;
45
+ }
46
+
47
+ if (scriptsAdded) {
48
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
49
+ console.log(' ✓ Added "flowbook" and "build-flowbook" scripts to package.json');
50
+ } else {
51
+ console.log(" ✓ Scripts already exist in package.json");
52
+ }
53
+
54
+ // 2. Create example flow file
55
+ const flowsDir = resolve(cwd, "flows");
56
+ const examplePath = resolve(flowsDir, "example.flow.md");
57
+
58
+ if (!existsSync(examplePath)) {
59
+ mkdirSync(flowsDir, { recursive: true });
60
+ writeFileSync(examplePath, EXAMPLE_FLOW);
61
+ console.log(" ✓ Created flows/example.flow.md");
62
+ } else {
63
+ console.log(" ✓ Example flow already exists");
64
+ }
65
+
66
+ console.log("");
67
+ console.log(" Next steps:");
68
+ console.log(" npm run flowbook Start the dev server");
69
+ console.log(" npm run build-flowbook Build static site");
70
+ console.log("");
71
+ }
@@ -0,0 +1,41 @@
1
+ import matter from "gray-matter";
2
+ import { readFileSync } from "node:fs";
3
+ import { relative } from "node:path";
4
+ import type { FlowEntry } from "../types";
5
+
6
+ const MERMAID_BLOCK_RE = /```mermaid\n([\s\S]*?)```/g;
7
+
8
+ export function parseFlowFile(filePath: string, cwd: string): FlowEntry {
9
+ const raw = readFileSync(filePath, "utf-8");
10
+ const { data, content } = matter(raw);
11
+
12
+ const mermaidBlocks: string[] = [];
13
+ let match: RegExpExecArray | null;
14
+ while ((match = MERMAID_BLOCK_RE.exec(content)) !== null) {
15
+ mermaidBlocks.push(match[1].trim());
16
+ }
17
+ // Reset regex lastIndex for next call
18
+ MERMAID_BLOCK_RE.lastIndex = 0;
19
+
20
+ const relPath = relative(cwd, filePath);
21
+ const id = relPath
22
+ .replace(/[/\\]/g, "-")
23
+ .replace(/\.flow(chart)?\.md$/, "");
24
+
25
+ const fileName = relPath
26
+ .replace(/\.flow(chart)?\.md$/, "")
27
+ .split("/")
28
+ .pop();
29
+
30
+ return {
31
+ id,
32
+ title: typeof data.title === "string" ? data.title : fileName ?? "Untitled",
33
+ category:
34
+ typeof data.category === "string" ? data.category : "Uncategorized",
35
+ tags: Array.isArray(data.tags) ? data.tags : [],
36
+ description: typeof data.description === "string" ? data.description : "",
37
+ order: typeof data.order === "number" ? data.order : 999,
38
+ filePath: relPath,
39
+ mermaidBlocks,
40
+ };
41
+ }
@@ -0,0 +1,53 @@
1
+ import type { Plugin } from "vite";
2
+ import { discoverFlowFiles } from "./discovery";
3
+ import { parseFlowFile } from "./parser";
4
+ import type { FlowbookPluginOptions, FlowbookData } from "../types";
5
+
6
+ const VIRTUAL_MODULE_ID = "virtual:flowbook-data";
7
+ const RESOLVED_ID = "\0" + VIRTUAL_MODULE_ID;
8
+
9
+ export function flowbookPlugin(options: FlowbookPluginOptions = {}): Plugin {
10
+ const cwd = options.cwd ?? process.cwd();
11
+
12
+ async function loadFlows(): Promise<FlowbookData> {
13
+ const files = await discoverFlowFiles({ ...options, cwd });
14
+ const flows = files.map((f) => parseFlowFile(f, cwd));
15
+ flows.sort((a, b) => {
16
+ if (a.category !== b.category)
17
+ return a.category.localeCompare(b.category);
18
+ return a.order - b.order;
19
+ });
20
+ return { flows };
21
+ }
22
+
23
+ return {
24
+ name: "flowbook",
25
+
26
+ resolveId(id) {
27
+ if (id === VIRTUAL_MODULE_ID) return RESOLVED_ID;
28
+ },
29
+
30
+ async load(id) {
31
+ if (id === RESOLVED_ID) {
32
+ const data = await loadFlows();
33
+ return `export default ${JSON.stringify(data)}`;
34
+ }
35
+ },
36
+
37
+ handleHotUpdate({ file, server }) {
38
+ if (file.endsWith(".flow.md") || file.endsWith(".flowchart.md")) {
39
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID);
40
+ if (mod) {
41
+ server.moduleGraph.invalidateModule(mod);
42
+ server.ws.send({ type: "full-reload" });
43
+ }
44
+ }
45
+ },
46
+
47
+ configureServer(server) {
48
+ if (cwd !== process.cwd()) {
49
+ server.watcher.add(cwd);
50
+ }
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,56 @@
1
+ import { createServer, build, type InlineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+ import { flowbookPlugin } from "./plugin";
5
+ import { resolve, dirname } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url));
9
+
10
+ function getClientDir(): string {
11
+ // dist/cli.js → ../src/client
12
+ return resolve(__dirname, "..", "src", "client");
13
+ }
14
+
15
+ function createConfig(options: {
16
+ port?: number;
17
+ cwd?: string;
18
+ outDir?: string;
19
+ }): InlineConfig {
20
+ const cwd = options.cwd ?? process.cwd();
21
+ const clientDir = getClientDir();
22
+
23
+ return {
24
+ configFile: false,
25
+ root: clientDir,
26
+ plugins: [
27
+ react(),
28
+ tailwindcss(),
29
+ flowbookPlugin({
30
+ include: ["**/*.flow.md", "**/*.flowchart.md"],
31
+ ignore: ["node_modules/**", ".git/**", "dist/**"],
32
+ cwd,
33
+ }),
34
+ ],
35
+ server: {
36
+ port: options.port ?? 6200,
37
+ },
38
+ build: {
39
+ outDir: resolve(cwd, options.outDir ?? "flowbook-static"),
40
+ emptyOutDir: true,
41
+ },
42
+ };
43
+ }
44
+
45
+ export async function startDevServer(options: { port?: number }) {
46
+ const config = createConfig({ port: options.port });
47
+ const server = await createServer(config);
48
+ await server.listen();
49
+ server.printUrls();
50
+ }
51
+
52
+ export async function buildStatic(options: { outDir?: string }) {
53
+ const config = createConfig({ outDir: options.outDir });
54
+ await build(config);
55
+ console.log(`\n Static site built to ${options.outDir ?? "flowbook-static"}/\n`);
56
+ }
package/src/types.ts ADDED
@@ -0,0 +1,20 @@
1
+ export interface FlowEntry {
2
+ id: string;
3
+ title: string;
4
+ category: string;
5
+ tags: string[];
6
+ description: string;
7
+ order: number;
8
+ filePath: string;
9
+ mermaidBlocks: string[];
10
+ }
11
+
12
+ export interface FlowbookData {
13
+ flows: FlowEntry[];
14
+ }
15
+
16
+ export interface FlowbookPluginOptions {
17
+ include?: string[];
18
+ ignore?: string[];
19
+ cwd?: string;
20
+ }