Skip to content

CS-10572: ProseMirror WYSIWYG editor for RichMarkdownField#4349

Draft
lukemelia wants to merge 51 commits intomainfrom
cs-10572-prosemirror-integration-schema-and-basic-editing-surface
Draft

CS-10572: ProseMirror WYSIWYG editor for RichMarkdownField#4349
lukemelia wants to merge 51 commits intomainfrom
cs-10572-prosemirror-integration-schema-and-basic-editing-surface

Conversation

@lukemelia
Copy link
Copy Markdown
Contributor

Summary

  • Add ProseMirror-based WYSIWYG editing surface for RichMarkdownField, following the established lazy-loading pattern (KaTeX, Mermaid, Monaco)
  • New prosemirror-context.ts lazy-loaded module: schema with 14 node types (including boxel_card_atom/boxel_card_block placeholders), 4 mark types, hand-rolled markdown parser and serializer
  • New ProseMirrorEditor Glimmer component in base package: ember-concurrency lazy loading, ember-modifier for EditorView, keyboard shortcuts (bold, italic, code, undo/redo, list operations), debounced save
  • RichMarkdownField edit template now renders ProseMirror instead of plain textarea
  • 25 tests covering schema, parsing, serialization, round-trip, EditorView DOM rendering, and lazy loading
  • Playground card in experiments realm for manual testing

Test plan

  • 25 integration tests pass (ember test --filter prosemirror-context)
  • Manual: open a card with RichMarkdownField in edit mode, verify ProseMirror editor appears
  • Manual: type text, apply bold (Cmd+B), italic (Cmd+I), undo (Cmd+Z)
  • Manual: create bullet/ordered lists, blockquotes, code blocks
  • Manual: verify content persists after saving (serializes to markdown)
  • Manual: verify card atom/block placeholders render as inert DOM
  • Manual: verify editor lazy-loads (not in initial bundle)

🤖 Generated with Claude Code

lukemelia and others added 30 commits April 6, 2026 11:27
Support `:card[URL]` (inline) and `::card[URL]` (block) Boxel Flavored
Markdown syntax in MarkdownDef files. Inline references render as atom-format
cards, block references as embedded-format. The parsing utility is keyword-
generic to support future `:file` syntax (CS-10583).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Import BaseDef instead of missing CardDef in markdown.gts
- Wrap querySelectorAll with Array.from() for NodeListOf iteration
- Use code submode (not card stack) to render file defs in tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Import both BaseDef and CardDef from card-api (CardDef has .id)
- Remove early-return in captureCardSlots when linkedCards is empty,
  so fallback text is still set on unresolvable BFM placeholders

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The modifier was setting el.textContent for unresolved BFM references,
but Glimmer re-renders would wipe the text. Now the marked extensions
include the URL as text content in the placeholder elements, so fallback
text is part of the initial HTML and survives re-renders.

Also fixes prettier formatting in bfm-card-references.ts and unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Strip text content from card-type BFM elements in renderedHtml using
DOMParser so no URL text is ever in the DOM. The modifier uses a
_modifierHasRun flag to skip fallback injection on the first run (when
linkedCards is still loading as an empty []), and only injects fallback
text on subsequent runs for truly unresolvable refs.

Also adds MutationObserver + scheduleOnce to handle back-navigation
re-rendering, and a new acceptance test for that scenario.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The _modifierHasRun flag prevented fallback text from appearing when
the modifier only runs once (all card refs unresolvable). Since text
content is already stripped from the HTML, the afterRender fallback
injection is brief enough to not cause a visible flash.

Also adds @ember/runloop to the expected card references in the
indexing tests since the markdown template now imports scheduleOnce.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Document why scheduleOnce, MutationObserver, pendingUpdate, and
didChange are all load-bearing in the captureCardSlots modifier.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Restore _modifierHasRun gate to skip fallback text injection on the
  first modifier run (when linkedCards is still loading as empty [])
