mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 15:35:12 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Its Skills library imports reusable instructions from GitHub. > - Some skills depend on shared files and sibling skills outside their folders. > - Directory-only imports lose those dependencies. > - This pull request keeps repository files together and lets users select which skills appear in the library. > - Agents receive the original files and paths through the existing skill delivery system. ## Linked Issues or Issue Description Refs #14713. Related: #13656. **Current behavior** GitHub imports copy independent skill directories. Shared scripts, policies, helper skills, and credit files can be omitted. One shared-file error can block every skill. **Proposed behavior** New imports keep repository files together at the scanned commit. Show all skills with their folders and select every skill by default. Collapse individual package files. Omit unsupported or oversized files with a warning. Import available content without repair choices or an extra confirmation. Keep warnings on the saved source. **Reason and benefit** Skills can read their shared repository files after Paperclip delivers them to an agent. Original instructions stay unchanged. Users can inspect included files and understand omissions before and after import. ## What Changed - Store immutable company-scoped repository snapshots and associate them with skill versions. Regenerate the migration after merging master. - Preserve original bytes, binary encoding, and executable modes. Shared-file changes version each usable selected skill. Pinned versions retain their original files. - Deliver a discovery wrapper with the original tree under `.paperclip-repository/`. Preserve invocation policy when present. Fork and create-from-existing retain repository files. - Keep repository files together for new UI imports. Show every skill, select all by default, and collapse package files. Remove the packaging-mode menu. Keep the mobile import footer visible. - Warn about symlinks, submodules, oversized files, missing references, and invalid optional manifests. A malformed skill remains selected with a Needs setup warning. Usable skills still publish, and its original file remains repository support content. Source warnings persist through refresh. Unsafe execution and secret-disclosure findings omit the affected files before snapshot publication. - Expose saved repository files in the installed skill viewer, including binary encoding and executable flags. Reads use the immutable company-scoped snapshot; relative instruction links resolve from their original repository directory. - Audit delivered inventories and derive trust from all repository files. Accept evaluation prose while retaining dynamic-execution findings. Read blobs in one bounded Git batch and check budgets before reading support files. Imports never execute setup commands. - Add scanner, database, delivery, UI, and browser regressions. Update Storybooks, screenshots, and documentation. ## Verification - 69 focused scanner, Git snapshot, delivery, and PostgreSQL lifecycle tests passed. They cover partial imports, warning persistence, missing references, binary files, executable modes, copies, pinned versions, cancellation, company isolation, and batched Git reads. - 110 import, skill-detail, selection-tree, saved-file link, and Markdown renderer tests passed. A file-switch regression failed before the resolver fix and passed afterward. Browser walkthroughs cover the all-selected open tree, repository preview, non-blocking warnings, missing references, setup problems, warning persistence, and mobile layout. - Full workspace typecheck and build passed. Runner E2E TypeScript checks and design-token gates passed. The full serial local suite was attempted and stopped after over an hour. The existing 40,000-file Git streaming stress test failed after 303 seconds on macOS; the broad local suite is not counted as passed. All 55 applicable PR checks passed on the final commit, including the full sharded CI suite. Two optional Storybook workflows were skipped; the five local Storybook walkthroughs passed. - On October 12, live campaigns imported the private Poteto repository through the production GitHub Sources UI, selected architect and bro, and invoked them with native Codex on local and Daytona. The source connection was disabled before invocation. Independent filesystem inspections matched every supported Git blob, size, and executable mode. Both tasks completed and cleanup passed. Campaigns: `local-2026-10-12T15-07-39-504Z` (both environments), `local-2026-10-12T16-08-39-682Z` (local), and `local-2026-10-12T16-03-33-938Z` (Daytona). Each of the two selected skills received all 234 supported repository files. Later changes only affect the installed-file viewer and documentation. This qualifies skill delivery and instruction reads, not every Poteto workflow or adapter. - A manual source-checkout test drive imported a synthetic private GitHub repository with a symlink, an oversized file, a missing reference, and malformed skill metadata. Available skills published with their shared checklist. The walkthrough found duplicate warning text after import; the final UI fix removes that duplicate. The file viewer exposes saved repository dependencies and resolves authored relative links without changing the instructions. A follow-up live refresh omitted unsafe helper files with warnings, preserved every safe file byte, and survived a server restart. Screenshots below use the production UI with Storybook fixtures. They do not replace the live GitHub and runtime checks. <details> <summary>All skills visible and selected by default</summary>  </details> <details> <summary>Import stays available with warnings</summary>  </details> <details> <summary>Repository file inspector</summary>  </details> <details> <summary>Mobile import</summary>  </details> ## Risks - Each selected skill carries a repository copy in the runtime. The database stores each snapshot once. Runtime transfer and storage increase with the number of assigned skills. - Runtime budgets remain: 9,990 repository entries, 1 MiB per file, and 61 MiB total. File omissions are recorded as warnings. Source-access or download failures retain installed versions. - Import success does not prove that every skill can run. Environment dependencies, credentials, delegation tools, and schedules still need configuration. Imported setup scripts are not executed. - Live runtime coverage is native Codex on local and Daytona. Other adapters use the shared delivery path but were not exercised in these campaigns. The roadmap marks Skills Manager as shipped. This extends the existing importer. Related pull requests were checked. ## Model Used OpenAI Codex, based on GPT-6, with repository inspection, code execution, automated tests, and browser tools. The exact deployment identifier and context-window size were not exposed in this session. ## 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 - [ ] 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
1048 lines
35 KiB
TypeScript
1048 lines
35 KiB
TypeScript
import { isValidElement, memo, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Check, Copy, ExternalLink, WrapText } from "lucide-react";
|
|
import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown";
|
|
import remarkGfm from "remark-gfm";
|
|
import { cn } from "../lib/utils";
|
|
import { GithubIcon } from "@/components/icons/github-icon";
|
|
import { Link, useCaseHref } from "@/lib/router";
|
|
import { useTheme } from "../context/ThemeContext";
|
|
import { useOptionalCompany } from "../context/CompanyContext";
|
|
import { mentionChipInlineStyle, parseMentionChipHref } from "../lib/mention-chips";
|
|
import { issuesApi } from "../api/issues";
|
|
import { ApiError } from "../api/client";
|
|
import { LockedIssueChip } from "./LockedIssueChip";
|
|
import { agentsApi } from "../api/agents";
|
|
import { getCachedIssueDetail } from "../lib/issueDetailCache";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { parseIssueReferenceFromHref, remarkLinkIssueReferences } from "../lib/issue-reference";
|
|
import { remarkLinkCaseReferences } from "../lib/case-reference";
|
|
|
|
const CASE_HREF_RE = /^\/cases\/([A-Z][A-Z0-9]*-C\d+)$/i;
|
|
|
|
/** Recover the case identifier from a `/cases/PAP-C7` href produced by the plugin. */
|
|
function caseIdentifierFromHref(href: string | undefined): string | null {
|
|
if (!href) return null;
|
|
const match = decodeURIComponent(href.trim()).match(CASE_HREF_RE);
|
|
return match ? match[1]!.toUpperCase() : null;
|
|
}
|
|
import {
|
|
createRemarkWorkspaceFileRefs,
|
|
parseWorkspaceFileHref,
|
|
WORKSPACE_FILE_HREF_PREFIX,
|
|
type WorkspaceFileRefResolver,
|
|
} from "../lib/remark-workspace-file-refs";
|
|
import { remarkSoftBreaks } from "../lib/remark-soft-breaks";
|
|
import { StatusIcon } from "./StatusIcon";
|
|
import { WorkspaceFileLink } from "./WorkspaceFileLink";
|
|
import { ExternalObjectStatusIcon } from "./ExternalObjectStatusIcon";
|
|
import {
|
|
externalObjectDisplayStatusLabel,
|
|
externalObjectLivenessLabel,
|
|
externalObjectProviderLabel,
|
|
} from "../lib/external-objects";
|
|
import { normalizeExternalObjectHref } from "../lib/external-object-href";
|
|
import { copyTextToClipboard } from "../lib/clipboard";
|
|
import type {
|
|
Agent,
|
|
ExternalObjectLivenessState,
|
|
ExternalObjectStatusCategory,
|
|
} from "@paperclipai/shared";
|
|
|
|
function MarkdownAgentMention({ agentId, children, style }: {
|
|
agentId: string;
|
|
children: ReactNode;
|
|
style?: React.CSSProperties;
|
|
}) {
|
|
const companyId = useOptionalCompany()?.selectedCompanyId;
|
|
// All mentions share the company list cache; never fetch one agent per chip.
|
|
const { data: agents } = useQuery<Agent[]>({
|
|
queryKey: queryKeys.agents.list(companyId ?? "__none__"),
|
|
queryFn: () => agentsApi.list(companyId!),
|
|
enabled: Boolean(companyId),
|
|
staleTime: 60_000,
|
|
});
|
|
const appearance = agents?.find((agent) => agent.id === agentId)?.appearance;
|
|
return (
|
|
<a
|
|
href={`/agents/${agentId}`}
|
|
className="paperclip-mention-chip paperclip-mention-chip--agent"
|
|
data-mention-kind="agent"
|
|
style={{ ...mergeWrapStyle(style), ...mentionChipInlineStyle({ kind: "agent", agentId, icon: null, appearance }) }}
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Host-resolved external-object metadata for inline markdown decoration.
|
|
* The renderer only consumes the host normalized fields here — plugin React
|
|
* is never mounted inline (Phase 1B security review).
|
|
*/
|
|
export interface MarkdownExternalReference {
|
|
providerKey: string | null;
|
|
objectType: string | null;
|
|
displayKey?: string | null;
|
|
iconKey?: string | null;
|
|
statusCategory: ExternalObjectStatusCategory;
|
|
liveness: ExternalObjectLivenessState;
|
|
statusLabel?: string | null;
|
|
statusIconKey?: string | null;
|
|
displayTitle?: string | null;
|
|
}
|
|
|
|
export type MarkdownExternalReferenceMap = Record<string, MarkdownExternalReference>;
|
|
|
|
interface MarkdownBodyProps {
|
|
children: string;
|
|
className?: string;
|
|
style?: React.CSSProperties;
|
|
softBreaks?: boolean;
|
|
linkIssueReferences?: boolean;
|
|
/**
|
|
* Linkify bare case identifiers (`PAP-C7`) to the case detail page. Off by
|
|
* default; enabled on surfaces behind the experimental Cases flag (PAP-12969).
|
|
*/
|
|
linkCaseReferences?: boolean;
|
|
/** Opt into Obsidian-style [[target]] / [[target|label]] wikilinks. */
|
|
enableWikiLinks?: boolean;
|
|
/** Base href used for wikilinks when no resolver is supplied. */
|
|
wikiLinkRoot?: string;
|
|
/** Optional href resolver for wikilinks. Return null to leave a token as plain text. */
|
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
|
/**
|
|
* Optional map of `normalizeExternalObjectHref(href)` → host-resolved metadata.
|
|
* Hrefs in the markdown that resolve to one of these keys get the inline
|
|
* status icon prefix used by §2 of the UX spec.
|
|
*/
|
|
externalReferences?: MarkdownExternalReferenceMap;
|
|
/** Optional resolver for relative image paths (e.g. within export packages) */
|
|
resolveImageSrc?: (src: string) => string | null;
|
|
/** Resolve file links to app routes; null leaves the label without a link. */
|
|
resolveLinkHref?: (href: string) => string | null;
|
|
/** Called when a user clicks an inline image */
|
|
onImageClick?: (src: string) => void;
|
|
/** Keep untrusted decision media inert: image references and diagram source only. */
|
|
mediaMode?: "render" | "reference";
|
|
/**
|
|
* Resolver that decides which inline-code workspace file paths may be linked
|
|
* to the issue file viewer. Omitting it (or returning null) leaves every
|
|
* path-shaped code span as ordinary inline code — the fail-closed default.
|
|
*
|
|
* Its identity must change when previously-pending references become
|
|
* openable, so the markdown re-parses with the new answers.
|
|
*/
|
|
resolveWorkspaceFileRef?: WorkspaceFileRefResolver;
|
|
}
|
|
|
|
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
|
|
|
function MarkdownIssueLink({
|
|
issuePathId,
|
|
href,
|
|
children,
|
|
}: {
|
|
issuePathId: string;
|
|
href: string;
|
|
children: ReactNode;
|
|
}) {
|
|
const queryClient = useQueryClient();
|
|
const [engaged, setEngaged] = useState(false);
|
|
const { data, error } = useQuery({
|
|
queryKey: queryKeys.issues.detail(issuePathId),
|
|
// A transcript can mention dozens of tasks. Their full detail projections
|
|
// are hover information, not prerequisites for reading this conversation.
|
|
enabled: engaged,
|
|
placeholderData: getCachedIssueDetail(queryClient, issuePathId),
|
|
queryFn: () => issuesApi.get(issuePathId),
|
|
staleTime: 60_000,
|
|
// A private issue 404s on direct fetch (indistinguishable from deleted, by
|
|
// design). Don't burn retries on it — settle straight to the locked chip.
|
|
retry: (failureCount, err) =>
|
|
!(err instanceof ApiError && err.status === 404) && failureCount < 3,
|
|
});
|
|
|
|
// Mention of an issue this viewer can't read → existence-only locked chip
|
|
// (never a title or a link), matching the locked-stub treatment on edges.
|
|
if (error instanceof ApiError && error.status === 404) {
|
|
return <LockedIssueChip identifier={issuePathId} unavailable />;
|
|
}
|
|
|
|
const identifier = data?.identifier ?? issuePathId;
|
|
const title = data?.title ?? identifier;
|
|
const status = data?.status;
|
|
const issueLabel = title !== identifier ? `Issue ${identifier}: ${title}` : `Issue ${identifier}`;
|
|
|
|
// Until the fetch settles we don't yet know whether this viewer can read the
|
|
// issue. Keep the mention a plain link (clickable, styled as today) but hold
|
|
// off mounting the IssueLinkQuicklook hover preview — a Radix Popover portal —
|
|
// until `data` confirms the issue is readable.
|
|
// - Correctness (PAP-16070): a private mention 404s straight to the locked
|
|
// chip. Mounting the quicklook popover during the loading `<Link>` only to
|
|
// tear the portal down and swap in a plain `<span>` chip on the 404 is the
|
|
// element churn that crashed the chat transcript's primary renderer (it
|
|
// fell through to the safe fallback). Loading link → chip stays portal-free.
|
|
// - Privacy: don't prefetch / hover-preview an issue of unconfirmed
|
|
// readability. `data-mention-pending` marks the transient state for tests.
|
|
const pending = !data;
|
|
|
|
return (
|
|
<Link
|
|
to={href}
|
|
data-mention-kind="issue"
|
|
disableIssueQuicklook={pending}
|
|
data-mention-pending={pending ? "true" : undefined}
|
|
onPointerEnter={() => setEngaged(true)}
|
|
onFocus={() => setEngaged(true)}
|
|
// Boxless inline mention: the unified status glyph + a regular-weight
|
|
// underlined link, optically centered with the body text.
|
|
className={cn("paperclip-markdown-issue-ref", "font-normal underline")}
|
|
title={title}
|
|
aria-label={issueLabel}
|
|
>
|
|
{status ? (
|
|
<StatusIcon status={status} size="md" className="relative -top-px mr-1 inline-block h-4 w-4 align-middle" />
|
|
) : null}
|
|
{children}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function MarkdownCaseLink({
|
|
identifier,
|
|
children,
|
|
}: {
|
|
identifier: string;
|
|
children: ReactNode;
|
|
}) {
|
|
// Cases resolve via the get-by-identifier route; navigate there on click.
|
|
// Kept boxless/underlined to match the issue mention treatment.
|
|
const caseHref = useCaseHref();
|
|
return (
|
|
<Link
|
|
to={caseHref(identifier)}
|
|
data-mention-kind="case"
|
|
className={cn("paperclip-markdown-case-ref", "font-normal underline")}
|
|
aria-label={`Case ${identifier}`}
|
|
>
|
|
{children}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function MarkdownExternalLink({
|
|
href,
|
|
reference,
|
|
children,
|
|
}: {
|
|
href: string;
|
|
reference: MarkdownExternalReference;
|
|
children: ReactNode;
|
|
}) {
|
|
const provider = externalObjectProviderLabel(reference.providerKey);
|
|
const displayKey = reference.displayKey?.trim() || provider;
|
|
const statusLabel = externalObjectDisplayStatusLabel(reference);
|
|
const livenessLabel = externalObjectLivenessLabel(reference.liveness);
|
|
const livenessSuffix = reference.liveness === "fresh" || reference.liveness === "unknown"
|
|
|| livenessLabel.toLowerCase() === statusLabel.toLowerCase()
|
|
? ""
|
|
: ` (${livenessLabel})`;
|
|
const titleParts = [
|
|
reference.displayTitle ?? `${displayKey} ${statusLabel}`,
|
|
`${displayKey} — ${statusLabel}${livenessSuffix}`,
|
|
];
|
|
const title = titleParts.filter(Boolean).join(" · ");
|
|
return (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
data-external-link="resolved"
|
|
data-external-status={reference.statusCategory}
|
|
data-external-liveness={reference.liveness}
|
|
title={title}
|
|
aria-label={`${displayKey} ${statusLabel}${livenessSuffix}: ${reference.displayTitle ?? href}`}
|
|
className="paperclip-markdown-external-ref"
|
|
>
|
|
<ExternalObjectStatusIcon
|
|
category={reference.statusCategory}
|
|
liveness={reference.liveness}
|
|
statusIconKey={reference.statusIconKey}
|
|
label={`${displayKey}: ${statusLabel}`}
|
|
inline
|
|
/>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function loadMermaid() {
|
|
if (!mermaidLoaderPromise) {
|
|
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
|
|
}
|
|
return mermaidLoaderPromise;
|
|
}
|
|
|
|
const wrapAnywhereStyle: React.CSSProperties = {
|
|
overflowWrap: "anywhere",
|
|
wordBreak: "break-word",
|
|
};
|
|
|
|
const scrollableBlockStyle: React.CSSProperties = {
|
|
maxWidth: "100%",
|
|
overflowX: "auto",
|
|
};
|
|
|
|
const codeBlockActionsStyle: React.CSSProperties = {
|
|
position: "absolute",
|
|
top: "0.4rem",
|
|
right: "0.4rem",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: "0.25rem",
|
|
};
|
|
|
|
export const codeBlockActionStyle: React.CSSProperties = {
|
|
position: "static",
|
|
opacity: 1,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: "0.25rem",
|
|
minHeight: "1.55rem",
|
|
padding: "0.2rem 0.4rem",
|
|
borderRadius: "calc(var(--radius) - 4px)",
|
|
border: "1px solid color-mix(in oklab, var(--foreground) 14%, transparent)",
|
|
backgroundColor: "var(--background)",
|
|
color: "var(--muted-foreground)",
|
|
fontSize: "var(--text-micro)",
|
|
lineHeight: 1,
|
|
cursor: "pointer",
|
|
};
|
|
|
|
const codeBlockWrapActionStyle: React.CSSProperties = {
|
|
...codeBlockActionStyle,
|
|
width: "1.55rem",
|
|
paddingInline: 0,
|
|
};
|
|
|
|
const tableCellWrapStyle: React.CSSProperties = {
|
|
overflowWrap: "anywhere",
|
|
wordBreak: "normal",
|
|
};
|
|
|
|
function isHtmlCommentNode(node: MarkdownAstNode) {
|
|
return node.type === "html" && typeof node.value === "string" && /^<!--[\s\S]*-->$/.test(node.value.trim());
|
|
}
|
|
|
|
function isEscapedHtmlCommentPlaceholder(node: MarkdownAstNode) {
|
|
if (node.type !== "text" || typeof node.value !== "string") return false;
|
|
const value = node.value.trim();
|
|
return /^\\?<!--(?:\s*-{0,2}>?)?$/.test(value) || /^<!--(?:\s*-{0,2}(?:>)?)?$/.test(value);
|
|
}
|
|
|
|
function remarkDropHtmlComments() {
|
|
return (tree: MarkdownAstNode) => {
|
|
const visit = (node: MarkdownAstNode) => {
|
|
const children = node.children;
|
|
if (!children) return;
|
|
node.children = children.filter((child) => !isHtmlCommentNode(child) && !isEscapedHtmlCommentPlaceholder(child));
|
|
for (const child of node.children) {
|
|
visit(child);
|
|
}
|
|
};
|
|
visit(tree);
|
|
};
|
|
}
|
|
|
|
function mergeWrapStyle(style?: React.CSSProperties): React.CSSProperties {
|
|
return {
|
|
...wrapAnywhereStyle,
|
|
...style,
|
|
};
|
|
}
|
|
|
|
function mergeTableCellStyle(style?: React.CSSProperties): React.CSSProperties {
|
|
return {
|
|
...tableCellWrapStyle,
|
|
...style,
|
|
};
|
|
}
|
|
|
|
function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProperties {
|
|
return {
|
|
...scrollableBlockStyle,
|
|
...style,
|
|
};
|
|
}
|
|
|
|
function flattenText(value: ReactNode): string {
|
|
if (value == null) return "";
|
|
if (typeof value === "string" || typeof value === "number") return String(value);
|
|
if (Array.isArray(value)) return value.map((item) => flattenText(item)).join("");
|
|
return "";
|
|
}
|
|
|
|
function extractMermaidSource(children: ReactNode): string | null {
|
|
if (!isValidElement(children)) return null;
|
|
const childProps = children.props as { className?: unknown; children?: ReactNode };
|
|
if (typeof childProps.className !== "string") return null;
|
|
if (!/\blanguage-mermaid\b/i.test(childProps.className)) return null;
|
|
return flattenText(childProps.children).replace(/\n$/, "");
|
|
}
|
|
|
|
function safeMarkdownUrlTransform(url: string): string {
|
|
if (url.startsWith(WORKSPACE_FILE_HREF_PREFIX)) return url;
|
|
return parseMentionChipHref(url) ? url : defaultUrlTransform(url);
|
|
}
|
|
|
|
type MarkdownAstNode = {
|
|
type?: string;
|
|
value?: string;
|
|
children?: MarkdownAstNode[];
|
|
url?: string;
|
|
title?: string | null;
|
|
data?: {
|
|
hProperties?: Record<string, string>;
|
|
};
|
|
};
|
|
|
|
type ParsedWikiLink = {
|
|
target: string;
|
|
label: string;
|
|
};
|
|
|
|
const WIKI_LINK_PATTERN = /\[\[([^\]\r\n]+)\]\]/g;
|
|
const WIKI_LINK_SKIP_PARENT_TYPES = new Set([
|
|
"definition",
|
|
"image",
|
|
"imageReference",
|
|
"link",
|
|
"linkReference",
|
|
]);
|
|
|
|
function parseWikiLinkBody(body: string): ParsedWikiLink | null {
|
|
const [rawTarget, ...rawLabelParts] = body.split("|");
|
|
const target = rawTarget?.trim() ?? "";
|
|
const label = rawLabelParts.length > 0 ? rawLabelParts.join("|").trim() : target;
|
|
if (!target || target.includes("[") || target.includes("]")) return null;
|
|
return {
|
|
target,
|
|
label: label || target,
|
|
};
|
|
}
|
|
|
|
function encodeWikiLinkTarget(target: string): string | null {
|
|
const trimmed = target.trim();
|
|
if (!trimmed || /^[a-z][a-z\d+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null;
|
|
|
|
const hashIndex = trimmed.indexOf("#");
|
|
const rawPath = (hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed)
|
|
.trim()
|
|
.replace(/^\/+/, "");
|
|
if (
|
|
!rawPath ||
|
|
rawPath.includes("\\") ||
|
|
rawPath.split("/").some((segment) => !segment || segment === "." || segment === "..")
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const encodedPath = rawPath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
const rawHash = hashIndex >= 0 ? trimmed.slice(hashIndex + 1).trim() : "";
|
|
return rawHash ? `${encodedPath}#${encodeURIComponent(rawHash)}` : encodedPath;
|
|
}
|
|
|
|
function defaultWikiLinkHref(target: string, wikiLinkRoot?: string): string | null {
|
|
const encodedTarget = encodeWikiLinkTarget(target);
|
|
if (!encodedTarget) return null;
|
|
const root = wikiLinkRoot?.trim().replace(/\/+$/, "") ?? "";
|
|
return root ? `${root}/${encodedTarget}` : encodedTarget;
|
|
}
|
|
|
|
function createWikiLinkNode(href: string, wikiLink: ParsedWikiLink): MarkdownAstNode {
|
|
return {
|
|
type: "link",
|
|
url: href,
|
|
title: null,
|
|
data: {
|
|
hProperties: {
|
|
"data-paperclip-wiki-link": "true",
|
|
"data-paperclip-wiki-target": wikiLink.target,
|
|
},
|
|
},
|
|
children: [{ type: "text", value: wikiLink.label }],
|
|
};
|
|
}
|
|
|
|
function splitTextByWikiLinks(
|
|
value: string,
|
|
options: {
|
|
wikiLinkRoot?: string;
|
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
|
},
|
|
): MarkdownAstNode[] {
|
|
const nodes: MarkdownAstNode[] = [];
|
|
let lastIndex = 0;
|
|
|
|
for (const match of value.matchAll(WIKI_LINK_PATTERN)) {
|
|
const raw = match[0] ?? "";
|
|
const body = match[1] ?? "";
|
|
const start = match.index ?? 0;
|
|
if (start > lastIndex) {
|
|
nodes.push({ type: "text", value: value.slice(lastIndex, start) });
|
|
}
|
|
|
|
const wikiLink = parseWikiLinkBody(body);
|
|
let resolvedHref: string | null = null;
|
|
if (wikiLink) {
|
|
if (options.resolveWikiLinkHref) {
|
|
const customHref = options.resolveWikiLinkHref(wikiLink.target, wikiLink.label);
|
|
resolvedHref = customHref === undefined
|
|
? defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot)
|
|
: customHref;
|
|
} else {
|
|
resolvedHref = defaultWikiLinkHref(wikiLink.target, options.wikiLinkRoot);
|
|
}
|
|
}
|
|
|
|
if (wikiLink && resolvedHref) {
|
|
nodes.push(createWikiLinkNode(resolvedHref, wikiLink));
|
|
} else {
|
|
nodes.push({ type: "text", value: raw });
|
|
}
|
|
lastIndex = start + raw.length;
|
|
}
|
|
|
|
if (lastIndex < value.length) {
|
|
nodes.push({ type: "text", value: value.slice(lastIndex) });
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
function transformWikiLinkChildren(
|
|
node: MarkdownAstNode,
|
|
options: {
|
|
wikiLinkRoot?: string;
|
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
|
},
|
|
) {
|
|
if (!node.children || WIKI_LINK_SKIP_PARENT_TYPES.has(node.type ?? "")) return;
|
|
|
|
node.children = node.children.flatMap((child) => {
|
|
if (child.type === "text" && typeof child.value === "string" && child.value.includes("[[")) {
|
|
return splitTextByWikiLinks(child.value, options);
|
|
}
|
|
transformWikiLinkChildren(child, options);
|
|
return child;
|
|
});
|
|
}
|
|
|
|
function createRemarkWikiLinks(options: {
|
|
wikiLinkRoot?: string;
|
|
resolveWikiLinkHref?: (target: string, label: string) => string | null | undefined;
|
|
}) {
|
|
return function remarkWikiLinks() {
|
|
return (tree: MarkdownAstNode) => {
|
|
transformWikiLinkChildren(tree, options);
|
|
};
|
|
};
|
|
}
|
|
|
|
function isGitHubUrl(href: string | null | undefined): boolean {
|
|
if (!href) return false;
|
|
try {
|
|
const url = new URL(href);
|
|
return url.protocol === "https:" && (url.hostname === "github.com" || url.hostname === "www.github.com");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isExternalHttpUrl(href: string | null | undefined): boolean {
|
|
if (!href) return false;
|
|
try {
|
|
const url = new URL(href);
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
if (typeof window === "undefined") return true;
|
|
return url.origin !== window.location.origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function renderLinkBody(
|
|
children: ReactNode,
|
|
leadingIcon: ReactNode,
|
|
trailingIcon: ReactNode,
|
|
): ReactNode {
|
|
if (!leadingIcon && !trailingIcon) return children;
|
|
|
|
// React-markdown can pass arrays/elements for styled link text; the nowrap
|
|
// splitting below is intentionally limited to plain text links.
|
|
if (typeof children === "string" && children.length > 0) {
|
|
if (children.length === 1) {
|
|
return (
|
|
<span style={{ whiteSpace: "nowrap" }}>
|
|
{leadingIcon}
|
|
{children}
|
|
{trailingIcon}
|
|
</span>
|
|
);
|
|
}
|
|
const first = children[0];
|
|
const last = children[children.length - 1];
|
|
const middle = children.slice(1, -1);
|
|
return (
|
|
<>
|
|
{leadingIcon ? (
|
|
<span style={{ whiteSpace: "nowrap" }}>
|
|
{leadingIcon}
|
|
{first}
|
|
</span>
|
|
) : first}
|
|
{middle}
|
|
{trailingIcon ? (
|
|
<span style={{ whiteSpace: "nowrap" }}>
|
|
{last}
|
|
{trailingIcon}
|
|
</span>
|
|
) : last}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{leadingIcon}
|
|
{children}
|
|
{trailingIcon}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function CodeBlock({
|
|
children,
|
|
preProps,
|
|
}: {
|
|
children: ReactNode;
|
|
preProps: React.HTMLAttributes<HTMLPreElement>;
|
|
}) {
|
|
const [copied, setCopied] = useState(false);
|
|
const [failed, setFailed] = useState(false);
|
|
const [wrapLines, setWrapLines] = useState(false);
|
|
const preRef = useRef<HTMLPreElement>(null);
|
|
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
useEffect(() => () => clearTimeout(timerRef.current), []);
|
|
|
|
const handleCopy = useCallback(async () => {
|
|
const text = preRef.current?.innerText ?? flattenText(children);
|
|
try {
|
|
await copyTextToClipboard(text);
|
|
setFailed(false);
|
|
setCopied(true);
|
|
} catch {
|
|
setFailed(true);
|
|
setCopied(true);
|
|
}
|
|
clearTimeout(timerRef.current);
|
|
timerRef.current = setTimeout(() => {
|
|
setCopied(false);
|
|
setFailed(false);
|
|
}, 1500);
|
|
}, [children]);
|
|
|
|
const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
|
|
const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines";
|
|
|
|
return (
|
|
<div className="paperclip-markdown-codeblock" data-wrap-lines={wrapLines || undefined}>
|
|
<pre
|
|
{...preProps}
|
|
ref={preRef}
|
|
style={{
|
|
...mergeScrollableBlockStyle(preProps.style as React.CSSProperties | undefined),
|
|
...(wrapLines
|
|
? {
|
|
overflowX: "hidden",
|
|
whiteSpace: "pre-wrap",
|
|
overflowWrap: "anywhere",
|
|
wordBreak: "break-word",
|
|
}
|
|
: null),
|
|
}}
|
|
>
|
|
{children}
|
|
</pre>
|
|
<div
|
|
className="paperclip-markdown-codeblock-actions"
|
|
style={codeBlockActionsStyle}
|
|
data-active={copied || failed || wrapLines || undefined}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setWrapLines((value) => !value)}
|
|
aria-label={wrapLabel}
|
|
title={wrapLabel}
|
|
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
|
|
style={wrapLines
|
|
? {
|
|
...codeBlockWrapActionStyle,
|
|
borderColor: "color-mix(in oklab, var(--primary) 38%, transparent)",
|
|
color: "var(--primary)",
|
|
}
|
|
: codeBlockWrapActionStyle}
|
|
aria-pressed={wrapLines}
|
|
data-active={wrapLines || undefined}
|
|
>
|
|
<WrapText aria-hidden="true" className="h-3.5 w-3.5" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
aria-label="Copy code"
|
|
title={copyLabel}
|
|
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-copy"
|
|
style={codeBlockActionStyle}
|
|
data-copied={copied || undefined}
|
|
data-failed={failed || undefined}
|
|
>
|
|
{copied && !failed ? (
|
|
<Check aria-hidden="true" className="h-3.5 w-3.5" />
|
|
) : (
|
|
<Copy aria-hidden="true" className="h-3.5 w-3.5" />
|
|
)}
|
|
<span className="paperclip-markdown-codeblock-action-label">{copyLabel}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
|
|
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
const [svg, setSvg] = useState<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
setSvg(null);
|
|
setError(null);
|
|
|
|
loadMermaid()
|
|
.then(async (mermaid) => {
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
securityLevel: "strict",
|
|
theme: darkMode ? "dark" : "default",
|
|
fontFamily: "inherit",
|
|
suppressErrorRendering: true,
|
|
});
|
|
const rendered = await mermaid.render(`paperclip-mermaid-${renderId}`, source);
|
|
if (!active) return;
|
|
setSvg(rendered.svg);
|
|
})
|
|
.catch((err) => {
|
|
if (!active) return;
|
|
const message =
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: "Failed to render Mermaid diagram.";
|
|
setError(message);
|
|
});
|
|
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [darkMode, renderId, source]);
|
|
|
|
return (
|
|
<div className="paperclip-mermaid">
|
|
{svg ? (
|
|
<div dangerouslySetInnerHTML={{ __html: svg }} />
|
|
) : (
|
|
<>
|
|
<p className={cn("paperclip-mermaid-status", error && "paperclip-mermaid-status-error")}>
|
|
{error ? `Unable to render Mermaid diagram: ${error}` : "Rendering Mermaid diagram..."}
|
|
</p>
|
|
<pre className="paperclip-mermaid-source">
|
|
<code className="language-mermaid">{source}</code>
|
|
</pre>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MarkdownBodyImpl({
|
|
children,
|
|
className,
|
|
style,
|
|
softBreaks = true,
|
|
linkIssueReferences = true,
|
|
linkCaseReferences = false,
|
|
enableWikiLinks = false,
|
|
wikiLinkRoot,
|
|
resolveWikiLinkHref,
|
|
externalReferences,
|
|
resolveImageSrc,
|
|
resolveLinkHref,
|
|
onImageClick,
|
|
mediaMode = "render",
|
|
resolveWorkspaceFileRef,
|
|
}: MarkdownBodyProps) {
|
|
const { theme } = useTheme();
|
|
// Read company prefixes non-throwingly: MarkdownBody renders in surfaces that
|
|
// may lack a CompanyProvider. A null context (or no companies yet) leaves
|
|
// knownPrefixes undefined, which keeps issue auto-linking permissive.
|
|
const company = useOptionalCompany();
|
|
const companies = company?.companies;
|
|
// Stable identity so it can feed the memoized remark plugins without
|
|
// re-creating them (and forcing a full markdown re-parse) every render.
|
|
const knownPrefixes = useMemo(
|
|
() => (companies?.length ? companies.map((c) => c.issuePrefix) : undefined),
|
|
[companies],
|
|
);
|
|
const externalReferenceLookup = useMemo<MarkdownExternalReferenceMap | null>(() => {
|
|
if (!externalReferences) return null;
|
|
const lookup: MarkdownExternalReferenceMap = {};
|
|
for (const [key, value] of Object.entries(externalReferences)) {
|
|
const normalized = normalizeExternalObjectHref(key) ?? key;
|
|
lookup[normalized] = value;
|
|
}
|
|
return lookup;
|
|
}, [externalReferences]);
|
|
// react-markdown treats the values of `components` as component *types* and
|
|
// the `remarkPlugins` array by identity. Rebuilding either on every render
|
|
// forces react-markdown to unmount/remount the rendered tree, which discards
|
|
// scroll position and text selection and causes visible flashing when a
|
|
// parent re-renders frequently (see PAP-10767). Memoize both so re-renders
|
|
// that don't change the inputs are cheap and non-destructive.
|
|
const remarkPlugins = useMemo<NonNullable<Options["remarkPlugins"]>>(() => {
|
|
const plugins: NonNullable<Options["remarkPlugins"]> = [remarkGfm, remarkDropHtmlComments];
|
|
if (enableWikiLinks) {
|
|
plugins.push(createRemarkWikiLinks({ wikiLinkRoot, resolveWikiLinkHref }));
|
|
}
|
|
if (resolveWorkspaceFileRef) {
|
|
plugins.push(createRemarkWorkspaceFileRefs(resolveWorkspaceFileRef));
|
|
}
|
|
if (linkIssueReferences) {
|
|
plugins.push([remarkLinkIssueReferences, { knownPrefixes }]);
|
|
}
|
|
if (linkCaseReferences) {
|
|
plugins.push([remarkLinkCaseReferences, { knownPrefixes }]);
|
|
}
|
|
if (softBreaks) {
|
|
plugins.push(remarkSoftBreaks);
|
|
}
|
|
return plugins;
|
|
}, [enableWikiLinks, wikiLinkRoot, resolveWikiLinkHref, resolveWorkspaceFileRef, linkIssueReferences, linkCaseReferences, knownPrefixes, softBreaks]);
|
|
const components = useMemo<Components>(() => {
|
|
const map: Components = {
|
|
p: ({ node: _node, style: paragraphStyle, children: paragraphChildren, ...paragraphProps }) => (
|
|
<p {...paragraphProps} style={mergeWrapStyle(paragraphStyle as React.CSSProperties | undefined)}>
|
|
{paragraphChildren}
|
|
</p>
|
|
),
|
|
li: ({ node: _node, style: listItemStyle, children: listItemChildren, ...listItemProps }) => (
|
|
<li {...listItemProps} style={mergeWrapStyle(listItemStyle as React.CSSProperties | undefined)}>
|
|
{listItemChildren}
|
|
</li>
|
|
),
|
|
blockquote: ({ node: _node, style: blockquoteStyle, children: blockquoteChildren, ...blockquoteProps }) => (
|
|
<blockquote {...blockquoteProps} style={mergeWrapStyle(blockquoteStyle as React.CSSProperties | undefined)}>
|
|
{blockquoteChildren}
|
|
</blockquote>
|
|
),
|
|
table: ({ node: _node, style: tableStyle, children: tableChildren, ...tableProps }) => (
|
|
<div className="paperclip-markdown-table-scroll" role="region" aria-label="Scrollable table" tabIndex={0}>
|
|
<table {...tableProps} style={tableStyle as React.CSSProperties | undefined}>
|
|
{tableChildren}
|
|
</table>
|
|
</div>
|
|
),
|
|
td: ({ node: _node, style: tableCellStyle, children: tableCellChildren, ...tableCellProps }) => (
|
|
<td {...tableCellProps} style={mergeTableCellStyle(tableCellStyle as React.CSSProperties | undefined)}>
|
|
{tableCellChildren}
|
|
</td>
|
|
),
|
|
th: ({ node: _node, style: tableHeaderStyle, children: tableHeaderChildren, ...tableHeaderProps }) => (
|
|
<th {...tableHeaderProps} style={mergeTableCellStyle(tableHeaderStyle as React.CSSProperties | undefined)}>
|
|
{tableHeaderChildren}
|
|
</th>
|
|
),
|
|
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
|
const mermaidSource = extractMermaidSource(preChildren);
|
|
if (mermaidSource && mediaMode === "render") {
|
|
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
|
|
}
|
|
return <CodeBlock preProps={preProps}>{preChildren}</CodeBlock>;
|
|
},
|
|
code: ({ node: _node, style: codeStyle, children: codeChildren, ...codeProps }) => (
|
|
<code {...codeProps} style={mergeWrapStyle(codeStyle as React.CSSProperties | undefined)}>
|
|
{codeChildren}
|
|
</code>
|
|
),
|
|
a: ({ node: _node, href, style: linkStyle, children: linkChildren, ...anchorProps }) => {
|
|
const workspaceFileRef = parseWorkspaceFileHref(href);
|
|
if (workspaceFileRef) {
|
|
return (
|
|
<WorkspaceFileLink
|
|
workspaceFileRef={workspaceFileRef}
|
|
label={linkChildren}
|
|
className={typeof anchorProps.className === "string" ? anchorProps.className : undefined}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const dataProps = anchorProps as Record<string, unknown>;
|
|
const isWikiLink = dataProps["data-paperclip-wiki-link"] === "true";
|
|
if (isWikiLink && href && !/^[a-z][a-z\d+.-]*:/i.test(href) && !href.startsWith("//")) {
|
|
return (
|
|
<Link
|
|
to={href}
|
|
{...anchorProps}
|
|
rel="noreferrer"
|
|
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
|
|
>
|
|
{linkChildren}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
const issueRef = linkIssueReferences ? parseIssueReferenceFromHref(href) : null;
|
|
if (issueRef) {
|
|
return (
|
|
<MarkdownIssueLink issuePathId={issueRef.issuePathId} href={issueRef.href}>
|
|
{linkChildren}
|
|
</MarkdownIssueLink>
|
|
);
|
|
}
|
|
|
|
const caseIdentifier = linkCaseReferences ? caseIdentifierFromHref(href) : null;
|
|
if (caseIdentifier) {
|
|
return <MarkdownCaseLink identifier={caseIdentifier}>{linkChildren}</MarkdownCaseLink>;
|
|
}
|
|
|
|
const parsed = href ? parseMentionChipHref(href) : null;
|
|
if (parsed) {
|
|
if (parsed.kind === "agent") {
|
|
return (
|
|
<MarkdownAgentMention agentId={parsed.agentId} style={linkStyle as React.CSSProperties | undefined}>
|
|
{linkChildren}
|
|
</MarkdownAgentMention>
|
|
);
|
|
}
|
|
const targetHref = parsed.kind === "project"
|
|
? `/projects/${parsed.projectId}`
|
|
: parsed.kind === "issue"
|
|
? `/issues/${parsed.identifier}`
|
|
: parsed.kind === "skill"
|
|
? `/skills/${parsed.skillId}`
|
|
: parsed.kind === "routine"
|
|
? `/routines/${parsed.routineId}`
|
|
: "/company/settings/access";
|
|
return (
|
|
<a
|
|
href={targetHref}
|
|
className={cn(
|
|
"paperclip-mention-chip",
|
|
`paperclip-mention-chip--${parsed.kind}`,
|
|
parsed.kind === "project" && "paperclip-project-mention-chip",
|
|
)}
|
|
data-mention-kind={parsed.kind}
|
|
style={{ ...mergeWrapStyle(linkStyle as React.CSSProperties | undefined), ...mentionChipInlineStyle(parsed) }}
|
|
>
|
|
{linkChildren}
|
|
</a>
|
|
);
|
|
}
|
|
const externalReference = href && externalReferenceLookup
|
|
? externalReferenceLookup[normalizeExternalObjectHref(href) ?? ""] ?? null
|
|
: null;
|
|
if (externalReference && href) {
|
|
return (
|
|
<MarkdownExternalLink href={href} reference={externalReference}>
|
|
{linkChildren}
|
|
</MarkdownExternalLink>
|
|
);
|
|
}
|
|
|
|
const resolvedHref = href && resolveLinkHref ? resolveLinkHref(href) : href;
|
|
if (resolveLinkHref && resolvedHref !== href) {
|
|
return resolvedHref ? (
|
|
<Link to={resolvedHref} style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}>
|
|
{linkChildren}
|
|
</Link>
|
|
) : <>{linkChildren}</>;
|
|
}
|
|
const isGitHubLink = isGitHubUrl(href);
|
|
const isExternal = isExternalHttpUrl(href);
|
|
const leadingIcon = isGitHubLink ? (
|
|
<GithubIcon aria-hidden="true" className="mr-1 inline h-3.5 w-3.5 align-(--va-0_125em)" />
|
|
) : null;
|
|
const trailingIcon = isExternal && !isGitHubLink ? (
|
|
<ExternalLink aria-hidden="true" className="ml-1 inline h-3 w-3 align-(--va-0_125em)" />
|
|
) : null;
|
|
return (
|
|
<a
|
|
href={href}
|
|
{...(isExternal
|
|
? { target: "_blank", rel: "noopener noreferrer" }
|
|
: { rel: "noreferrer" })}
|
|
style={mergeWrapStyle(linkStyle as React.CSSProperties | undefined)}
|
|
>
|
|
{renderLinkBody(linkChildren, leadingIcon, trailingIcon)}
|
|
</a>
|
|
);
|
|
},
|
|
};
|
|
if (mediaMode === "reference") {
|
|
map.img = ({ src, alt, title }) => (
|
|
<span data-markdown-image-reference title={title}>
|
|
Image: {alt || "Untitled image"}{src ? ` (${src})` : ""}
|
|
</span>
|
|
);
|
|
} else if (resolveImageSrc || onImageClick) {
|
|
map.img = ({ node: _node, src, alt, ...imgProps }) => {
|
|
const resolved = resolveImageSrc && src ? resolveImageSrc(src) : null;
|
|
const finalSrc = resolved ?? src;
|
|
return (
|
|
<img
|
|
{...imgProps}
|
|
src={finalSrc}
|
|
alt={alt ?? ""}
|
|
onClick={onImageClick && finalSrc ? (e) => { e.preventDefault(); onImageClick(finalSrc); } : undefined}
|
|
style={onImageClick ? { cursor: "pointer", ...(imgProps.style as React.CSSProperties | undefined) } : imgProps.style as React.CSSProperties | undefined}
|
|
/>
|
|
);
|
|
};
|
|
}
|
|
return map;
|
|
}, [theme, linkIssueReferences, linkCaseReferences, externalReferenceLookup, resolveImageSrc, resolveLinkHref, onImageClick, mediaMode]);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"paperclip-markdown prose prose-sm min-w-0 max-w-full break-words overflow-hidden",
|
|
theme === "dark" && "prose-invert",
|
|
className,
|
|
)}
|
|
style={mergeWrapStyle(style)}
|
|
>
|
|
<Markdown
|
|
remarkPlugins={remarkPlugins}
|
|
components={components}
|
|
urlTransform={safeMarkdownUrlTransform}
|
|
>
|
|
{children}
|
|
</Markdown>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export const MarkdownBody = memo(MarkdownBodyImpl);
|