Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ node_modules/
dist/
coverage/
docs/businessLogic/
docs/test-output/
examples/*/docs/
2 changes: 1 addition & 1 deletion deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
34 changes: 30 additions & 4 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const isDeno = typeof globalThis !== "undefined" && "Deno" in globalThis;
export interface FSLike {
readFile(p: string): Promise<string>;
writeFile(p: string, data: string): Promise<void>;
writeBinaryFile(p: string, data: Uint8Array): Promise<void>;
mkdirp(p: string): Promise<void>;
exists(p: string): Promise<boolean>;
}
Expand Down Expand Up @@ -48,6 +49,7 @@ export async function loadAdapters(): Promise<Adapters> {
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),
};
Expand All @@ -71,6 +73,7 @@ export async function loadAdapters(): Promise<Adapters> {
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),
};
Expand Down
36 changes: 36 additions & 0 deletions src/fsm/example.machine.ts
Original file line number Diff line number Diff line change
@@ -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'
}
}
}
}
};
33 changes: 12 additions & 21 deletions src/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Machine[]> {
// 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);
Expand Down Expand Up @@ -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);
}
}
}
}
151 changes: 151 additions & 0 deletions src/parser.ts
Original file line number Diff line number Diff line change
@@ -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<Machine[]> {
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<Machine[]> {
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;
}
7 changes: 3 additions & 4 deletions src/tpl.ts
Original file line number Diff line number Diff line change
@@ -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);
}