journey-dsl
v0.8.0
Published
A TypeScript toolkit for defining, compiling, verifying, and running interactive user journeys as state machines.
Readme
journey-dsl
A TypeScript toolkit for defining, compiling, verifying, and running interactive user journeys as state machines.
Author flows in a readable .journey DSL, compile them to typed TypeScript definitions, verify graph correctness and component wiring, and run them with a built-in runtime engine.
Install
npm install journey-dslQuick Start
# Scaffold a new project
npx journey init
# Create a journey
npx journey new daily-checkin --personas user,admin
# Edit journeys/dsl/authored/daily-checkin.journey, then:
npx journey compile
# Verify everything
npx journey checkNew to journey-dsl? The Build an App guide walks you through building a complete onboarding flow from scratch — from writing the
.journeyfile through component wiring, manifests, verification, and runtime setup. It covers the end-to-end concepts that this README's API reference assumes you already know.
The .journey DSL
Journeys are authored as indentation-based .journey files that describe state machines with nodes (screens, decisions, exits) and edges (transitions between them).
journey daily-checkin
description "Daily mood check-in with optional journaling"
persona survivor guardian
effect save_checkin
api POST /api/checkins
req { mood: string!, journalText: string? }
res { checkinId: string!, streakDays: number! }
err 400 invalid_mood
entry mood-screen "How Are You Feeling?"
story checkin--mood-screen
produces { mood: string! }
decision journal-check "Should Journal?"
view journal-screen "Write a Journal Entry"
story checkin--journal-screen
requires { mood: string! }
produces { journalText: string! }
exit success "Complete"
abandon skip "Skip for Now"
mood-screen -> skip close
mood-screen -> journal-check submit
journal-check -> journal-screen @system route guard "mood !== 'great'" "Bad days"
journal-check -> success @system route
journal-screen -> success save_checkinNode Types
| Type | Screen? | Purpose |
| ------------ | ------- | --------------------------------------------- |
| entry | Yes | Journey start point (exactly one per journey) |
| view | Yes | Interactive screen |
| decision | No | Routing logic via guards |
| action | No | Non-interactive processing |
| exit | No | Successful completion (absorbing) |
| abandon | No | User cancellation (absorbing) |
| error | No | Error state (absorbing) |
| subjourney | No | Reference to another journey |
Node Properties
entry welcome "Welcome Screen"
story my-app--welcome # Storybook story ID (entry/view only)
requires { userId: string! } # Data this node needs
produces { userName: string! } # Data this node emitsField types: string, number, boolean, object, array. Append ! for required (default), ? for optional.
Effects
Declare API contracts that edges can reference:
effect create_user
api POST /api/users
req { name: string!, email: string! }
res { userId: string! }
err 400 validation_error
err 409 duplicate_emailEdges
source -> target trigger [+ effect]... [guard "condition" "description"]Trigger types:
- User action (button click):
next,submit,close - System event (async):
@system route,@system data_loaded
Guards add conditions to edges:
decision-node -> path-a @system route guard "role === 'admin'" "Admin only"
decision-node -> path-b @system routeEffects are chained with +:
form -> result submit + save_checkin + send_notificationConfiguration
Create journey.config.ts at your project root:
import { defineConfig } from "journey-dsl";
export default defineConfig({
journeyDirs: ["journeys/dsl/authored"],
definitionsDir: "journeys/definitions",
indexFile: "journeys/definitions/index.ts",
manifestsFile: "stories/manifests.ts",
stories: {
graph: "stories/organisms/JourneyGraph.stories.js",
walkthrough: "stories/organisms/JourneyWalkthrough.stories.js",
visualizer: "stories/visualizer/JourneyVisualizer.stories.js",
},
specs: {
openapi: ["specs/api.yaml"],
graphql: ["specs/schema.graphql"],
},
rootJourney: "app-lifecycle",
});All paths are relative to the config file. Every field has sensible defaults matching the layout above.
CLI
| Command | Description |
| --------------------------------------- | ------------------------------------------------------------------ |
| journey init | Scaffold folder structure, config, and empty barrel/manifest files |
| journey new <name> [--personas p1,p2] | Create .journey template and update barrel + manifest files |
| journey compile [--force] [--dry-run] | Compile .journey files to .generated.ts, update barrel + stories |
| journey validate [--all] [files...] | Static AST and cross-file validation without writing files |
| journey verify [--no-spec] [--strict] [--quiet] [--format=json] | Full graph verification + component wiring checks |
| journey gen:stories | Regenerate Storybook story files from compiled definitions |
| journey check [--no-spec] [--strict] [--quiet] [--format=json] | All-in-one: validate + compile + verify |
| journey explore [file.journey] | Launch interactive visualizer dev server (port 4777) |
| journey watch [--verify] | Watch .journey files, validate on change |
| journey diff <file.journey> | Semantic diff: source vs compiled output |
| journey doctor | Health check for project setup |
| journey install-hooks | Install git pre-commit hook for validation |
CI Integration
Use --strict, --quiet, and --format=json flags for CI pipelines:
# Fail on warnings (not just errors)
journey check --strict
# Machine-readable output for CI parsers
journey verify --format=json
# Suppress info-level diagnostics
journey check --strict --quietInstall a git pre-commit hook that validates staged .journey files before each commit:
journey install-hooksThe hook only validates files in the current commit, so it stays fast.
Explorer
Launch a standalone interactive visualizer without Storybook:
# Explore all journeys in configured directories
journey explore
# Focus on a specific file
journey explore journeys/dsl/authored/daily-checkin.journeyThe explorer starts a Vite dev server on port 4777 with:
- Journey picker sidebar — searchable list of all discovered journeys with node/edge counts and personas
- Drag-and-drop — drop
.journeyfiles onto the explorer to compile and visualize them on the fly - Single-file mode — pass a file argument to open directly to that journey
- No React install needed — React is resolved from the package internals, so your project doesn't need it as a dependency
Programmatic API
Parse & Compile
import { parse, compile, decompile } from "journey-dsl";
const ast = parse(sourceText);
const definition = compile(ast);
const roundTripped = decompile(definition);Validation
import { validateAST, validateCrossFile } from "journey-dsl";
const diagnostics = validateAST(ast, { filePath: "my-flow.journey" });
const crossFileDiags = validateCrossFile(astMap, {
rootJourney: "app-lifecycle",
});Graph Construction
import {
JourneyGraph,
entry,
view,
exit,
edge,
resetEdgeCounter,
} from "journey-dsl";
resetEdgeCounter();
const graph = new JourneyGraph();
graph.addNode(entry({ id: "start", label: "Start", storyId: "app--start" }));
graph.addNode(exit({ id: "done", label: "Done" }));
graph.addEdge(
edge({
source: "start",
target: "done",
trigger: { type: "user_action", action: "next" },
}),
);Verification
import { verify } from "journey-dsl";
const result = verify(graph, {
knownStoryIds: new Set(["app--start"]),
knownJourneyNames: new Set(["daily-checkin"]),
registry: manifestRegistry,
externalRegistry: apiRegistry,
});
// result.diagnostics: VerificationDiagnostic[]
// result.pass: booleanIndividual checks are also exported: checkReachability, checkDeadEnds, checkCycles, checkAbsorbing, checkGuards, checkDataFlow, checkStoryIds, checkApiContracts, checkFormSchemas, checkSubjourneys, checkCircularSubjourneys, checkComplexity.
Component Wiring Checks
Validate that Storybook components match what the journey graph expects. Each check reads specific manifest properties:
| Check | Reads | Element Types | Severity | Behavior |
| --- | --- | --- | --- | --- |
| checkActionCoverage | emitsActions + edge triggers | buttons | error + warning | Bidirectional. Error if edge expects action component doesn't emit. Warning if component emits action no edge consumes. |
| checkDataFieldCoverage | emitsData + node produces | inputs/toggles/etc. | warning | Warns if declared production has no matching emitted data field. Skips inherited fields (those with source). |
| checkInteractiveTracking | interactiveElements[].fieldName | all except buttons | warning | Warns if non-button element lacks fieldName. Buttons skipped — covered by action-coverage. |
| checkAbandonPaths | interactiveElements[] (buttons) | buttons | error | Errors if edge targets abandon node but component has no button for that action. |
| checkDeadButtons | interactiveElements[] (buttons) | buttons | warning | Warns if button emits action no outgoing edge consumes. |
| checkManifestCompleteness | registry entries | — | error + warning | Error if storyId has no registry entry. Warning if entry exists but has no manifest. |
| checkRequiresData | requiresData + node data fields | — | error | Errors if a required requiresData field isn't available from the node's dataRequirements or dataProductions. |
import {
checkActionCoverage,
checkDataFieldCoverage,
checkInteractiveTracking,
checkAbandonPaths,
checkDeadButtons,
checkManifestCompleteness,
checkRequiresData,
} from "journey-dsl";
// All 7 checks are also run automatically by verify() when a registry is provided:
const result = verify(graph, { registry: manifestRegistry, knownStoryIds });Runtime
journey-dsl provides two runtime APIs:
createStateMachine— render-agnostic state machine. Your app owns rendering (React, Svelte, vanilla JS, etc.). This is the recommended approach for most apps.createRuntime— DOM-based runtime that manages rendering via a component registry. Backward-compatible wrapper aroundcreateStateMachine.
createStateMachine (render-agnostic)
import { createStateMachine } from "journey-dsl";
const sm = createStateMachine({
journey: definition,
journeyMap: new Map([
["daily-checkin", dailyCheckin()],
["safety-plan", safetyPlan()],
]),
guardResolvers: {
'mood === "struggling"': (guard, data) => data.mood === "struggling",
},
effectHandlers: {
api_call: async (effect, data) => {
const res = await fetch(effect.payload.endpoint, {
method: effect.payload.method,
});
return res.json();
},
},
onTransition: (state, from, to, edge) => {
console.log(`${from.id} -> ${to.id}`);
},
onValidationError: (nodeId, fieldErrors) => {
// Show validation errors in your UI framework
},
onUnhandledAction: (nodeId, action) => {
console.warn(`No edge for action "${action}" at node "${nodeId}"`);
},
});
// Core API
await sm.dispatch("next"); // Trigger a user_action edge
sm.sendSystemEvent("data_loaded"); // Trigger a system_event edge
sm.mergeData({ name: "Alice" }); // Update journey data store
sm.getState(); // { currentNodeId, data, history }
sm.getCurrentNode(); // Current JourneyNode
sm.getGraph(); // Active JourneyGraph (child graph when inside subjourney)
sm.getContextDepth(); // 0 = root, 1+ = inside nested subjourney
sm.getDiagnostics(); // RuntimeDiagnostic[]
// Subscribe for reactive updates (React, Svelte, etc.)
const unsubscribe = sm.subscribe((state) => {
// Re-render your UI with the new state
renderMyComponent(state.currentNodeId, state.data);
});React integration example:
function JourneyScreen({ sm }: { sm: StateMachineHandle }) {
const [state, setState] = useState(sm.getState());
useEffect(() => sm.subscribe(setState), [sm]);
const node = sm.getCurrentNode();
// Render based on node.id, node.type, state.data, etc.
}createRuntime (DOM-based)
Wraps createStateMachine with DOM rendering, event listeners, and validation UI. Best for vanilla JS apps where you want the runtime to own rendering.
import { createRuntime } from "journey-dsl";
const runtime = createRuntime({
journey: definition,
registry: componentRegistry,
container: document.getElementById("app"),
journeyMap: new Map([
["daily-checkin", dailyCheckin()],
["safety-plan", safetyPlan()],
]),
guardResolvers: {
'mood === "struggling"': (guard, data) => data.mood === "struggling",
},
effectHandlers: {
api_call: async (effect, data) => {
const res = await fetch(effect.payload.endpoint, {
method: effect.payload.method,
});
return res.json();
},
},
onTransition: (state, from, to) => {
console.log(`${from.id} -> ${to.id}`);
},
});
runtime.dispatch("next"); // Take a user_action edge
runtime.getState(); // { currentNodeId, data, history }
runtime.getContextDepth(); // 0 = root journey, 1+ = inside subjourney
runtime.getDiagnostics(); // RuntimeDiagnostic[] (includes post-render-dom, dead-render)The DOM runtime automatically:
- Listens for
journey:actionandjourney:dataCustomEvents on the container - Renders the active node's component from the registry on each transition
- Shows/clears validation errors on
[data-journey-field]elements - Runs post-render DOM validation against component manifests (checks
[data-action], button text,[name],[data-journey-field]attributes) - Detects dead renders (empty container content when manifest declares interactive elements)
Subjourney resolution
Both runtimes support nested subjourneys via a context stack. When a transition lands on a subjourney node:
- The node's
journeyRefis looked up injourneyMap - The parent context (graph, node position, exit mapping) is pushed onto a stack
- The child journey's entry node becomes the active node
- When the child journey reaches an
exitorabandonnode, the runtime pops the stack, maps the exit node ID throughexitMappingto a system event, and dispatches that event in the parent — continuing the parent journey
Data is shared across parent and child (single state.data store). Nesting depth is unlimited. If journeyMap is not provided or the journeyRef can't be resolved, a subjourney-error diagnostic is emitted.
Form Validation Adapters
Wrap Zod or Yup schemas for use as node formSchema:
import { zodSchema, yupSchema } from "journey-dsl";
const schema = zodSchema(
z.object({
email: z.string().email(),
age: z.number().min(18),
}),
);
const result = schema({ email: "[email protected]", age: 25 });
// { valid: true, fieldErrors: {} }API Spec Compliance
Check that journey effects match external OpenAPI/GraphQL specs:
import {
loadOpenApiSpec,
buildRegistry,
checkApiSpecCompliance,
} from "journey-dsl";
const registry = await buildRegistry({
openapi: ["specs/api.yaml"],
graphql: ["specs/schema.graphql"],
});
const diagnostics = checkApiSpecCompliance(graph, registry);Formatting
import {
formatDiagnostic,
formatJourneyResult,
formatSummary,
} from "journey-dsl";Vite Plugin
Import .journey files directly in your app with HMR support:
// vite.config.ts
import { journeyPlugin } from "journey-dsl/vite";
export default {
plugins: [journeyPlugin()],
};// In your app
import { journey } from "./daily-checkin.journey";
const definition = journey(); // JourneyDefinitionComponent Manifests
The verification system checks components against the graph. Define manifests for each Storybook story:
import type { ComponentManifest } from "journey-dsl";
export const manifestRegistry = new Map<
string,
{ manifest: ComponentManifest }
>([
[
"app--welcome",
{
manifest: {
emitsActions: ["next", "close"],
emitsData: ["userName"],
interactiveElements: [
{ type: "button", action: "next", label: "Continue" },
{ type: "input", fieldName: "userName", label: "Your Name" },
],
// Optional: declare inbound data the component needs to render correctly
requiresData: [
{ name: "greeting", type: "string", required: true },
{ name: "showTips", type: "boolean", required: false },
],
},
},
],
]);Interactive element types: button, input, toggle, scale, chip-group, selectable-row, grid. Buttons use the action property (maps to edge triggers); all other types use fieldName (maps to data fields the node produces).
The optional requiresData array declares what data the component expects as props. The checkRequiresData wiring check verifies that required fields are available from upstream dataRequirements or dataProductions on the node.
Verification Diagnostics
Every check produces diagnostics with a severity and category:
Severities: error | warning | info
Categories:
| Category | What it checks |
| ---------------------- | -------------------------------------------- |
| reachability | All nodes reachable from entry |
| dead-ends | All nodes can reach an exit/abandon |
| cycles | Loops have exit edges |
| absorbing | Exit nodes exist |
| guards | Decision guards are exhaustive |
| data-flow | Required data available at each node |
| story-ids | Story IDs exist in registry |
| api-contracts | Effect schemas are consistent |
| api-spec-compliance | Effects match external OpenAPI/GraphQL specs |
| action-coverage | Bidirectional: edge actions match emitsActions, and vice versa (error + warning) |
| data-field-coverage | Node produces fields match emitsData; skips inherited fields (warning) |
| interactive-tracking | Non-button elements have fieldName; buttons excluded (warning) |
| dead-buttons | Button actions in manifest consumed by at least one edge (warning) |
| missing-abandon | Abandon-edge actions have matching buttons in component (error) |
| component-manifest | All story IDs have registry entries with manifests (error + warning) |
| requires-data | Component requiresData fields available on the node |
| post-render-dom | DOM elements match manifest after render (runtime only) |
| dead-render | Container not empty when manifest declares interactives (runtime only) |
| subjourney-refs | Referenced sub-journeys exist |
| circular-subjourneys | Circular subjourney reference cycles |
| complexity | High branching (>5 edges) or large journeys (>30 nodes) |
| unused-journeys | Journeys not reachable from rootJourney |
Visualizer
An interactive React-based journey visualizer with graph layout, simulation, node inspection, and diagnostics. Available as a separate entry point so the core package stays React-free.
# Install peer dependencies (only needed for the visualizer)
npm install react react-dom @xyflow/react dagreMount in any container
import { mountVisualizer } from "journey-dsl/visualizer";
const cleanup = mountVisualizer(
document.getElementById("viz"),
journeyDefinition,
storyRegistry, // optional — enables component preview mode
journeyMap, // optional — Map<name, JourneyDefinition> for subjourney expansion
knownStoryIds, // optional — Set<string> for diagnostics
);
// Later: cleanup() to unmountUse individual components
All visualizer internals are exported for custom layouts:
import {
// Layout
graphToReactFlow,
// React components
JourneyNodeComponent,
JourneyEdgeComponent,
DiagnosticsPanel,
NodeInspector,
SimulationControls,
SearchBar,
ExportMenu,
// Hooks
useSimulation,
useKeyboardShortcuts,
// Analysis
analyzeDataFlow,
} from "journey-dsl/visualizer";
import type {
ViewMode,
StoryRegistry,
JourneyMap,
SimulationState,
} from "journey-dsl/visualizer";Storybook integration
The journey gen:stories command auto-generates Storybook stories that use the visualizer. Each journey gets its own fullscreen story with interactive graph navigation, simulation controls, and live diagnostics.
Features
- Summary / Preview modes — toggle between compact node labels and live component previews
- Simulation — step through journeys manually or auto-play, with accumulated data tracking and form validation status
- Node inspector — click any node to see data requirements, productions, incoming/outgoing edges
- Diagnostics panel — live verification results grouped by node or category, with severity filtering
- Diagnostic badges — red/yellow badges on nodes with verification errors or warnings
- Subjourney expansion — expand subjourney nodes inline to see the child journey's full graph
- Dagre auto-layout — automatic left-to-right graph layout with happy path highlighting
- Search — press
/to search nodes by id, label, type, or storyId - Keyboard shortcuts — Space (play/pause), Arrow Right (step), R (reset), E (zoom to error), F (fit), ? (help)
- Export — Mermaid, SVG, JSON to clipboard; JSON file download
- Coverage overlay — toggle to see visited vs unvisited nodes/edges with color-coded stats
- Data flow analysis — trace field producers/consumers through the graph
Peer Dependencies
All optional:
vite >= 5— for the Vite plugin@apidevtools/swagger-parser >= 12— for OpenAPI spec loadinggraphql >= 16— for GraphQL schema loadingreact >= 18— for the visualizerreact-dom >= 18— for the visualizer@xyflow/react >= 12— for the visualizerdagre >= 0.8— for the visualizer
License
Proprietary. See LICENSE for details.
