Attach pull request feedback to chat (#1400)
* feat(app): add pull request panel context actions Attach pull request comments, reviews, and failed check details to chat context. The PR panel now lives in its own module so the activity, query, and context policies have one home. * fix(server): mark capped check details as truncated * fix(app): preserve concurrent PR panel attachments * fix(server): preserve annotation truncation state * fix(app): keep matrix check attachments distinct * fix(app): streamline pull request context attachments * fix(app): simplify pull request feedback rendering * feat(app): group pull request review threads * fix(app): tidy pull request panel boundaries * fix: use worktree cwd for dev services * fix(app): keep git query keys UI-free * feat(app): collapse resolved comments and add chat actions to headers * fix: address PR feedback and Playwright flakes * fix(app): hide check chat action without attachment scope * fix(server): dedupe inline review timeline comments
This commit is contained in:
@@ -13,9 +13,12 @@ import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitHubIcon } from "@/components/icons/github-icon";
|
||||
import { PrPane } from "@/git/pr-pane";
|
||||
import { usePrPaneData } from "@/hooks/use-pr-pane-data";
|
||||
import {
|
||||
formatPrTabLabel,
|
||||
PullRequestPane,
|
||||
PullRequestTabIcon,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
@@ -33,6 +36,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useWindowControlsPadding } from "@/utils/desktop-window";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { buildWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
function logExplorerSidebar(_event: string, _details: Record<string, unknown>): void {}
|
||||
@@ -433,7 +437,11 @@ function SidebarContent({
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab =
|
||||
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||
const prTabLabel = prPane.prNumber === null ? "" : `#${prPane.prNumber}`;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
);
|
||||
|
||||
const headerStyle = useMemo(
|
||||
() => [styles.header, { paddingRight: padding.right }],
|
||||
@@ -470,7 +478,7 @@ function SidebarContent({
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-pr"
|
||||
>
|
||||
<GitHubIcon
|
||||
<PullRequestTabIcon
|
||||
size={13}
|
||||
color={
|
||||
resolvedTab === "pr" ? theme.colors.foreground : theme.colors.foregroundMuted
|
||||
@@ -507,7 +515,14 @@ function SidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && <PrPane data={prPane.data} />}
|
||||
{resolvedTab === "pr" && prPane.data && (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceRoot}
|
||||
data={prPane.data}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
||||
import Markdown, {
|
||||
type ASTNode,
|
||||
MarkdownIt,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image as RNImage,
|
||||
ScrollView as RNScrollView,
|
||||
Text,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
||||
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
||||
import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { highlightCode, type HighlightToken } from "@getpaseo/highlight";
|
||||
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
@@ -33,9 +22,6 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBytes } from "@/attachments/service";
|
||||
@@ -60,8 +46,6 @@ interface FilePreviewBodyProps {
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -132,299 +116,6 @@ function clampLineSelection(input: {
|
||||
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface FilePreviewMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function FilePreviewMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: FilePreviewMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
function createFilePreviewMarkdownRules(): RenderRules {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<FilePreviewMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</FilePreviewMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
@@ -506,9 +197,6 @@ function FilePreviewBody({
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const filePath = location.path;
|
||||
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
||||
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
||||
const markdownRules = useMemo(() => createFilePreviewMarkdownRules(), []);
|
||||
const isMarkdownFile =
|
||||
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
||||
|
||||
@@ -591,11 +279,7 @@ function FilePreviewBody({
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
||||
>
|
||||
<AppearanceStyleBoundary>
|
||||
<Markdown style={markdownStyles} rules={markdownRules} markdownit={markdownParser}>
|
||||
{preview.content ?? ""}
|
||||
</Markdown>
|
||||
</AppearanceStyleBoundary>
|
||||
<MarkdownRenderer text={preview.content ?? ""} />
|
||||
</RNScrollView>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
|
||||
@@ -39,7 +39,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Paragraph stays a <View>, not a <Text>, for layout fidelity. RN Android's
|
||||
// text engine *does* accept inline View children (TextInlineViewPlaceholderSpan
|
||||
|
||||
@@ -49,7 +49,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// iOS-only: paragraph wraps in UITextView so the entire paragraph is one
|
||||
// native text view. That's what unlocks cross-inline drag selection — handles
|
||||
|
||||
@@ -50,7 +50,7 @@ interface MarkdownParagraphViewProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = { marginBottom: 0 };
|
||||
const MARKDOWN_PARAGRAPH_RESET: ViewStyle = {};
|
||||
|
||||
// Same shape as Android — paragraph is a View so block-level children (images)
|
||||
// keep their natural layout. Web text selection already spans nested inline
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
|
||||
|
||||
describe("splitHtmlishMarkdown", () => {
|
||||
const inlineImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
|
||||
"",
|
||||
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
|
||||
"",
|
||||
"```ts",
|
||||
'const isBrowser = userAgent.includes("Mozilla");',
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
const multiDetailsBody = [
|
||||
"### Bot Review",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Important Files Changed</h3></summary>",
|
||||
"",
|
||||
"- `packages/server/src/server/session.ts`",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<details>",
|
||||
"<summary><h3>Security Findings</h3></summary>",
|
||||
"",
|
||||
"No blocking findings.",
|
||||
"",
|
||||
"</details>",
|
||||
"",
|
||||
"<!-- bot_other_comments_section -->",
|
||||
'<sub>Reviews (8): Last reviewed commit: “revert: undo parser” | <a href="https://app.greptile.com">Re-trigger Greptile</a></sub>',
|
||||
].join("\n");
|
||||
|
||||
it("classifies linked HTML images as generic inline images, not block markdown images", () => {
|
||||
const [image, text] = splitHtmlishMarkdown(inlineImageBody);
|
||||
|
||||
expect(image).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/assets/priority.svg?v=9",
|
||||
flowsWithText: true,
|
||||
});
|
||||
expect(text).toEqual({
|
||||
kind: "markdown",
|
||||
text: [
|
||||
" Spoofed browser User-Agent allows access control bypass",
|
||||
"",
|
||||
"The middleware now trusts any browser-like User-Agent for unauthenticated requests.",
|
||||
"",
|
||||
"```ts",
|
||||
'const isBrowser = userAgent.includes("Mozilla");',
|
||||
"```",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
it("flags both images as flowsWithText when two inline images precede title text on the same line", () => {
|
||||
const twoImageBody = [
|
||||
'<a href="#"><img alt="Priority" src="https://example.com/priority.svg" align="top"></a> <a href="#"><img alt="Security" src="https://example.com/security.svg" align="top"></a> **Title text here**',
|
||||
"",
|
||||
"Body paragraph.",
|
||||
].join("\n");
|
||||
|
||||
const parts = splitHtmlishMarkdown(twoImageBody);
|
||||
const [first, second, third] = parts;
|
||||
|
||||
expect(first).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
});
|
||||
expect(second).toEqual({ kind: "markdown", text: " " });
|
||||
expect(third).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Security",
|
||||
src: "https://example.com/security.svg",
|
||||
flowsWithText: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps safe width and height attributes on inline images", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<img alt="Small" src="https://example.com/small.svg" width="18" height="12">',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
width: 18,
|
||||
height: 12,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps safe non-empty image links", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<a href="https://example.com/details"><img alt="Small" src="https://example.com/small.svg"></a>',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Small",
|
||||
src: "https://example.com/small.svg",
|
||||
href: "https://example.com/details",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves inline image parts inside details bodies", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
'<details><summary>Images</summary><a href="https://example.com/page"><img alt="Icon" src="https://example.com/icon.svg"></a> Inline text</details>',
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "details",
|
||||
summary: "Images",
|
||||
body: "Inline text",
|
||||
bodyParts: [
|
||||
{
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.svg",
|
||||
href: "https://example.com/page",
|
||||
flowsWithText: true,
|
||||
},
|
||||
{ kind: "markdown", text: " Inline text" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag standalone inline images as flowing with text", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown('<img alt="Shot" src="https://example.com/shot.png">\n\nCaption below'),
|
||||
).toEqual([
|
||||
{ kind: "inlineImage", alt: "Shot", src: "https://example.com/shot.png" },
|
||||
{ kind: "markdown", text: "\n\nCaption below" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag mid-line inline images as flowing with text", () => {
|
||||
const [, image] = splitHtmlishMarkdown(
|
||||
'Before <img alt="Icon" src="https://example.com/icon.png"> after',
|
||||
);
|
||||
|
||||
expect(image).toEqual({
|
||||
kind: "inlineImage",
|
||||
alt: "Icon",
|
||||
src: "https://example.com/icon.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves ordinary markdown images on the markdown path", () => {
|
||||
const source = "";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("leaves unsafe HTML image sources inert", () => {
|
||||
const source = '<img alt="Bad" src="javascript:alert(1)">';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("removes raw image anchor and image tags from rendered markdown text", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).not.toContain("<a ");
|
||||
expect(text).not.toContain("<img ");
|
||||
expect(text).not.toContain("</a>");
|
||||
});
|
||||
|
||||
it("unwraps sub text and strips HTML comments", () => {
|
||||
const parts = splitHtmlishMarkdown(multiDetailsBody);
|
||||
const tail = parts.at(-1);
|
||||
|
||||
expect(tail).toEqual({
|
||||
kind: "markdown",
|
||||
text: "\n\nReviews (8): Last reviewed commit: “revert: undo parser” | [Re-trigger Greptile](https://app.greptile.com)",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leak stray closing details tags across multiple details blocks", () => {
|
||||
const renderedText = splitHtmlishMarkdown(multiDetailsBody)
|
||||
.map((part) => {
|
||||
if (part.kind === "markdown") return part.text;
|
||||
if (part.kind === "details") return `${part.summary}\n${part.body}`;
|
||||
return part.alt;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
expect(renderedText).not.toContain("</details>");
|
||||
expect(renderedText).not.toContain("<!--");
|
||||
expect(splitHtmlishMarkdown(multiDetailsBody).slice(0, 4)).toMatchObject([
|
||||
{ kind: "markdown", text: "### Bot Review\n\n" },
|
||||
{ kind: "details", summary: "Important Files Changed" },
|
||||
{ kind: "markdown", text: "\n\n" },
|
||||
{ kind: "details", summary: "Security Findings" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves paragraph boundaries around title, prose, and code block", () => {
|
||||
const text = splitHtmlishMarkdown(inlineImageBody)
|
||||
.map((part) => (part.kind === "markdown" ? part.text : ""))
|
||||
.join("");
|
||||
|
||||
expect(text).toContain(
|
||||
"Spoofed browser User-Agent allows access control bypass\n\nThe middleware now trusts",
|
||||
);
|
||||
expect(text).toContain("unauthenticated requests.\n\n```ts\nconst isBrowser");
|
||||
});
|
||||
|
||||
it("keeps product-specific image language out of parser and renderer logic", () => {
|
||||
const parserSource = readFileSync(new URL("./html-ish.ts", import.meta.url), "utf8");
|
||||
const rendererSource = readFileSync(new URL("./renderer.tsx", import.meta.url), "utf8");
|
||||
const productionSource = `${parserSource}\n${rendererSource}`;
|
||||
|
||||
expect(productionSource).not.toMatch(/Greptile|badge|P0|P1|P2|greptile-static-assets/i);
|
||||
});
|
||||
|
||||
it("parses Greptile-style details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary><h3>Greptile Summary</h3></summary>Body markdown</details>",
|
||||
),
|
||||
).toEqual([{ kind: "details", summary: "Greptile Summary", body: "Body markdown" }]);
|
||||
});
|
||||
|
||||
it("keeps details inside fenced code as literal markdown", () => {
|
||||
const source = "```html\n<details><summary>Example</summary>Body</details>\n```";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("keeps details inside inline code as literal markdown", () => {
|
||||
const source = "Use `<details><summary>Example</summary>Body</details>` as an example.";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("still parses normal details outside code", () => {
|
||||
expect(splitHtmlishMarkdown("`code`\n<details><summary>Real</summary>Body</details>")).toEqual([
|
||||
{ kind: "markdown", text: "`code`\n" },
|
||||
{ kind: "details", summary: "Real", body: "Body" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves text before and after details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown("Before\n<details><summary>More</summary>Hidden</details>\nAfter"),
|
||||
).toEqual([
|
||||
{ kind: "markdown", text: "Before\n" },
|
||||
{ kind: "details", summary: "More", body: "Hidden" },
|
||||
{ kind: "markdown", text: "\nAfter" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses multiple details blocks", () => {
|
||||
expect(
|
||||
splitHtmlishMarkdown(
|
||||
"<details><summary>One</summary>A</details><details><summary>Two</summary>B</details>",
|
||||
),
|
||||
).toEqual([
|
||||
{ kind: "details", summary: "One", body: "A" },
|
||||
{ kind: "details", summary: "Two", body: "B" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes br and simple code tags into markdown", () => {
|
||||
expect(normalizeHtmlishMarkdown("Line 1<br/>Line 2 <code>safe-value</code>")).toBe(
|
||||
"Line 1\nLine 2 `safe-value`",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves complex code tags inert instead of parsing HTML", () => {
|
||||
expect(normalizeHtmlishMarkdown('<code onclick="evil()"><script>x</script></code>')).toBe(
|
||||
'<code onclick="evil()"><script>x</script></code>',
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when details are unclosed", () => {
|
||||
const source = "<details><summary>Open</summary>Still open";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when summary is missing", () => {
|
||||
const source = "<details>Hidden</details>";
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it("does not execute or render unknown HTML as structured content", () => {
|
||||
const source =
|
||||
'<script>alert(1)</script><details onclick="evil()"><summary>Safe</summary><iframe src="x"></iframe></details>';
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([
|
||||
{ kind: "markdown", text: "<script>alert(1)</script>" },
|
||||
{ kind: "details", summary: "Safe", body: '<iframe src="x"></iframe>' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,705 @@
|
||||
import { Parser } from "htmlparser2";
|
||||
|
||||
export interface MarkdownTextPart {
|
||||
kind: "markdown";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface MarkdownDetailsPart {
|
||||
kind: "details";
|
||||
summary: string;
|
||||
body: string;
|
||||
bodyParts?: MarkdownDisplayPart[];
|
||||
}
|
||||
|
||||
export interface MarkdownInlineImagePart {
|
||||
kind: "inlineImage";
|
||||
alt: string;
|
||||
src: string;
|
||||
href?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** The image starts a line and text continues on that same line. */
|
||||
flowsWithText?: boolean;
|
||||
}
|
||||
|
||||
export type MarkdownDisplayPart = MarkdownTextPart | MarkdownDetailsPart | MarkdownInlineImagePart;
|
||||
|
||||
const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
||||
const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
const VOID_HTML_TAGS = new Set(["br", "img"]);
|
||||
|
||||
interface ProtectedMarkdownRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownImageDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
interface HtmlTextToken {
|
||||
kind: "text";
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface HtmlCommentToken {
|
||||
kind: "comment";
|
||||
}
|
||||
|
||||
interface HtmlTagToken {
|
||||
kind: "tag";
|
||||
name: string;
|
||||
closing: boolean;
|
||||
selfClosing: boolean;
|
||||
attributes: Record<string, string>;
|
||||
raw: string;
|
||||
}
|
||||
|
||||
type HtmlToken = HtmlTextToken | HtmlCommentToken | HtmlTagToken;
|
||||
|
||||
interface InlineImageParseResult {
|
||||
part: MarkdownInlineImagePart;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MarkdownDelimiterMatch {
|
||||
index: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface HtmlishTokenParser {
|
||||
write(chunk: string): void;
|
||||
skip(length: number): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
export function splitHtmlishMarkdown(source: string): MarkdownDisplayPart[] {
|
||||
return splitHtmlishTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
|
||||
function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
|
||||
const parts: MarkdownDisplayPart[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (isOpenTag(token, "details")) {
|
||||
const closeIndex = findMatchingClose(tokens, cursor, "details");
|
||||
if (closeIndex !== null) {
|
||||
const details = parseDetailsTokens(tokens.slice(cursor + 1, closeIndex));
|
||||
if (details) {
|
||||
parts.push(details);
|
||||
cursor = closeIndex + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const inlineImage = parseInlineImageAt(tokens, cursor);
|
||||
if (inlineImage) {
|
||||
parts.push(
|
||||
flowsWithFollowingText(tokens, cursor, inlineImage.end)
|
||||
? { ...inlineImage.part, flowsWithText: true }
|
||||
: inlineImage.part,
|
||||
);
|
||||
cursor = inlineImage.end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
|
||||
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
|
||||
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
|
||||
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
|
||||
cursor = end;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
function parseInlineImageAt(tokens: HtmlToken[], start: number): InlineImageParseResult | null {
|
||||
const token = tokens[start];
|
||||
if (token?.kind !== "tag" || token.closing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
return image ? { part: image, end: start + 1 } : null;
|
||||
}
|
||||
|
||||
if (token.name !== "a") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeIndex = findMatchingClose(tokens, start, "a");
|
||||
if (closeIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const image = getSingleImageChild(tokens.slice(start + 1, closeIndex));
|
||||
if (!image) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlineImage = imageTokenToInlineImage(image, safeHref(token.attributes.href));
|
||||
return inlineImage ? { part: inlineImage, end: closeIndex + 1 } : null;
|
||||
}
|
||||
|
||||
function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number): boolean {
|
||||
const previous = tokens[start - 1];
|
||||
// At line start if nothing precedes this image, or the preceding token ends with only
|
||||
// whitespace since the last newline (covers a bare space between two images on the same line).
|
||||
const atLineStart =
|
||||
previous === undefined ||
|
||||
(previous.kind === "text" && /(?:^|[\n\r])[ \t]*$/.test(previous.value));
|
||||
if (!atLineStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Scan forward past same-line whitespace-only text tokens and inline images to find
|
||||
// the first substantive text token, without crossing a newline.
|
||||
let cursor = end;
|
||||
while (cursor < tokens.length) {
|
||||
const token = tokens[cursor];
|
||||
if (token === undefined) {
|
||||
break;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
const sameLine = token.value.split(/\r?\n/, 1)[0] ?? "";
|
||||
if (sameLine.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
// Whitespace-only on this line — keep scanning only if no newline was crossed.
|
||||
if (token.value.includes("\n") || token.value.includes("\r")) {
|
||||
return false;
|
||||
}
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
// An inline image tag — skip over it (the image itself and its possible wrapping close tag).
|
||||
if (token.kind === "tag") {
|
||||
const imageResult = parseInlineImageAt(tokens, cursor);
|
||||
if (imageResult) {
|
||||
cursor = imageResult.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (parseInlineImageAt(tokens, index)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const previous = parts.at(-1);
|
||||
if (previous?.kind === "markdown") {
|
||||
previous.text += text;
|
||||
return;
|
||||
}
|
||||
parts.push({ kind: "markdown", text });
|
||||
}
|
||||
|
||||
function parseDetailsTokens(tokens: HtmlToken[]): MarkdownDetailsPart | null {
|
||||
const summaryOpenIndex = findNextOpenTag(tokens, 0, "summary");
|
||||
if (summaryOpenIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryCloseIndex = findMatchingClose(tokens, summaryOpenIndex, "summary");
|
||||
if (summaryCloseIndex === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const summaryTokens = tokens.slice(summaryOpenIndex + 1, summaryCloseIndex);
|
||||
const bodyTokens = [...tokens.slice(0, summaryOpenIndex), ...tokens.slice(summaryCloseIndex + 1)];
|
||||
const summary = renderSummaryTokens(summaryTokens).trim();
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bodyParts = splitHtmlishTokens(bodyTokens);
|
||||
const body = renderBodyText(bodyParts);
|
||||
|
||||
return {
|
||||
kind: "details",
|
||||
summary,
|
||||
body: body.trim(),
|
||||
...(bodyParts.some((part) => part.kind !== "markdown")
|
||||
? { bodyParts: trimBodyParts(bodyParts) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function renderBodyText(parts: MarkdownDisplayPart[]): string {
|
||||
return parts.map((part) => (part.kind === "markdown" ? part.text : "")).join("");
|
||||
}
|
||||
|
||||
function trimBodyParts(parts: MarkdownDisplayPart[]): MarkdownDisplayPart[] {
|
||||
const trimmed = [...parts];
|
||||
const first = trimmed[0];
|
||||
if (first?.kind === "markdown") {
|
||||
first.text = first.text.trimStart();
|
||||
}
|
||||
const last = trimmed.at(-1);
|
||||
if (last?.kind === "markdown") {
|
||||
last.text = last.text.trimEnd();
|
||||
}
|
||||
return trimmed.filter((part) => part.kind !== "markdown" || part.text.length > 0);
|
||||
}
|
||||
|
||||
function renderSummaryTokens(tokens: HtmlToken[]): string {
|
||||
return stripSingleHeadingWrapper(renderInlineTokens(tokens));
|
||||
}
|
||||
|
||||
function stripSingleHeadingWrapper(text: string): string {
|
||||
const tokens = tokenizeHtmlishMarkdown(text.trim());
|
||||
if (tokens.length < 3) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const first = tokens[0];
|
||||
const last = tokens.at(-1);
|
||||
if (!isHeadingTag(first) || !isClosingTag(last, first.name)) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return renderInlineTokens(tokens.slice(1, -1));
|
||||
}
|
||||
|
||||
function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
let output = "";
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (!token) {
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "text") {
|
||||
output += token.value;
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "comment" || token.closing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "br") {
|
||||
output += "\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.name === "img") {
|
||||
output += renderImageToken(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const closeIndex = token.selfClosing ? null : findMatchingClose(tokens, index, token.name);
|
||||
if (closeIndex === null) {
|
||||
output += renderUnknownTag(token);
|
||||
continue;
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
if (token.name === "a") {
|
||||
output += renderLinkToken(token, children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "sub") {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
if (token.name === "code" && children.every((child) => child.kind === "text")) {
|
||||
output += `\`${renderInlineTokens(children)}\``;
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
const rawTag = token.raw;
|
||||
const tagName = token.name;
|
||||
if (isHeadingTag(token)) {
|
||||
output += renderInlineTokens(children);
|
||||
index = closeIndex;
|
||||
continue;
|
||||
}
|
||||
|
||||
output += `${rawTag}${renderInlineTokens(children)}</${tagName}>`;
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderImageToken(token: HtmlTagToken): string {
|
||||
const image = imageTokenToInlineImage(token, undefined);
|
||||
if (!image) {
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
return ``;
|
||||
}
|
||||
|
||||
function renderLinkToken(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
const imageOnly = getSingleImageChild(children);
|
||||
if (imageOnly) {
|
||||
return renderImageToken(imageOnly);
|
||||
}
|
||||
|
||||
const label = renderInlineTokens(children);
|
||||
const href = token.attributes.href ?? "";
|
||||
if (!label || !SAFE_LINK_HREF_RE.test(href) || href === "#") {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `[${label}](${href})`;
|
||||
}
|
||||
|
||||
function imageTokenToInlineImage(
|
||||
token: HtmlTagToken,
|
||||
href: string | undefined,
|
||||
): MarkdownInlineImagePart | null {
|
||||
const src = token.attributes.src ?? "";
|
||||
if (!SAFE_IMAGE_SRC_RE.test(src)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "inlineImage",
|
||||
alt: token.attributes.alt ?? "",
|
||||
src,
|
||||
...(href ? { href } : {}),
|
||||
...parseImageDimensions(token.attributes),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimensions(attributes: Record<string, string>): MarkdownImageDimensions {
|
||||
return {
|
||||
...parseImageDimension("width", attributes.width),
|
||||
...parseImageDimension("height", attributes.height),
|
||||
};
|
||||
}
|
||||
|
||||
function parseImageDimension(key: "width" | "height", value: string | undefined) {
|
||||
if (!value || !/^\d+(?:\.\d+)?$/.test(value)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? { [key]: parsed } : {};
|
||||
}
|
||||
|
||||
function safeHref(href: string | undefined): string | undefined {
|
||||
if (!href || href === "#" || !SAFE_LINK_HREF_RE.test(href)) {
|
||||
return undefined;
|
||||
}
|
||||
return href;
|
||||
}
|
||||
|
||||
function getSingleImageChild(tokens: HtmlToken[]): HtmlTagToken | null {
|
||||
const visible = tokens.filter((token) => token.kind !== "comment" && !isWhitespaceText(token));
|
||||
return visible.length === 1 && isOpenTag(visible[0], "img") ? visible[0] : null;
|
||||
}
|
||||
|
||||
function renderUnknownTag(token: HtmlTagToken): string {
|
||||
if (token.name === "script" || token.name === "style") {
|
||||
return "";
|
||||
}
|
||||
return token.raw;
|
||||
}
|
||||
|
||||
function escapeMarkdownImageAlt(value: string): string {
|
||||
return value.replace(/]/g, "\\]");
|
||||
}
|
||||
|
||||
function findNextOpenTag(tokens: HtmlToken[], start: number, name: string): number | null {
|
||||
for (let index = start; index < tokens.length; index += 1) {
|
||||
if (isOpenTag(tokens[index], name)) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMatchingClose(tokens: HtmlToken[], openIndex: number, name: string): number | null {
|
||||
let depth = 1;
|
||||
for (let index = openIndex + 1; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (isOpenTag(token, name) && !token.selfClosing) {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
if (isClosingTag(token, name)) {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isOpenTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && !token.closing;
|
||||
}
|
||||
|
||||
function isClosingTag(token: HtmlToken | undefined, name: string): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && token.name === name && token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTag(token: HtmlToken | undefined): token is HtmlTagToken {
|
||||
return token?.kind === "tag" && isHeadingTagName(token.name) && !token.closing;
|
||||
}
|
||||
|
||||
function isHeadingTagName(name: string): boolean {
|
||||
return (
|
||||
name === "h1" ||
|
||||
name === "h2" ||
|
||||
name === "h3" ||
|
||||
name === "h4" ||
|
||||
name === "h5" ||
|
||||
name === "h6"
|
||||
);
|
||||
}
|
||||
|
||||
function isWhitespaceText(token: HtmlToken): boolean {
|
||||
return token.kind === "text" && token.value.trim() === "";
|
||||
}
|
||||
|
||||
function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
|
||||
const protectedRanges = getProtectedMarkdownRanges(source);
|
||||
const tokens: HtmlToken[] = [];
|
||||
const parser = createHtmlishTokenParser(source, tokens);
|
||||
let cursor = 0;
|
||||
|
||||
for (const range of protectedRanges) {
|
||||
parser.write(source.slice(cursor, range.start));
|
||||
tokens.push({ kind: "text", value: source.slice(range.start, range.end) });
|
||||
parser.skip(range.end - range.start);
|
||||
cursor = range.end;
|
||||
}
|
||||
|
||||
parser.write(source.slice(cursor));
|
||||
parser.end();
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
||||
let stripNextLeadingLineBreak = false;
|
||||
let skippedLength = 0;
|
||||
let parser!: Parser;
|
||||
parser = new Parser(
|
||||
{
|
||||
onopentag(name, attributes, isImplied) {
|
||||
if (isImplied) {
|
||||
return;
|
||||
}
|
||||
const selfClosing = VOID_HTML_TAGS.has(name);
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: false,
|
||||
selfClosing,
|
||||
attributes,
|
||||
raw: renderStartTag(name, attributes),
|
||||
});
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
if (isImplied || VOID_HTML_TAGS.has(name)) {
|
||||
return;
|
||||
}
|
||||
tokens.push({
|
||||
kind: "tag",
|
||||
name,
|
||||
closing: true,
|
||||
selfClosing: false,
|
||||
attributes: {},
|
||||
raw: `</${name}>`,
|
||||
});
|
||||
},
|
||||
ontext(value) {
|
||||
if (stripNextLeadingLineBreak) {
|
||||
stripNextLeadingLineBreak = false;
|
||||
value = stripLeadingLineBreak(value);
|
||||
}
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
tokens.push({ kind: "text", value });
|
||||
},
|
||||
oncomment() {
|
||||
stripNextLeadingLineBreak = isLineStart(source, parser.startIndex + skippedLength);
|
||||
tokens.push({ kind: "comment" });
|
||||
},
|
||||
},
|
||||
{
|
||||
decodeEntities: false,
|
||||
lowerCaseAttributeNames: true,
|
||||
lowerCaseTags: true,
|
||||
recognizeSelfClosing: true,
|
||||
},
|
||||
);
|
||||
return {
|
||||
write(chunk) {
|
||||
parser.write(chunk);
|
||||
},
|
||||
skip(length) {
|
||||
skippedLength += length;
|
||||
},
|
||||
end() {
|
||||
parser.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isLineStart(source: string, index: number): boolean {
|
||||
return index === 0 || source[index - 1] === "\n" || source[index - 1] === "\r";
|
||||
}
|
||||
|
||||
function stripLeadingLineBreak(value: string): string {
|
||||
if (value.startsWith("\r\n")) {
|
||||
return value.slice(2);
|
||||
}
|
||||
if (value.startsWith("\n") || value.startsWith("\r")) {
|
||||
return value.slice(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderStartTag(name: string, attributes: Record<string, string>): string {
|
||||
const renderedAttributes = Object.entries(attributes)
|
||||
.map(([key, value]) => (value === "" ? ` ${key}` : ` ${key}="${escapeAttribute(value)}"`))
|
||||
.join("");
|
||||
return `<${name}${renderedAttributes}>`;
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value.replaceAll("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const fencedRanges = getFencedCodeRanges(source);
|
||||
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
|
||||
}
|
||||
|
||||
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
FENCE_LINE_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = FENCE_LINE_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
|
||||
const marker = open[1];
|
||||
if (!marker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const close = findClosingFence(source, FENCE_LINE_RE.lastIndex, marker);
|
||||
if (!close) {
|
||||
ranges.push({ start: open.index, end: source.length });
|
||||
return ranges;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
FENCE_LINE_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingFence(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
): MarkdownDelimiterMatch | null {
|
||||
const closeRe = new RegExp(
|
||||
`^ {0,3}[${marker[0]}]{${marker.length},}[^\\n\\r]*(?:\\r?\\n|$)`,
|
||||
"gm",
|
||||
);
|
||||
closeRe.lastIndex = start;
|
||||
const close = closeRe.exec(source);
|
||||
return close ? { index: close.index, end: closeRe.lastIndex } : null;
|
||||
}
|
||||
|
||||
function getInlineCodeRanges(
|
||||
source: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
BACKTICK_RUN_RE.lastIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const open = BACKTICK_RUN_RE.exec(source);
|
||||
if (!open) {
|
||||
return ranges;
|
||||
}
|
||||
if (isProtectedIndex(open.index, fencedRanges)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const marker = open[0];
|
||||
const close = findClosingBacktickRun(source, BACKTICK_RUN_RE.lastIndex, marker, fencedRanges);
|
||||
if (!close) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ranges.push({ start: open.index, end: close.end });
|
||||
BACKTICK_RUN_RE.lastIndex = close.end;
|
||||
}
|
||||
}
|
||||
|
||||
function findClosingBacktickRun(
|
||||
source: string,
|
||||
start: number,
|
||||
marker: string,
|
||||
fencedRanges: ProtectedMarkdownRange[],
|
||||
): MarkdownDelimiterMatch | null {
|
||||
BACKTICK_RUN_RE.lastIndex = start;
|
||||
|
||||
while (true) {
|
||||
const close = BACKTICK_RUN_RE.exec(source);
|
||||
if (!close) {
|
||||
return null;
|
||||
}
|
||||
if (close[0] === marker && !isProtectedIndex(close.index, fencedRanges)) {
|
||||
return { index: close.index, end: BACKTICK_RUN_RE.lastIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeProtectedRanges(ranges: ProtectedMarkdownRange[]): ProtectedMarkdownRange[] {
|
||||
const sorted = ranges.toSorted((a, b) => a.start - b.start);
|
||||
const merged: ProtectedMarkdownRange[] = [];
|
||||
|
||||
for (const range of sorted) {
|
||||
const previous = merged.at(-1);
|
||||
if (!previous || range.start > previous.end) {
|
||||
merged.push({ ...range });
|
||||
continue;
|
||||
}
|
||||
previous.end = Math.max(previous.end, range.end);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isProtectedIndex(index: number, ranges: ProtectedMarkdownRange[]): boolean {
|
||||
return ranges.some((range) => index >= range.start && index < range.end);
|
||||
}
|
||||
|
||||
export function normalizeHtmlishMarkdown(source: string): string {
|
||||
return renderInlineTokens(tokenizeHtmlishMarkdown(source));
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export interface InlineImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface InlineImageExplicitDimensions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const INLINE_IMAGE_FALLBACK_SIZE = 16;
|
||||
const INLINE_IMAGE_MAX_WIDTH = 240;
|
||||
const INLINE_IMAGE_MAX_HEIGHT = 160;
|
||||
|
||||
export function resolveInlineImageSize(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
const dimensions = resolveInlineImageDimensions(input);
|
||||
const scale = Math.min(
|
||||
1,
|
||||
INLINE_IMAGE_MAX_WIDTH / dimensions.width,
|
||||
INLINE_IMAGE_MAX_HEIGHT / dimensions.height,
|
||||
);
|
||||
|
||||
return {
|
||||
width: Math.round(dimensions.width * scale),
|
||||
height: Math.round(dimensions.height * scale),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveInlineImageDimensions(input: {
|
||||
explicit: InlineImageExplicitDimensions;
|
||||
natural: InlineImageDimensions | null;
|
||||
}): InlineImageDimensions {
|
||||
if (input.explicit.width && input.explicit.height) {
|
||||
return { width: input.explicit.width, height: input.explicit.height };
|
||||
}
|
||||
|
||||
if (input.explicit.width && input.natural) {
|
||||
return {
|
||||
width: input.explicit.width,
|
||||
height: input.explicit.width * (input.natural.height / input.natural.width),
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.height && input.natural) {
|
||||
return {
|
||||
width: input.explicit.height * (input.natural.width / input.natural.height),
|
||||
height: input.explicit.height,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.explicit.width) {
|
||||
return { width: input.explicit.width, height: input.explicit.width };
|
||||
}
|
||||
|
||||
if (input.explicit.height) {
|
||||
return { width: input.explicit.height, height: input.explicit.height };
|
||||
}
|
||||
|
||||
return input.natural ?? { width: INLINE_IMAGE_FALLBACK_SIZE, height: INLINE_IMAGE_FALLBACK_SIZE };
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarkdownDisplayPart } from "./html-ish";
|
||||
import { groupMarkdownParts } from "./part-groups";
|
||||
|
||||
describe("groupMarkdownParts", () => {
|
||||
const flowImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Priority",
|
||||
src: "https://example.com/priority.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
it("flows an image with the lead paragraph and keeps later paragraphs full width", () => {
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " **Title line**\n\nSecond paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage],
|
||||
lead: "**Title line**",
|
||||
rest: "Second paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flows an image with a single-paragraph text", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: " Only line" }])).toEqual([
|
||||
{ kind: "imageText", images: [flowImage], lead: "Only line", rest: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups two consecutive flowing images with the lead paragraph", () => {
|
||||
const secondImage: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Severity",
|
||||
src: "https://example.com/severity.svg",
|
||||
flowsWithText: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
groupMarkdownParts([
|
||||
flowImage,
|
||||
{ kind: "markdown", text: " " },
|
||||
secondImage,
|
||||
{ kind: "markdown", text: " **Title text here**\n\nBody paragraph." },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "imageText",
|
||||
images: [flowImage, secondImage],
|
||||
lead: "**Title text here**",
|
||||
rest: "Body paragraph.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps standalone images as plain parts", () => {
|
||||
const image: MarkdownDisplayPart = {
|
||||
kind: "inlineImage",
|
||||
alt: "Shot",
|
||||
src: "https://example.com/shot.png",
|
||||
};
|
||||
|
||||
expect(groupMarkdownParts([image, { kind: "markdown", text: "\n\nCaption" }])).toEqual([
|
||||
{ kind: "part", part: image },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nCaption" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when no markdown follows", () => {
|
||||
expect(groupMarkdownParts([flowImage])).toEqual([{ kind: "part", part: flowImage }]);
|
||||
});
|
||||
|
||||
it("keeps a flowing image alone when the following markdown starts with a blank line", () => {
|
||||
expect(groupMarkdownParts([flowImage, { kind: "markdown", text: "\n\nBelow" }])).toEqual([
|
||||
{ kind: "part", part: flowImage },
|
||||
{ kind: "part", part: { kind: "markdown", text: "\n\nBelow" } },
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes other part kinds through unchanged", () => {
|
||||
const details: MarkdownDisplayPart = { kind: "details", summary: "More", body: "Body" };
|
||||
|
||||
expect(groupMarkdownParts([{ kind: "markdown", text: "Intro" }, details])).toEqual([
|
||||
{ kind: "part", part: { kind: "markdown", text: "Intro" } },
|
||||
{ kind: "part", part: details },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { MarkdownDisplayPart, MarkdownInlineImagePart } from "./html-ish";
|
||||
|
||||
export type MarkdownPartGroup =
|
||||
| { kind: "part"; part: MarkdownDisplayPart }
|
||||
| { kind: "imageText"; images: MarkdownInlineImagePart[]; lead: string; rest: string };
|
||||
|
||||
/**
|
||||
* Pairs a run of inline images that flow with text (small status images at the
|
||||
* start of a line, GitHub-style) with the lead paragraph of the following
|
||||
* markdown part, so the renderer can lay them out side by side instead of
|
||||
* stacking the images as their own blocks.
|
||||
*
|
||||
* Between consecutive flowing images the parser may emit whitespace-only
|
||||
* markdown parts — these are consumed and discarded when building the group.
|
||||
*/
|
||||
export function groupMarkdownParts(parts: readonly MarkdownDisplayPart[]): MarkdownPartGroup[] {
|
||||
const groups: MarkdownPartGroup[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < parts.length) {
|
||||
const part = parts[index];
|
||||
|
||||
if (part.kind === "inlineImage" && part.flowsWithText) {
|
||||
// Collect the maximal run of flowing images, skipping whitespace-only markdown between them.
|
||||
const images: MarkdownInlineImagePart[] = [part];
|
||||
let lookahead = index + 1;
|
||||
|
||||
while (lookahead < parts.length) {
|
||||
const candidate = parts[lookahead];
|
||||
if (candidate === undefined) {
|
||||
break;
|
||||
}
|
||||
if (isWhitespaceOnlyMarkdown(candidate)) {
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
if (candidate.kind === "inlineImage" && candidate.flowsWithText) {
|
||||
images.push(candidate);
|
||||
lookahead += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const trailing = parts[lookahead];
|
||||
if (trailing?.kind === "markdown") {
|
||||
const { lead, rest } = splitLeadParagraph(trailing.text);
|
||||
if (lead) {
|
||||
groups.push({ kind: "imageText", images, lead, rest });
|
||||
index = lookahead + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No usable lead — emit the images (and any whitespace parts) as plain parts.
|
||||
for (let i = index; i < lookahead; i += 1) {
|
||||
const p = parts[i];
|
||||
if (p !== undefined) {
|
||||
groups.push({ kind: "part", part: p });
|
||||
}
|
||||
}
|
||||
index = lookahead;
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.push({ kind: "part", part });
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function isWhitespaceOnlyMarkdown(part: MarkdownDisplayPart): boolean {
|
||||
return part.kind === "markdown" && part.text.trim() === "";
|
||||
}
|
||||
|
||||
function splitLeadParagraph(text: string): { lead: string; rest: string } {
|
||||
if (/^[ \t]*\r?\n/.test(text)) {
|
||||
return { lead: "", rest: text };
|
||||
}
|
||||
const boundary = /\r?\n[ \t]*\r?\n/.exec(text);
|
||||
if (!boundary) {
|
||||
return { lead: text.trim(), rest: "" };
|
||||
}
|
||||
return {
|
||||
lead: text.slice(0, boundary.index).trim(),
|
||||
rest: text.slice(boundary.index + boundary[0].length),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveInlineImageSize } from "./inline-image-size";
|
||||
|
||||
describe("resolveInlineImageSize", () => {
|
||||
it("respects a one-sided explicit width using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { width: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 18,
|
||||
height: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("respects a one-sided explicit height using natural aspect ratio", () => {
|
||||
expect(
|
||||
resolveInlineImageSize({ explicit: { height: 18 }, natural: { width: 90, height: 45 } }),
|
||||
).toEqual({
|
||||
width: 36,
|
||||
height: 18,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a generic small fallback when no dimensions are known", () => {
|
||||
expect(resolveInlineImageSize({ explicit: {}, natural: null })).toEqual({
|
||||
width: 16,
|
||||
height: 16,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,758 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
Image,
|
||||
Pressable,
|
||||
Text,
|
||||
View,
|
||||
type TextProps,
|
||||
type TextStyle,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { getMarkdownListMarker, getMarkdownListSpacing } from "@/utils/markdown-list";
|
||||
import { markdownNodeContainsType } from "@/utils/markdown-ast";
|
||||
import { createCompactMarkdownStyles, createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import {
|
||||
splitHtmlishMarkdown,
|
||||
type MarkdownDisplayPart,
|
||||
type MarkdownInlineImagePart,
|
||||
} from "./html-ish";
|
||||
import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size";
|
||||
import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups";
|
||||
|
||||
export type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles> | ReturnType<typeof createCompactMarkdownStyles>;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
|
||||
function markdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
function compactMarkdownStyleMapping(theme: Theme): Partial<MarkdownWithStableRendererProps> {
|
||||
return { style: createCompactMarkdownStyles(theme) };
|
||||
}
|
||||
|
||||
const defaultMarkdownParser = MarkdownIt({ typographer: true, linkify: true });
|
||||
const EMPTY_TEXT_STYLE: TextStyle = {};
|
||||
const MARKDOWN_LIST_ITEM_CONTENT_FLEX: ViewStyle = { flex: 1, flexShrink: 1, minWidth: 0 };
|
||||
export interface MarkdownRendererProps {
|
||||
text: string;
|
||||
compact?: boolean;
|
||||
rules?: RenderRules;
|
||||
markdownit?: ReturnType<typeof MarkdownIt>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
allowedImageHandlers?: readonly string[];
|
||||
topLevelMaxExceededItem?: ReactNode;
|
||||
enableHtmlish?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({
|
||||
text,
|
||||
compact = false,
|
||||
rules,
|
||||
markdownit = defaultMarkdownParser,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
enableHtmlish = true,
|
||||
}: MarkdownRendererProps) {
|
||||
const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]);
|
||||
const parts = useMemo(
|
||||
() => (enableHtmlish ? splitHtmlishMarkdown(text) : [{ kind: "markdown" as const, text }]),
|
||||
[enableHtmlish, text],
|
||||
);
|
||||
const rendererProps = useMemo(
|
||||
() => ({
|
||||
compact,
|
||||
rules: markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}),
|
||||
[
|
||||
allowedImageHandlers,
|
||||
compact,
|
||||
markdownRules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
topLevelMaxExceededItem,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<MarkdownPartList parts={parts} rendererProps={rendererProps} />
|
||||
</AppearanceStyleBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
type MarkdownPartRendererProps = Omit<MarkdownRendererProps, "text" | "enableHtmlish"> & {
|
||||
rules: RenderRules;
|
||||
};
|
||||
|
||||
function MarkdownPartList({
|
||||
parts,
|
||||
rendererProps,
|
||||
}: {
|
||||
parts: MarkdownDisplayPart[];
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const keyedGroups = useMemo(() => keyMarkdownGroups(groupMarkdownParts(parts)), [parts]);
|
||||
return (
|
||||
<>
|
||||
{keyedGroups.map(({ key, group }) =>
|
||||
group.kind === "part" ? (
|
||||
<MarkdownPart key={key} part={group.part} rendererProps={rendererProps} />
|
||||
) : (
|
||||
<MarkdownImageTextGroup key={key} group={group} rendererProps={rendererProps} />
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function keyMarkdownGroups(
|
||||
groups: MarkdownPartGroup[],
|
||||
): { key: string; group: MarkdownPartGroup }[] {
|
||||
const seen = new Map<string, number>();
|
||||
return groups.map((group) => {
|
||||
const identity =
|
||||
group.kind === "part"
|
||||
? getMarkdownPartIdentity(group.part)
|
||||
: `imageText:${group.images.map((i) => i.src).join(",")}:${group.lead.slice(0, 80)}`;
|
||||
const seenCount = seen.get(identity) ?? 0;
|
||||
seen.set(identity, seenCount + 1);
|
||||
return { key: `${identity}:${seenCount}`, group };
|
||||
});
|
||||
}
|
||||
|
||||
function getMarkdownPartIdentity(part: MarkdownDisplayPart): string {
|
||||
if (part.kind === "markdown") {
|
||||
return `markdown:${part.text.slice(0, 80)}`;
|
||||
}
|
||||
if (part.kind === "inlineImage") {
|
||||
return `inlineImage:${part.src}:${part.alt}`;
|
||||
}
|
||||
return `details:${part.summary.slice(0, 80)}:${part.body.slice(0, 80)}`;
|
||||
}
|
||||
|
||||
function MarkdownPart({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: MarkdownDisplayPart;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
if (part.kind === "details") {
|
||||
return <MarkdownDetails part={part} rendererProps={rendererProps} />;
|
||||
}
|
||||
|
||||
if (part.kind === "inlineImage") {
|
||||
return <MarkdownInlineImage part={part} onLinkPress={rendererProps.onLinkPress} />;
|
||||
}
|
||||
|
||||
if (part.text.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <MarkdownFragment text={part.text} {...rendererProps} />;
|
||||
}
|
||||
|
||||
function MarkdownFragment({
|
||||
text,
|
||||
compact,
|
||||
rules,
|
||||
markdownit,
|
||||
onLinkPress,
|
||||
allowedImageHandlers,
|
||||
topLevelMaxExceededItem,
|
||||
}: MarkdownRendererProps & { rules: RenderRules }) {
|
||||
const uniProps = compact ? compactMarkdownStyleMapping : markdownStyleMapping;
|
||||
return (
|
||||
<ThemedMarkdown
|
||||
uniProps={uniProps}
|
||||
rules={rules}
|
||||
markdownit={markdownit}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={allowedImageHandlers}
|
||||
topLevelMaxExceededItem={topLevelMaxExceededItem}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
);
|
||||
}
|
||||
|
||||
function useNaturalImageDimensions(part: MarkdownInlineImagePart): {
|
||||
natural: InlineImageDimensions | null;
|
||||
failed: boolean;
|
||||
setFailed: (failed: boolean) => void;
|
||||
} {
|
||||
const [natural, setNatural] = useState<InlineImageDimensions | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (part.width && part.height) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
Image.getSize(
|
||||
part.src,
|
||||
(width, height) => {
|
||||
if (!cancelled) {
|
||||
setNatural({ width, height });
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
setFailed(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [part.height, part.src, part.width]);
|
||||
|
||||
return { natural, failed, setFailed };
|
||||
}
|
||||
|
||||
function MarkdownInlineImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "inlineImage" }>;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural: naturalDimensions } = useNaturalImageDimensions(part);
|
||||
const explicitDimensions = useMemo(
|
||||
() => ({ width: part.width, height: part.height }),
|
||||
[part.height, part.width],
|
||||
);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(
|
||||
() => resolveInlineImageSize({ explicit: explicitDimensions, natural: naturalDimensions }),
|
||||
[explicitDimensions, naturalDimensions],
|
||||
);
|
||||
const imageStyle = useMemo(() => [detailsStyles.inlineImage, imageSize], [imageSize]);
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return <View style={detailsStyles.inlineImageWrap}>{image}</View>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable style={detailsStyles.inlineImageWrap} onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const FLOW_IMAGE_MAX_HEIGHT = 18;
|
||||
|
||||
function MarkdownFlowImage({
|
||||
part,
|
||||
onLinkPress,
|
||||
}: {
|
||||
part: MarkdownInlineImagePart;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
}) {
|
||||
const { natural, failed, setFailed } = useNaturalImageDimensions(part);
|
||||
const handlePress = useCallback(() => {
|
||||
if (!part.href) return;
|
||||
if (onLinkPress?.(part.href) === false) return;
|
||||
void openExternalUrl(part.href);
|
||||
}, [onLinkPress, part.href]);
|
||||
const handleError = useCallback(() => setFailed(true), [setFailed]);
|
||||
const source = useMemo(() => ({ uri: part.src }), [part.src]);
|
||||
const imageSize = useMemo(() => {
|
||||
const size = resolveInlineImageSize({
|
||||
explicit: { width: part.width, height: part.height },
|
||||
natural,
|
||||
});
|
||||
const scale = Math.min(1, FLOW_IMAGE_MAX_HEIGHT / size.height);
|
||||
return { width: Math.round(size.width * scale), height: Math.round(size.height * scale) };
|
||||
}, [natural, part.height, part.width]);
|
||||
const imageStyle = useMemo(() => [detailsStyles.flowImage, imageSize], [imageSize]);
|
||||
|
||||
if (failed) {
|
||||
if (!part.alt) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View style={detailsStyles.flowImageFallback}>
|
||||
<Text style={detailsStyles.flowImageFallbackText}>{part.alt}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const image = (
|
||||
<Image
|
||||
source={source}
|
||||
style={imageStyle}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={part.alt || undefined}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!part.href) {
|
||||
return image;
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable onPress={handlePress} accessibilityRole="link">
|
||||
{image}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownImageTextGroup({
|
||||
group,
|
||||
rendererProps,
|
||||
}: {
|
||||
group: Extract<MarkdownPartGroup, { kind: "imageText" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<View style={detailsStyles.imageTextRow}>
|
||||
{group.images.map((image) => (
|
||||
<MarkdownFlowImage key={image.src} part={image} onLinkPress={rendererProps.onLinkPress} />
|
||||
))}
|
||||
<View style={detailsStyles.imageTextRowContent}>
|
||||
<MarkdownFragment text={group.lead} {...rendererProps} />
|
||||
</View>
|
||||
</View>
|
||||
{group.rest ? <MarkdownFragment text={group.rest} {...rendererProps} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownDetails({
|
||||
part,
|
||||
rendererProps,
|
||||
}: {
|
||||
part: Extract<MarkdownDisplayPart, { kind: "details" }>;
|
||||
rendererProps: MarkdownPartRendererProps;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const toggle = useCallback(() => setOpen((current) => !current), []);
|
||||
const bodyParts = useMemo(
|
||||
() => part.bodyParts ?? [{ kind: "markdown" as const, text: part.body }],
|
||||
[part.body, part.bodyParts],
|
||||
);
|
||||
return (
|
||||
<View style={detailsStyles.container}>
|
||||
<Pressable style={detailsStyles.summaryRow} onPress={toggle} accessibilityRole="button">
|
||||
{open ? (
|
||||
<ChevronDown size={14} color={detailsStyles.summaryIcon.color} />
|
||||
) : (
|
||||
<ChevronRight size={14} color={detailsStyles.summaryIcon.color} />
|
||||
)}
|
||||
<Text style={detailsStyles.summaryText}>{part.summary}</Text>
|
||||
</Pressable>
|
||||
{open ? (
|
||||
<View style={detailsStyles.body}>
|
||||
<MarkdownPartList parts={bodyParts} rendererProps={rendererProps} />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownInheritedTextProps {
|
||||
inheritedStyles: TextStyle;
|
||||
textStyle: TextStyle;
|
||||
style?: TextStyle;
|
||||
monoSurface?: boolean;
|
||||
onPress?: TextProps["onPress"];
|
||||
accessibilityRole?: TextProps["accessibilityRole"];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MarkdownInheritedText({
|
||||
inheritedStyles,
|
||||
textStyle,
|
||||
style: overrideStyle,
|
||||
monoSurface,
|
||||
onPress,
|
||||
accessibilityRole,
|
||||
children,
|
||||
}: MarkdownInheritedTextProps) {
|
||||
const style = useMemo(
|
||||
() => [inheritedStyles, textStyle, overrideStyle],
|
||||
[inheritedStyles, textStyle, overrideStyle],
|
||||
);
|
||||
return (
|
||||
<MarkdownTextSpan
|
||||
monoSurface={monoSurface}
|
||||
onPress={onPress}
|
||||
accessibilityRole={accessibilityRole}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</MarkdownTextSpan>
|
||||
);
|
||||
}
|
||||
|
||||
interface MarkdownListItemContentProps {
|
||||
contentStyle: ViewStyle;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListItemContent({ contentStyle, children }: MarkdownListItemContentProps) {
|
||||
const style = useMemo(() => [contentStyle, MARKDOWN_LIST_ITEM_CONTENT_FLEX], [contentStyle]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface MarkdownListViewProps {
|
||||
baseStyle: ViewStyle;
|
||||
spacing: { marginTop: number; marginBottom: number };
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function MarkdownListView({ baseStyle, spacing, children }: MarkdownListViewProps) {
|
||||
const style = useMemo(() => [baseStyle, spacing], [baseStyle, spacing]);
|
||||
return <View style={style}>{children}</View>;
|
||||
}
|
||||
|
||||
interface SharedMarkdownLinkProps {
|
||||
href: string;
|
||||
inheritedStyles: TextStyle;
|
||||
linkStyle: TextStyle;
|
||||
onLinkPress?: (url: string) => boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function SharedMarkdownLink({
|
||||
href,
|
||||
inheritedStyles,
|
||||
linkStyle,
|
||||
onLinkPress,
|
||||
children,
|
||||
}: SharedMarkdownLinkProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (!href) return;
|
||||
if (onLinkPress?.(href) === false) return;
|
||||
void openExternalUrl(href);
|
||||
}, [href, onLinkPress]);
|
||||
|
||||
return (
|
||||
<MarkdownInheritedText
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={linkStyle}
|
||||
accessibilityRole="link"
|
||||
onPress={handlePress}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
);
|
||||
}
|
||||
|
||||
function getMarkdownLinkHref(node: ASTNode): string {
|
||||
const href = node.attributes?.href;
|
||||
return typeof href === "string" ? href : "";
|
||||
}
|
||||
|
||||
export function createSharedMarkdownRules(): RenderRules {
|
||||
return {
|
||||
text: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.text}
|
||||
>
|
||||
{node.content}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
textgroup: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.textgroup}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
strong: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.strong}
|
||||
>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
em: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.em}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
s: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText key={node.key} inheritedStyles={inheritedStyles} textStyle={styles.s}>
|
||||
{children}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
hardbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
softbreak: (node: ASTNode) => <MarkdownTextSpan key={node.key}>{"\n"}</MarkdownTextSpan>,
|
||||
code_block: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={null}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_block}
|
||||
/>
|
||||
),
|
||||
fence: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<HighlightedCodeBlock
|
||||
key={node.key}
|
||||
code={node.content}
|
||||
language={node.sourceInfo}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.fence}
|
||||
/>
|
||||
),
|
||||
code_inline: (
|
||||
node: ASTNode,
|
||||
_children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
inheritedStyles: TextStyle = {},
|
||||
) => (
|
||||
<MarkdownInheritedText
|
||||
key={node.key}
|
||||
inheritedStyles={inheritedStyles}
|
||||
textStyle={styles.code_inline}
|
||||
monoSurface
|
||||
>
|
||||
{node.content ?? ""}
|
||||
</MarkdownInheritedText>
|
||||
),
|
||||
bullet_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.bullet_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
ordered_list: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownListView
|
||||
key={node.key}
|
||||
baseStyle={styles.ordered_list}
|
||||
spacing={getMarkdownListSpacing(node, parent)}
|
||||
>
|
||||
{children}
|
||||
</MarkdownListView>
|
||||
),
|
||||
list_item: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => {
|
||||
const { isOrdered, marker } = getMarkdownListMarker(node, parent);
|
||||
const iconStyle = isOrdered ? styles.ordered_list_icon : styles.bullet_list_icon;
|
||||
const contentStyle = isOrdered ? styles.ordered_list_content : styles.bullet_list_content;
|
||||
|
||||
return (
|
||||
<View key={node.key} style={styles.list_item}>
|
||||
<Text style={iconStyle}>{marker}</Text>
|
||||
<MarkdownListItemContent contentStyle={contentStyle}>{children}</MarkdownListItemContent>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
paragraph: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
) => (
|
||||
<MarkdownParagraphView
|
||||
key={node.key}
|
||||
paragraphStyle={styles.paragraph}
|
||||
containsImage={markdownNodeContainsType(node, "image")}
|
||||
>
|
||||
{children}
|
||||
</MarkdownParagraphView>
|
||||
),
|
||||
link: (
|
||||
node: ASTNode,
|
||||
children: ReactNode[],
|
||||
_parent: ASTNode[],
|
||||
styles: MarkdownStyles,
|
||||
onLinkPress?: (url: string) => boolean,
|
||||
) => (
|
||||
<SharedMarkdownLink
|
||||
key={node.key}
|
||||
href={getMarkdownLinkHref(node)}
|
||||
inheritedStyles={EMPTY_TEXT_STYLE}
|
||||
linkStyle={styles.link}
|
||||
onLinkPress={onLinkPress}
|
||||
>
|
||||
{children}
|
||||
</SharedMarkdownLink>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const detailsStyles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
marginBottom: theme.spacing[2],
|
||||
overflow: "hidden",
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingVertical: theme.spacing[2],
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
summaryIcon: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
summaryText: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
lineHeight: 18,
|
||||
},
|
||||
body: {
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
paddingTop: theme.spacing[2],
|
||||
},
|
||||
inlineImageWrap: {
|
||||
alignSelf: "flex-start",
|
||||
marginBottom: theme.spacing[1],
|
||||
},
|
||||
inlineImage: {},
|
||||
imageTextRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[3],
|
||||
},
|
||||
imageTextRowContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
flowImage: {
|
||||
marginTop: 2,
|
||||
},
|
||||
flowImageFallback: {
|
||||
marginTop: 2,
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
flowImageFallbackText: {
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
}));
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MarkdownParagraphView, MarkdownTextSpan } from "@/components/markdown-text";
|
||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import * as React from "react";
|
||||
import {
|
||||
useState,
|
||||
@@ -27,12 +26,8 @@ import {
|
||||
Children,
|
||||
cloneElement,
|
||||
} from "react";
|
||||
import type { ReactNode, ComponentType } from "react";
|
||||
import Markdown, {
|
||||
MarkdownIt,
|
||||
type ASTNode,
|
||||
type RenderRules,
|
||||
} from "react-native-markdown-display";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { MarkdownIt, type ASTNode, type RenderRules } from "react-native-markdown-display";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MaskedView from "@react-native-masked-view/masked-view";
|
||||
import {
|
||||
@@ -52,7 +47,7 @@ import {
|
||||
FileSymlink,
|
||||
} from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -63,8 +58,8 @@ import Animated, {
|
||||
withTiming,
|
||||
} from "react-native-reanimated";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
||||
import { MarkdownRenderer, type MarkdownStyles } from "@/components/markdown/renderer";
|
||||
import type { TodoEntry, UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { ToolCallDetail } from "@getpaseo/protocol/agent-types";
|
||||
@@ -113,8 +108,6 @@ import { RewindMenu, type RewindMode } from "@/components/rewind/rewind-menu";
|
||||
import { useRewindAgentMutation } from "@/components/rewind/use-rewind-agent-mutation";
|
||||
export type { InlinePathTarget } from "@/assistant-file-links";
|
||||
|
||||
type MarkdownStyles = Record<string, TextStyle & ViewStyle & { [key: string]: unknown }>;
|
||||
|
||||
interface UserMessageProps {
|
||||
serverId?: string;
|
||||
agentId?: string;
|
||||
@@ -162,22 +155,6 @@ const MARKDOWN_ALLOWED_IMAGE_HANDLERS = [
|
||||
] as const;
|
||||
const MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM = <Text key="dotdotdot">...</Text>;
|
||||
|
||||
interface MarkdownWithStableRendererProps {
|
||||
children: ReactNode;
|
||||
style: ReturnType<typeof createMarkdownStyles>;
|
||||
rules: RenderRules;
|
||||
markdownit: MarkdownIt;
|
||||
onLinkPress: (url: string) => boolean;
|
||||
allowedImageHandlers: readonly string[];
|
||||
topLevelMaxExceededItem: ReactNode;
|
||||
}
|
||||
|
||||
const MarkdownWithStableRenderer = Markdown as ComponentType<MarkdownWithStableRendererProps>;
|
||||
const ThemedMarkdown = withUnistyles(MarkdownWithStableRenderer);
|
||||
const markdownStyleMapping = (theme: Theme): Partial<MarkdownWithStableRendererProps> => ({
|
||||
style: createMarkdownStyles(theme),
|
||||
});
|
||||
|
||||
const ThemedMicVocal = withUnistyles(MicVocal);
|
||||
const ThemedTodoCheckIcon = withUnistyles(Check);
|
||||
const ThemedFileSymlinkIcon = withUnistyles(FileSymlink);
|
||||
@@ -1521,18 +1498,15 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
|
||||
onLinkPress,
|
||||
}: MemoizedMarkdownBlockProps) {
|
||||
return (
|
||||
<AppearanceStyleBoundary>
|
||||
<ThemedMarkdown
|
||||
uniProps={markdownStyleMapping}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
>
|
||||
{text}
|
||||
</ThemedMarkdown>
|
||||
</AppearanceStyleBoundary>
|
||||
<MarkdownRenderer
|
||||
text={text}
|
||||
enableHtmlish={false}
|
||||
rules={rules}
|
||||
markdownit={parser}
|
||||
onLinkPress={onLinkPress}
|
||||
allowedImageHandlers={MARKDOWN_ALLOWED_IMAGE_HANDLERS}
|
||||
topLevelMaxExceededItem={MARKDOWN_TOP_LEVEL_MAX_EXCEEDED_ITEM}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user