Files
PaperClipAI/ui/src/lib/blockquote-markdown.ts
T
DottaandPaperclip b1ac92f305 fix(ui): recover blockquotes the markdown editor escaped as backslash-gt (#10466)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The web UI uses one shared markdown editor for comments, issue
descriptions, and documents.
> - Users type `>` at the start of a line to insert a blockquote.
> - The live editor shortcut does not always run in every browser and
input method.
> - The markdown exporter then changes the leading `>` to `\>` and saves
literal text.
> - The saved text does not render as a blockquote.
> - This pull request restores the blockquote marker when markdown
enters or leaves the editor.
> - The benefit is reliable blockquote insertion on every surface that
uses the shared editor.

## Linked Issues or Issue Description

No public GitHub issue exists.

Related prior attempt: #10465.

**What happened?**

The shared markdown editor sometimes saved a blockquote as literal text.
This happened when the live shortcut did not run. The exporter saved `\>
text`, which rendered as literal `> text`.

**Expected behavior**

A line that starts with `>` must render as a blockquote in comments,
issue descriptions, and documents.

**Steps to reproduce**

1. Open a task comment composer, description editor, or document editor.
2. Add `> ` to an existing line, or use an input method that does not
run the live shortcut.
3. Save the content.
4. Observe that the saved line renders as literal text instead of a
blockquote.

**Paperclip version or commit**

`master` at `78f8c6c3d4`.

**Deployment mode**

Self-hosted server.

## What Changed

- Add `unescapeBlockquoteMarkers()` to restore block-level `\>` markers.
- Keep indented code, list content, nested content, and fenced code
unchanged.
- Apply the helper when markdown enters and leaves `MarkdownEditor`.
- Add focused tests for line position, indentation, container prefixes,
and CommonMark fence rules.

## Verification

- `pnpm exec vitest run ui/src/lib/blockquote-markdown.test.ts` passes
with 22 tests.
- `pnpm exec vitest run ui/src/components/MarkdownEditor.test.tsx`
passes with 37 tests.
- `pnpm --filter @paperclipai/ui typecheck` passes.
- `pnpm check:token-gates` passes.
- `git diff --check origin/master...HEAD` passes.
- A browser harness used the real `MarkdownEditor` and `IssueChatThread`
composer. It confirmed that `> text` renders as a blockquote and exports
as `> text`.
- The [Cutter
preview](https://github.com/paperclipai/paperclip/pull/10466#issuecomment-5140558263)
supplies a task-page screenshot and an editor interaction video.

## Risks

- Low risk. The helper returns the input unchanged when it contains no
`\>`.
- A paragraph that deliberately starts with literal `\>` now becomes a
blockquote. The editor has no literal-marker control, so this matches
the available input behavior.
- There are no database, API, or migration changes.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Anthropic Claude Opus 4.8 (`claude-opus-4-8`, 1M context), extended
thinking, with tool use and code execution.
- OpenAI Codex with GPT-5 (`gpt-5`; runtime build and context-window
metadata were not exposed), with reasoning, tool use, code execution,
and GitHub review tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change and contains no internal
Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes (none
needed)
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-07-31 18:52:42 -07:00

113 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* When the WYSIWYG blockquote shortcut does not fire (e.g. the `> ` prefix is
* assembled by an edit that Lexical's markdown-shortcut transform doesn't catch,
* which happens on some browsers/IMEs), MDXEditor exports the paragraph as an
* *escaped* blockquote — `\> text`. `mdast-util-to-markdown` escapes a leading
* `>` so a literal paragraph round-trips as text rather than a blockquote.
*
* In this product `>` at the start of a line always means "blockquote" (there is
* no separate literal-`>` affordance and the composer has no blockquote toolbar
* button), so an escaped `\>` is never the user's intent — it is a silently
* dropped blockquote. This helper rewrites a leading `\>` back to `>` so the
* stored markdown renders as the blockquote the user typed.
*
* Only a marker at the block-level line start is rewritten. The exporter escapes
* a `>` exactly where CommonMark would otherwise start a blockquote — the first
* column of a block, allowing the 0–3 spaces of insignificant indent. Restricting
* to `^ {0,3}\>` deliberately skips:
* - indented code blocks (4+ spaces of indent), whose `\>` is literal content;
* - content nested in a blockquote or list, whose lines carry a `>`/`-`/`digit.`
* container prefix rather than plain indent.
*
* Fenced code blocks are also skipped, including fences nested in blockquote or
* list containers: their contents are never `\`-escaped by the exporter, and a
* `\>` inside a code fence is meaningful literal text. Fence tracking follows
* CommonMark: a closing fence must use the same character as the opening fence,
* be at least as long, and carry no trailing content (an info string is only
* allowed on the opening fence).
*/
// An opening fence may follow blockquote/list container markers. Capture the
// whole prefix so the closing scan can preserve that container context.
const FENCE_OPEN_RE =
/^( {0,3}(?:(?:> ?|(?:[-+*]|\d{1,9}[.)]) +))*)(`{3,}|~{3,})(.*)$/;
const LIST_MARKER_RE = /(?:[-+*]|\d{1,9}[.)]) +/g;
const BLOCKQUOTE_MARKER_RE = />/g;
const FENCE_CLOSE_RE = /^( *)(`{3,}|~{3,})[ \t]*$/;
// A block-level escaped blockquote marker: `\>` at column 0, allowing only the
// 0–3 spaces of insignificant leading indent CommonMark permits before a block.
const ESCAPED_BLOCKQUOTE_RE = /^( {0,3})\\>/;
function stripBlockquotePrefix(line: string, depth: number): string | null {
let rest = line;
for (let i = 0; i < depth; i += 1) {
const marker = /^ {0,3}> ?/.exec(rest);
if (!marker) return null;
rest = rest.slice(marker[0].length);
}
return rest;
}
export function unescapeBlockquoteMarkers(markdown: string): string {
if (!markdown.includes("\\>")) return markdown;
const lines = markdown.split("\n");
let fenceChar = ""; // "" when not inside a fenced code block
let fenceLen = 0;
let fenceBlockquoteDepth = 0;
let fenceCloseIndentMax = 3;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (fenceChar) {
const closeCandidate = stripBlockquotePrefix(line, fenceBlockquoteDepth);
const closeMatch = closeCandidate ? FENCE_CLOSE_RE.exec(closeCandidate) : null;
if (closeMatch) {
const indent = closeMatch[1].length;
const run = closeMatch[2];
if (indent <= fenceCloseIndentMax && run[0] === fenceChar && run.length >= fenceLen) {
fenceChar = "";
fenceLen = 0;
fenceBlockquoteDepth = 0;
fenceCloseIndentMax = 3;
}
}
continue;
}
const fenceMatch = FENCE_OPEN_RE.exec(line);
if (fenceMatch) {
const prefix = fenceMatch[1];
const run = fenceMatch[2];
const char = run[0];
const rest = fenceMatch[3];
// A backtick info string may not itself contain a backtick (CommonMark);
// such a line is not a valid opening fence.
if (!(char === "`" && rest.includes("`"))) {
const listMarkers = prefix.match(LIST_MARKER_RE) ?? [];
fenceChar = char;
fenceLen = run.length;
fenceBlockquoteDepth = (prefix.match(BLOCKQUOTE_MARKER_RE) ?? []).length;
// A list's continuation indent includes its marker and following spaces.
// A closing fence may add CommonMark's normal 0–3 spaces after that.
fenceCloseIndentMax =
(listMarkers.length > 0 ? listMarkers.reduce((sum, marker) => sum + marker.length, 0) : 0) + 3;
continue;
}
}
if (ESCAPED_BLOCKQUOTE_RE.test(line)) {
lines[i] = line.replace(ESCAPED_BLOCKQUOTE_RE, "$1>");
}
}
return lines.join("\n");
}