mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +01:00
Reuse the UI Markdown renderer without new dependencies
Keep Markdown projection with its UI owner and let journey evidence consume normalized content. Reuse existing react-markdown and remark-gfm packages; remove the root dependency additions and retain all observer, browser and source checks. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b9a2571b35
commit
023f78c8b9
4 files changed
+57
-26
No files matched your search
@@ -101,10 +101,7 @@
|
||||
"agentmail": "^0.5.31",
|
||||
"cross-env": "^10.1.0",
|
||||
"esbuild": "^0.28.2",
|
||||
"remark-gfm": "4.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"typescript": "^7.0.2",
|
||||
"unified": "11.0.5",
|
||||
"vitest": "^5.0.3",
|
||||
"yaml": "^2.8.1",
|
||||
"turbo": "2.11.10"
|
||||
|
||||
@@ -116,7 +116,7 @@ reply path. A finish summary by itself, an earlier turn, a progress
|
||||
bubble, or a cancelled run cannot satisfy it. Private receipts record the run,
|
||||
thread anchor and observed text; classic protocol comment matchers stay strict.
|
||||
Markdown replies compare complete text and exact link destinations after parsing
|
||||
with the production GFM and issue-reference plugins. The observer receives the
|
||||
through the UI-owned projection of its existing Markdown renderer and GFM/issue-reference plugins. The observer receives the
|
||||
same visible-company prefix list used by the UI, so bare issue keys and URLs
|
||||
match their rendered links. Whitespace and inline formatting can change during rendering. A
|
||||
document or issue chip can replace a link label, but must retain its destination.
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { unified } from "unified";
|
||||
import remarkParse from "remark-parse";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { markdownReplyContent } from "../../ui/src/lib/markdown-reply-content.js";
|
||||
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "../../ui/src/lib/company-routes.js";
|
||||
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../../ui/src/lib/issue-reference.js";
|
||||
import { parseIssueReferenceFromHref } from "../../ui/src/lib/issue-reference.js";
|
||||
|
||||
export type ReplyContentPart = { text: string } | { href: string; label?: string; decorated?: boolean };
|
||||
type MarkdownNode = { type: string; value?: string; url?: string; children?: MarkdownNode[] };
|
||||
|
||||
function normalized(parts: ReplyContentPart[], pageUrl: string, source = false) {
|
||||
const result: ReplyContentPart[] = [];
|
||||
@@ -43,23 +40,8 @@ export function taskReplyContentMatches(
|
||||
const displayed = observation.content.map(part => "text" in part ? part.text : part.label!).join("").replace(/\s+/g, " ").trim();
|
||||
if (displayed !== observation.body.replace(/\s+/g, " ").trim()) return false;
|
||||
}
|
||||
const parts: ReplyContentPart[] = [];
|
||||
const inlineLabel = (node: MarkdownNode): boolean => ["text", "inlineCode", "strong", "emphasis", "delete"].includes(node.type)
|
||||
&& (node.children ?? []).every(inlineLabel);
|
||||
const labelText = (node: MarkdownNode): string => node.value ?? (node.children ?? []).map(labelText).join("");
|
||||
function visit(node: MarkdownNode): boolean {
|
||||
if (node.type === "text" || node.type === "inlineCode") { parts.push({ text: node.value ?? "" }); return true; }
|
||||
if (node.type === "break") { parts.push({ text: " " }); return true; }
|
||||
if (node.type === "link" && node.url && (node.children ?? []).every(inlineLabel)) { parts.push({ href: node.url, label: labelText(node) }); return true; }
|
||||
if (!["root", "paragraph", "heading", "blockquote", "list", "listItem", "strong", "emphasis", "delete"].includes(node.type)) return false;
|
||||
for (const child of node.children ?? []) if (!visit(child)) return false;
|
||||
if (!["strong", "emphasis", "delete"].includes(node.type)) parts.push({ text: " " });
|
||||
return true;
|
||||
}
|
||||
const parser = unified().use(remarkParse).use(remarkGfm);
|
||||
const tree = parser.runSync(parser.parse(markdown)) as MarkdownNode;
|
||||
remarkLinkIssueReferences({ knownPrefixes: observation.knownCompanyPrefixes })(tree);
|
||||
if (!visit(tree)) return false;
|
||||
const parts = markdownReplyContent(markdown, observation.knownCompanyPrefixes);
|
||||
if (!parts) return false;
|
||||
const pageUrl = observation.pageUrl ?? observation.origin ?? "https://reply.invalid";
|
||||
const expected = normalized(parts, pageUrl, true);
|
||||
const actual = normalized(observation.content ?? [{ text: observation.body }], pageUrl);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Fragment, isValidElement, type ReactNode } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { remarkLinkIssueReferences } from "./issue-reference.js";
|
||||
|
||||
export type MarkdownReplyPart = { text: string } | { href: string; label: string };
|
||||
type AstNode = { type: string; children?: AstNode[] };
|
||||
|
||||
/** The reply observer consumes the UI's existing parser without adding a
|
||||
* parallel Markdown dependency graph. Unsupported structures fail closed. */
|
||||
export function markdownReplyContent(markdown: string, knownPrefixes?: string[]): MarkdownReplyPart[] | null {
|
||||
let rawHtml = false;
|
||||
function rejectHtml() {
|
||||
return (tree: AstNode) => {
|
||||
const nodes = [tree];
|
||||
while (nodes.length) {
|
||||
const node = nodes.pop()!;
|
||||
if (node.type === "html") rawHtml = true;
|
||||
nodes.push(...node.children ?? []);
|
||||
}
|
||||
};
|
||||
}
|
||||
const rendered = Markdown({ children: markdown,
|
||||
remarkPlugins: [remarkGfm, [remarkLinkIssueReferences, { knownPrefixes }], rejectHtml] });
|
||||
if (rawHtml) return null;
|
||||
const parts: MarkdownReplyPart[] = [];
|
||||
function visit(node: ReactNode, label?: string[]): boolean {
|
||||
if (node == null || typeof node === "boolean") return true;
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
if (label) label.push(String(node)); else parts.push({ text: String(node) });
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(node)) return node.every(child => visit(child, label));
|
||||
if (!isValidElement<{ children?: ReactNode; href?: string }>(node)) return false;
|
||||
if (node.type === Fragment) return visit(node.props.children, label);
|
||||
if (node.type === "a" && !label && node.props.href) {
|
||||
const words: string[] = [];
|
||||
if (!visit(node.props.children, words)) return false;
|
||||
parts.push({ href: node.props.href, label: words.join("") });
|
||||
return true;
|
||||
}
|
||||
if (node.type === "br") { if (label) label.push(" "); else parts.push({ text: " " }); return true; }
|
||||
if (["strong", "em", "del", "code"].includes(String(node.type))) return visit(node.props.children, label);
|
||||
if (!label && ["p", "ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6"].includes(String(node.type))) {
|
||||
if (!visit(node.props.children)) return false;
|
||||
parts.push({ text: " " });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return visit(rendered) ? parts : null;
|
||||
}
|
||||
Reference in new issue
Block a user