mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 23:39:21 +02:00
## Thinking Path
> - Paperclip helps people manage AI agents and their work
> - People use the rich markdown editor to write task descriptions,
comments, and agent instructions
> - The editor disables HTML processing, but the markdown parser still
treats some angle-bracket text as HTML
> - A value such as `<name>` could therefore stop the rich editor and
show the raw-source fallback
> - The retry action used the same input, so it could not recover
> - This pull request escapes unsupported angle brackets only while the
editor processes the value
> - The benefit is that the rich editor works and stored markdown stays
unchanged
## Linked Issues or Issue Description
Fixes: #12197
Related prior attempt: #2696
**What happened?**
The rich markdown editor did not start when prose contained a bare angle
bracket, such as `<name>`. It showed the raw-source fallback. The retry
action repeated the same failure.
**Expected behavior**
The rich editor starts for normal prose. It stores the text in its clean
form because agent prompts can use this text.
**Steps to reproduce**
1. Open a task description, comment, or other field that uses the rich
markdown editor.
2. Enter `Rename <name> to the real name`.
3. Reload the field.
4. Observe the raw-source fallback.
**Paperclip version or commit**
`master` at d785b1921.
**Deployment mode**
All modes. The defect is in the UI package.
## What Changed
- Escape only angle brackets that the parser treats as unsupported HTML
constructs.
- Restore the clean markdown before the editor sends a value to its
parent.
- Keep code, autolinks, link destinations, and existing escapes
unchanged.
- Apply the same conversion to pasted and inserted markdown.
- Make the empty-editor fallback wait for editor initialization and
confirm the empty state.
- Re-arm the initial empty-change guard when the user retries the
editor.
- Add `MDE-PARSE`, `MDE-RENDER`, and `MDE-EMPTY` fallback codes.
- Correct the editor mock and add regression tests for parsing, round
trips, paste, insert, retry, and fallback behavior.
## Verification
- `npx vitest run ui/src/lib/angle-bracket-markdown.test.ts
ui/src/lib/blockquote-markdown.test.ts
ui/src/components/MarkdownEditor.test.tsx` — 106 tests passed.
- `pnpm --filter @paperclipai/ui typecheck` — passed.
- `pnpm -r typecheck` — passed.
- `pnpm build` — passed.
- `pnpm check:token-gates` — reports nine existing color findings in
`ui/src/components/onboarding/PillGuy.tsx`. This pull request does not
change that file.
- `pnpm test:run` — the local run found four existing port-allocation
failures in unrelated workspace-runtime tests. The focused editor tests
passed. The pull request CI runs these suites on clean workers.
## Risks
- Low data risk. The conversion applies only while MDXEditor processes a
value. Stored markdown keeps the clean form.
- The scanner skips code, autolinks, link destinations, and existing
escapes to prevent content changes.
- The raw-source fallback can appear about 300 ms later because it now
confirms that the editor stayed empty.
- There is no database or API change.
> 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
- Original implementation: Anthropic Claude Opus, 1M context, with
agentic tool use in Claude Code.
- PR preparation and verification: OpenAI Codex with GPT-5, reasoning,
code execution, and tool use. The runtime did not expose the
context-window size.
## 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 (e.g. `docs/...`, `fix/...`)
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
- [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>
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
/**
|
||
* The rich markdown editor mounts MDXEditor with `suppressHtmlProcessing`, which
|
||
* strips the HTML visitor out of the import pipeline. Anything the markdown
|
||
* parser tokenizes as an HTML construct therefore arrives with no visitor able
|
||
* to handle it and throws `UnrecognizedMarkdownConstructError`, dropping the
|
||
* whole component into its raw-source fallback.
|
||
*
|
||
* The parser opens an HTML construct on a bare `<` followed by a letter, `!`,
|
||
* `/` or `?` — which is ordinary prose, not markup: a `<name>` placeholder, an
|
||
* inline `</close>`, an `<!-- note -->`. `escapeUnsupportedAngleBrackets`
|
||
* rewrites exactly those `<` to `\<` on the way into the editor so the parser
|
||
* sees literal text, and `unescapeAngleBracketEscapes` reverses it on the way
|
||
* out so the stored value keeps its clean, human-authored form. These fields
|
||
* feed agent prompts, so the escape is a transport detail that must never
|
||
* survive into storage.
|
||
*
|
||
* The pair is byte-lossless across the editor's own parse/serialize round trip:
|
||
* `mdast-util-to-markdown` re-escapes `<` to `\<` in precisely the contexts
|
||
* escaped here, and in no others (`<=` stays bare; code spans and code blocks
|
||
* are never escaped).
|
||
*
|
||
* One deliberate normalization: markdown that *already* contains `\<name>` is
|
||
* left alone on the way in and comes back out as `<name>`, because both forms
|
||
* mean the same literal text and the clean form is what this product stores.
|
||
* That only happens when the user actually edits the field.
|
||
*
|
||
* Contexts skipped, because the parser does not open an HTML construct there
|
||
* and rewriting would corrupt the content:
|
||
* - fenced code blocks (including fences carried by blockquote/list prefixes)
|
||
* - indented code blocks
|
||
* - inline code spans (matched backtick runs only — an unmatched or
|
||
* backslash-escaped backtick is literal text and protects nothing)
|
||
* - autolinks: `<scheme:...>` and `<user@host>`
|
||
* - pointed-bracket link destinations: `](<...>)`
|
||
* - an already-escaped `\<`
|
||
*
|
||
* Fence tracking mirrors `blockquote-markdown.ts`. The two scanners stay
|
||
* separate on purpose: that one only inspects the leading marker of a line,
|
||
* while this one has to classify every line as code or inline content and then
|
||
* walk the inline runs character by character.
|
||
*/
|
||
|
||
/** Characters that make the markdown parser treat a `<` as opening HTML. */
|
||
const HTML_OPENER_RE = /[A-Za-z!/?]/;
|
||
|
||
// 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]*$/;
|
||
/** Blockquote container markers, stripped before measuring block indent. */
|
||
const BLOCKQUOTE_PREFIX_RE = /^ {0,3}(?:> ?)+/;
|
||
/** Four spaces or a tab: the indent that opens a CommonMark indented code block. */
|
||
const INDENTED_CODE_RE = /^(?: {4}|\t)/;
|
||
|
||
// CommonMark autolinks. The scheme needs 2–32 characters, which is why `<x:y>`
|
||
// is not an autolink (it is also not HTML, so escaping it is harmless).
|
||
const AUTOLINK_URI_RE = /^<[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>\x00-\x1f]*>/;
|
||
const AUTOLINK_EMAIL_RE =
|
||
/^<[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*>/;
|
||
/** A pointed-bracket link destination may hold spaces but never `<` or `>`. */
|
||
const POINTED_DESTINATION_RE = /^<[^<>\n]*>/;
|
||
|
||
type Mode = "escape" | "unescape";
|
||
|
||
function isHtmlOpener(char: string | undefined): boolean {
|
||
return char !== undefined && HTML_OPENER_RE.test(char);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Index just past the backtick run that closes a code span opened at
|
||
* `contentStart` with `runLength` backticks, or -1 when nothing closes it.
|
||
*
|
||
* Only a run of exactly the same length closes the span, and backslashes are
|
||
* literal inside a code span — so the closing scan must not skip a backtick
|
||
* that happens to follow one.
|
||
*/
|
||
function findCodeSpanEnd(text: string, contentStart: number, runLength: number): number {
|
||
let i = contentStart;
|
||
|
||
while (i < text.length) {
|
||
if (text[i] !== "`") {
|
||
i += 1;
|
||
continue;
|
||
}
|
||
let run = 1;
|
||
while (text[i + run] === "`") run += 1;
|
||
if (run === runLength) return i + run;
|
||
i += run;
|
||
}
|
||
|
||
return -1;
|
||
}
|
||
|
||
/**
|
||
* The non-HTML constructs a `<` can open: an autolink, or a pointed-bracket
|
||
* link destination. Returns the whole construct, or null when the `<` opens
|
||
* neither.
|
||
*
|
||
* Both modes consult this one definition on purpose. Escape mode leaves these
|
||
* brackets bare, so unescape mode must refuse to *create* one — otherwise the
|
||
* pair is not an inverse, and an author's escaped `\<https://x>` would silently
|
||
* become a live link the first time the document is edited.
|
||
*
|
||
* `rest` starts at the `<`. `emitted` is the output so far, whose tail is what
|
||
* identifies a link destination.
|
||
*/
|
||
function matchProtectedConstruct(rest: string, emitted: string): string | null {
|
||
const autolink = AUTOLINK_URI_RE.exec(rest) ?? AUTOLINK_EMAIL_RE.exec(rest);
|
||
if (autolink) return autolink[0];
|
||
|
||
if (emitted.endsWith("](")) {
|
||
const destination = POINTED_DESTINATION_RE.exec(rest);
|
||
if (destination) return destination[0];
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Walk one run of inline content (one or more consecutive non-code lines) and
|
||
* rewrite the angle brackets the parser would treat as HTML.
|
||
*/
|
||
function transformInlineChunk(text: string, mode: Mode): string {
|
||
let out = "";
|
||
let i = 0;
|
||
|
||
while (i < text.length) {
|
||
const char = text[i];
|
||
|
||
if (char === "`") {
|
||
let run = 1;
|
||
while (text[i + run] === "`") run += 1;
|
||
const end = findCodeSpanEnd(text, i + run, run);
|
||
if (end !== -1) {
|
||
// A matched code span: its contents are literal, copy them through.
|
||
out += text.slice(i, end);
|
||
i = end;
|
||
continue;
|
||
}
|
||
// An unmatched run is ordinary text and protects nothing after it.
|
||
out += text.slice(i, i + run);
|
||
i += run;
|
||
continue;
|
||
}
|
||
|
||
if (char === "\\") {
|
||
if (
|
||
mode === "unescape"
|
||
&& text[i + 1] === "<"
|
||
&& isHtmlOpener(text[i + 2])
|
||
&& matchProtectedConstruct(text.slice(i + 1), out) === null
|
||
) {
|
||
out += "<";
|
||
i += 2;
|
||
continue;
|
||
}
|
||
// Copy the escape pair through untouched. This is what keeps backslash
|
||
// parity intact: `\\<name>` is a literal backslash followed by markup, so
|
||
// the `<` after it is still eligible for escaping, while the `<` in an
|
||
// already-escaped `\<name>` is skipped.
|
||
out += text.slice(i, i + 2);
|
||
i += 2;
|
||
continue;
|
||
}
|
||
|
||
if (mode === "escape" && char === "<" && isHtmlOpener(text[i + 1])) {
|
||
const protectedConstruct = matchProtectedConstruct(text.slice(i), out);
|
||
if (protectedConstruct) {
|
||
out += protectedConstruct;
|
||
i += protectedConstruct.length;
|
||
continue;
|
||
}
|
||
|
||
out += "\\<";
|
||
i += 1;
|
||
continue;
|
||
}
|
||
|
||
out += char;
|
||
i += 1;
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
function transformAngleBrackets(markdown: string, mode: Mode): string {
|
||
const lines = markdown.split("\n");
|
||
const out: string[] = [];
|
||
let chunk: string[] = [];
|
||
|
||
const flushChunk = () => {
|
||
if (chunk.length === 0) return;
|
||
out.push(transformInlineChunk(chunk.join("\n"), mode));
|
||
chunk = [];
|
||
};
|
||
|
||
let fenceChar = ""; // "" when not inside a fenced code block
|
||
let fenceLen = 0;
|
||
let fenceBlockquoteDepth = 0;
|
||
let fenceCloseIndentMax = 3;
|
||
// A paragraph is open while the previous line held inline content. Indented
|
||
// code cannot interrupt a paragraph, so a 4-space-indented line is a lazy
|
||
// continuation of the prose above it rather than code.
|
||
let paragraphOpen = false;
|
||
|
||
for (const line of lines) {
|
||
if (fenceChar) {
|
||
flushChunk();
|
||
out.push(line);
|
||
|
||
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) ?? [];
|
||
flushChunk();
|
||
out.push(line);
|
||
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;
|
||
paragraphOpen = false;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// Block structure is measured on the line's own content, after any
|
||
// blockquote markers, so quoted prose and quoted code are classified the
|
||
// same way as their unquoted equivalents.
|
||
const content = line.replace(BLOCKQUOTE_PREFIX_RE, "");
|
||
|
||
if (content.trim().length === 0) {
|
||
flushChunk();
|
||
out.push(line);
|
||
paragraphOpen = false;
|
||
continue;
|
||
}
|
||
|
||
if (!paragraphOpen && INDENTED_CODE_RE.test(content)) {
|
||
flushChunk();
|
||
out.push(line);
|
||
continue;
|
||
}
|
||
|
||
paragraphOpen = true;
|
||
chunk.push(line);
|
||
}
|
||
|
||
flushChunk();
|
||
return out.join("\n");
|
||
}
|
||
|
||
/**
|
||
* Rewrite `<` to `\<` wherever the markdown parser would otherwise open an HTML
|
||
* construct, leaving code, autolinks, link destinations and existing escapes
|
||
* untouched. Idempotent.
|
||
*/
|
||
export function escapeUnsupportedAngleBrackets(markdown: string): string {
|
||
if (!markdown.includes("<")) return markdown;
|
||
return transformAngleBrackets(markdown, "escape");
|
||
}
|
||
|
||
/**
|
||
* The inverse of {@link escapeUnsupportedAngleBrackets}: drop the backslash
|
||
* from `\<` wherever it only exists to hide an HTML construct from the parser.
|
||
*
|
||
* An escape is kept when removing it would open an autolink or a pointed link
|
||
* destination, because those are constructs the escape half deliberately leaves
|
||
* bare — unescaping one would turn an author's literal `\<https://x>` into a
|
||
* live link rather than reversing anything. Idempotent.
|
||
*/
|
||
export function unescapeAngleBracketEscapes(markdown: string): string {
|
||
if (!markdown.includes("\\<")) return markdown;
|
||
return transformAngleBrackets(markdown, "unescape");
|
||
}
|