- Clean up leftover fallback text nodes when a card later resolves
- Guard fallback injection with textContent !== url to prevent infinite
  MutationObserver loop
- Update fallback test to waitUntil the text appears (second modifier
  run is async)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The loadLinksForResource call caused timeouts for non-markdown files
and relativizeDocument broke adoptsFrom URL assertions. The client
deserialization pipeline also can't handle included resources in
file-meta docs. Instead, cardReferenceUrls is stored in indexed
attributes via extractAttributes, letting client-side queries work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eouts

The new card-refs.md test fixture was missing from the expected directory
listing assertion. Also increased waitUntil timeouts in the back-navigation
test from the default 1000ms to 5000ms to avoid CI flakiness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The back-navigation test clicked the inline card reference before the
Overlays component had bound its click handler. ElementTracker schedules
afterRender, then Overlays re-renders and binds handlers — two render
cycles after the card component appears. Wait for cursor:pointer (set
by Overlays) as a signal the handler is ready. Also add card-refs.md
to the directory listing assertion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The cursor:pointer waitUntil was at 5000ms but CI runners under load
(widespread Failed to fetch errors for icons/modules) can need longer
for the two render cycles before the Overlays click handler binds.
Increase to 10000ms to match the other waitFor timeouts in this test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The back-navigation test has three waitUntil calls — overlay handler
bind, URL bar after click, and URL bar after history.back(). All
involve async card search queries that can be slow on CI runners under
load. Bumped all three from 5-10s to 15s and added timeoutMessage to
each so future failures identify which step timed out.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Code-mode navigation uses replaceState (not pushState), so
history.back() has no entry to return to — causing the test to
time out on CI and close the browser tab locally. Navigate back
via the URL bar instead, which still tests that card references
re-render correctly after returning to a markdown file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix document-order preservation in extractBfmReferences(): collect all
  matches with their index and sort before deduplicating, instead of
  grouping by keyword which reorders references.
- Handle multi-backtick inline code spans (e.g. ``code``) in extraction
  regex, not just single-backtick spans.
- Update doc comment for bfmExtensionsForKeyword() to reflect that
  placeholders include URL as fallback text content.
