This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Mathflow is a dependency-free TypeScript library that evaluates math expressions written in natural notation (2x + 3(y - 1)), and renders them as HTML or LaTeX. Published as @mathflowjs/mathflow.
pnpm dev # Vite demo (index.html + demo/main.ts)
pnpm test # vitest run
pnpm exec vitest run tests/lexer.test.ts # single file
pnpm exec vitest run -t 'implicit' # single test by name
pnpm lint # eslint src tests + prettier --check
pnpm lint:fix # eslint --fix + prettier --write
pnpm build # unbuild -> dist/ (ESM + CJS + .d.ts)tsc --noEmit currently fails on vitest's own type declarations (moduleResolution: "node" vs vite/module-runner) — pre-existing, not caused by your changes. Type errors in src/ surface through pnpm build and the editor instead.
Contribution conventions (style, commit format, PR expectations) live in AGENTS.md.
Pipeline, orchestrated by src/solve.ts:
code ──tokenize(ctx, code)──> IToken[] ──parse(tokens)──> AST ──evaluate(ctx, node, solution)──> value + solution.steps
└── renderTokensAsHTML / renderTokensAsLaTeX
createContext() (src/context.ts) is the single entry point for consumers: it holds variables, constants, functions, and preferences (precision, fractionDigits, angles), and exposes solve / solveBatch / renderAsHTML / renderAsLaTeX bound to that context. Every stage takes ctx explicitly — there is no global state.
Things that are non-obvious from any single file:
- The lexer is context-dependent.
tokenizeclassifies an identifier asTOKEN.FUNCTIONonly ifctx.functions.has(name). Custom functions must be registered on the context before the expression is tokenized, otherwise they lex as plain identifiers — and sincename(then becomes implicit multiplication, a missing registration surfaces as a syntax error, not a missing-function one. This is whytests/functions.test.tsexercises every builtin throughctx.solve(). - Implicit multiplication is a lexer concern, not a parser one.
expandImplicitMultiplicationsplices a synthetic*token (markedimplicit: true) between e.g.)(,2x,2sin. The parser sees fully explicit token streams. Add new implicit-product cases there, not inparseFactor. - Renderers consume tokens, not the AST.
src/render/html.tsandsrc/render/latex.tswalkIToken[], so they preserve source order and must decide themselves what to do withimplicittokens. Rendering never evaluates. - Solutions come from reducing the tree, not from logging strings.
evaluateinsrc/evaluator/index.tsfirst callsresolve(names → values, and where an unknown variable throws), then loopsreduceOnceto a fixpoint. EachreduceOncecollapses every sub-expression whose operands are already literals, so one call is one step. Each intermediate tree is rendered withstringify(src/evaluator/solution.ts) into thecreateSolutionStack()it is given;solveBatchpackages that as{ value, solution }. Anything that produces a step must go through a node type the reducer handles — that uniformity is the point, an earlier design instrumented node types individually and silently dropped multi-argument calls and unary expressions. - A unary over a literal is folded, not reduced.
-3is notation for a negative number, sosigned()collapses it duringresolverather than spending a step on it. Without that,-(3+4)emits-7twice. stringifyderives parentheses from precedence, including that^is right-associative and that a negative literal needs wrapping as a right operand (2 - (-3)). Steps are valid mathflow source and round-trip throughtokenize— the demo andrenderAsHTMLrely on that.- Precision is applied per node, not once at the end.
toNumberrunstoPrecision/toFixedon every literal and intermediate result, so each printed step's arithmetic checks out on its own. That is also why1/3*3is0.9999atfractionDigits: 4. Changing where it's called changes rounding across the whole suite. - Errors are plain objects, not
Errorinstances.createError(src/error/index.ts) returns{ name, type, message, suggestion }withtypefrom theERRORSenum. Never useinstanceof Erroron them. ThesafeXwrappers insrc/safe.tsare one-liners oversafeExecutor(alsosrc/error/index.ts), which turns a throwing call intoISafeResult({ data, error }). src/index.tsis the public surface, and it does not break. Anything not re-exported there is internal, regardless of visibility. Never remove or rename an export, an option, or a parameter that shipped onmaster, and don't add exports either for now — keep new helpers internal.tests/compat.test.tspins the exact runtime export list. Behaviour fixes are fine; API changes are not. Built-ins are registered insrc/functions/index.ts, oneinitX()per domain module; trig functions close overctx.preferencesto honourangles: 'rad' | 'deg'(only true angles are converted — an inverse function takes a ratio, a hyperbolic one a plain real).- Every value is a
number, deliberately. No arrays, matrices, complex numbers or fractions; list-shaped functions are variadic (mean(1, 2, 3)), and predicates return1/0.TODO.mdrecords what this rules out and why — check it before adding a function that wants a new value type.
dist/ is build output — never edit it. Releases go through release-it (conventional-changelog, master only, manual workflow_dispatch in CI).