mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 10:36:26 +02:00
## 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>
113 lines
4.5 KiB
TypeScript
113 lines
4.5 KiB
TypeScript
/**
|
||
* 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");
|
||
}
|