Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chilly-lamps-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tiptap/markdown": patch
---

Fix extra mark tokens after inline atom nodes during Markdown serialization
1 change: 1 addition & 0 deletions .github/instructions/tiptap.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ If a single package is failing types, run a targeted build for that package (e.g

### Guidance for automated agents and AI assistants

- Disclose when AI tools are used to generate any part of a contribution, including code, documentation, tests, and other content — the PR template includes an AI usage disclosure checkbox. Follow the project's guidelines on AI disclosure.
- Make single-purpose, small diffs. Avoid sweeping changes in one PR.
- Always run the validation checklist above after edits.
- Add or update a demo and tests for user-visible behavior. For deterministic behaviour, favour unit tests over fragile e2e tests where possible.
Expand Down
8 changes: 7 additions & 1 deletion .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@

<!-- Add any other notes or screenshots about the PR here. -->

## AI Usage

- [ ] I have used AI tools (e.g., ChatGPT, Claude, Copilot) in creating this PR.
- [ ] If yes, describe which tools were used and how they contributed to this PR:
<!-- Required if AI tools were used. Include enough detail for reviewers to understand the scope of the AI-generated contribution. -->

## Checklist

- [ ] I have created a [changeset](https://github.com/changesets/changesets) for this PR if necessary.
Expand All @@ -28,4 +34,4 @@

## Related Issues

<!-- Link any related issues here -->
<!-- Link the issue this PR addresses. The linked issue should also be assigned to you. PRs without a linked issue (except for trivial fixes) may be closed. -->
195 changes: 195 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Tiptap

This document explains how to work on the Tiptap monorepo. It covers repo layout, local dev, linting and formatting, tests, docs, and release workflow. It is written to be friendly for both humans and AI coding assistants.

---

## What is Tiptap

Tiptap is a headless rich text editor toolkit built on ProseMirror. It ships a small Core and many opt-in Extensions so you can compose exactly the editor you need for React, Vue, or vanilla apps. The project is optimized for user experience and developer experience. APIs are predictable, behavior is testable, and everything should be documented with JSDoc and runnable examples so we can generate API docs automatically.

Key points for AI assistants:

* Treat Tiptap as a collection of focused packages that together form an editor system.
* Do not assume a single framework. Many packages are framework agnostic, with separate bindings for React and Vue.
* Favor small pure utilities and deterministic code. Side effects should be explicit.

---

## Repository layout

```
.
├─ packages/ # Core and all first-party extensions
│ ├─ core/ # Editor core (@tiptap/core)
│ ├─ extension-*/ # Individual extensions
│ ├─ pm/ # ProseMirror related internals and helpers
│ └─ ... # Shared utilities, framework bindings, etc.
├─ demos/ # Vite app for live examples
│ └─ src/
│ ├─ react/ # React demos
│ └─ vue/ # Vue demos
├─ tests/ # Cypress e2e tests that run against the demos
├─ .changeset/ # Changesets for versioning and changelogs
└─ .github/ # Workflows and GitHub-related config/docs
```

Notes:

* All packages we publish or use live under `packages/*`.
* The `demos/` folder contains a Vite app. It automatically discovers and parses React and Vue demos so they appear in the UI without manual wiring.
* Cypress tests in `tests/` expect the demos to be available on `http://localhost:3000`.

## NPM scripts

Scripts defined at the repo root:

* `pnpm dev` - start the demos on port 3000
* `pnpm build` - build all packages via Turborepo
* `pnpm lint` - run eslint checks
* `pnpm lint:fix` - run prettier + eslint fix
* `pnpm test:e2e:open` - open Cypress against `tests/`
* `pnpm test:e2e` - run Cypress in headless mode
* `pnpm test` - build then run all tests
* `pnpm serve` - build and serve the demos on port 3000
* `pnpm publish` - build and publish with Changesets
* `pnpm reset` - remove caches, build artifacts, and reinstall deps

---

## Linting & formatting

* ESLint config is at **`.eslintrc.js`** in the repo root.
* Prettier config is at **`.prettierrc`**.
* Husky and lint-staged run automatically on commits.

Run manually:

```bash
pnpm lint
pnpm lint:fix
```

---

## Demos

* Demos are a Vite app in `demos/`.
* React and Vue examples live in `demos/react` and `demos/vue`. They are automatically parsed into the app.
* Start in dev mode:

```bash
pnpm dev
```
* Build static output and serve locally:

```bash
pnpm serve
```

When adding a demo, keep it small and self-contained, with imports from published package names (`@tiptap/...`).

---

## Testing with Cypress

* Cypress lives in `tests/` and drives the demos in a browser.
* Tests assume the app is running on `http://localhost:3000`.

Workflow:

```bash
pnpm dev # terminal A
pnpm test:open # terminal B
```

or for headless CI runs:

```bash
pnpm test:run
```

---

## Documentation style

We focus heavily on **User Experience** and **Developer Experience**. Every public API must be documented with JSDoc, including:

* `@param` and `@returns` annotations
* Argument descriptions
* At least one runnable example

This ensures our automated API docs are complete and examples are usable without extra context.

---

## Versioning and releases with Changesets

* Run `pnpm changeset` to create a new changeset (choose packages + bump type).
* Run `pnpm version` to update versions and changelogs.
* Maintainers publish with `pnpm publish`.

Changelogs must describe **user-facing changes**. Avoid internal noise.

---

## Cleaning and resetting

* `pnpm run clean:packages` - remove build artifacts
* `pnpm run clean:packs` - remove generated tarballs
* `pnpm reset` - full reset of caches, node\_modules, and dependencies

---

## Principles

* Keep packages modular and framework-agnostic where possible.
* Breaking changes require a major bump and a clear migration path.
* Always add or update demos and tests when introducing a feature.
* Code should be deterministic, documented, and tested.

---

## Extra guidance (short additions)

### Environment

- Recommended Node version: >=24.x. Use a node version manager (nvm, fnm) or Corepack to pin a runtime that matches the root `package.json` `engines.node` requirement.
- Recommended package manager: pnpm (use the repo's lockfile). If you see unexpected errors, run `pnpm reset`.

### Where to edit packages

Packages live under `packages/*`. Public entry points are typically `packages/<name>/src/index.ts` and are referenced by the package's `package.json` (`main`/`module`/`exports`). Prefer editing `src/` files and keep package diffs focused. For framework bindings check `packages/react/` and `packages/vue-2/` or `packages/vue-3/`.

### Validation checklist (run locally before opening a PR)

Run the following to validate changes quickly:

```bash
pnpm lint
pnpm build
pnpm test # runs unit and/or cypress where configured
pnpm dev # optionally run the demos and open http://localhost:3000
```

If a single package is failing types, run a targeted build for that package (e.g. `pnpm -w -F @tiptap/core build`), or run `pnpm build` at the repo root.

### PR checklist

- All checks pass (lint/build/tests).
- Changeset added for user-facing changes (`pnpm changeset`).
- Demo added/updated for UI-visible changes.
- Short, clear PR description and changelog entry that explains why the change is needed.

### Guidance for automated agents and AI assistants

- Disclose when AI tools are used to generate any part of a contribution, including code, docs, tests, or other changes — the PR template includes an AI usage disclosure checkbox. Follow the project's guidelines on AI disclosure.
- Make single-purpose, small diffs. Avoid sweeping changes in one PR.
- Always run the validation checklist above after edits.
- Add or update a demo and tests for user-visible behavior. For deterministic behaviour, favour unit tests over fragile e2e tests where possible.
- Add a Changeset for any user-facing change. Do not change public APIs without a major bump; document migration steps in the PR description.

### Troubleshooting notes

- If CI fails with dependency or lockfile errors, run `pnpm reset` locally and re-run the build.
- For flaky Cypress tests, run the demo locally with `pnpm dev` and reproduce the failing test in `pnpm test:open`.
1 change: 1 addition & 0 deletions CLAUDE.md
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,10 @@ If the project maintainer has any additional requirements, you will find them li

- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting.

- **Disclose AI usage** — If you used AI tools (e.g., ChatGPT, Claude, GitHub Copilot) to generate any part of your contribution, you must clearly disclose this in your pull request description.

- **Link your pull request to an issue** — Pull requests must be linked to an existing issue that has been assigned to you. Before opening a PR, ensure there is an issue describing the bug or feature you're addressing. Trivial fixes (e.g., typos, broken links) are exempt.

- **Respond to feedback** — Maintainers may ask follow-up questions or request changes on your pull request. If you do not respond within 30 days, your PR may be closed. You are welcome to reopen it or submit a new PR once you're able to address the feedback.

**Happy coding**!
20 changes: 20 additions & 0 deletions packages/markdown/__tests__/manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,26 @@ Final paragraph.`
})
})

it('does not reopen marks after inline atom that does not carry them', () => {
const doc = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Bold ', marks: [{ type: 'bold' }] },
{ type: 'tag', attrs: { label: 'node' } },
{ type: 'text', text: ' some text' },
],
},
],
}

const markdown = markdownManager.renderNodes(doc)
// Trailing space is moved outside bold markers (per Issue #7180 whitespace convention)
expect(markdown).toBe('**Bold** [tag label="node"] some text')
})

describe('Round-trip Tests', () => {
beforeEach(() => {
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
Expand Down
12 changes: 10 additions & 2 deletions packages/markdown/src/MarkdownManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,8 +1186,16 @@ export class MarkdownManager {
result.push(textContent)
} else {
// For non-text nodes, close all active marks before rendering, then reopen after
const marksToReopen = new Map(activeMarks)
const openingModesToReopen = new Map(markOpeningModes)
// Only reopen marks that the node itself carries — marks don't skip over inline atoms.
const nodeMarkTypes = new Set((node.marks || []).map((mark: { type: string }) => mark.type))
const marksToReopen = new Map<string, { type: string; attrs?: Record<string, any> }>()
const openingModesToReopen = new Map<string, 'markdown' | 'html'>()
activeMarks.forEach((mark, type) => {
if (nodeMarkTypes.has(type)) {
marksToReopen.set(type, mark)
openingModesToReopen.set(type, markOpeningModes.get(type) ?? 'markdown')
}
})

// Close all marks before the node
const beforeMarkdown = closeMarksBeforeNode(activeMarks, (markType, mark) => {
Expand Down
7 changes: 3 additions & 4 deletions packages/markdown/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,18 @@ export function findMarksToCloseAtEnd(
activeMarks: Map<string, any>,
currentMarks: Map<string, any>,
nextNode: any,
markSetsEqual: (a: Map<string, any>, b: Map<string, any>) => boolean,
markSetsEqual: (a: Map<string, { type: string }>, b: Map<string, { type: string }>) => boolean,
): string[] {
const isLastNode = !nextNode
const nextNodeHasNoMarks = nextNode && nextNode.type === 'text' && (!nextNode.marks || nextNode.marks.length === 0)
const nextNodeHasNoMarks = nextNode && (!nextNode.marks || nextNode.marks.length === 0)
const nextNodeHasDifferentMarks =
nextNode &&
nextNode.type === 'text' &&
nextNode.marks &&
!markSetsEqual(currentMarks, new Map(nextNode.marks.map((mark: any) => [mark.type, mark])))

const marksToCloseAtEnd: string[] = []
if (isLastNode || nextNodeHasNoMarks || nextNodeHasDifferentMarks) {
if (nextNode && nextNode.type === 'text' && nextNode.marks) {
if (nextNode && nextNode.marks) {
const nextMarks = new Map(nextNode.marks.map((mark: any) => [mark.type, mark]))
Array.from(activeMarks.keys())
.reverse()
Expand Down
Loading