- Add test for multi-backtick inline code case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Card IDs in the index don't include .json, but users may write
:card[http://example.com/my-card.json] in markdown. Strip the .json
extension in extractBfmReferences() (so the linksToMany query matches)
and in the resolveUrl helper (so slot matching finds the card).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… heading IDs, mermaid

Register 5 community marked extensions and 1 custom extension for BFM
(Boxel Flavored Markdown) Layer 3 features:

- marked-alert: GFM alert/admonition syntax (> [!NOTE], > [!WARNING], etc.)
- marked-footnote: footnote syntax ([^1] references + definitions)
- marked-extended-tables: colspan/rowspan in markdown tables
- marked-gfm-heading-id: auto-generated slug IDs on headings
- bfm-math (custom): LaTeX math placeholders ($...$, $$...$$) that emit
  lightweight placeholder HTML instead of bundling KaTeX (~268KB min +
  1.1MB fonts), enabling lazy client-side rendering
- Mermaid code blocks (```mermaid) emit a <pre class="mermaid"> placeholder
  for client-side rendering

Each feature has smoke tests covering both markedSync output and
DOMPurify sanitization pass-through.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The gfmHeadingId extension now adds id attributes to headings,
so test assertions need to match the new output format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Lazy-load Mermaid.js (~2MB) to render diagram placeholders emitted by
the BFM markdown pipeline. Mermaid is never included in the initial
bundle — it's loaded on-demand only when pre.mermaid elements are
present in the rendered markdown.

Implementation:
- Add `mermaid` dependency to host package (webpack code-splits it)
- Register `__loadMermaid` global loader in application route (same
  pattern as Monaco)
- Add `renderMermaidDiagrams` modifier to markdown template that
  queries pre.mermaid elements, lazy-loads mermaid, and calls
  mermaid.run() with securityLevel: 'strict'
- Add CSS for rendered diagrams (transparent background, centered,
  responsive SVG sizing)
- Add acceptance test verifying mermaid code blocks render as SVG

On failure, the placeholder <pre> with raw diagram source remains
visible as readable fallback text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Lazy-load KaTeX to render math placeholders emitted by the BFM
markdown pipeline. KaTeX JS (~268KB) is loaded on-demand only when
.math-placeholder elements are present in the rendered markdown.

Implementation:
- Add `katex` dependency to host package (webpack code-splits the JS)
- Import katex.min.css statically in app.ts (24KB, needed for font
  declarations so glyphs render correctly on first paint)
- Register `__loadKatex` global loader in application route (same
  pattern as Monaco/Mermaid)
- Add `renderMathExpressions` modifier to markdown template that
  queries .math-placeholder elements, lazy-loads KaTeX, and calls
  katex.render() with throwOnError: false for graceful degradation
- Add acceptance test verifying math placeholders render with KaTeX

On failure, the placeholder with raw LaTeX source ($E = mc^2$)
remains visible as readable fallback text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
KaTeX CSS references both .woff2 and .woff font files. The webpack
config only had a rule for .woff2, causing parse failures on .woff
files. Widen the regex to match both extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Exercises all Layer 3 features: card references, GFM alerts, math/LaTeX,
mermaid diagrams, footnotes, extended tables, heading IDs, and code blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Split logical && in assert.true into separate assertions (qunit/no-assert-logical-expression)
- Fix prettier formatting across test files
- Use eslint-disable block for require() in marked-sync.ts to survive
  prettier line-wrapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Once

The modifiers were creating new arrow functions on each invocation and
passing null as the scheduleOnce target. This differs from the working
captureCardSlots pattern which uses this (component instance) and a
stable method reference. The changes:

- Use this as scheduleOnce target (consistent with captureCardSlots)
- Use stable method references (_renderMath, _renderMermaid) for proper
  scheduleOnce deduplication
- Query DOM fresh inside async methods instead of capturing a NodeList
  that may become stale during async loading
- Check el.isConnected before rendering each node

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lukemelia and others added 17 commits April 6, 2026 11:28
…g ids

The modifier-based DOM mutation approach for KaTeX and Mermaid was
unreliable because Glimmer re-renders overwrite imperative DOM changes.
Refactored to process math and mermaid at the HTML-string level inside
the `renderedHtml` getter so autotracking sees the final content.

Also fixes:
- DOMPurify stripping heading ids by adding `user-content-` prefix to
  `gfmHeadingId()` (avoids DOM clobbering protection for names like
  "title", "body", "links")
- Missing type declaration for `marked-gfm-heading-id` (CJS resolution
  in `nodenext` mode)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Deep path imports like `@cardstack/runtime-common/bfm-mermaid-render`
are not resolved by the card runtime's module loader. Move imports to
the main barrel export so they resolve correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…efix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The declaration in runtime-common/global.d.ts wasn't being resolved on
CI due to pnpm's module layout differing from the local workspace. Add
the declaration directly to local-types where all packages find it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The declare module in local-types/index.d.ts was treated as a module
augmentation (not an ambient declaration) because index.d.ts has
top-level imports making it a module. Ambient declarations must live
in script files with no top-level import/export.

Created a separate marked-gfm-heading-id.d.ts with only the declare
module block (using import() type expressions to avoid top-level
imports), following the same pattern as matrix-js-sdk.d.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These packages declare "type": "module" which triggers TS1479 under
Node16/nodenext module resolution since runtime-common lacks
"type": "module". Use require() with type assertions, matching the
existing pattern for marked-extended-tables.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
marked.use({ renderer }) wraps the previous renderer in a new closure on
each call. Since markedSync() called use() on every invocation, this created
an ever-growing closure chain that leaked memory (~MB per test), eventually
crashing Chrome on CI. Fix by using a dedicated Marked instance with the
code renderer registered once at module load time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
esbuild (used by workspace-sync-cli) preserves ESM default exports as
{ default: fn } when bundling to CJS, unlike webpack which unwraps them.
Add unwrapDefault() helper to handle both bundlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
New composite FieldDef that stores markdown content and exposes structured
card relationships via query-based linkedCards. Includes integration tests
for markdown rendering, BFM card references, footnotes, and edit mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Conflicts resolved by taking the base branch changes:
- bfm-showcase.md: user-content- prefix for heading IDs
- bfm-math.ts: dynamic displayMode detection
- bfm-mermaid-render.ts: DOMPurify sanitization and robust regex

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Integrate ProseMirror as a platform feature for rich markdown editing,
following the established lazy-loading pattern used by KaTeX, Mermaid,
and Monaco.

New files:
- packages/host/app/lib/prosemirror-context.ts: lazy-loaded module with
  ProseMirror schema (doc, paragraph, heading, blockquote, code_block,
  lists, hr, hard_break, card atom/block placeholders), marks (strong,
  em, code, link), markdown parser, and markdown serializer
- packages/base/prosemirror-editor.gts: Glimmer component with
  ember-concurrency lazy loading, ember-modifier for EditorView
  mounting, keyboard bindings, debounced onUpdate, and scoped CSS
- packages/host/tests/integration/components/prosemirror-editor-test.gts:
  25 tests covering schema, parsing, serialization, round-trip,
  EditorView mounting, card placeholder rendering, and lazy loading

Modified files:
- packages/host/app/routes/application.ts: register __loadProseMirror
- packages/base/rich-markdown.gts: use ProseMirrorEditor in edit mode
- pnpm-workspace.yaml + packages/host/package.json: add prosemirror-*
  dependencies

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

Preview deployments

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

Host Test Results

    1 files  ± 0      1 suites  ±0   2h 19m 46s ⏱️ - 2m 26s
2 165 tests +31  2 149 ✅ +30  15 💤 ±0  0 ❌ ±0  1 🔥 +1 
2 165 runs  +31  2 148 ✅ +29  15 💤 ±0  1 ❌ +1  1 🔥 +1 

For more details on these errors, see this check.

Results for commit ce5f918. ± Comparison against base commit e45d4de.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 7, 2026

Realm Server Test Results

  1 files  ±0    1 suites  ±0   14m 12s ⏱️ +33s
835 tests ±0  835 ✅ ±0  0 💤 ±0  0 ❌ ±0 
906 runs  ±0  906 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit ce5f918. ± Comparison against base commit e45d4de.

Base automatically changed from cs-bfm-markdown-extensions to main April 7, 2026 22:16
lukemelia and others added 4 commits April 7, 2026 22:45
Strengthen parser and serializer for proper round-trip fidelity:
- Fix combined bold+italic mark serialization (***text***)
- Add link title support in parse and serialize
- Guard against ::card[URL] being misinterpreted as inline card atom
- Auto-derive card atom labels from URL path segment
- Overhaul blockquote handling: bare > lines, multi-paragraph support
- Fix empty code block serialization
- Add 26 comprehensive round-trip tests covering all standard elements,
  card references with relative/absolute URLs, and edge cases

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add card nodeView infrastructure that renders live card components inside
the ProseMirror editing surface using custom nodeViews and Glimmer's
{{in-element}}. Card references (:card[URL] / ::card[URL]) now display
as resolved card components when available, with fallback text otherwise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement /card slash command in ProseMirror editor that lets users insert
card references. Typing "/" triggers a contextual command menu, selecting
"Card" opens a search/URL-paste popup, then a format picker (inline/block)
inserts the reference at the cursor position. The slash command system is
extensible for future commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant