diff --git a/README.md b/README.md
index 0e5f123..bfa588e 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@ This package contains extracted UI components from the Open CRM frontend, design
- **InputGroup** — Composite input with addons and buttons
- **Combobox** — Searchable dropdown with chip support (based on Base UI)
- **TagMultiSelect** — Multi-select tag picker with colored chips
+- **MarkdownEditor** — WYSIWYG Markdown editor that round-trips all supported Markdown constructs without data loss
+- **MarkdownView** — Read-only Markdown renderer with structural output (headings, lists, task lists, blockquotes, code)
## Usage
diff --git a/docs/upgrade-to-0.10.md b/docs/upgrade-to-0.10.md
new file mode 100644
index 0000000..642a4ce
--- /dev/null
+++ b/docs/upgrade-to-0.10.md
@@ -0,0 +1,78 @@
+# Upgrade prompt: `@open-elements/ui` 0.9.x → 0.10.0
+
+`@open-elements/ui` 0.10.0 fixes a **data-loss bug** in `MarkdownEditor` and a **rendering gap** in `MarkdownView`. Both components previously trimmed the TipTap schema down to paragraphs, a few marks and links, so every other Markdown construct (headings, bullet/ordered lists, task lists, blockquotes, code blocks, horizontal rules) was silently discarded when content was loaded.
+
+In `MarkdownEditor` this destroyed stored data: simply opening a form with such content was enough to corrupt the value passed back through `onChange`, without a single keystroke. In `MarkdownView` the same constructs were flattened to plain text.
+
+0.10.0 opens the schema so all of those constructs **round-trip untouched**. There is **no API change** — `MarkdownEditor` and `MarkdownView` keep the exact same props. What the editor toolbar lets a user *create* is unchanged (still Bold, Italic, Strikethrough, Link); task lists render but cannot be created.
+
+The consumer-visible effect is that content which used to appear as flat text now renders as real structure. This is the intended fix, but it means blocks that were previously invisible-as-structure now take up their natural space.
+
+This file is a self-contained prompt for an agent (Claude Code, etc.) to run inside a consumer repo. Paste it verbatim.
+
+---
+
+## Prompt
+
+You are working inside an app that depends on `@open-elements/ui`. Goal: upgrade to `^0.10.0`. There is **no prop or import migration** — the change is behavioral, plus a documentation/visual verification pass.
+
+### What changed in 0.10.0
+
+- **`MarkdownEditor` no longer corrupts content on open.** Loading a value containing headings, lists, task lists, blockquotes, code blocks or horizontal rules no longer strips those constructs and no longer fires a spurious `onChange`. If your app worked around this bug (e.g. by re-saving on load, or by pre-stripping Markdown before passing it in), remove that workaround.
+- **`MarkdownView` now renders structure.** Content that previously appeared as a single flat paragraph now renders as headings, lists, task lists (as checkboxes), blockquotes, code blocks and rules.
+- **Task lists render but stay non-creatable.** `- [x]` / `- [ ]` items display as checkboxes and survive editing. There is no toolbar button, keyboard shortcut, or `[ ] ` input rule to create one — this is deliberate.
+- **Checkboxes in `MarkdownView` are read-only.** They reflect the stored state; clicking them does nothing (interactive toggling arrives in a later release).
+- **Dependency manifest.** Internally the library dropped a duplicate `@tiptap/extension-link` and declared `@tiptap/extension-list`. These are the library's own `dependencies`, resolved transitively — you do **not** manage them in your app.
+
+### Steps
+
+1. **Find the consumer's frontend `package.json`** (repo root or under `frontend/`). Confirm `@open-elements/ui` is listed, then bump it to `^0.10.0` and run:
+
+ ```bash
+ pnpm install
+ ```
+
+2. **Confirm the library sources are in your Tailwind content globs.** The newly rendered blocks and the task-list styling rely on utility classes (`prose prose-sm`, `list-none`, `pl-0`, `flex`, …) that Tailwind must see in the library source. If you already render any `@open-elements/ui` component correctly, this is already the case — verify your `tailwind.config` `content` array includes the package, e.g.:
+
+ ```js
+ content: ["./src/**/*.{ts,tsx}", "./node_modules/@open-elements/ui/src/**/*.{ts,tsx}"],
+ ```
+
+3. **Visually check every screen that uses `MarkdownView` or `MarkdownEditor`.** Content that was silently flattened will now render as structure. In compact contexts (detail panels, cards, table cells) headings and code blocks may look oversized. If so, add local `prose` overrides *in your app* — do not edit the library. Example:
+
+ ```html
+
+
+
+ ```
+
+4. **Remove any load-time corruption workarounds.** Search for code that pre-processes Markdown before handing it to `MarkdownEditor`, or that ignores the first `onChange` after mount. These existed to paper over the old bug and are now unnecessary.
+
+5. **Verify.** All three must pass:
+
+ ```bash
+ pnpm exec tsc --noEmit
+ pnpm test
+ pnpm build
+ ```
+
+6. **Commit** with a clear message:
+
+ ```
+ chore(deps): upgrade @open-elements/ui to 0.10.0
+
+ MarkdownEditor no longer corrupts stored Markdown on open and
+ MarkdownView now renders full structure. Verified prose styling.
+ ```
+
+### Guard rails
+
+- **Do not** change how you call `MarkdownEditor` / `MarkdownView` — their props are identical to 0.9.0.
+- **Do not** add `@tiptap/*` packages to your app's dependencies to "fix" the upgrade. They are the library's transitive dependencies.
+- **Do not** edit `@open-elements/ui` from the consumer side. `prose` overrides belong in your app's markup or stylesheet.
+- **Do not** bundle unrelated dependency bumps into the same change.
+
+### Don't do this
+
+- Do not try to re-add a way to *create* task lists — their non-creatability is intentional in this release.
+- Do not treat the new rendering as a regression and revert the bump; flattened text was the bug.
diff --git a/package.json b/package.json
index daf84cb..c09dc38 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
"@tiptap/core": "^3.22.0",
"@tiptap/react": "^3.22.0",
"@tiptap/starter-kit": "^3.22.0",
- "@tiptap/extension-link": "^3.22.0",
+ "@tiptap/extension-list": "^3.22.0",
"@tiptap/extension-placeholder": "^3.22.0",
"tiptap-markdown": "^0.9.0"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4401980..5a689dc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,7 +11,7 @@ importers:
'@tiptap/core':
specifier: ^3.22.0
version: 3.22.5(@tiptap/pm@3.22.5)
- '@tiptap/extension-link':
+ '@tiptap/extension-list':
specifier: ^3.22.0
version: 3.22.5(@tiptap/core@3.22.5(@tiptap/pm@3.22.5))(@tiptap/pm@3.22.5)
'@tiptap/extension-placeholder':
@@ -1219,66 +1219,79 @@ packages:
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
cpu: [arm]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.60.2':
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
cpu: [arm]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.60.2':
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
cpu: [arm64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.60.2':
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
cpu: [arm64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.60.2':
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
cpu: [loong64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.60.2':
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
cpu: [loong64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.60.2':
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
cpu: [ppc64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.60.2':
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
cpu: [ppc64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.60.2':
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
cpu: [riscv64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.60.2':
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.60.2':
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
cpu: [s390x]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.60.2':
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
cpu: [x64]
os: [linux]
+ libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.60.2':
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.60.2':
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
diff --git a/specs/001-markdown-schema-roundtrip/steps.md b/specs/001-markdown-schema-roundtrip/steps.md
new file mode 100644
index 0000000..08a5348
--- /dev/null
+++ b/specs/001-markdown-schema-roundtrip/steps.md
@@ -0,0 +1,142 @@
+# Implementation Steps: Markdown schema round-trip
+
+## Step 1: Shared extension factory and dependency changes
+
+- [x] Add `@tiptap/extension-list` at `^3.22.0` to `dependencies` in `package.json`
+- [x] Remove `@tiptap/extension-link` from `dependencies` in `package.json`
+- [x] Run `pnpm install` so `@tiptap/extension-list` is hoisted as a direct dependency
+- [x] Create `src/lib/markdown-extensions.ts` exporting `MarkdownExtensionsOptions` and `createMarkdownExtensions(options?)`
+- [x] Open the schema: use `StarterKit.configure` without disabling `heading`, `codeBlock`, `blockquote`, `horizontalRule`, `bulletList`, `orderedList`, `listItem`
+- [x] Set `underline: false` in the StarterKit config
+- [x] Configure `link` **through** StarterKit (`openOnClick` from `options.openLinksOnClick`, default `false`; view adds `target`/`rel`)
+- [x] Add `TaskList.extend({ addKeyboardShortcuts: () => ({}) })` configured with `HTMLAttributes: { class: "list-none pl-0" }`
+- [x] Add `TaskItem.extend({ addInputRules: () => [] })` configured with `nested: true`, `HTMLAttributes: { class: "flex items-start gap-2" }`
+- [x] Add `Placeholder.configure({ placeholder: options.placeholder ?? "" })` and `Markdown`
+
+**Acceptance criteria:**
+- [x] `pnpm typecheck` passes
+- [x] `pnpm build` succeeds
+- [x] The factory is the single source of extension configuration
+
+**Related behaviors:** foundation for all scenarios
+
+---
+
+## Step 2: Refactor `MarkdownEditor` to use the factory
+
+- [x] Replace the inline `extensions` array in `markdown-editor.tsx` with `createMarkdownExtensions({ placeholder, openLinksOnClick: false })`
+- [x] Remove now-unused imports (`StarterKit`, `Link`, `Placeholder`, `Markdown`)
+- [x] Leave `MarkdownEditorProps`, the toolbar, `onUpdate`, and the sync `useEffect` unchanged
+
+**Acceptance criteria:**
+- [x] `pnpm typecheck` and `pnpm build` pass
+- [x] Toolbar still renders Bold, Italic, Strikethrough, Link (and Unlink in a link)
+
+**Related behaviors:** Opening without editing leaves the value untouched; A real edit still reports the full document; An externally changed value replaces the document; The toolbar is unchanged
+
+---
+
+## Step 3: Refactor `MarkdownView` to use the factory
+
+- [x] Replace the inline `extensions` array in `markdown-view.tsx` with `createMarkdownExtensions({ openLinksOnClick: true })`
+- [x] Remove now-unused imports (`StarterKit`, `Link`, `Markdown`)
+- [x] Leave `MarkdownViewProps`, `editable: false`, and the sync `useEffect` unchanged
+
+**Acceptance criteria:**
+- [x] `pnpm typecheck` and `pnpm build` pass
+
+**Related behaviors:** Structural Markdown renders as structure; Task lists render as checkboxes reflecting their state; Clicking a checkbox has no effect; Checkboxes are not decorated with a list bullet
+
+---
+
+## Step 4: Round-trip tests against a real `Editor`
+
+- [x] Create `src/lib/__tests__/markdown-extensions.test.ts`
+- [x] Instantiate `new Editor({ extensions: createMarkdownExtensions(), content: input })` and assert `editor.storage.markdown.getMarkdown() === input`
+- [x] Drive it from a table of Markdown inputs covering: task list, bullet list, ordered list, H1–H6, blockquote + fenced code block with language + `---`, nested task list, marks inside blocks (bold + link), mixed list, trailing-paragraph guard (doc ending in code block / hr), empty string, unchecked-only task list, plain text
+- [x] If a trailing blank line appears, disable `trailingNode` in the factory and re-run
+
+**Acceptance criteria:**
+- [x] `pnpm test` passes for all round-trip cases
+
+**Related behaviors:** Task list survives unchanged; Bullet list survives unchanged; Ordered list survives unchanged; Headings survive at every level; Blockquote, code block and horizontal rule survive; Nested task lists survive; Marks inside blocks survive; A mixed list keeps both kinds of items; The document does not grow a trailing paragraph; Empty content produces empty Markdown; An unchecked-only task list round-trips; Content that is already plain text is unaffected
+
+---
+
+## Step 5: Editor behaviour tests (real editor, no React mock)
+
+- [x] Add tests that build a real `Editor` from the factory to verify creation paths are closed:
+ - [x] `Mod-Shift-9` creates no task list
+ - [x] typing `[ ] ` leaves literal text and creates no task item
+ - [x] underline command is unavailable (`editor.can().toggleUnderline()` is false / command absent)
+- [x] Add editing tests for an existing task list: `Enter` splits into a new unchecked item (serializes to two lines); `Shift-Tab` lifts a nested item (reduced indentation)
+- [x] Rewrite `markdown-editor.test.tsx` so the "no corruption on open" behaviours are testable: render a real `MarkdownEditor`, spy on `onChange`, assert it is not called after mount; assert a typed edit reports the full document; assert an external `value` change replaces the document
+- [x] Keep a toolbar test asserting exactly Bold, Italic, Strikethrough, Link (and Unlink only inside a link)
+
+**Acceptance criteria:**
+- [x] `pnpm test` passes
+
+**Related behaviors:** The keyboard shortcut does nothing; The input rule does nothing; Underline cannot be applied; Enter splits a task item; Shift-Tab lifts a nested item; Opening without editing leaves the value untouched; A real edit still reports the full document; An externally changed value replaces the document; The toolbar is unchanged
+
+---
+
+## Step 6: View rendering tests (real editor, no React mock)
+
+- [x] Rewrite `markdown-view.test.tsx` to render a real `MarkdownView`
+- [x] Assert structural Markdown renders as `h1`/`ul`/`blockquote` elements, not plain paragraphs
+- [x] Assert `- [x] Done\n- [ ] Open` renders two checkbox inputs, first checked, second unchecked
+- [x] Assert clicking a checkbox leaves its state unchanged and fires no callback
+- [x] Assert the task list carries the `list-none pl-0` classes (bullet suppression)
+
+**Acceptance criteria:**
+- [x] `pnpm test` passes
+
+**Related behaviors:** Structural Markdown renders as structure; Task lists render as checkboxes reflecting their state; Clicking a checkbox has no effect; Checkboxes are not decorated with a list bullet
+
+---
+
+## Step 7: Documentation
+
+- [x] Create `docs/upgrade-to-0.10.md` documenting the rendering change (previously flattened constructs now render as structure) and the removal of `@tiptap/extension-link` from the public dependency set
+- [x] Update `CLAUDE.md` Project Context (Features / Tech Stack / Structure / Architecture) if the markdown components or the new `markdown-extensions` module warrant it
+- [x] Update `README.md` if user-facing behaviour or the dependency list is documented there
+
+**Acceptance criteria:**
+- [x] `pnpm build`, `pnpm test`, `pnpm lint`, `pnpm typecheck` all pass
+- [x] Docs reflect the change
+
+**Related behaviors:** none (documentation)
+
+---
+
+## Behavior Coverage
+
+| Scenario | Layer | Covered in Step |
+|----------|-------|-----------------|
+| Task list survives unchanged | Frontend | 4 |
+| Bullet list survives unchanged | Frontend | 4 |
+| Ordered list survives unchanged | Frontend | 4 |
+| Headings survive at every level | Frontend | 4 |
+| Blockquote, code block and horizontal rule survive | Frontend | 4 |
+| Nested task lists survive | Frontend | 4 |
+| Marks inside blocks survive | Frontend | 4 |
+| A mixed list keeps both kinds of items | Frontend | 4 |
+| The document does not grow a trailing paragraph | Frontend | 4 |
+| Opening without editing leaves the value untouched | Frontend | 5 |
+| A real edit still reports the full document | Frontend | 5 |
+| An externally changed value replaces the document | Frontend | 5 |
+| Structural Markdown renders as structure | Frontend | 6 |
+| Task lists render as checkboxes reflecting their state | Frontend | 6 |
+| Clicking a checkbox has no effect | Frontend | 6 |
+| Checkboxes are not decorated with a list bullet | Frontend | 6 |
+| The keyboard shortcut does nothing | Frontend | 5 |
+| The input rule does nothing | Frontend | 5 |
+| The toolbar is unchanged | Frontend | 5 |
+| Enter splits a task item | Frontend | 5 |
+| Shift-Tab lifts a nested item | Frontend | 5 |
+| Underline cannot be applied | Frontend | 5 |
+| Empty content produces empty Markdown | Frontend | 4 |
+| An unchecked-only task list round-trips | Frontend | 4 |
+| Content that is already plain text is unaffected | Frontend | 4 |
+
+Every scenario is a frontend/library scenario and is assigned to a step. No backend layer exists.
diff --git a/specs/INDEX.md b/specs/INDEX.md
index 9a504f3..d08cdb1 100644
--- a/specs/INDEX.md
+++ b/specs/INDEX.md
@@ -2,6 +2,6 @@
| ID | Spec-Folder | Name | Areas | Description | GitHub Issue | Status |
|-----|-------------|------|-------|-------------|--------------|--------|
-| 001 | 001-markdown-schema-roundtrip | Markdown schema round-trip | frontend, api, testing | Stop destroying unsupported Markdown in MarkdownEditor/MarkdownView by teaching the schema everything Markdown can express | — | open |
+| 001 | 001-markdown-schema-roundtrip | Markdown schema round-trip | frontend, api, testing | Stop destroying unsupported Markdown in MarkdownEditor/MarkdownView by teaching the schema everything Markdown can express | — | done |
| 002 | 002-markdown-toolbar-actions | Markdown toolbar actions | frontend, api | Compose the MarkdownEditor toolbar per usage via an explicit action allowlist | — | open |
| 003 | 003-markdown-view-checkboxes | Markdown view checkboxes | frontend, api | Tick task list checkboxes directly in MarkdownView with optimistic update and rollback | — | open |
diff --git a/src/components/__tests__/markdown-editor.test.tsx b/src/components/__tests__/markdown-editor.test.tsx
index 6869ef7..a6c140f 100644
--- a/src/components/__tests__/markdown-editor.test.tsx
+++ b/src/components/__tests__/markdown-editor.test.tsx
@@ -1,35 +1,91 @@
import { describe, it, expect, vi, afterEach } from "vitest";
-import { render, cleanup } from "@testing-library/react";
-
-// Mock TipTap before importing the component
-vi.mock("@tiptap/react", () => ({
- useEditor: () => null,
- EditorContent: () => ,
-}));
-vi.mock("@tiptap/starter-kit", () => ({ default: { configure: () => ({}) } }));
-vi.mock("@tiptap/extension-link", () => ({ default: { configure: () => ({}) } }));
-vi.mock("@tiptap/extension-placeholder", () => ({ default: { configure: () => ({}) } }));
-vi.mock("tiptap-markdown", () => ({ Markdown: {} }));
-
+import { render, cleanup, waitFor } from "@testing-library/react";
+import type { Editor } from "@tiptap/core";
import { MarkdownEditor } from "../markdown-editor.tsx";
afterEach(cleanup);
-describe("MarkdownEditor", () => {
- it("mounts without error", () => {
- const { container } = render( {}} />);
- expect(container).toBeTruthy();
+/** The rendered ProseMirror element carries a back-reference to the live editor. */
+function getEditor(container: HTMLElement): Editor {
+ const dom = container.querySelector(".ProseMirror") as (HTMLElement & { editor?: Editor }) | null;
+ if (!dom?.editor) throw new Error("editor not mounted yet");
+ return dom.editor;
+}
+
+async function waitForEditor(container: HTMLElement): Promise {
+ await waitFor(() => expect(container.querySelector(".ProseMirror")).toBeTruthy());
+ return getEditor(container);
+}
+
+describe("MarkdownEditor — no corruption on open", () => {
+ it("does not call onChange when opened with content it used to strip", async () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ await waitForEditor(container);
+ // Give the sync effect a chance to run before asserting nothing fired.
+ await waitFor(() => {
+ expect(getEditor(container).storage.markdown.getMarkdown()).toBe(
+ "- [x] Write the press release",
+ );
+ });
+ expect(onChange).not.toHaveBeenCalled();
});
- it("renders editor content area", () => {
- const { container } = render( {}} />);
- expect(container.querySelector("[data-testid='editor-content']")).toBeTruthy();
+ it("reports the full document, task list intact, when the paragraph is edited", async () => {
+ const onChange = vi.fn();
+ const { container } = render(
+ ,
+ );
+ const editor = await waitForEditor(container);
+ onChange.mockClear();
+
+ // Type a character at the end of the paragraph — same onUpdate path as a keystroke.
+ editor.chain().focus("end").insertContent("X").run();
+
+ await waitFor(() => expect(onChange).toHaveBeenCalled());
+ const reported = onChange.mock.calls.at(-1)?.[0] as string;
+ expect(reported).toContain("- [x] Task");
+ expect(reported).toContain("HelloX");
+ });
+
+ it("replaces the document when the parent passes a different value", async () => {
+ const onChange = vi.fn();
+ const { container, rerender } = render(
+ ,
+ );
+ const editor = await waitForEditor(container);
+ await waitFor(() => expect(editor.storage.markdown.getMarkdown()).toBe("# First"));
+
+ rerender();
+ await waitFor(() => expect(editor.storage.markdown.getMarkdown()).toBe("# Second"));
+ });
+});
+
+describe("MarkdownEditor — toolbar", () => {
+ it("offers exactly Bold, Italic, Strikethrough and Link by default", async () => {
+ const { container } = render( {}} />);
+ await waitForEditor(container);
+ const titles = Array.from(container.querySelectorAll("button[title]")).map((b) =>
+ b.getAttribute("title"),
+ );
+ expect(titles).toEqual(["Bold", "Italic", "Strikethrough", "Link"]);
+ expect(titles).not.toContain("Remove link");
});
- it("accepts placeholder prop without error", () => {
+ it("adds Unlink only while the cursor sits in a link", async () => {
const { container } = render(
- {}} placeholder="Type here..." />,
+ {}} />,
);
- expect(container).toBeTruthy();
+ const editor = await waitForEditor(container);
+ // Move the selection into the link text.
+ editor.chain().focus().setTextSelection(3).run();
+ await waitFor(() => {
+ const titles = Array.from(container.querySelectorAll("button[title]")).map((b) =>
+ b.getAttribute("title"),
+ );
+ expect(titles).toContain("Remove link");
+ });
});
});
diff --git a/src/components/__tests__/markdown-view.test.tsx b/src/components/__tests__/markdown-view.test.tsx
index b62afca..36a1b9c 100644
--- a/src/components/__tests__/markdown-view.test.tsx
+++ b/src/components/__tests__/markdown-view.test.tsx
@@ -1,32 +1,47 @@
-import { describe, it, expect, vi, afterEach } from "vitest";
-import { render, cleanup } from "@testing-library/react";
-
-// Mock TipTap before importing the component
-vi.mock("@tiptap/react", () => ({
- useEditor: () => null,
- EditorContent: () => ,
-}));
-vi.mock("@tiptap/starter-kit", () => ({ default: { configure: () => ({}) } }));
-vi.mock("@tiptap/extension-link", () => ({ default: { configure: () => ({}) } }));
-vi.mock("tiptap-markdown", () => ({ Markdown: {} }));
-
+import { describe, it, expect, afterEach } from "vitest";
+import { render, cleanup, waitFor, fireEvent } from "@testing-library/react";
import { MarkdownView } from "../markdown-view.tsx";
afterEach(cleanup);
-describe("MarkdownView", () => {
- it("mounts without error", () => {
- const { container } = render();
- expect(container).toBeTruthy();
+async function renderView(content: string) {
+ const result = render();
+ await waitFor(() => expect(result.container.querySelector(".ProseMirror")).toBeTruthy());
+ return result;
+}
+
+describe("MarkdownView — rendering", () => {
+ it("renders structural Markdown as structure, not plain paragraphs", async () => {
+ const { container } = await renderView("# Title\n\n- a\n- b\n\n> quote");
+ expect(container.querySelector("h1")).toBeTruthy();
+ expect(container.querySelector("ul")).toBeTruthy();
+ expect(container.querySelector("blockquote")).toBeTruthy();
+ });
+
+ it("renders a task list as checkboxes reflecting their state", async () => {
+ const { container } = await renderView("- [x] Done\n- [ ] Open");
+ const checkboxes = container.querySelectorAll("input[type=checkbox]");
+ expect(checkboxes).toHaveLength(2);
+ expect(checkboxes[0].checked).toBe(true);
+ expect(checkboxes[1].checked).toBe(false);
});
- it("renders editor content area", () => {
- const { container } = render();
- expect(container.querySelector("[data-testid='editor-content']")).toBeTruthy();
+ it("does not change a checkbox when it is clicked", async () => {
+ const { container } = await renderView("- [x] Done\n- [ ] Open");
+ const checkboxes = container.querySelectorAll("input[type=checkbox]");
+ fireEvent.click(checkboxes[0]);
+ fireEvent.click(checkboxes[1]);
+ await waitFor(() => {
+ const after = container.querySelectorAll("input[type=checkbox]");
+ expect(after[0].checked).toBe(true);
+ expect(after[1].checked).toBe(false);
+ });
});
- it("accepts markdown content without error", () => {
- const { container } = render();
- expect(container).toBeTruthy();
+ it("suppresses the prose bullet on the task list", async () => {
+ const { container } = await renderView("- [x] Done\n- [ ] Open");
+ const list = container.querySelector('[data-type="taskList"]');
+ expect(list?.classList.contains("list-none")).toBe(true);
+ expect(list?.classList.contains("pl-0")).toBe(true);
});
});
diff --git a/src/components/markdown-editor.tsx b/src/components/markdown-editor.tsx
index 852e899..d66bba4 100644
--- a/src/components/markdown-editor.tsx
+++ b/src/components/markdown-editor.tsx
@@ -1,13 +1,10 @@
"use client";
import { useEditor, EditorContent, type Editor } from "@tiptap/react";
-import StarterKit from "@tiptap/starter-kit";
-import Link from "@tiptap/extension-link";
-import Placeholder from "@tiptap/extension-placeholder";
-import { Markdown } from "tiptap-markdown";
import { Bold, Italic, Strikethrough, Link as LinkIcon, Unlink } from "lucide-react";
import { useEffect, useCallback } from "react";
import { cn } from "../lib/utils.ts";
+import { createMarkdownExtensions } from "../lib/markdown-extensions.ts";
import type { MarkdownEditorProps } from "../types/index.ts";
function ToolbarButton({
@@ -95,23 +92,7 @@ function Toolbar({ editor }: { readonly editor: Editor | null }) {
export function MarkdownEditor({ value, onChange, placeholder }: MarkdownEditorProps) {
const editor = useEditor({
- extensions: [
- StarterKit.configure({
- heading: false,
- codeBlock: false,
- blockquote: false,
- horizontalRule: false,
- bulletList: false,
- orderedList: false,
- listItem: false,
- }),
- Link.configure({
- openOnClick: false,
- HTMLAttributes: { class: "text-blue-600 underline" },
- }),
- Placeholder.configure({ placeholder: placeholder ?? "" }),
- Markdown,
- ],
+ extensions: createMarkdownExtensions({ placeholder, openLinksOnClick: false }),
content: value,
immediatelyRender: false,
onUpdate: ({ editor: ed }) => {
diff --git a/src/components/markdown-view.tsx b/src/components/markdown-view.tsx
index 8711eba..ab28093 100644
--- a/src/components/markdown-view.tsx
+++ b/src/components/markdown-view.tsx
@@ -2,33 +2,12 @@
import { useEffect } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
-import StarterKit from "@tiptap/starter-kit";
-import Link from "@tiptap/extension-link";
-import { Markdown } from "tiptap-markdown";
+import { createMarkdownExtensions } from "../lib/markdown-extensions.ts";
import type { MarkdownViewProps } from "../types/index.ts";
export function MarkdownView({ content }: MarkdownViewProps) {
const editor = useEditor({
- extensions: [
- StarterKit.configure({
- heading: false,
- codeBlock: false,
- blockquote: false,
- horizontalRule: false,
- bulletList: false,
- orderedList: false,
- listItem: false,
- }),
- Link.configure({
- openOnClick: true,
- HTMLAttributes: {
- class: "text-blue-600 underline",
- target: "_blank",
- rel: "noopener noreferrer",
- },
- }),
- Markdown,
- ],
+ extensions: createMarkdownExtensions({ openLinksOnClick: true }),
content,
immediatelyRender: false,
editable: false,
diff --git a/src/lib/__tests__/markdown-extensions.test.ts b/src/lib/__tests__/markdown-extensions.test.ts
new file mode 100644
index 0000000..afb9810
--- /dev/null
+++ b/src/lib/__tests__/markdown-extensions.test.ts
@@ -0,0 +1,200 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { Editor } from "@tiptap/core";
+import StarterKit from "@tiptap/starter-kit";
+import { TaskList, TaskItem } from "@tiptap/extension-list";
+import { Markdown } from "tiptap-markdown";
+import { createMarkdownExtensions } from "../markdown-extensions.ts";
+
+/**
+ * Serialize `input` through a real editor built from the shared factory and
+ * return the Markdown the document produces. This is exactly the pipeline the
+ * components use, so what these tests verify is what ships.
+ */
+function roundTrip(input: string): string {
+ const editor = new Editor({ extensions: createMarkdownExtensions(), content: input });
+ const markdown = editor.storage.markdown.getMarkdown();
+ editor.destroy();
+ return markdown;
+}
+
+let editors: Editor[] = [];
+
+afterEach(() => {
+ editors.forEach((e) => e.destroy());
+ editors = [];
+});
+
+describe("createMarkdownExtensions — round-trip", () => {
+ it("keeps a task list unchanged, including the [x] and [ ] markers", () => {
+ const input = "- [x] Write the press release\n- [ ] Send invitations";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("keeps a bullet list unchanged", () => {
+ const input = "- Milk\n- Bread";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("keeps an ordered list unchanged", () => {
+ const input = "1. First\n2. Second";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("keeps headings at every level H1–H6", () => {
+ const input = "# H1\n\n## H2\n\n### H3\n\n#### H4\n\n##### H5\n\n###### H6";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("keeps a blockquote, a fenced code block with a language tag, and a rule", () => {
+ const input = "> A wise quote\n\n```js\nconst x = 1;\n```\n\n---";
+ const out = roundTrip(input);
+ expect(out).toBe(input);
+ expect(out).toContain("> A wise quote");
+ expect(out).toContain("```js\nconst x = 1;\n```");
+ expect(out).toContain("---");
+ });
+
+ it("keeps a nested task list, preserving indentation and nesting level", () => {
+ const input = "- [ ] parent\n - [ ] child";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("keeps marks inside blocks — bold and links survive", () => {
+ const input = "- [x] Call **Anna** about [the offer](https://example.com)";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("keeps both kinds of items in a mixed list", () => {
+ const out = roundTrip("- [x] Done\n- Just an item");
+ // A checklist item and a plain item cannot share one node, so the serializer
+ // splits them; byte-identity is not promised, but nothing may be dropped.
+ expect(out).toContain("- [x] Done");
+ expect(out).toContain("- Just an item");
+ });
+
+ it("does not grow a trailing paragraph after a code block", () => {
+ const input = "intro\n\n```js\nconst y = 2;\n```";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("does not grow a trailing paragraph after a horizontal rule", () => {
+ const input = "intro\n\n---";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("produces empty Markdown for empty content", () => {
+ expect(roundTrip("")).toBe("");
+ });
+
+ it("keeps an unchecked-only task list and does not normalise the marker", () => {
+ const input = "- [ ] Open item";
+ const out = roundTrip(input);
+ expect(out).toBe(input);
+ expect(out).not.toContain("[x]");
+ });
+
+ it("leaves plain text byte-identical", () => {
+ const input = "Just a plain paragraph with no markdown.";
+ expect(roundTrip(input)).toBe(input);
+ });
+
+ it("shows the placeholder when one is configured and the document is empty", () => {
+ const editor = new Editor({
+ extensions: createMarkdownExtensions({ placeholder: "Type here..." }),
+ content: "",
+ });
+ editors.push(editor);
+ const placeholderExt = editor.extensionManager.extensions.find((e) => e.name === "placeholder");
+ expect(placeholderExt?.options.placeholder).toBe("Type here...");
+ });
+});
+
+describe("createMarkdownExtensions — marks Markdown cannot express", () => {
+ it("does not register underline, so the command is unavailable", () => {
+ const editor = new Editor({ extensions: createMarkdownExtensions(), content: "hello" });
+ editors.push(editor);
+ const hasUnderline = editor.extensionManager.extensions.some((e) => e.name === "underline");
+ expect(hasUnderline).toBe(false);
+ // The mark is absent from the schema, so no command can apply it —
+ // attempting to resolve the mark type throws rather than mutating the doc.
+ expect(editor.schema.marks.underline).toBeUndefined();
+ editor.commands.selectAll();
+ expect(() => editor.chain().setMark("underline").run()).toThrow();
+ });
+});
+
+/** Mount a real editor attached to the DOM so keyboard handling is active. */
+function mountEditor(content: string, extensions = createMarkdownExtensions()): Editor {
+ const element = document.createElement("div");
+ document.body.appendChild(element);
+ const editor = new Editor({ element, extensions, content });
+ editors.push(editor);
+ return editor;
+}
+
+function pressKey(editor: Editor, init: KeyboardEventInit): void {
+ editor.view.dom.dispatchEvent(
+ new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }),
+ );
+}
+
+/** Simulate typing the trailing space of "[ ] " so a task-item input rule would fire. */
+function typeSpaceAfterBracket(editor: Editor): void {
+ editor.commands.setContent("[ ]
");
+ editor.commands.focus("end");
+ const { from } = editor.state.selection;
+ editor.view.someProp("handleTextInput", (handler) =>
+ handler(editor.view, from, from, " ", () => editor.state.tr),
+ );
+}
+
+function hasTaskList(editor: Editor): boolean {
+ return (editor.getJSON().content ?? []).some((node) => node.type === "taskList");
+}
+
+describe("createMarkdownExtensions — task list creation stays closed", () => {
+ it("does nothing when Mod-Shift-9 is pressed", () => {
+ const editor = mountEditor("");
+ editor.commands.focus();
+ pressKey(editor, { key: "9", code: "Digit9", metaKey: true, ctrlKey: true, shiftKey: true });
+ expect(hasTaskList(editor)).toBe(false);
+ });
+
+ it("does nothing when the user types '[ ] ' — the literal text remains", () => {
+ const editor = mountEditor("");
+ typeSpaceAfterBracket(editor);
+ expect(hasTaskList(editor)).toBe(false);
+ expect(editor.getText()).toContain("[ ]");
+ });
+
+ it("guard: the same '[ ] ' input DOES create a task list with the default TaskItem", () => {
+ // Proves the simulated input rule genuinely fires, so the stripped-rule
+ // assertion above cannot silently pass if the trigger stops working.
+ const editor = mountEditor("", [
+ StarterKit.configure({ underline: false }),
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ Markdown,
+ ]);
+ typeSpaceAfterBracket(editor);
+ expect(hasTaskList(editor)).toBe(true);
+ });
+});
+
+describe("createMarkdownExtensions — editing an existing task list", () => {
+ it("splits a task item into a new unchecked item on Enter", () => {
+ const editor = mountEditor("- [x] Done");
+ editor.commands.focus("end");
+ pressKey(editor, { key: "Enter", code: "Enter" });
+ const md = editor.storage.markdown.getMarkdown();
+ expect(md).toContain("- [x] Done");
+ expect(md.split("\n").some((line) => line.startsWith("- [ ]"))).toBe(true);
+ });
+
+ it("lifts a nested item one level on Shift-Tab", () => {
+ const editor = mountEditor("- [ ] parent\n - [ ] child");
+ editor.commands.focus("end");
+ pressKey(editor, { key: "Tab", code: "Tab", shiftKey: true });
+ expect(editor.storage.markdown.getMarkdown()).toBe("- [ ] parent\n- [ ] child");
+ });
+});
diff --git a/src/lib/markdown-extensions.ts b/src/lib/markdown-extensions.ts
new file mode 100644
index 0000000..9959ea5
--- /dev/null
+++ b/src/lib/markdown-extensions.ts
@@ -0,0 +1,79 @@
+import type { Extensions } from "@tiptap/core";
+import StarterKit from "@tiptap/starter-kit";
+import { TaskList, TaskItem } from "@tiptap/extension-list";
+import Placeholder from "@tiptap/extension-placeholder";
+import { Markdown } from "tiptap-markdown";
+
+/**
+ * Options for {@link createMarkdownExtensions}. The two fields capture the only
+ * genuine differences between the editor and the read-only view.
+ */
+export interface MarkdownExtensionsOptions {
+ /** Placeholder text shown while the document is empty. Editor only. */
+ readonly placeholder?: string;
+ /** Whether clicking a link opens it. `true` in the view, `false` in the editor. */
+ readonly openLinksOnClick?: boolean;
+}
+
+/**
+ * Builds the TipTap extension set shared by `MarkdownEditor`, `MarkdownView`
+ * and the round-trip tests.
+ *
+ * The schema intentionally covers everything Markdown can express and TipTap
+ * can model with the StarterKit plus task lists, so stored content round-trips
+ * untouched. Two marks are deliberately excluded:
+ *
+ * - `underline` — Markdown has no representation for it, so enabling it would
+ * reintroduce the data-loss bug this configuration exists to prevent.
+ * - a separate `Link` extension — StarterKit already ships `link`, so it is
+ * configured through StarterKit rather than registered twice.
+ *
+ * Task lists render but cannot be created: the `Mod-Shift-9` shortcut and the
+ * `[ ] ` input rule are stripped. Editing shortcuts inside an existing task
+ * list (Enter, Tab, Shift-Tab) are kept.
+ */
+export function createMarkdownExtensions(options?: MarkdownExtensionsOptions): Extensions {
+ const openLinksOnClick = options?.openLinksOnClick ?? false;
+
+ // Strip the creation paths while keeping the in-list editing shortcuts.
+ // The `tight` attribute makes the Markdown serializer render task lists
+ // tightly (no blank line between items), matching how tiptap-markdown already
+ // treats bullet and ordered lists. Without it, prosemirror-markdown falls back
+ // to a loose list and a multi-item checklist no longer round-trips byte-for-byte.
+ // `rendered: false` keeps the attribute out of the DOM so it is not persisted.
+ const TaskListNode = TaskList.extend({
+ addKeyboardShortcuts: () => ({}),
+ addAttributes() {
+ return {
+ ...this.parent?.(),
+ tight: { default: true, rendered: false },
+ };
+ },
+ });
+ const TaskItemNode = TaskItem.extend({ addInputRules: () => [] });
+
+ return [
+ StarterKit.configure({
+ underline: false,
+ link: {
+ openOnClick: openLinksOnClick,
+ HTMLAttributes: openLinksOnClick
+ ? {
+ class: "text-blue-600 underline",
+ target: "_blank",
+ rel: "noopener noreferrer",
+ }
+ : { class: "text-blue-600 underline" },
+ },
+ }),
+ // `list-none pl-0` suppresses the `prose` bullet so a checkbox does not also
+ // get a list marker; `flex items-start gap-2` aligns the box with its label.
+ TaskListNode.configure({ HTMLAttributes: { class: "list-none pl-0" } }),
+ TaskItemNode.configure({
+ nested: true,
+ HTMLAttributes: { class: "flex items-start gap-2" },
+ }),
+ Placeholder.configure({ placeholder: options?.placeholder ?? "" }),
+ Markdown,
+ ];
+}