Canonical guide for agents (and humans) working in this repository. Covers architecture, build commands, testing, coding conventions, commit hygiene, PR creation, and changesets.
DoenetML is a semantic markup language for building interactive web activities. The system has three layers:
The parser (packages/parser) converts DoenetML XML into a DAST (Document Abstract Syntax Tree). It handles XML parsing, validation, and normalization. Key exports: stringToLezer(), lezerToDast(), normalizeDocumentDast().
The worker (packages/doenetml-worker) runs in a Web Worker and manages document state and computation. It combines:
- JavaScript logic (
packages/doenetml-worker-javascript) for component evaluation, dependency tracking, and state updates - Rust/WASM logic (
packages/doenetml-worker-rust/lib-js-wasm-binding). Reference-resolution paths run in Rust today; the rest of the worker is slowly being transitioned over.
Communication between main thread and worker uses structured messages. The worker is responsible for evaluating components, tracking dependencies (DAG), and managing variants.
The main component (packages/doenetml/src/doenetml.tsx) exports two top-level React components:
DoenetViewer— read-only rendering of DoenetMLDoenetEditor— editor UI with live preview
Both use Redux for state management (packages/doenetml/src/state/) and share a Web Worker instance.
@doenet/prefigure— backend for executing Python/computation-heavy activities@doenet/standalone,@doenet/doenetml-iframe— bundled variants of the main library for different hosting scenarios@doenet/codemirror— code editor integration@doenet/ui-components— reusable UI components (used across doenetml, prefigure, etc.)packages/vscode-extension— VS Code extension with LSP support (packages/lsp)
This is an npm workspace monorepo. Key points:
- All packages build via Vite and Wireit (a task orchestration tool that manages build dependencies)
- Wireit is configured in each
package.json'swireitfield; it automatically rebuilds dependencies when inputs change - Each package can be built/tested independently with
-w <package-name>or-w @scope/package-nameflags
# Start dev server (port 8012, builds doenetml and dependencies)
npm run dev
# Build a single package (rebuilds dependencies automatically via wireit)
npm run build -w @doenet/doenetml
# Build all packages (one-shot, no watch)
npm run build:all
# Format code with Prettier (required before commits)
npm run prettier:format
# Check formatting
npm run prettier:checknpm run docsBuilds prerequisites and serves docs (Nextra-based) at http://localhost:3000.
Read TEST_RUN_INSTRUCTIONS_FOR_AGENTS.md before running tests. Highlights:
- For
@doenet/test-cypress, rebuild before Cypress runs after code changes. - Follow the required sequence:
build -> preview -> cypress run. - Do not rely on an old build or an already-running preview server after source edits.
- The runbook includes non-interactive test commands, Cypress preview-server workflow, fail-fast Cypress commands, and stale-asset troubleshooting.
- Vitest for unit tests, component logic, and utility functions (files:
*.test.ts,*.test.tsx) - Cypress for e2e tests, user interactions, and full rendering (files:
cypress/e2e/*.cy.js) - Tests are grouped; run by group number to parallelize CI
# Run all tests (Vitest only, very slow)
npm run test
# Run Vitest only (all packages except `doenetml-worker-javascript`)
npm run test:all-no-worker-js
# Run targeted Vitest (e.g., prefigure package)
npm run test -w @doenet/prefigure -- --run test/index-api.test.ts
# Run Cypress e2e tests in groups (recommended)
npm run test:e2e-group1
npm run test:e2e-group2
npm run test:e2e-group3
npm run test:e2e-group4
npm run test:e2e-group5
npm run test:codemirror-cypress
# Run a single Cypress spec (fast-fail mode)
npm run test-cypress-fast-fail -w @doenet/test-cypress -- --config specPattern=cypress/e2e/tagSpecific/choiceinput.cy.js- No
privateclass fields or methods. Use an underscore prefix for internal members (_field). - No fire-and-forget promises. Always attach an explicit
.catch(...)handler to intentionally unawaited Promises, or useasync/await. - Prefer function declarations over function-valued variables (
function foo() {}overconst foo = () => {}), unless reassignment or dynamic replacement is required. - Prefer
async/awaitover.then(...)/.catch(...)chains.
- Format changed files with Prettier before committing:
npm run prettier:format - Files that should never be staged or committed (local development / planning notes):
packages/doenetml/dev/testCode.doenet- Untracked
*.mdfiles in the repository root
If you edit these during development they will show as modified, but should not be staged. (packages/doenetml/dev/main.tsx is shared dev-harness infrastructure: intentional changes to it may be committed, but avoid committing throwaway local edits such as the USE_LOCAL_PREFIGURE toggle.)
When an agent composes a commit, credit it with a Co-authored-by: trailer (a blank line before the trailer block) rather than a marketing-style "Generated with …" footer in the commit body. Use the actual model/agent name and its no-reply email — do not hardcode one specific model. For example:
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The goal is simply to record that an agent helped author the change; substitute whatever model or agent is actually composing the commit.
This checkout may use a personal fork as origin and the canonical Doenet/DoenetML as upstream.
- Always base PRs on
upstream/main, notorigin/main. - Push your branch to your fork (
origin), then create the PR targetingDoenet/DoenetML:main. - Preferred method: GitHub CLI (
gh). Themcp_gitkraken_pull_request_createtool requires authentication that may not be available. - Command format:
gh pr create --repo Doenet/DoenetML --base main --head <fork-owner>:<branch>. Replace<fork-owner>with your GitHub username (e.g.,dqnykamp:my-branch). - Before pushing, run
npm run prettier:formaton modified files. - Before creating the PR, confirm only intended files are staged — this repository often has unrelated local work in the tree.
- After creating the PR, verify the branch has been pushed to
originand the PR links to the correct target branch (Doenet/DoenetML:main). - Do not put the issue number in the PR title or in commit subjects on the branch. Squash-merge appends
(#<PR-number>)automatically; a title likefix: ... (#1179)becomesfix: ... (#1179) (#1182)after merge. Reference issues from the PR body instead (e.g.Closes #1179.).
When an agent posts a PR comment, opens an issue, or comments on an issue, end the body with a footer noting that an agent helped compose the text, so reviewers can see at a glance that it was machine-generated. Use the footer appropriate to whatever agent system is actually in use — the point is to flag agent involvement, not to advertise a particular product. For example:
🤖 Generated with [Claude Code](https://claude.com/claude-code)
🤖 Generated with [GitHub Copilot](https://github.com/features/copilot)
Match the footer to the agent that authored the content (and update the link/label accordingly for any other agent system).
This includes review-comment replies posted via gh api ... /replies, full reviews (their summary body and any inline comments) posted via gh api ... /reviews, top-level PR comments, and any gh issue create / gh issue comment invocations. PR and issue descriptions created via gh pr create / gh issue create already get the footer through their templated body — this rule is the catch for the smaller surfaces where it's easy to forget.
The repo uses Changesets for version management. Configuration is in .changeset/config.json. When creating or editing a file under .changeset/, invoke the changesets skill — it documents which @doenet/* packages a changeset must list, which must never appear, how version propagation works (one-directional, forward to consumers only), the private-flag trap, and the changeset file format. Don't pattern-match the package list from a sibling .changeset/*.md without consulting the skill — recurring mistakes (notably adding @doenet/lsp-tools or @doenet/static-assets) have crept in that way.
Located in packages/doenetml/src/state/:
mainslice — document state, component data, update queuekeyboardslice — virtual keyboard focus tracking
Components dispatch actions to update UI state; the worker listens for changes and updates document computation.
The worker receives serialized updates and returns rendered component states. Redux selectors provide derived state to UI components.
- Implement the worker logic in
packages/doenetml-worker-javascript(component class, attributes, state variables, actions) - Implement the UI renderer in
packages/doenetml/src/Viewer/renderersor similar - Register the component in
componentInfoObjectsso the worker knows about it; if the component appears in the DAST/normalized-DAST schema, update the relevant schema definitions too - Add tests in both Vitest and Cypress
- Add a changeset if user-facing (see the
changesetsskill for which packages to list)
- Start
npm run devand inspect the browser console - Use Redux DevTools to inspect state changes
- Run
npm run test -w @doenet/test-cypressto verify e2e tests still pass
- The worker runs heavy computations off the main thread; avoid blocking UI updates
- Large documents or deeply nested variants can cause lag; consider profiling with DevTools
- Rust/WASM changes require a full rebuild of
packages/doenetml-worker-rust; Wireit handles this but can be slow the first time