Bun + React (TSX) WYSIWYG editor for DOCX files:
- Display DOCX — render with full WYSIWYG fidelity per ECMA-376 spec
- Insert docxtemplater variables —
{variable}mappings with live preview
Two entry points: src/index.ts (full UI), src/headless.ts (Node.js API).
Client-side only. No backend.
# Fast cycle (use this 95% of the time)
bun run typecheck && npx playwright test --grep "<pattern>" --timeout=30000 --workers=4
# Single test file
bun run typecheck && npx playwright test tests/formatting.spec.ts --timeout=30000
# Only affected test files (use this after targeted changes)
bun run typecheck && npx playwright test tests/formatting.spec.ts tests/demo-docx.spec.ts --timeout=30000 --workers=4
# Full suite (only for final validation — NEVER run casually, 500+ tests)
bun run typecheck && npx playwright test --timeout=60000 --workers=4| Feature Area | Test File | Quick Verify Pattern |
|---|---|---|
| Bold/Italic/Underline | formatting.spec.ts |
--grep "apply bold" |
| Alignment | alignment.spec.ts |
--grep "align text" |
| Lists | lists.spec.ts |
--grep "bullet list" |
| Colors | colors.spec.ts |
--grep "text color" |
| Fonts | fonts.spec.ts |
--grep "font family" |
| Enter/Paragraphs | text-editing.spec.ts |
--grep "Enter" |
| Undo/Redo | scenario-driven.spec.ts |
--grep "undo" |
| Line spacing | line-spacing.spec.ts |
--grep "line spacing" |
| Paragraph styles | paragraph-styles.spec.ts |
--grep "Heading" |
| Toolbar state | toolbar-state.spec.ts |
--grep "toolbar" |
| Cursor-only ops | cursor-paragraph-ops.spec.ts |
--grep "cursor only" |
| Comments sidebar | comments-sidebar.spec.ts |
--grep "Comments sidebar" |
When touching anything in these paths, run comments-sidebar.spec.ts:
packages/react/src/components/UnifiedSidebar.tsxpackages/react/src/components/sidebar/**packages/react/src/hooks/useCommentSidebarItems.tsxpackages/react/src/paged-editor/PagedEditor.tsx→updateSelectionOverlay/onSelectionChangepackages/react/src/components/DocxEditor.tsx→onSelectionChangehandler,expandedSidebarItemstate
Known flaky tests: formatting.spec.ts (bold toggle/undo/redo), text-editing.spec.ts (clipboard ops).
- Never run all 500+ tests at once unless explicitly validating final results
- Use
--timeout=30000(30s max per test) - Use
--workers=4for parallel execution - If a command takes >60s, Ctrl+C and retry with narrower scope
- Avoid
git logwith large outputs; use--oneline -10
Spin up subagents for parallel work using the Task tool:
- Explore agent — codebase exploration, finding files, understanding architecture
- Plan agent — designing implementation approaches
- Bash agent — running commands, git operations
Use when: searching across multiple files, investigating cross-cutting features, running parallel tests, complex research.
reference/quick-ref/wordprocessingml.md # Paragraphs, runs, formatting
reference/quick-ref/themes-colors.md # Theme colors, fonts, tints
reference/ecma-376/part1/schemas/wml.xsd # WordprocessingML schema
reference/ecma-376/part1/schemas/dml-main.xsd # DrawingML schemaOutput must look identical to Microsoft Word. Must preserve: fonts, theme colors, styles, character formatting, tables (borders, shading, merged cells), headers/footers, section layout (margins, page size, orientation).
This editor has TWO separate rendering systems. You MUST understand which one you're working with.
┌──────────────────────────────────────────────────────────────┐
│ HIDDEN ProseMirror (left: -9999px) │
│ - Real editing state (selection, undo/redo, commands) │
│ - Receives keyboard input │
│ - CSS class: .paged-editor__hidden-pm │
│ - Component: src/paged-editor/HiddenProseMirror.tsx │
└──────────────────────────────────────────────────────────────┘
│ state changes trigger re-render ↓
┌──────────────────────────────────────────────────────────────┐
│ VISIBLE Pages (layout-painter) │
│ - What the user actually sees │
│ - Static DOM, re-built from PM state on every change │
│ - Has its own rendering logic (NOT toDOM) │
│ - CSS class: .paged-editor__pages │
│ - Entry: src/layout-painter/renderPage.ts │
└──────────────────────────────────────────────────────────────┘
DOCX file
→ unzip.ts → parser.ts → Document model (types/)
→ toProseDoc.ts → ProseMirror document
→ HiddenProseMirror renders off-screen
→ PagedEditor.tsx reads PM state → layout-painter renders visible pages
→ User edits → PM state updates → layout-painter re-renders
Saving:
PM state → fromProseDoc.ts → Document model → serializer/ → XML → rezip.ts → DOCX
User clicks on visible page → PagedEditor.handlePagesMouseDown() → getPositionFromMouse(clientX, clientY) maps pixel coordinates to a PM document position → hiddenPMRef.current.setSelection(pos) → PM state update → visible pages re-render with selection overlay.
-
Visual rendering bug or editing/data bug?
- Visual only → fix in
layout-painter/ - Editing behavior → fix in
prosemirror/extensions/ - Both → likely need changes in both systems
- Visual only → fix in
-
Which renderer owns the output?
- Visible pages are rendered by
layout-painter/, NOT by ProseMirror'stoDOM - If you fix
toDOMfor a visual bug, the user won't see the change
- Visible pages are rendered by
-
Where does the data come from?
- DOCX XML →
src/docx/parsers →Documentmodel insrc/types/ toProseDoc.tsconverts Document → PM nodesfromProseDoc.tsconverts PM → Document (round-trip for saving)
- DOCX XML →
| What you're debugging | Look here |
|---|---|
| How text/paragraphs appear on screen | layout-painter/renderParagraph.ts |
| How images appear on screen | layout-painter/renderImage.ts |
| How tables appear on screen | layout-painter/renderTable.ts |
| How pages are composed | layout-painter/renderPage.ts |
| How a formatting command works | prosemirror/extensions/ (marks/ and nodes/) |
| How keyboard shortcuts work | prosemirror/extensions/features/BaseKeymapExtension.ts |
| How toolbar reflects selection | prosemirror/plugins/selectionTracker.ts |
| How DOCX XML is parsed | docx/paragraphParser.ts, docx/tableParser.ts, etc. |
| How PM doc is built from parsed data | prosemirror/conversion/toProseDoc.ts |
| Schema (node/mark definitions) | prosemirror/extensions/nodes/, marks/ |
| Table toolbar/dropdown | components/ui/TableOptionsDropdown.tsx |
| Main toolbar | components/Toolbar.tsx |
| CSS for editor | prosemirror/editor.css |
Extensions live in src/prosemirror/extensions/:
nodes/— ParagraphExtension, TableExtension, ImageExtension, etc.marks/— BoldExtension, ColorExtension, FontExtension, etc.features/— BaseKeymapExtension, ListExtension, HistoryExtension, etc.StarterKit.tsbundles all extensions;ExtensionManagerbuilds schema + runtime- Two-phase init:
ExtensionManager.buildSchema()(sync) →initializeRuntime()(after EditorState)
- Toolbar icons must be SVG imports: Icons use inline SVGs in
components/ui/Icons.tsx, NOT a font.<MaterialSymbol name="foo">looks up the icon iniconMap. If you use a name that's not in the map, it renders as raw text. Always add new icons as SVG path components (source: https://fonts.google.com/icons) and register them iniconMap. - Tailwind CSS conflicts: Library CSS is scoped via
.ep-rootbut layout-painter output isn't always protected. Use explicit inline styles on painted elements. - ProseMirror focus stealing: Any mousedown that propagates to the PM view will move the cursor. Dropdown/dialog elements need
onMouseDownwithstopPropagation(). - Never use
require()in extension files — Vite/ESM only.
For visual testing of UI changes:
- Prefer Claude in Chrome (
mcp__claude-in-chrome__*tools) — connects to user's actual Chrome, faster, supports file uploads natively - Use
tabs_context_mcpfirst, then navigate tohttp://localhost:5173/ - Take screenshots with
computeractionscreenshot
Playwright MCP is better for: automated E2E test runs, file upload via browser_file_upload, headless/CI scenarios.
- Type error? Read the actual types, don't guess
- Test failing? Run with
--debugand check console output - Selection bug? Add
console.logingetSelectionRange()to trace - OOXML spec question? Check
reference/quick-ref/or ECMA-376 schemas - Timeout? Kill command, narrow test scope, retry
- Complex task? Spin up a subagent with Task tool
Issue tracker: https://github.com/eigenpal/docx-js-editor/issues
gh issue view <N> --repo eigenpal/docx-js-editor- Read the issue — get description, repro steps, attached files
- Reproduce locally —
bun run dev+ browser atlocalhost:5173 - Investigate root cause — use Debugging Checklist + Key File Map above
- Fix — minimal change, fix the right renderer (layout-painter vs PM)
- Test — add/update Playwright E2E tests (see Test File Mapping)
- Verify —
bun run typecheck+ targeted Playwright tests + visual check - Commit — reference issue number:
fix: ... (fixes #N) - PR —
gh pr createreferencing issue, include screenshots for visual bugs
Before opening any PR, self-review the diff against DRY, KISS, YAGNI:
- DRY — Is the same logic/style repeated across files? Extract shared code.
- KISS — Is the solution more complex than needed? Simpler alternatives?
- YAGNI — Did you add anything not required by the task? Remove it.
- Formatting — Run
bun run formatto ensure Prettier compliance before pushing.
All user-facing strings are translatable via a lightweight i18n system (no external dependencies).
| What | Where |
|---|---|
| Default English strings | packages/react/i18n/en.json |
| Types (auto-derived) | packages/react/src/i18n/types.ts |
| Context + hook | packages/react/src/i18n/LocaleContext.tsx |
| Barrel export | packages/react/src/i18n/index.ts |
LocaleStringstype is auto-derived fromen.jsonviatypeof import— no manual interfaceTranslationKeyis a union of all valid dot-paths (e.g.,"toolbar.bold" | "dialogs.findReplace.title" | ...)<DocxEditor i18n={de} />deep-merges with English defaults (null keys fall back to English)useTranslation()hook returnst(key, vars?)for string lookup with{variable}interpolation
import { useTranslation } from '../i18n'; // adjust path
function MyComponent() {
const { t } = useTranslation();
return <button title={t('toolbar.bold')}>{t('common.apply')}</button>;
}
// With interpolation:
t('dialogs.findReplace.matchCount', { current: 3, total: 15 })
// → "3 of 15 matches"- Add the key + English value to
i18n/en.json(nest by feature area) - Use
t('your.new.key')in the component — types update automatically - Run
bun run i18n:fixto sync community locale files (adds new keys asnull)
| Value | Meaning | Behavior |
|---|---|---|
"Fett" |
Translated | Displayed to user |
null |
Not yet translated | Falls back to English |
| (missing) | Out of sync | CI fails — run bun run i18n:fix |
bun run i18n:new <lang> # scaffold new locale (e.g., bun run i18n:new de)
bun run i18n:status # show translation coverage for all locales
bun run i18n:validate # check all locale files in sync with en.json
bun run i18n:fix # auto-add missing keys as null, remove extrasAlways use t() for user-facing text. Never hardcode English strings in components. After adding new keys to en.json, run bun run i18n:fix to sync all community locale files.
Full contribution guide: docs/i18n.md
Releases follow the canonical changesets/action@v1 flow: every code-touching PR drops a .changeset/*.md describing its change; pushes to main open or update a chore: release PR aggregating those entries; merging that PR publishes to npm.
| Package | Path | Published? |
|---|---|---|
@eigenpal/docx-js-editor |
packages/react |
✅ |
@eigenpal/docx-editor-agents |
packages/agent-use |
✅ |
@eigenpal/docx-core |
packages/core |
❌ private |
@eigenpal/docx-editor-vue |
packages/vue |
❌ private / community |
The two published packages are in a fixed group in .changeset/config.json — they always ship the same version. A changeset only needs to declare the bump for one; the other follows automatically.
bun changeset # interactive — pick bump + write a one-line summary
git add .changeset/*.md
# ... commit with the rest of your PRSkip only for test-only / docs-only / CI-only PRs (no published-package code changed). When in doubt, add one — an extra patch entry is harmless; a missing entry ships invisibly.
The frontmatter must use the full npm package name, not the repo name or a guess:
---
'@eigenpal/docx-js-editor': patch
---Only @eigenpal/docx-js-editor needs to be listed — the fixed group in .changeset/config.json auto-bumps @eigenpal/docx-editor-agents to match. Always run bun changeset rather than hand-writing the file; the interactive prompt picks valid names from the workspace. A wrong name (e.g. bare docx-editor) does not fail the PR's CI but crashes the post-merge Release workflow with Found changeset X for package Y which is not in the workspace, blocking all releases until someone edits the bad changeset.
- patch — bug fix, internal refactor, no public API change. Default — use this unless you have a clear reason not to.
- minor — new public API (additive, backward compatible)
- major — breaking change to existing public API
changeset version resolves to the highest bump across all pending changesets, so a single minor from another PR will correctly bump everything. You don't need to coordinate.
The summary you write (Add foo prop to DocxEditor) goes verbatim into CHANGELOG.md, so write it for the consumer of the package — not for the team. Avoid PR/issue numbers in the body; the changelog tooling can backlink them automatically when needed.
- Look for an open PR titled
chore: releaseonmain. The bot opens it automatically the first time a changeset lands; subsequent changeset-bearing PRs update the same PR with the latest bumps and CHANGELOG entries. - Review the PR. It shows: version bumps in
package.jsons, new CHANGELOG sections, and the.mdfiles being drained from.changeset/. Treat it like any other PR — CI runs on it. - Merge it. Standard merge. No bypass, no manual workflow trigger needed.
- Wait ~3 minutes. The post-merge workflow run sees an empty changeset queue, runs
changeset publishagainst npm via OIDC Trusted Publishing (noNPM_TOKEN), creates per-package git tags (@eigenpal/docx-js-editor@X.Y.Z), and creates a GitHub Release with the new CHANGELOG section.
That's the entire release. One PR merge.
| Situation | What to do |
|---|---|
| Hotfix, ship now | Land the fix PR with a patch changeset → release PR auto-updates → merge it. |
| Several PRs, ship together | All landed PRs aggregated into one release PR. Merge once, one coordinated release. |
| Forgot a changeset on a merged PR | Open a tiny follow-up PR with just .changeset/foo.md, or edit the release PR's frontmatter inline. |
| Not ready to release yet | Don't merge the release PR. It keeps updating as new PRs land. |
| Publish step crashed after PR merged | Re-run the workflow manually (workflow_dispatch is kept for this). changeset publish is idempotent. |
| Need to force a major bump for marketing | Edit a pending changeset's frontmatter from minor → major before merging. |
| No pending changesets | No release PR opens. Nothing to ship. |
| Where | What |
|---|---|
| npmjs.com | Trusted Publisher configured for both packages → repo eigenpal/docx-editor, workflow release.yml |
package.json |
"publishConfig": { "access": "public" } on each published package (already set) |
.changeset/config.json |
"access": "public"; fixed: [["@eigenpal/docx-js-editor", "@eigenpal/docx-editor-agents"]] (already set) |
| GitHub perms | Settings → Actions → General → Workflow permissions = Read and write, Allow GitHub Actions to create and approve pull requests = on |
| GitHub secrets | SLACK_WEBHOOK_URL (optional — release notifications) |
bun run version-packages # consume .changeset/*.md → bump versions + write CHANGELOGs
bun run release # build + changeset publish (needs NPM_TOKEN locally)The published-from-CI flow is preferred because it uses OIDC (no long-lived npm token needed) and produces npm provenance.
- Don't push directly to
mainwith achore: releasecommit by hand. That bypasses the release PR, skips CI, and confuses the changesets/action state machine on the next push. - Don't manually delete
.changeset/*.mdfiles outside ofchangeset version. They're the single source of truth for what's pending. - Don't edit
CHANGELOG.mdby hand. It's auto-generated from changesets; manual edits get clobbered on the next release. - Don't edit the
versionfield inpackage.jsonby hand.changeset versionowns it. - Don't open changesets for
@eigenpal/docx-coreor@eigenpal/docx-editor-vue— they're listed in.changeset/config.jsonignore. - Don't hand-write the package name in changeset frontmatter. Use
bun changesetso the package list comes from the workspace. A baredocx-editor(or any name not inpackage.json) crashes the Release workflow post-merge.
- Client-side only. No backend.
- Toolbar icons are Material Symbol fonts (same as Google Docs), saved locally as SVGs.
- Save screenshots to
screenshots/folder