diff --git a/.gitignore b/.gitignore index e9470d2..620c5ee 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ node_modules/ dist/ coverage/ docs/businessLogic/ +docs/test-output/ examples/*/docs/ diff --git a/deno.json b/deno.json index 0f64292..778f447 100644 --- a/deno.json +++ b/deno.json @@ -17,6 +17,6 @@ "@deno/dnt": "jsr:@deno/dnt@^0.42.3", "@std/fs": "jsr:@std/fs@^1", "@std/path": "jsr:@std/path@^1", - "eta": "npm:eta@^3.4.0" + "handlebars": "npm:handlebars@^4.7.8" } } diff --git a/deno.lock b/deno.lock index 3649add..bb5226f 100644 --- a/deno.lock +++ b/deno.lock @@ -12,7 +12,7 @@ "jsr:@ts-morph/bootstrap@0.27": "0.27.0", "jsr:@ts-morph/common@0.27": "0.27.0", "npm:@types/node@*": "24.2.0", - "npm:eta@^3.4.0": "3.5.0" + "npm:handlebars@^4.7.8": "4.7.8" }, "jsr": { "@david/code-block-writer@13.0.3": { @@ -68,11 +68,37 @@ "undici-types" ] }, - "eta@3.5.0": { - "integrity": "sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==" + "handlebars@4.7.8": { + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dependencies": [ + "minimist", + "neo-async", + "source-map", + "wordwrap" + ], + "optionalDependencies": [ + "uglify-js" + ], + "bin": true + }, + "minimist@1.2.8": { + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, + "neo-async@2.6.2": { + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "source-map@0.6.1": { + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "uglify-js@3.19.3": { + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "bin": true }, "undici-types@7.10.0": { "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==" + }, + "wordwrap@1.0.0": { + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" } }, "workspace": { @@ -80,7 +106,7 @@ "jsr:@deno/dnt@~0.42.3", "jsr:@std/fs@1", "jsr:@std/path@1", - "npm:eta@^3.4.0" + "npm:handlebars@^4.7.8" ] } } diff --git a/runtime.ts b/runtime.ts index eaeebac..d12d874 100644 --- a/runtime.ts +++ b/runtime.ts @@ -6,6 +6,7 @@ export const isDeno = typeof globalThis !== "undefined" && "Deno" in globalThis; export interface FSLike { readFile(p: string): Promise; writeFile(p: string, data: string): Promise; + writeBinaryFile(p: string, data: Uint8Array): Promise; mkdirp(p: string): Promise; exists(p: string): Promise; } @@ -48,6 +49,7 @@ export async function loadAdapters(): Promise { const fs: FSLike = { readFile: p => Deno.readTextFile(p), writeFile: (p, d) => Deno.writeTextFile(p, d), + writeBinaryFile: (p, d) => Deno.writeFile(p, d), mkdirp: p => ensureDir(p), exists: p => Deno.stat(p).then(()=>true).catch(()=>false), }; @@ -71,6 +73,7 @@ export async function loadAdapters(): Promise { const fs: FSLike = { readFile: p => fsP.readFile(p, "utf8"), writeFile: (p, d) => fsP.writeFile(p, d, "utf8").then(()=>{}), + writeBinaryFile: (p, d) => fsP.writeFile(p, d).then(()=>{}), mkdirp: p => fsP.mkdir(p, { recursive: true }).then(() => {}), exists: p => fsP.stat(p).then(() => true).catch(() => false), }; diff --git a/src/fsm/example.machine.ts b/src/fsm/example.machine.ts new file mode 100644 index 0000000..9a85b16 --- /dev/null +++ b/src/fsm/example.machine.ts @@ -0,0 +1,36 @@ +/** + * Example state machine for testing + */ +export const trafficLightMachine = { + id: 'trafficLight', + initial: 'red', + states: { + red: { + description: 'Stop - vehicles must wait', + on: { + TIMER: { + target: 'green', + description: 'Light changes to green' + } + } + }, + yellow: { + description: 'Caution - prepare to stop', + on: { + TIMER: { + target: 'red', + description: 'Light changes to red' + } + } + }, + green: { + description: 'Go - vehicles may proceed', + on: { + TIMER: { + target: 'yellow', + description: 'Light changes to yellow' + } + } + } + } +}; diff --git a/src/generate.ts b/src/generate.ts index 294671d..1af723c 100644 --- a/src/generate.ts +++ b/src/generate.ts @@ -2,28 +2,10 @@ import type { Adapters } from "../runtime.ts"; import type { StateDocConfig } from "../mod.ts"; import { renderTemplate } from "./tpl.ts"; - -type Machine = { - name: string; desc: string; slug: string; - states: { name: string; desc: string; slug: string; on: { event: string; target: string }[] }[]; -}; - -// Placeholder parser. Replace with TS compiler API extraction. -function fakeParseMachines(_cfg: StateDocConfig, _adapters: Adapters): Promise { - // Generates one demo machine so the pipeline runs end-to-end. - return Promise.resolve([{ - name: "demoMachine", - desc: "Demo machine parsed placeholder", - slug: "demo-machine", - states: [ - { name: "idle", desc: "Waiting", slug: "idle", on: [{ event: "START", target: "running"}] }, - { name: "running", desc: "Working", slug: "running", on: [{ event: "STOP", target: "idle"}] } - ] - }]); -} +import { parseMachines } from "./parser.ts"; export async function generateDocs(cfg: StateDocConfig, adapters: Adapters) { - const machines = await fakeParseMachines(cfg, adapters); + const machines = await parseMachines(cfg, adapters); // Ensure target dirs await adapters.fs.mkdirp(cfg.target); @@ -59,6 +41,15 @@ export async function generateDocs(cfg: StateDocConfig, adapters: Adapters) { st.on.map((tr: { event: string; target: string }) => ` ${st.slug} --> ${tr.target}: ${tr.event}`) ) ]; - await adapters.fs.writeFile(adapters.join(mdir, "diagram.mmd"), lines.join("\n")); + const mermaidText = lines.join("\n"); + await adapters.fs.writeFile(adapters.join(mdir, "diagram.mmd"), mermaidText); + + // Export PNG if configured (feature not yet implemented, will be null) + if (cfg.visualization?.exportPng) { + const pngData = await adapters.mermaid.toPng(mermaidText); + if (pngData) { + await adapters.fs.writeBinaryFile(adapters.join(mdir, "diagram.png"), pngData); + } + } } } diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..17f169e --- /dev/null +++ b/src/parser.ts @@ -0,0 +1,151 @@ +import type { Adapters } from "../runtime.ts"; +import type { StateDocConfig } from "../mod.ts"; + +export type Machine = { + name: string; + desc: string; + slug: string; + states: { name: string; desc: string; slug: string; on: { event: string; target: string }[] }[]; +}; + +function slugify(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); +} + +/** + * Parse a machine object extracted from source code + */ +function parseMachineObject(machineObj: any, varName: string): Machine { + const name = machineObj.id || varName; + const slug = slugify(name); + + // Flatten all states (including nested ones) + const states: Machine['states'] = []; + + function processStates(statesObj: any, prefix = '') { + if (!statesObj || typeof statesObj !== 'object') return; + + for (const [stateName, stateConfig] of Object.entries(statesObj)) { + const config = stateConfig as any; + const fullName = prefix ? `${prefix}.${stateName}` : stateName; + const desc = config.description || config.desc || ''; + const on: { event: string; target: string }[] = []; + + if (config.on) { + for (const [event, transition] of Object.entries(config.on)) { + const t = transition as any; + let target = ''; + + if (typeof t === 'string') { + target = t; + } else if (t && typeof t === 'object' && t.target) { + target = t.target; + } + + // Remove machine ID prefix from target (e.g., #shoppingCart.active -> active) + target = target.replace(/^#[^.]+\./, ''); + + if (target) { + on.push({ event, target }); + } + } + } + + states.push({ + name: fullName, + desc, + slug: slugify(fullName), + on + }); + + // Process nested states + if (config.states && typeof config.states === 'object') { + processStates(config.states, fullName); + } + } + } + + if (machineObj.states) { + processStates(machineObj.states); + } + + return { + name, + desc: `State machine for ${name}`, + slug, + states + }; +} + +/** + * Extract machine definitions from a JavaScript/TypeScript file + * This is a runtime evaluation approach - we import the file and extract exported machines + */ +async function extractMachinesFromFile(filePath: string, _adapters: Adapters): Promise { + try { + // Convert to absolute file:// URL + let importPath = filePath; + if (!importPath.startsWith('file://') && !importPath.startsWith('http://') && !importPath.startsWith('https://')) { + // Check if Deno is available + const isDeno = typeof globalThis !== "undefined" && "Deno" in globalThis; + + // Make it absolute if it's not already + if (!importPath.startsWith('/')) { + if (isDeno) { + importPath = `${(globalThis as any).Deno.cwd()}/${importPath}`; + } else { + const process = await import("node:process"); + importPath = `${process.cwd()}/${importPath}`; + } + } + importPath = `file://${importPath}`; + } + + // Import the file as a module + const module = await import(importPath); + const machines: Machine[] = []; + + // Look for exported objects that look like state machines + for (const [exportName, exportValue] of Object.entries(module)) { + if (exportValue && typeof exportValue === 'object') { + const obj = exportValue as any; + + // Check if it looks like a state machine (has id or states property) + if (obj.states || obj.id) { + try { + const machine = parseMachineObject(obj, exportName); + machines.push(machine); + } catch (e) { + console.warn(`Warning: Failed to parse ${exportName} in ${filePath}:`, e); + } + } + } + } + + return machines; + } catch (e) { + console.warn(`Warning: Failed to import ${filePath}:`, e); + return []; + } +} + +/** + * Parse machines from all files matching the configuration + */ +export async function parseMachines(cfg: StateDocConfig, adapters: Adapters): Promise { + const globs = cfg.globs || ['**/*.machine.ts', '**/*.machine.js']; + const files = await adapters.glob.glob(cfg.source, globs); + + const allMachines: Machine[] = []; + + for (const file of files) { + const machines = await extractMachinesFromFile(file, adapters); + allMachines.push(...machines); + } + + if (allMachines.length === 0) { + console.warn('No state machines found. Check your source path and globs configuration.'); + } + + return allMachines; +} diff --git a/src/tpl.ts b/src/tpl.ts index 77485c7..9ae5cc3 100644 --- a/src/tpl.ts +++ b/src/tpl.ts @@ -1,7 +1,6 @@ -import { Eta } from "eta"; +import Handlebars from "handlebars"; export function renderTemplate(tpl: string, data: object): string { - const eta = new Eta(); - return eta.renderString(tpl, data) as string; - + const template = Handlebars.compile(tpl); + return template(data); }