Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/src/components/chat/acp/blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { JsonRpcError, mergeToolCalls } from "use-acp";
import { z } from "zod";
import { ReadonlyDiff } from "@/components/editor/code/readonly-diff";
import { JsonOutput } from "@/components/editor/output/JsonOutput";
import { MarkdownRenderer } from "@/components/markdown/markdown-renderer";
import { Button } from "@/components/ui/button";
import {
Popover,
Expand All @@ -36,7 +37,6 @@ import { uniqueByTakeLast } from "@/utils/arrays";
import { logNever } from "@/utils/assertNever";
import { cn } from "@/utils/cn";
import { Strings } from "@/utils/strings";
import { MarkdownRenderer } from "../markdown-renderer";
import { SimpleAccordion } from "./common";
import type {
AgentNotificationEvent,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "lucide-react";
import { memo, useEffect, useRef, useState } from "react";
import useEvent from "react-use-event-hook";
import { MarkdownRenderer } from "@/components/markdown/markdown-renderer";
import { Button } from "@/components/ui/button";
import {
Select,
Expand Down Expand Up @@ -74,7 +75,6 @@ import {
hasPendingToolCalls,
isLastMessageReasoning,
} from "./chat-utils";
import { MarkdownRenderer } from "./markdown-renderer";
import { ReasoningAccordion } from "./reasoning-accordion";
import { ToolCallAccordion } from "./tool-call-accordion";

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/chat/reasoning-accordion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@

import { BotMessageSquareIcon } from "lucide-react";
import React from "react";
import { MarkdownRenderer } from "@/components/markdown/markdown-renderer";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { MarkdownRenderer } from "./markdown-renderer";

interface ReasoningAccordionProps {
reasoning: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* Copyright 2024 Marimo. All rights reserved. */
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { parseContent, UrlDetector } from "../url-detector";
import { isMarkdown, parseContent, UrlDetector } from "../url-detector";

describe("parseContent", () => {
it("handles data URIs", () => {
Expand Down Expand Up @@ -167,3 +167,94 @@ describe("UrlDetector", () => {
expect(mockStopPropagation).toHaveBeenCalled();
});
});

describe("isMarkdown", () => {
it("returns true for headings", () => {
expect(isMarkdown("# Heading 1")).toBe(true);
expect(isMarkdown("## Heading 2")).toBe(true);
expect(isMarkdown("### Heading 3")).toBe(true);
expect(isMarkdown("Heading\n===")).toBe(true);
expect(isMarkdown("Heading\n---")).toBe(true);
});

it.fails("returns true for bold text", () => {
expect(isMarkdown("**bold**")).toBe(true);
expect(isMarkdown("__bold__")).toBe(true);
});

it.fails("returns true for italic text", () => {
expect(isMarkdown("*italic*")).toBe(true);
expect(isMarkdown("_italic_")).toBe(true);
});

it("returns false for inline code", () => {
expect(isMarkdown("`code`")).toBe(false);
expect(isMarkdown("Text with `inline code` in it")).toBe(false);
});

it("returns true for code blocks", () => {
expect(isMarkdown("```\ncode block\n```")).toBe(true);
expect(isMarkdown("```python\ndef hello():\n pass\n```")).toBe(true);
});

it("returns true for lists", () => {
expect(isMarkdown("- item 1\n- item 2")).toBe(true);
expect(isMarkdown("* item 1\n* item 2")).toBe(true);
expect(isMarkdown("1. item 1\n2. item 2")).toBe(true);
});

it("returns true for blockquotes", () => {
expect(isMarkdown("> This is a quote")).toBe(true);
expect(isMarkdown("> Quote line 1\n> Quote line 2")).toBe(true);
});

it("returns true for horizontal rules", () => {
expect(isMarkdown("---")).toBe(true);
expect(isMarkdown("***")).toBe(true);
expect(isMarkdown("___")).toBe(true);
});

it("returns true for tables", () => {
expect(
isMarkdown("| col1 | col2 |\n|------|------|\n| val1 | val2 |"),
).toBe(true);
});

it("returns true for HTML tags", () => {
expect(isMarkdown("<div>content</div>")).toBe(true);
expect(isMarkdown("<br />")).toBe(true);
});

it.fails("returns true for escaped characters", () => {
expect(isMarkdown("\\*not bold\\*")).toBe(true);
expect(isMarkdown("\\# not a heading")).toBe(true);
});

it("returns false for plain text", () => {
expect(isMarkdown("Just plain text")).toBe(false);
expect(isMarkdown("No markdown here")).toBe(false);
});

it("returns false for empty string", () => {
expect(isMarkdown("")).toBe(false);
});

it("returns false for plain URLs without markdown syntax", () => {
expect(isMarkdown("https://example.com")).toBe(false);
expect(isMarkdown("Visit https://marimo.io for more")).toBe(false);
});

it("returns false for plain text with numbers", () => {
expect(isMarkdown("123 456 789")).toBe(false);
});

it.fails("returns true for mixed markdown and plain text", () => {
expect(isMarkdown("Plain text with **bold** in it")).toBe(true);
expect(isMarkdown("Start with text\n\n# Then a heading")).toBe(true);
});

it.fails("returns true for markdown with URLs", () => {
expect(isMarkdown("[Link](https://example.com)")).toBe(true);
expect(isMarkdown("Visit [marimo](https://marimo.io)")).toBe(true);
});
});
22 changes: 15 additions & 7 deletions frontend/src/components/data-table/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { Maps } from "@/utils/maps";
import { Objects } from "@/utils/objects";
import { EmotionCacheProvider } from "../editor/output/EmotionCacheProvider";
import { JsonOutput } from "../editor/output/JsonOutput";
import { CopyClipboardIcon } from "../icons/copy-icon";
import { Button } from "../ui/button";
import { Checkbox } from "../ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
Expand All @@ -33,7 +34,7 @@ import {
INDEX_COLUMN_NAME,
} from "./types";
import { uniformSample } from "./uniformSample";
import { parseContent, UrlDetector } from "./url-detector";
import { MarkdownUrlDetector, parseContent, UrlDetector } from "./url-detector";

// Artificial limit to display long strings
const MAX_STRING_LENGTH = 50;
Expand Down Expand Up @@ -354,11 +355,18 @@ const PopoutColumn = ({
align="start"
alignOffset={10}
>
<PopoverClose className="absolute top-2 right-2">
<Button variant="link" size="xs">
{buttonText ?? "Close"}
</Button>
</PopoverClose>
<div className="absolute top-2 right-2">
<CopyClipboardIcon
value={rawStringValue}
className="w-2.5 h-2.5"
tooltip={false}
/>
<PopoverClose>
<Button variant="link" size="xs">
{buttonText ?? "Close"}
</Button>
</PopoverClose>
</div>
{children}
</PopoverContent>
</Popover>
Expand Down Expand Up @@ -531,7 +539,7 @@ export function renderCellValue<TData, TValue>({
buttonText="X"
wrapped={isWrapped}
>
<UrlDetector parts={parts} />
<MarkdownUrlDetector content={stringValue} parts={parts} />
</PopoutColumn>
);
}
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/components/data-table/url-detector.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* Copyright 2024 Marimo. All rights reserved. */

import { marked } from "marked";
import { useState } from "react";
import { MarkdownRenderer } from "@/components/markdown/markdown-renderer";
import {
Popover,
PopoverContent,
Expand Down Expand Up @@ -82,6 +84,47 @@ export function parseContent(text: string): ContentPart[] {
});
}

export function isMarkdown(text: string): boolean {
const tokens = marked.lexer(text);

const commonMarkdownIndicators = new Set([
"space",
"code",
"fences",
"heading",
"hr",
"link",
"blockquote",
"list",
"html",
"def",
"table",
"lheading",
"escape",
"tag",
"reflink",
"strong",
"codespan",
"url",
]);

return tokens.some((token) => commonMarkdownIndicators.has(token.type));
}

// Wrapper component so that we call isMarkdown only on trigger
export const MarkdownUrlDetector = ({
content,
parts,
}: {
content: string;
parts: ContentPart[];
}) => {
if (isMarkdown(content)) {
return <MarkdownRenderer content={content} />;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this still runs on every cell, right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could make <PopoutColumn> take a renderContent instead of children and track when the popout is open and do {isOpen ? renderContent() : null}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only runs when the cell is clicked and the popout is opened. Radix's popover only renders the content on open.
But it has to be a component, previously I did not use a custom component and it would call on every cell.

Thanks for the other comments, I agree. And we can also merge this after release for safety.

}
return <UrlDetector parts={parts} />;
};

export const UrlDetector = ({ parts }: { parts: ContentPart[] }) => {
const markup = parts.map((part, idx) => {
if (part.type === "url") {
Expand Down
7 changes: 7 additions & 0 deletions frontend/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ export default defineConfig({
hooks: "parallel", // Maintain parallel hook execution from Vitest 1.x
},
watch: false,
server: {
deps: {
// Inline streamdown so it gets processed by Vite's transform pipeline.
// This allows CSS imports from streamdown's dependencies (e.g., katex CSS) to be processed.
inline: [/streamdown/],
},
},
},
resolve: {
tsconfigPaths: true,
Expand Down
52 changes: 52 additions & 0 deletions marimo/_smoke_tests/tables/markdown_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import marimo

__generated_with = "0.17.8"
app = marimo.App(width="medium")


@app.cell
def _():
import marimo as mo
return (mo,)


@app.cell
def _(markdown_sample, mo):
mo.ui.table(
{
"heading": ["### Hello" * 8],
"code_blocks": ["```python\n print('hi')\n```" * 3],
"lists": ["- item 1\n* item 2\n1. item 3" * 3],
"markdown_sample": [markdown_sample],
}
)
return


@app.cell
def _():
markdown_sample = """
# Markdown showcase

## Features:
- **Lists:**
- Bullet points
- Numbered lists

- **Emphasis:** *italic*, **bold**, ***bold italic***
- **Links:** [Visit OpenAI](https://www.openai.com)
- **Images:**

![Picture](https://picsum.photos/200/300)

- **Blockquotes:**

> Markdown makes writing simple and beautiful!

Enjoy exploring markdown's versatility!
"""
return (markdown_sample,)


if __name__ == "__main__":
app.run()
Loading