diff --git a/nix/npm-deps.hash b/nix/npm-deps.hash index c955eb427..f2b4328a7 100644 --- a/nix/npm-deps.hash +++ b/nix/npm-deps.hash @@ -1 +1 @@ -sha256-m6anpp3ESvWCVQAoTsOhnTMpoewhpJTeVha2Gv4Xnc4= +sha256-L73o8u6OsY3SsA5t2s5mWsMsMNc3qr5bCE4cd1NpbgI= diff --git a/package-lock.json b/package-lock.json index de6b745bf..e95d8602a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36179,6 +36179,7 @@ "turndown": "^7.2.4", "turndown-plugin-gfm": "^1.0.2", "use-sync-external-store": "^1.6.0", + "yaml": "^2.8.4", "zod": "^4.4.3", "zustand": "^5.0.9" }, diff --git a/packages/app/package.json b/packages/app/package.json index cd15f23a4..18bdb283f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -119,6 +119,7 @@ "turndown": "^7.2.4", "turndown-plugin-gfm": "^1.0.2", "use-sync-external-store": "^1.6.0", + "yaml": "^2.8.4", "zod": "^4.4.3", "zustand": "^5.0.9" }, diff --git a/packages/app/src/assistant-file-links/link.tsx b/packages/app/src/assistant-file-links/link.tsx index d99188cff..9b2838c3d 100644 --- a/packages/app/src/assistant-file-links/link.tsx +++ b/packages/app/src/assistant-file-links/link.tsx @@ -1,16 +1,9 @@ -import { useMemo, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; -import { - Platform, - Pressable, - Text, - View, - type StyleProp, - type TextStyle, - type ViewStyle, -} from "react-native"; +import { useMemo, type CSSProperties, type MouseEvent, type ReactNode } from "react"; +import { Platform, Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { isNative, isWeb } from "@/constants/platform"; import { MarkdownTextSpan } from "@/components/markdown-text"; +import { MarkdownLinkText } from "@/components/markdown/link-text"; import { AssistantLinkPressProvider, type AssistantLinkPress } from "./link-press-context"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -39,7 +32,6 @@ export function AssistantMarkdownLink({ monoSurface, children, }: AssistantMarkdownLinkProps) { - const [hovered, setHovered] = useState(false); const { target, onHoverIn, onPress, onAuxPress } = useFileLink(source); const { configRef } = useAssistantFileLinkResolverContext(); const workspaceRoot = configRef.current.workspaceRoot; @@ -55,15 +47,6 @@ export function AssistantMarkdownLink({ event.stopPropagation(); onAuxPress(); }); - const handleHoverIn = useStableEvent(() => { - setHovered(true); - onHoverIn(); - }); - const handleHoverOut = useStableEvent(() => setHovered(false)); - const hoveredTextStyle = useMemo>( - () => [style, hovered && { textDecorationLine: "underline" as const }], - [style, hovered], - ); const linkPress = useMemo( () => ({ onPress, accessibilityRole: "link" }), [onPress], @@ -114,19 +97,14 @@ export function AssistantMarkdownLink({ onAuxClickCapture={preventAnchorNavigation} style={LINK_ANCHOR_STYLE} > - - - {children} - - + {children} + ); diff --git a/packages/app/src/components/markdown/link-children.ts b/packages/app/src/components/markdown/link-children.ts new file mode 100644 index 000000000..9a81042c8 --- /dev/null +++ b/packages/app/src/components/markdown/link-children.ts @@ -0,0 +1,21 @@ +import { Children, cloneElement, isValidElement, type ReactNode } from "react"; +import type { StyleProp, TextStyle } from "react-native"; + +export function colorMarkdownLinkChildren( + children: ReactNode, + color: TextStyle["color"], +): ReactNode { + return Children.map(children, (child) => { + if (!isValidElement<{ style?: StyleProp }>(child)) return child; + return cloneElement(child, { style: [child.props.style, { color }] }); + }); +} + +const MARKDOWN_LINK_HOVER_STYLE: TextStyle = { textDecorationLine: "underline" }; + +export function markdownLinkTextStyle( + style: StyleProp, + hovered: boolean, +): StyleProp { + return [style, hovered && MARKDOWN_LINK_HOVER_STYLE]; +} diff --git a/packages/app/src/components/markdown/link-text.tsx b/packages/app/src/components/markdown/link-text.tsx new file mode 100644 index 000000000..18b1b6c93 --- /dev/null +++ b/packages/app/src/components/markdown/link-text.tsx @@ -0,0 +1,41 @@ +import { useMemo, useState, type ReactNode } from "react"; +import { Pressable, Text, type StyleProp, type TextStyle } from "react-native"; +import { useStableEvent } from "@/hooks/use-stable-event"; +import { markdownLinkTextStyle } from "./link-children"; + +interface MarkdownLinkTextProps { + style: StyleProp; + dataSet?: Record; + onPress(): void; + onHoverIn?(): void; + children?: ReactNode; +} + +export function MarkdownLinkText({ + style, + dataSet, + onPress, + onHoverIn, + children, +}: MarkdownLinkTextProps) { + const [hovered, setHovered] = useState(false); + const handleHoverIn = useStableEvent(() => { + setHovered(true); + onHoverIn?.(); + }); + const handleHoverOut = useStableEvent(() => setHovered(false)); + const textStyle = useMemo(() => markdownLinkTextStyle(style, hovered), [hovered, style]); + + return ( + + + {children} + + + ); +} diff --git a/packages/app/src/components/markdown/renderer.test.ts b/packages/app/src/components/markdown/renderer.test.ts index 9412d4633..1dc279a2d 100644 --- a/packages/app/src/components/markdown/renderer.test.ts +++ b/packages/app/src/components/markdown/renderer.test.ts @@ -1,5 +1,48 @@ -import { describe, expect, it } from "vitest"; +/** + * @vitest-environment jsdom + */ +import * as React from "react"; +import { createElement, type ReactNode } from "react"; +import { fireEvent, render } from "@testing-library/react"; +import { Text, type StyleProp, type TextStyle } from "react-native"; +import { describe, expect, it, vi } from "vitest"; import { resolveInlineImageSize } from "./inline-image-size"; +import { colorMarkdownLinkChildren } from "./link-children"; +import { MarkdownLinkText } from "./link-text"; + +vi.stubGlobal("React", React); + +vi.mock("react-native", () => ({ + Pressable: ({ + accessibilityRole, + children, + onHoverIn, + onHoverOut, + onPress, + }: { + accessibilityRole?: string; + children?: ReactNode; + onHoverIn?(): void; + onHoverOut?(): void; + onPress?(): void; + }) => + createElement( + "div", + { + role: accessibilityRole, + onClick: onPress, + onMouseEnter: onHoverIn, + onMouseLeave: onHoverOut, + }, + children, + ), + Text: ({ children, style }: { children?: ReactNode; style?: StyleProp }) => + createElement("span", { style: flattenStyle(style) }, children), +})); + +function flattenStyle(style: StyleProp): TextStyle { + return Object.assign({}, ...(Array.isArray(style) ? style.filter(Boolean) : [style])); +} describe("resolveInlineImageSize", () => { it("respects a one-sided explicit width using natural aspect ratio", () => { @@ -27,3 +70,30 @@ describe("resolveInlineImageSize", () => { }); }); }); + +describe("shared Markdown links", () => { + it("renders accent text and underlines it while hovered", () => { + const onPress = vi.fn(); + const children = colorMarkdownLinkChildren( + createElement(Text, { style: { color: "white" } }, "Paseo"), + "rgb(0, 122, 255)", + ); + const view = render( + createElement(MarkdownLinkText, { style: { color: "rgb(0, 122, 255)" }, onPress }, children), + ); + const link = view.getByRole("link"); + const linkText = link.firstElementChild as HTMLElement; + + expect((view.getByText("Paseo") as HTMLElement).style.color).toBe("rgb(0, 122, 255)"); + expect(linkText.style.textDecorationLine).toBe(""); + + fireEvent.mouseEnter(link); + expect(linkText.style.textDecorationLine).toBe("underline"); + + fireEvent.mouseLeave(link); + expect(linkText.style.textDecorationLine).toBe(""); + + fireEvent.click(link); + expect(onPress).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/app/src/components/markdown/renderer.tsx b/packages/app/src/components/markdown/renderer.tsx index ddb82ee33..d9b0cc2be 100644 --- a/packages/app/src/components/markdown/renderer.tsx +++ b/packages/app/src/components/markdown/renderer.tsx @@ -31,6 +31,7 @@ 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 { isNative } from "@/constants/platform"; import { splitHtmlishMarkdown, type MarkdownDisplayPart, @@ -38,6 +39,8 @@ import { } from "./html-ish"; import { resolveInlineImageSize, type InlineImageDimensions } from "./inline-image-size"; import { groupMarkdownParts, type MarkdownPartGroup } from "./part-groups"; +import { colorMarkdownLinkChildren } from "./link-children"; +import { MarkdownLinkText } from "./link-text"; export type MarkdownStyles = Record; @@ -478,6 +481,15 @@ function SharedMarkdownLink({ if (onLinkPress?.(href) === false) return; void openExternalUrl(href); }, [href, onLinkPress]); + const style = useMemo(() => [inheritedStyles, linkStyle], [inheritedStyles, linkStyle]); + + if (!isNative) { + return ( + + {children} + + ); + } return ( - {children} + {colorMarkdownLinkChildren(children, styles.link.color)} ), }; diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index b6a0162fd..1615c7b73 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -23,9 +23,6 @@ import { useCallback, createContext, useContext, - isValidElement, - Children, - cloneElement, } from "react"; import type { ComponentType, ReactNode } from "react"; import type MarkdownIt from "markdown-it"; @@ -77,6 +74,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block"; import { MarkdownFenceBlock } from "@/components/markdown/fence"; import type { MarkdownPhase } from "@/components/markdown/fence/types"; import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks"; +import { colorMarkdownLinkChildren } from "@/components/markdown/link-children"; import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser"; import { formatDuration, formatMessageTimestamp } from "@/utils/time"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; @@ -1863,13 +1861,7 @@ export const AssistantMessage = memo(function AssistantMessage({ source={getMarkdownLinkSource(node)} style={styles.link} > - {Children.map(children, (child) => { - if (!isValidElement(child)) return child; - const childProps = child.props as { style?: StyleProp }; - return cloneElement(child, { - style: [childProps.style, { color: styles.link.color }], - } as Partial<{ style: StyleProp }>); - })} + {colorMarkdownLinkChildren(children, styles.link.color)} ), image: ( diff --git a/packages/app/src/file-pane/markdown-preview/document.test.ts b/packages/app/src/file-pane/markdown-preview/document.test.ts new file mode 100644 index 000000000..020d7246d --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/document.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { parseMarkdownPreviewDocument } from "./document"; + +describe("parseMarkdownPreviewDocument", () => { + it("separates YAML front matter from the Markdown body", () => { + expect( + parseMarkdownPreviewDocument(`--- +name: release-beta +description: Cut a beta release +allowed-tools: + - Bash + - Read +--- +# Release beta + +Ship it. +`), + ).toEqual({ + frontMatter: [ + { key: "name", value: "release-beta" }, + { key: "description", value: "Cut a beta release" }, + { key: "allowed-tools", value: "Bash, Read" }, + ], + body: "# Release beta\n\nShip it.\n", + }); + }); + + it("formats nested metadata without flattening its meaning", () => { + expect( + parseMarkdownPreviewDocument(`--- +metadata: + audience: maintainers + stable: true +--- +Body +`), + ).toEqual({ + frontMatter: [{ key: "metadata", value: "audience: maintainers\nstable: true" }], + body: "Body\n", + }); + }); + + it("leaves the document intact when front matter is invalid", () => { + const source = `--- +name: [broken +--- +Body +`; + + expect(parseMarkdownPreviewDocument(source)).toEqual({ + frontMatter: [], + body: source, + }); + }); + + it("leaves the document intact when YAML conversion rejects excessive aliases", () => { + const aliases = Array.from({ length: 101 }, () => "*item").join(", "); + const source = `--- +item: &item value +items: [${aliases}] +--- +Body +`; + + expect(parseMarkdownPreviewDocument(source)).toEqual({ + frontMatter: [], + body: source, + }); + }); + + it("does not treat a thematic break inside the document as front matter", () => { + const source = `Introduction + +--- + +Body +`; + + expect(parseMarkdownPreviewDocument(source)).toEqual({ + frontMatter: [], + body: source, + }); + }); +}); diff --git a/packages/app/src/file-pane/markdown-preview/document.ts b/packages/app/src/file-pane/markdown-preview/document.ts new file mode 100644 index 000000000..7e2fff520 --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/document.ts @@ -0,0 +1,70 @@ +import { parseDocument, stringify } from "yaml"; + +export interface MarkdownFrontMatterRow { + key: string; + value: string; +} + +export interface MarkdownPreviewDocument { + frontMatter: MarkdownFrontMatterRow[]; + body: string; +} + +const FRONT_MATTER_PATTERN = /^\uFEFF?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +export function parseMarkdownPreviewDocument(source: string): MarkdownPreviewDocument { + const match = FRONT_MATTER_PATTERN.exec(source); + if (!match) { + return { frontMatter: [], body: source }; + } + + try { + return parseFrontMatter(match, source); + } catch { + return { frontMatter: [], body: source }; + } +} + +function parseFrontMatter(match: RegExpExecArray, source: string): MarkdownPreviewDocument { + const document = parseDocument(match[1], { schema: "core" }); + if (document.errors.length > 0) { + return { frontMatter: [], body: source }; + } + + const metadata = document.toJS({ maxAliasCount: 100 }); + if (!isMetadataRecord(metadata)) { + return { frontMatter: [], body: source }; + } + + const frontMatter = Object.entries(metadata).map(([key, value]) => ({ + key, + value: formatFrontMatterValue(value), + })); + + return { + frontMatter, + body: source.slice(match[0].length), + }; +} + +function isMetadataRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatFrontMatterValue(value: unknown): string { + if (Array.isArray(value) && value.every(isScalar)) { + return value.map(formatScalar).join(", "); + } + if (isScalar(value)) { + return formatScalar(value); + } + return stringify(value).trim(); +} + +function isScalar(value: unknown): value is string | number | boolean | null { + return value === null || ["string", "number", "boolean"].includes(typeof value); +} + +function formatScalar(value: string | number | boolean | null): string { + return value === null ? "null" : String(value); +} diff --git a/packages/app/src/file-pane/markdown-preview/index.tsx b/packages/app/src/file-pane/markdown-preview/index.tsx new file mode 100644 index 000000000..1e8a3bfbd --- /dev/null +++ b/packages/app/src/file-pane/markdown-preview/index.tsx @@ -0,0 +1,95 @@ +import { useMemo } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { MarkdownRenderer } from "@/components/markdown/renderer"; +import { MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { parseMarkdownPreviewDocument } from "./document"; + +export function FileMarkdownPreview({ source }: { source: string }) { + const document = useMemo(() => parseMarkdownPreviewDocument(source), [source]); + + return ( + + + {document.frontMatter.length > 0 ? ( + + {document.frontMatter.map((row, index) => ( + 0 && styles.frontMatterRowBorder]} + > + + + {row.key} + + + + + {row.value} + + + + ))} + + ) : null} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + outerGutter: { + width: "100%", + paddingHorizontal: { + xs: theme.spacing[3], + md: theme.spacing[4], + }, + paddingVertical: theme.spacing[4], + }, + readingFrame: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + alignSelf: "center", + paddingHorizontal: theme.spacing[2], + }, + frontMatterTable: { + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + overflow: "hidden", + marginBottom: theme.spacing[6], + }, + frontMatterRow: { + flexDirection: "row", + alignItems: "stretch", + }, + frontMatterRowBorder: { + borderTopWidth: theme.borderWidth[1], + borderTopColor: theme.colors.border, + }, + frontMatterKeyCell: { + flexBasis: "30%", + maxWidth: 180, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface2, + }, + frontMatterValueCell: { + flex: 1, + minWidth: 0, + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + frontMatterKey: { + color: theme.colors.foregroundMuted, + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, + frontMatterValue: { + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + lineHeight: 20, + }, +})); diff --git a/packages/app/src/file-pane/pane.tsx b/packages/app/src/file-pane/pane.tsx index 07b0ee267..c412504e0 100644 --- a/packages/app/src/file-pane/pane.tsx +++ b/packages/app/src/file-pane/pane.tsx @@ -12,7 +12,6 @@ import type { DaemonClient, FileReadResult } from "@getpaseo/client/internal/dae import { Image as RNImage, ScrollView as RNScrollView, Text, View } from "react-native"; import { StyleSheet, UnistylesRuntime, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; -import { MarkdownRenderer } from "@/components/markdown/renderer"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useSessionStore, type ExplorerFile } from "@/stores/session-store"; import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; @@ -36,6 +35,7 @@ import { useAppSettings } from "@/hooks/use-settings"; import { useLiveFile } from "./live-file/hook"; import { FilePanelBar } from "./bar"; import { FileHtmlPreview } from "./html-preview"; +import { FileMarkdownPreview } from "./markdown-preview"; import { FileEditorModel, getFileConflictCallout, type FileConflictCallout } from "./editor/model"; import { createFileObservationSource } from "./editor/observation-source"; import { FileEditorView } from "./editor/view"; @@ -305,10 +305,9 @@ function FilePreviewBody({ - + ); @@ -881,9 +880,6 @@ const styles = StyleSheet.create((theme) => ({ previewCodeScrollContent: { padding: theme.spacing[4], }, - previewMarkdownScrollContent: { - padding: theme.spacing[4], - }, previewImageScrollContent: { flexGrow: 1, padding: theme.spacing[4],